From 67df1e2f4ba2cbf48c682a0cfa871346bf31dad6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 08:53:00 +0200 Subject: [PATCH] 5c: match the mark set's nesting on both sides of the round-trip --- AGENTS.md | 6 ++-- publish.sh | 6 ++-- src/adf/document.test.ts | 10 ++++--- src/adf/document.ts | 13 +++++---- src/markdown/emit/adf-to-markdown.test.ts | 34 +++++++++++++++------- src/markdown/parse/markdown-to-adf.test.ts | 1 + todo.md | 6 +++- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86b9ee8..1b88a31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,8 +133,10 @@ shape, and threading a path to the ninth — a malformed node anywhere in the tr manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names the violation instead. Depth is not one of the nine: `adfDocumentFault` returns the code with the message, so an attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as -it already is from the parser, both directions refusing the same value — the count is the levels -an attribute holds, never the `attrs` object holding it. `isAdfDocument` is true for a depth fault: +it already is from the parser, both directions refusing the same value. A node's attribute is +counted from the value itself, never from the `attrs` object holding it; a mark's is counted three +levels in, because the block directive spells the whole mark set as one JSON attribute and the +parser reads the value at the bottom of array, mark and `attrs`. `isAdfDocument` is true for a depth fault: a deep document is a document, as the 2000-level blocks and the 600-deep marks the guard already waves through are, and depth is the walks' answer rather than the shape's. A non-finite number stays parted where depth is joined: the parse says `unsupported-node-shape` because the markdown is diff --git a/publish.sh b/publish.sh index 5a3694b..d0f5999 100755 --- a/publish.sh +++ b/publish.sh @@ -19,9 +19,11 @@ fi name=$(read_field name) version=$(read_field version) +published=$(published_version "$name" "$version") +tagged=$(git ls-remote --tags origin "v$version") # Both steps observe their own end state, so a partial run converges on the next push to main. -if [ -z "$(published_version "$name" "$version")" ]; then +if [ -z "$published" ]; then : "${NPM_TOKEN:?the publish needs NPM_TOKEN}" in_image "$node_image" npm ci in_image "$node_image" npm run build @@ -29,7 +31,7 @@ if [ -z "$(published_version "$name" "$version")" ]; then 'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public' fi -if [ -z "$(git ls-remote --tags origin "v$version")" ]; then +if [ -z "$tagged" ]; then git tag "v$version" git push origin "v$version" fi diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 9f2cfc9..b72bfc9 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -61,14 +61,16 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => { }) test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => { - const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` + const deeper = (key: string, type: string, levels: number = largestNesting): string => + `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted') assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph')) assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth') assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true) - const marked = { content: [{ marks: [{ attrs: { a: nested(largestNesting + 1) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 } - assert.equal(fault(marked), deeper('a', 'link')) - assert.equal(isAdfDocument(marked), true) + const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }) + assert.equal(fault(marked(largestNesting - 3)), 'accepted') + assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3)) + assert.equal(isAdfDocument(marked(largestNesting - 2)), true) }) test('accepts the JSON values an attribute may hold', () => { diff --git a/src/adf/document.ts b/src/adf/document.ts index 46960d8..c4c72cf 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -23,6 +23,9 @@ export type AdfDocument = { version: number } +// A block directive spells the whole mark set as one JSON attribute, so a mark's value sits three levels inside it. +const markAttributeNesting = largestNesting - 3 + const documentKeys = ['content', 'type', 'version'] const markKeys = ['attrs', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] @@ -44,8 +47,8 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined { return nestingFault(content) } -export function attributeNestingMessage(key: string, type: string): string { - return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` +export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string { + return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` } export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { @@ -101,15 +104,15 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined { function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined { for (const mark of marks ?? []) { - const fault = attributesFault(mark.attrs, mark.type) + const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting) if (fault !== undefined) return fault } return undefined } -function attributesFault(attrs: AdfAttributes | undefined, type: string): ConvertFault | undefined { +function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined { for (const [key, value] of Object.entries(attrs ?? {})) { - if (overNested(value)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type) } + if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) } } return undefined } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 3c9df3e..1741901 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -336,19 +336,33 @@ test('refuses marks and attributes nested deeper than the emitter carries', () = assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth') let attrs: AdfMark['attrs'] = { depth: 'x' } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } - const deeper = (key: string, type: string): string => - `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` - assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em')) - const card = (levels: number): AdfNode => { - let data: JsonValue = 1 - for (let level = 0; level < levels; level += 1) data = [data] - return { attrs: { data, url: 'https://example.com/a' }, type: 'inlineCard' } + const deeper = (key: string, type: string, levels: number = largestNesting): string => + `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` + const nested = (levels: number): JsonValue => { + let value: JsonValue = 1 + for (let level = 0; level < levels; level += 1) value = [value] + return value } + const card = (levels: number): AdfNode => ({ attrs: { data: nested(levels), url: 'https://example.com/a' }, type: 'inlineCard' }) + // A block directive spells the mark set as one JSON attribute, so a mark's value is read three levels in. + const marked = (levels: number): AdfNode => ({ + attrs: { panelType: 'info' }, + content: [paragraph({ text: 'x', type: 'text' })], + marks: [{ attrs: { deep: nested(levels) }, type: 'em' }], + type: 'panel', + }) + const roundTrips = (node: AdfNode): void => { + const spelled = adfToMarkdown(document(node)) + assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) + assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) }) + } + + assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3)) assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard')) assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), []) - const spelled = adfToMarkdown(document(paragraph(card(largestNesting)))) - assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) - assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(paragraph(card(largestNesting))) }) + roundTrips(paragraph(card(largestNesting))) + assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3)) + roundTrips(marked(largestNesting - 3)) }) test('escapes a literal delimiter that would merge with an emitted one', () => { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 8173638..8e8f2d4 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -441,6 +441,7 @@ test('names the depth an attribute value nests past, never the kind the JSON rea `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${nested(largestNesting + 1)}"}\n:::\n`)), deeper('colwidth', 'tableCell')) assert.equal(content(markdownToAdf(`::rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule')) + assert.equal(content(markdownToAdf(`::rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule')) assert.equal(content(markdownToAdf(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number') }) diff --git a/todo.md b/todo.md index ec81b83..0140fa1 100644 --- a/todo.md +++ b/todo.md @@ -95,7 +95,11 @@ The numbering is the order the work was planned in, not the order it ships. fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a caller may hold one node object at two positions, where the cached depth and path are another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than - wrong until the patch. + wrong until the patch. `adfDocumentFault` is the second site to look at: `isNodeArray` reads + every node and attribute value, then `nestingFault` reads them again, so the emit entry the + export persona runs in bulk walks the document twice. Both walks are linear, so this is a + constant factor rather than 4b's class change, and the parting is what gives depth its own + code (§8) — measure before joining them back. - [ ] **4c — The scanning rule's remaining sites (`0.1.1`).** A trailing-anchored regex re-walks its run from every start position, so an interior whitespace run costs quadratic time rather than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk