28 - the line's retry takes one fallback per pass, so it cannot spin #110

Merged
lilleman merged 5 commits from line-fallback-progress into main 2026-09-20 18:04:47 +02:00
4 changed files with 55 additions and 37 deletions
+7 -2
View File
@@ -121,7 +121,10 @@ names the escape that unclaims the form claimed: `\!adf:` for a directive, block
alike, `\|` for every pipe row. alike, `\|` for every pipe row.
Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
name reads true of it in both directions; where none does and a plain name exists, a new code — in name reads true of it in both directions; where none does and a plain name exists, a new code — in
any 0.x minor, and after 1.0 only in a MAJOR (the maintainer, 2026-09-18). A code names the any 0.x minor, and after 1.0 only in a MAJOR (the maintainer, 2026-09-18). A refusal whose cause is
this library's own invariant rather than the input takes the existing code nearest what the consumer
sees — a document that does not convert is `unsupported-node-shape` — since a code no input reaches
is one no consumer can switch on (the maintainer, 2026-09-20). A code names the
cause; where one cause recurs across node types, across one mark's attributes or across cause; where one cause recurs across node types, across one mark's attributes or across
directions, one code covers them all and directions, one code covers them all and
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever `path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
@@ -208,7 +211,7 @@ wide `Result<T>`, since half their refusals come from an emit stage that read no
Test for the behaviour wanted first, then implement until green. `node --test`, beside the code. Test for the behaviour wanted first, then implement until green. `node --test`, beside the code.
Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent, Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent,
coverage does not decline, containers are torn down after a run. containers are torn down after a run.
The gate runs that same suite under Deno and Bun as well as Node, the three images pinned alike, The gate runs that same suite under Deno and Bun as well as Node, the three images pinned alike,
and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier, and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier,
@@ -314,6 +317,8 @@ someone spells it or pins it.
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result` mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result`
is owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and is owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and
is fine (4c). is fine (4c).
- A loop retrying an input until a fallback spells it refuses the pass taking no fallback, so its
termination is the loop's own check (28).
- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the - A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the
line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk
hoists the scan that does not vary with the character. The pipeline persona feeds documents hoists the scan that does not vary with the character. The pipeline persona feeds documents
+26 -24
View File
@@ -30,10 +30,9 @@ type InlineContext = {
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
type LineAttempt = type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
| { carry: NodeRange; line?: undefined; openingLinkAsDirective?: undefined }
| { carry?: undefined; line?: undefined; openingLinkAsDirective: true } type LineFallbacks = { carried: Set<number>; openingLinkAsDirective: boolean }
| { carry?: undefined; line: string; openingLinkAsDirective?: undefined }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const emitted = emitLine(nodes, container, path) const emitted = emitLine(nodes, container, path)
@@ -63,37 +62,40 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver
return attempt.ok ? attempt.value.line : undefined return attempt.ok ? attempt.value.line : undefined
} }
// Every pass carries at least one more node, or flips openingLinkAsDirective, which happens once — so the loop ends.
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> { function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
const carried = new Set<number>() const fallbacks: LineFallbacks = { carried: new Set(), openingLinkAsDirective: false }
let openingLinkAsDirective = false
for (;;) { for (;;) {
const emission = lineSegments(nodes, container, path, carried, openingLinkAsDirective) const emission = lineSegments(nodes, container, path, fallbacks)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) { if (emission.value.carry !== undefined) {
carryRange(carried, emission.value.carry) const taken = takeFallback(fallbacks, emission.value.carry, path)
if (!taken.ok) return taken
continue continue
} }
const attempt = attemptLine(emission.value.segments, container, path) const attempt = attemptLine(emission.value.segments, container, path)
if (!attempt.ok) return attempt if (!attempt.ok) return attempt
if (attempt.value.line !== undefined) return success({ line: attempt.value.line, openingLinkAsDirective, segments: emission.value.segments }) if (attempt.value.line !== undefined) {
if (attempt.value.carry !== undefined) carryRange(carried, attempt.value.carry) return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
else openingLinkAsDirective = true }
const taken = takeFallback(fallbacks, attempt.value.fallback, path)
if (!taken.ok) return taken
} }
} }
function carryRange(carried: Set<number>, range: NodeRange): void { function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result<null> {
for (let index = range.first; index <= range.last; index += 1) carried.add(index) if (fallback === 'opening-link') {
if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path)
fallbacks.openingLinkAsDirective = true
return success(null)
}
const before = fallbacks.carried.size
for (let index = fallback.first; index <= fallback.last; index += 1) fallbacks.carried.add(index)
if (fallbacks.carried.size === before) return failure('unsupported-node-shape', 'a carry took no inline node the line had not carried, so the line has no spelling left', path)
return success(null)
} }
function lineSegments( function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<Emission> {
nodes: readonly AdfNode[], const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, path, spansLines: container === 'paragraph' }
container: LineContainer,
path: ConvertErrorPath,
carried: ReadonlySet<number>,
openingLinkAsDirective: boolean,
): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, carried, openingLinkAsDirective, path, spansLines: container === 'paragraph' }
const emission = emitRun(nodes, 0, 0, context) const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) return emission if (emission.value.carry !== undefined) return emission
@@ -102,8 +104,8 @@ function lineSegments(
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> { function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const assembled = assembleInlineLine(segments, container) const assembled = assembleInlineLine(segments, container)
if (assembled.openingLinkAsDirective) return success({ openingLinkAsDirective: true }) if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' })
if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun }) if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun })
for (const [index, single] of assembled.line.split('\n').entries()) { for (const [index, single] of assembled.line.split('\n').entries()) {
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) { if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path) return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
+21
View File
@@ -886,6 +886,27 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
(the stability-reviewer, 2026-09-19). The one list accounting was not (the stability-reviewer, 2026-09-19). The one list accounting was not
taken: 4b settled that accounting the day this was filed, and reopening it is an ask rather taken: 4b settled that accounting the day this was filed, and reopening it is an ask rather
than a chunk. than a chunk.
- [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** `emit/inline-line.ts:67` is a
`for (;;)` that re-emits the line until every unspellable node has been carried, and its
termination rests on a comment: each pass carries at least one more node, or flips
`openingLinkAsDirective`, which happens once. Ten `return success({ carry: … })` sites in
that file have to honour it and nothing checks them — a range already inside `carried` loops
forever. The library has no I/O and no timeout, so that is a hung caller rather than an
error result, and §1's pipeline persona feeds documents nobody typed. Make the loop hold its
own guarantee: refuse a carry that adds no node and return an error. Reads first in `0.2.0`
because it is the only known way this library fails without a `Result`. Found by the
comprehension panel, 2026-09-20; the ten sites are confirmed, a document that reaches the
spin is not.
**Done** (2026-09-20): the loop's progress is one named state and every pass takes a
fallback through `takeFallback`, which refuses a carry adding no node and an opening link
asked for the directive form a second time. Both are `unsupported-node-shape` under §8's
rule that a new cause takes an existing code reading true of it: the emitter has no spelling
left for that node arrangement, and a code a consumer can never switch on costs a removal
later. Neither refusal is reachable — a carried node takes `emitLeaf`'s carried branch
before any run forms, so every range a site names holds an uncarried node, and the
directive-spelled opening link leaves the first segment with no node range for `escape` to
ask about — so both are uncovered branches like the repo's other guards, 98.92% to 98.84%
against the floor of 98.
## 5 — Ship `0.1.0` ## 5 — Ship `0.1.0`
+1 -11
View File
@@ -37,17 +37,6 @@ HTML format lands than after: 19 and 20 because HTML has no answer without them,
HTML doubles the importers and the file count they touch, and 25 to 27 because they are what the HTML doubles the importers and the file count they touch, and 25 to 27 because they are what the
panel says the next reader pays for. panel says the next reader pays for.
- [ ] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** `emit/inline-line.ts:67` is a
`for (;;)` that re-emits the line until every unspellable node has been carried, and its
termination rests on a comment: each pass carries at least one more node, or flips
`openingLinkAsDirective`, which happens once. Ten `return success({ carry: … })` sites in
that file have to honour it and nothing checks them — a range already inside `carried` loops
forever. The library has no I/O and no timeout, so that is a hung caller rather than an
error result, and §1's pipeline persona feeds documents nobody typed. Make the loop hold its
own guarantee: refuse a carry that adds no node and return an error. Reads first in `0.2.0`
because it is the only known way this library fails without a `Result`. Found by the
comprehension panel, 2026-09-20; the ten sites are confirmed, a document that reaches the
spin is not.
- [ ] **19 — A home for what both formats read (`0.2.0`).** Settle where a construct both formats - [ ] **19 — A home for what both formats read (`0.2.0`).** Settle where a construct both formats
need lives, and say so in AGENTS.md §11. Today `adf/` may hold no format knowledge and each need lives, and say so in AGENTS.md §11. Today `adf/` may hold no format knowledge and each
format directory holds its own shared layer, so there is no third place; the first ADF-shaped format directory holds its own shared layer, so there is no third place; the first ADF-shaped
@@ -277,6 +266,7 @@ panel says the next reader pays for.
four and the other the clearest map of the format in the repo. So the ceiling guards against four and the other the clearest map of the format in the repo. So the ceiling guards against
drift and never drives a refactor — 19 to 27 are where the hard work actually is. drift and never drives a refactor — 19 to 27 are where the hard work actually is.
- [x] **18 — The subtree the directive spelling asks about (`0.2.0`).** - [x] **18 — The subtree the directive spelling asks about (`0.2.0`).**
- [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).**
## The ADF inventory to cover ## The ADF inventory to cover