From d6756ea5e66500117c85411241c4c7c7c54f2e47 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 19 Sep 2026 13:37:58 +0200 Subject: [PATCH] A read below the depth that filled the memo re-spells, so the depth guards still run --- AGENTS.md | 9 ++++++--- src/markdown/emit/adf-to-markdown.ts | 7 +++++-- src/markdown/parse/markdown-to-adf.test.ts | 12 +++++++++++- todo-history.md | 17 +++++++++++------ 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d54004..8bae8d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -316,9 +316,12 @@ someone spells it or pins it. - The parse keeps each node's readable spelling in a memo, so the `commonMarkSpelling` ask spells a node once rather than once per level above it (18). The node reference is the key, which holds because the parse builds one object per position; `adfToMarkdown` passes no memo, where a - consumer's document may hold one node at two positions (4b). A kept spelling rebases: `text` and - `spelling` carry no depth, `headroom` is affine in it, and a reuse sits at or above the depth that - filled it. Only what succeeded is kept, so no path minted at another position is ever read. + consumer's document may hold one node at two positions (4b). `text` and `spelling` carry no depth + and `headroom` is affine in it, so a read above the depth that filled the entry rebases; a read + below re-spells, because a hit skips the depth guards the walk it replaces runs and an ordered + list past the marker cap gives way, spending two emitter levels where the parser spent one. A + give-way is kept too and serves any depth, reading the node's shape alone. Only what succeeded is + kept, so no path minted at another position is ever read. - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states unrepresentable. diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index ac1a936..6eb0da3 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -103,8 +103,11 @@ export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result | undefined { const kept = memo?.get(node) - // A reuse sits no deeper than the fill it reads, so the kept headroom only ever grows (AGENTS.md §11). - if (kept !== undefined) return kept.block === undefined ? undefined : success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth }) + if (kept !== undefined) { + if (kept.block === undefined) return undefined + // A read below the fill would skip the depth guards the walk it replaces runs (AGENTS.md §11). + if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth }) + } const spelled = spellReadableBlock(node, path, depth, memo) if (spelled === undefined) memo?.set(node, { block: undefined, depth }) else if (spelled.ok) memo?.set(node, { block: spelled.value, depth }) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 32da72d..62d88ae 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -672,7 +672,7 @@ test('refuses input nested deeper than the parser carries', () => { assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth') assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }]) const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('') - const repeated = (name: string): string[] => Array.from({ length: largestNesting }, () => name) + const repeated = (name: string, levels: number = largestNesting): string[] => Array.from({ length: levels }, () => name) assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok) assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 }) assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth') @@ -680,6 +680,16 @@ test('refuses input nested deeper than the parser carries', () => { const directiveLists = largestNesting / 2 assert.ok(markdownToAdf(listed(directiveLists)).ok) assert.equal(code(markdownToAdf(listed(directiveLists + 1))), 'unsupported-nesting-depth') + const asking = (body: string): string => `!adf:bulletList\n!adf:listItem\n${body}!adf:/listItem\n!adf:/bulletList\n` + // Two items past the largest list marker leave the list no readable spelling, so the emitter + // walks it as list plus item where the parser counted one level. + const overflowing = (body: string): string => { + const marker = '999999999. ' + return `${marker}${body.replace(/^(?!$)/gm, ' '.repeat(marker.length)).slice(marker.length)}\n${marker}z\n` + } + const overflowed = (panels: number): string => asking(overflowing(overflowing(asking(`---\n${nest(repeated('panel', panels), 'Part.\n')}`)))) + assert.equal(code(markdownToAdf(overflowed(493))), 'unsupported-node-shape') + assert.equal(code(markdownToAdf(overflowed(494))), 'unsupported-nesting-depth') assert.ok(markdownToAdf(`${'- '.repeat(largestNesting)}a\n`).ok) assert.equal(code(markdownToAdf(`${'- '.repeat(largestNesting + 1)}a\n`)), 'unsupported-nesting-depth') }) diff --git a/todo-history.md b/todo-history.md index e8ea4a9..90925aa 100644 --- a/todo-history.md +++ b/todo-history.md @@ -837,12 +837,17 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li rejected: it reports the outer offender where the build reports the inner one (the maintainer, 2026-09-18). **Measured** (2026-09-19): the parse keeps each node's readable spelling (AGENTS.md §11), and - 250 nested directive lists fall from 1.22 s to 0.07 s at 16.1 kB and from 5.20 s to 0.19 s at - 261 kB; a panel between every pair of lists 1.00 s to 0.07 s, an opaque carry in every item - 1.08 s to 0.04 s. The two depth-boundary tests are what pin the rebase — dropping it reddens - both — so no figure enters the gate (§14), as 4c settled for a behaviour-preserving cost fix. - 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. + 250 nested directive lists fall from 1.22 s to 0.04 s at 16.1 kB and from 5.20 s to 0.12 s at + 261 kB; a panel between every pair of lists 1.00 s to 0.05 s, an opaque carry in every item + 1.08 s to 0.03 s. No figure enters the gate (§14), as 4c settled for a behaviour-preserving + cost fix; what the gate holds is the refusal. The rebase's sign is pinned by the two + depth-boundary tests already there, its magnitude by a third case the review found: an ordered + list past the marker cap gives way, so the emitter spends two levels where the parser spent + one, and two such lists put a read below the depth that filled its entry — which a hit would + answer without the depth guards the walk runs, naming a different refusal at a different path. + That read re-spells (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. ## 5 — Ship `0.1.0`