5c: match the mark set's nesting on both sides of the round-trip
CI / gate (push) Successful in 8m35s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-04 08:53:00 +02:00
parent 0dfcc0f9ca
commit 67df1e2f4b
7 changed files with 52 additions and 24 deletions
+4 -2
View File
@@ -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 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 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 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 it already is from the parser, both directions refusing the same value. A node's attribute is
an attribute holds, never the `attrs` object holding it. `isAdfDocument` is true for a depth fault: 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 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 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 stays parted where depth is joined: the parse says `unsupported-node-shape` because the markdown is
+4 -2
View File
@@ -19,9 +19,11 @@ fi
name=$(read_field name) name=$(read_field name)
version=$(read_field version) 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. # 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}" : "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
in_image "$node_image" npm ci in_image "$node_image" npm ci
in_image "$node_image" npm run build 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' 'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
fi fi
if [ -z "$(git ls-remote --tags origin "v$version")" ]; then if [ -z "$tagged" ]; then
git tag "v$version" git tag "v$version"
git push origin "v$version" git push origin "v$version"
fi fi
+6 -4
View File
@@ -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', () => { 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))), 'accepted')
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph')) assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth') assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true) 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 } 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), deeper('a', 'link')) assert.equal(fault(marked(largestNesting - 3)), 'accepted')
assert.equal(isAdfDocument(marked), true) 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', () => { test('accepts the JSON values an attribute may hold', () => {
+8 -5
View File
@@ -23,6 +23,9 @@ export type AdfDocument = {
version: number 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 documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type'] const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
@@ -44,8 +47,8 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
return nestingFault(content) return nestingFault(content)
} }
export function attributeNestingMessage(key: string, type: string): string { export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string {
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
} }
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { 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 { function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined {
for (const mark of marks ?? []) { 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 if (fault !== undefined) return fault
} }
return undefined 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 ?? {})) { 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 return undefined
} }
+24 -10
View File
@@ -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') assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
let attrs: AdfMark['attrs'] = { depth: 'x' } let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
const deeper = (key: string, type: string): string => const deeper = (key: string, type: string, levels: number = largestNesting): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em')) const nested = (levels: number): JsonValue => {
const card = (levels: number): AdfNode => { let value: JsonValue = 1
let data: JsonValue = 1 for (let level = 0; level < levels; level += 1) value = [value]
for (let level = 0; level < levels; level += 1) data = [data] return value
return { attrs: { data, url: 'https://example.com/a' }, type: 'inlineCard' }
} }
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.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), []) assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
const spelled = adfToMarkdown(document(paragraph(card(largestNesting)))) roundTrips(paragraph(card(largestNesting)))
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3))
assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(paragraph(card(largestNesting))) }) roundTrips(marked(largestNesting - 3))
}) })
test('escapes a literal delimiter that would merge with an emitted one', () => { test('escapes a literal delimiter that would merge with an emitted one', () => {
@@ -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` `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(`:::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="${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') assert.equal(content(markdownToAdf(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number')
}) })
+5 -1
View File
@@ -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 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 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 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 - [ ] **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 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 than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk