From 4d0ad3728c36ec158e1eaf71d1beb7f9e2c188ba Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 08:01:27 +0200 Subject: [PATCH] Charge a carried node against the levels its position leaves, and part the causes one guard hid --- spec/flavour.md | 5 ++- src/json-value.ts | 4 +- src/markdown/directive-syntax.ts | 13 +++++++ src/markdown/emit/adf-to-markdown.test.ts | 8 +++- src/markdown/emit/adf-to-markdown.ts | 10 ++--- src/markdown/opaque-carry.ts | 44 ++++++++++------------ src/markdown/parse/inline-content.ts | 4 +- src/markdown/parse/markdown-to-adf.test.ts | 16 ++++++-- src/markdown/parse/markdown-to-adf.ts | 6 +-- src/markdown/text-directive.ts | 18 +++------ todo.md | 5 +-- 11 files changed, 74 insertions(+), 59 deletions(-) diff --git a/spec/flavour.md b/spec/flavour.md index ed2e966..d3dccb8 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -136,8 +136,9 @@ literal-text fallback — a typo that reparses as prose is the silent loss §2 r A node no section spells where it stands — an unknown type, or a known one whose spelling belongs to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold -a node the emitter spells natively: it restores verbatim, and the next emit spells it canonically -(AGENTS.md §2). Block and inline positions canonicalize differently, each fitting where it sits: +a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it +canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting +where it sits: - **Block position**: a fenced code block with info string `adf`, body = the node's JSON — two-space indent, object keys sorted. diff --git a/src/json-value.ts b/src/json-value.ts index 88b548a..5c3ef83 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -2,13 +2,13 @@ import { largestNesting } from './nesting.ts' export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } -export function isJsonValue(value: unknown): value is JsonValue { +export function isJsonValue(value: unknown, levels: number = largestNesting): value is JsonValue { const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }] while (pending.length > 0) { const entry = pending.pop() if (entry === undefined) continue const { depth, item } = entry - if (depth > largestNesting) return false + if (depth > levels) return false if (item === null || typeof item === 'boolean' || typeof item === 'string') continue if (typeof item === 'number') { if (!Number.isFinite(item)) return false diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 94dbf76..7005850 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -77,6 +77,15 @@ export function readInlineDirective(text: string, index: number): Read { + if (span.content !== undefined) return { fault: unsupportedNodeShape(`${span.name} takes no content`) } + const spelled = span.attributes.get(key) + if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupportedNodeShape(`${span.name} holds one ${key} attribute alone`) } + const spelling = spellStringAttribute(spelled.decoded) + if (spelling !== spelled.spelling) return { fault: unsupportedNodeShape(`${span.name} spells its ${key} attribute as ${key}=${spelling}`) } + return { value: spelled.decoded } +} + // Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending. export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined { if (!/[\n\r]/.test(text)) return undefined @@ -116,6 +125,10 @@ export function unknownDirectiveFault(name: string): ConvertFault { return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` } } +export function unsupportedNodeShape(message: string): ConvertFault { + return { code: 'unsupported-node-shape', message } +} + function keyOrder(left: string, right: string): number { if (left < right) return -1 return left > right ? 1 : 0 diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 2d2d399..9af0e95 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -167,11 +167,17 @@ test('breaks a mark run at the node it carries', () => { ) }) -test('refuses a carried node nested deeper than the emitter carries', () => { +test('refuses a carried node nested deeper than the levels its position leaves', () => { let node: AdfNode = { type: 'blockCard' } for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' } assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth') assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-nesting-depth') + let shallow: AdfNode = { type: 'blockCard' } + for (let depth = 0; depth < 200; depth += 1) shallow = { content: [shallow], type: 'blockCard' } + assert.ok(adfToMarkdown(document(shallow)).ok) + let quoted: AdfNode = shallow + for (let depth = 0; depth < 150; depth += 1) quoted = { content: [quoted], type: 'blockquote' } + assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth') }) test('refuses a node whose content model the canonical form cannot emit', () => { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 7125c34..d7cfef3 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -73,7 +73,7 @@ function interruptsParagraph(node: AdfNode): boolean { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { const directive = blockDirective(node.type) - if (directive === undefined) return commonMarkLine(carriedBlock(node, path)) + if (directive === undefined) return commonMarkLine(carriedBlock(node, path, depth)) const readable = readableBlock(node, path, depth) if (readable !== undefined) return readable return emitDirectiveBlock(node, directive, path, depth) @@ -114,9 +114,9 @@ function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: Conv if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) const content = node.content ?? [] if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) - if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path) + if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) const header = spellDirectiveHeader(node, directive) - if (header === undefined) return commonMarkLine(carriedBlock(node, path)) + if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) { return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) } @@ -154,10 +154,10 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { +function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { const slot = languageSlot(node.attrs?.['language']) const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language']) - if (header === undefined) return commonMarkLine(carriedBlock(node, path)) + if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) const text = codeBlockText(node, path) if (!text.ok) return text const info = slot.kind === 'fence' ? slot.info : '' diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 9117c31..a332b50 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -1,5 +1,4 @@ import type { AdfNode } from '../adf/document.ts' -import type { ConvertFault } from '../result.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' @@ -7,58 +6,57 @@ import { isAdfNode } from '../adf/document.ts' import { isJsonValue } from '../json-value.ts' import { fencedCodeBlock } from './backtick-runs.ts' import { largestNesting } from '../nesting.ts' -import { malformedDirective, spellAttributes, spellStringAttribute } from './directive-syntax.ts' +import { malformedDirective, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export const carryName = 'adf' const jsonAttribute = 'json' -export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result { - const json = carriedJson(node, 'two-space', path) +export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { + const json = carriedJson(node, 'two-space', path, largestNesting - depth) if (!json.ok) return json return success(fencedCodeBlock(carryName, json.value)) } export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { - const json = carriedJson(node, 'compact', path) + const json = carriedJson(node, 'compact', path, largestNesting) if (!json.ok) return json return success(`:${carryName}${spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]])}`) } -export function readCarriedBlock(body: string): Read { - return readCarriedJson(body, 'two-space') +export function readCarriedBlock(body: string, depth: number): Read { + return readCarriedJson(body, 'two-space', largestNesting - depth) } export function readCarriedInline(span: DirectiveSpan): Read | undefined { if (span.name !== carryName) return undefined - if (span.content !== undefined) return { fault: unsupported(`${carryName} takes no content`) } - const spelled = span.attributes.get(jsonAttribute) - if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${carryName} holds one ${jsonAttribute} attribute alone`) } - const spelling = spellStringAttribute(spelled.decoded) - if (spelling !== spelled.spelling) return { fault: unsupported(`${carryName} spells its ${jsonAttribute} attribute as ${jsonAttribute}=${spelling}`) } - return readCarriedJson(spelled.decoded, 'compact') + const spelled = readSoleStringAttribute(span, jsonAttribute) + if (spelled.fault !== undefined) return spelled + return readCarriedJson(spelled.value, 'compact', largestNesting) } -function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result { - if (!isJsonValue(node)) { - return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path) +function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result { + if (!isJsonValue(node, levels)) { + return failure('unsupported-nesting-depth', `a carried node's JSON nests the document deeper than the ${largestNesting} levels the emitter carries`, path) } return success(serializeCanonicalJson(node, spelling)) } -function readCarriedJson(raw: string, spelling: JsonSpelling): Read { +function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read { const parsed = parseJsonText(raw) if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') } const { value } = parsed - if (!isJsonValue(value)) { - return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries` } } + if (!isJsonValue(value, levels)) { + // Unbounded, the same walk parts the two causes one `false` holds (AGENTS.md §8). + if (!isJsonValue(value, Number.POSITIVE_INFINITY)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') } + return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests the input deeper than the ${largestNesting} levels the parser carries` } } } if (serializeCanonicalJson(value, spelling) !== raw) { const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' - return { fault: unsupported(`the opaque carry spells its node's JSON canonically: ${shape}`) } + return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) } } - if (!isAdfNode(value)) return { fault: unsupported("the opaque carry holds one ADF node's JSON") } + if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON") } return { value } } @@ -70,7 +68,3 @@ function parseJsonText(raw: string): { value: unknown } | undefined { return undefined } } - -function unsupported(message: string): ConvertFault { - return { code: 'unsupported-node-shape', message } -} diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 917cada..6df91e7 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -173,7 +173,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result { const text = readTextDirective(span) if (text?.fault !== undefined) return faulted(text.fault, scan.path) if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }) - const slot = slotNodes(scan, span.content) + const slot = slotContent(scan, span.content) if (!slot.ok) return slot const mark = readDirectiveMark(span.name, span.attributes, scan.path) if (mark !== undefined) { @@ -189,7 +189,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result { return success({ kind: 'nodes', nodes: [node.value] }) } -function slotNodes(scan: Scan, content: string | undefined): Result { +function slotContent(scan: Scan, content: string | undefined): Result { if (content === undefined) return success(undefined) const parsed = parseInline(content, scan.definitions, scan.path, false) if (!parsed.ok) return parsed diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 611d88c..5c44ba2 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -312,9 +312,19 @@ test('names the shape the inline carry reads alone', () => { assert.equal(content(markdownToAdf(':adf{json="null"}\n')), 'unsupported-node-shape: adf spells its json attribute as json=null') }) -test('holds a carried JSON value to the nesting the parser carries', () => { - const deep = `:adf{json="${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}"}\n` - assert.equal(content(markdownToAdf(deep)), `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries`) +test('holds a carried JSON value to the nesting its position leaves', () => { + const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}` + const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n` + const deeper = `unsupported-nesting-depth: a carried node's JSON nests the input deeper than the ${largestNesting} levels the parser carries` + assert.equal(content(markdownToAdf(`:adf{json="${nested(largestNesting + 2)}"}\n`)), deeper) + assert.equal(code(markdownToAdf(fence('', largestNesting + 1))), 'unsupported-node-shape') + assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper) +}) + +test('names the number no JSON spelling carries in an opaque carry', () => { + const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell' + assert.equal(content(markdownToAdf(':adf{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named) + assert.equal(content(markdownToAdf('```adf\n1e999\n```\n')), named) }) test('names the mark spelling no opaque carry sits inside', () => { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index eac2298..a98be1f 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -36,7 +36,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro case 'bulletList': return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) case 'code': - return codeBlockNode(block.language, block.text, path) + return codeBlockNode(block.language, block.text, path, depth) case 'directive': return directiveNode(block, definitions, path, depth) case 'fault': @@ -134,9 +134,9 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef return success({ ...node, content }) } -function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result { +function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result { if (language === carryName) { - const carried = readCarriedBlock(text) + const carried = readCarriedBlock(text, depth) if (carried.fault !== undefined) return faulted(carried.fault, path) return success(carried.value) } diff --git a/src/markdown/text-directive.ts b/src/markdown/text-directive.ts index f3d7eec..616b593 100644 --- a/src/markdown/text-directive.ts +++ b/src/markdown/text-directive.ts @@ -1,6 +1,5 @@ -import type { ConvertFault } from '../result.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts' -import { spellAttributes, spellLeafDirective, spellStringAttribute } from './directive-syntax.ts' +import { readSoleStringAttribute, spellAttributes, spellLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' const name = 'text' const whitespaceRun = /^(?:[ \t]+|\n+)$/ @@ -13,15 +12,8 @@ export function spellTextDirective(text: string): string { export function readTextDirective(span: DirectiveSpan): Read | undefined { if (span.name !== name) return undefined - if (span.content !== undefined) return { fault: unsupported(`${name} takes no content`) } - const spelled = span.attributes.get(name) - if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${name} holds one ${name} attribute alone`) } - const spelling = spellStringAttribute(spelled.decoded) - if (spelling !== spelled.spelling) return { fault: unsupported(`${name} spells its ${name} attribute as ${name}=${spelling}`) } - if (!whitespaceRun.test(spelled.decoded)) return { fault: unsupported(`${name} spells one run of spaces and tabs, or one run of newlines`) } - return { value: spelled.decoded } -} - -function unsupported(message: string): ConvertFault { - return { code: 'unsupported-node-shape', message } + const spelled = readSoleStringAttribute(span, name) + if (spelled.fault !== undefined) return spelled + if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines`) } + return spelled } diff --git a/todo.md b/todo.md index 5d5d18a..681631c 100644 --- a/todo.md +++ b/todo.md @@ -29,9 +29,8 @@ The numbering is the order the work was planned in, not the order it ships. - [x] **2e5 — Combined documents and the collision property.** - [x] **2f — The attributes CommonMark cannot hold.** - [ ] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and - the runner grows a parse half as they do: `parsingDirectories` beside `emittingDirectories`, a - round-trip directory joining it only once every fixture in it reads back to its document, - and readers for `corpus/normalization/` (setext, indented code, loose lists, `*`/`+` + the runner grows a parse half as they do: readers for `corpus/normalization/` (setext, + indented code, loose lists, `*`/`+` bullets, entity references, soft wraps — one-way, the markdown not canonical) and `corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`