From e34d39fdeec9af41e12f71701842496eda9f234c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 17:49:54 +0200 Subject: [PATCH 1/5] 28 - the line's retry takes one fallback per pass, so it cannot spin --- AGENTS.md | 3 +++ src/markdown/emit/inline-line.ts | 42 ++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e1fb8e2..9952a7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,6 +314,9 @@ someone spells it or pins it. 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 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 rather than an argument every give-way site has to honour + (28). - 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 hoists the scan that does not vary with the character. The pipeline persona feeds documents diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 320d4fd..b05385b 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -35,6 +35,8 @@ type LineAttempt = | { carry?: undefined; line?: undefined; openingLinkAsDirective: true } | { carry?: undefined; line: string; openingLinkAsDirective?: undefined } +type LineFallbacks = { carried: Set; openingLinkAsDirective: boolean } + export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const emitted = emitLine(nodes, container, path) if (!emitted.ok) return emitted @@ -63,37 +65,41 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver 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 { - const carried = new Set() - let openingLinkAsDirective = false + const fallbacks: LineFallbacks = { carried: new Set(), openingLinkAsDirective: false } for (;;) { - const emission = lineSegments(nodes, container, path, carried, openingLinkAsDirective) + const emission = lineSegments(nodes, container, path, fallbacks) if (!emission.ok) return emission if (emission.value.carry !== undefined) { - carryRange(carried, emission.value.carry) + const taken = takeFallback(fallbacks, emission.value.carry, path) + if (!taken.ok) return taken continue } const attempt = attemptLine(emission.value.segments, container, path) if (!attempt.ok) return attempt - if (attempt.value.line !== undefined) return success({ line: attempt.value.line, openingLinkAsDirective, segments: emission.value.segments }) - if (attempt.value.carry !== undefined) carryRange(carried, attempt.value.carry) - else openingLinkAsDirective = true + if (attempt.value.line !== undefined) { + return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments }) + } + const taken = takeFallback(fallbacks, attempt.value.carry ?? 'opening-link', path) + if (!taken.ok) return taken } } -function carryRange(carried: Set, range: NodeRange): void { - for (let index = range.first; index <= range.last; index += 1) carried.add(index) +// emitLine ends because each fallback is takeable once: a pass taking none re-emits the line it just emitted. +function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result { + 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( - nodes: readonly AdfNode[], - container: LineContainer, - path: ConvertErrorPath, - carried: ReadonlySet, - openingLinkAsDirective: boolean, -): Result { - const context: InlineContext = { atBlockEnd: true, bracketed: false, carried, openingLinkAsDirective, path, spansLines: container === 'paragraph' } +function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { + const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, path, spansLines: container === 'paragraph' } const emission = emitRun(nodes, 0, 0, context) if (!emission.ok) return emission if (emission.value.carry !== undefined) return emission -- 2.52.0 From ee10b63fe70c9490dfc91efe2d3857dd45956369 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 17:49:54 +0200 Subject: [PATCH 2/5] Tick 28, moving its text to todo-history.md --- todo-history.md | 21 +++++++++++++++++++++ todo.md | 12 +----------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/todo-history.md b/todo-history.md index 9b857e8..bdcf259 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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 taken: 4b settled that accounting the day this was filed, and reopening it is an ask rather 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` diff --git a/todo.md b/todo.md index d961bf8..740f8a6 100644 --- a/todo.md +++ b/todo.md @@ -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 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 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 @@ -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 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] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** ## The ADF inventory to cover -- 2.52.0 From 8b5e05f5a93452455f448ee6d7e2fac3d9fa5d84 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 17:57:13 +0200 Subject: [PATCH 3/5] Review nit: the attempt names the fallback it takes, so no arm is reached by elimination --- src/markdown/emit/inline-line.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index b05385b..ac12980 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -30,10 +30,7 @@ type InlineContext = { type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } -type LineAttempt = - | { carry: NodeRange; line?: undefined; openingLinkAsDirective?: undefined } - | { carry?: undefined; line?: undefined; openingLinkAsDirective: true } - | { carry?: undefined; line: string; openingLinkAsDirective?: undefined } +type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string } type LineFallbacks = { carried: Set; openingLinkAsDirective: boolean } @@ -80,12 +77,12 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con if (attempt.value.line !== undefined) { return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments }) } - const taken = takeFallback(fallbacks, attempt.value.carry ?? 'opening-link', path) + const taken = takeFallback(fallbacks, attempt.value.fallback, path) if (!taken.ok) return taken } } -// emitLine ends because each fallback is takeable once: a pass taking none re-emits the line it just emitted. +// A pass taking no fallback re-emits the line it just emitted, so refusing loses no spelling. function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result { 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) @@ -108,8 +105,8 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result { const assembled = assembleInlineLine(segments, container) - if (assembled.openingLinkAsDirective) return success({ openingLinkAsDirective: true }) - if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun }) + if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' }) + if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun }) for (const [index, single] of assembled.line.split('\n').entries()) { 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) -- 2.52.0 From 057729f830bdf690d7f3be2e7248f789c74c7eaa Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 17:59:34 +0200 Subject: [PATCH 4/5] Prose pass: the guard's comment goes, and one spelling states the coverage bar --- AGENTS.md | 5 ++--- src/markdown/emit/inline-line.ts | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9952a7e..3481522 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,7 +208,7 @@ wide `Result`, 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. 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, and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier, @@ -315,8 +315,7 @@ someone spells it or pins it. is owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and 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 rather than an argument every give-way site has to honour - (28). + 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 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 diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index ac12980..5872a9a 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -82,7 +82,6 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } } -// A pass taking no fallback re-emits the line it just emitted, so refusing loses no spelling. function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result { 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) -- 2.52.0 From 4b2f3b1bb383ac4de7a4d0adcd7b219651a460ba Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 18:03:55 +0200 Subject: [PATCH 5/5] AGENTS.md 8: a refusal from our own invariant takes the nearest existing code --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3481522..64645e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,10 @@ names the escape that unclaims the form claimed: `\!adf:` for a directive, block alike, `\|` for every pipe row. 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 -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 directions, one code covers them all and `path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever -- 2.52.0