From a5b8a4654f680fd871daf0dbc5bd7a0a18be4820 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 11:42:43 +0200 Subject: [PATCH] Fix the false claims round one found, and refuse the content a code span dropped --- AGENTS.md | 9 +++-- .../round-trip/combinations/carry-marks.json | 35 +++++++++++++++++++ corpus/round-trip/combinations/carry-marks.md | 2 ++ spec/flavour.md | 14 ++++---- src/adf-to-markdown.test.ts | 5 ++- src/adf-to-markdown.ts | 8 ++--- src/markdown-inline.ts | 3 +- todo.md | 2 +- 8 files changed, 59 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d0eae7d..a5df455 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,8 +82,8 @@ closed list a consumer may switch exhaustively, the message free text, the path from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the cause; where one cause recurs across node types, one code covers them all and `path` and `message` -say which. A cause the carry answers gets no code: no mark refuses, since a mark no spelling writes -rides the carry with its node. +say which. A cause the carry answers gets no code: a mark no spelling writes rides the carry with +its node. ## 9. Release automation @@ -154,9 +154,8 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema validation or exported validator — a refusal that keeps the round-trip is not schema validation, so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no -streaming APIs, no performance budget — -conversions are O(n), real documents are kilobytes. A CLI is a later goal (`todo.md`), not a -non-goal. +streaming APIs, no performance budget — real documents are kilobytes. A CLI is a later goal +(`todo.md`), not a non-goal. ## 15. The working loop diff --git a/corpus/round-trip/combinations/carry-marks.json b/corpus/round-trip/combinations/carry-marks.json index 7ebbf57..06d2dfe 100644 --- a/corpus/round-trip/combinations/carry-marks.json +++ b/corpus/round-trip/combinations/carry-marks.json @@ -87,6 +87,41 @@ } ], "type": "paragraph" + }, + { + "content": [ + { + "text": "a", + "type": "text" + }, + { + "marks": [ + { + "type": "em" + } + ], + "text": ".", + "type": "text" + }, + { + "text": "b", + "type": "text" + }, + { + "marks": [ + { + "type": "strong" + } + ], + "text": ",", + "type": "text" + }, + { + "text": "c", + "type": "text" + } + ], + "type": "paragraph" } ], "type": "doc", diff --git a/corpus/round-trip/combinations/carry-marks.md b/corpus/round-trip/combinations/carry-marks.md index 53accb3..bcd5077 100644 --- a/corpus/round-trip/combinations/carry-marks.md +++ b/corpus/round-trip/combinations/carry-marks.md @@ -7,3 +7,5 @@ Reviewed :adf{json="{\"marks\":[{\"attrs\":{\"annotationType\":\"inlineComment\" :adf{json="{\"marks\":[{\"type\":\"code\"}],\"text\":\"a\\nb\",\"type\":\"text\"}"} :adf{json="{\"marks\":[{\"type\":\"link\"}],\"text\":\"Release notes\",\"type\":\"text\"}"} + +a:adf{json="{\"marks\":[{\"type\":\"em\"}],\"text\":\".\",\"type\":\"text\"}"}b:adf{json="{\"marks\":[{\"type\":\"strong\"}],\"text\":\",\",\"type\":\"text\"}"}c diff --git a/spec/flavour.md b/spec/flavour.md index dca03a0..bb1da93 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -386,12 +386,14 @@ restores the array, not a set — and opens each spelling once over the longest inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling. -An inline node whose marks no nesting spells — a mark type not listed here, an attribute no -spelling holds or one a spelling needs and the mark lacks, an order putting a code span outside -another mark, `code` over anything but a text node or over text holding a newline, or a spelling -CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`) — rides -the inline carry whole. An opaque carry inside a mark spelling is a -named error in input: the carry restores its node exactly, marks included (AGENTS.md §3). +An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its +spelling does not list, a value that is not the spelling's type, an attribute the spelling needs +and the mark lacks, an order putting a code span outside another mark, `code` over anything but a +text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or +close where the run sits (`un**-real**istic`) — rides the inline carry whole. A value the spelling +holds but CommonMark cannot write — a link destination or title — is a named error instead. An +opaque carry inside a mark spelling is a named error in input: the carry restores its node +exactly, marks included (AGENTS.md §3). ``` :textColor[**Overdue**]{color="#ae2e24"}, H:subsup[2]{type=sub}O, :underline[signed]. diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index 7a5bff1..cc38962 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -317,9 +317,12 @@ test('refuses the characters CommonMark rewrites', () => { assert.equal(code(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape') }) -test('refuses a text node carrying no text at all', () => { +test('refuses a text node the spelling would empty out', () => { + const nested: AdfNode[] = [{ text: 'lost', type: 'text' }] assert.equal(code(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), 'unsupported-node-shape') }) test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => { diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index 71add5e..0287dec 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -110,13 +110,11 @@ function commonMarkContainer(body: Result): Result { function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) + const content = node.content ?? [] + if (directive.body === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) const header = spellDirectiveHeader(node, directive) if (header === undefined) return commonMarkLine(carriedBlock(node, path)) - const content = node.content ?? [] - if (directive.body === 'none') { - if (content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) - return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) - } + if (directive.body === 'none') return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) if (!body.ok) return body const fenceColons = Math.max(3, body.value.fenceColons + 1) diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index dd94a84..ae4f211 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -48,7 +48,7 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver return attempt.ok ? attempt.value.line : undefined } -// A demand names a run no spelling holds, and a carried node joins no run, so every pass carries one more node. +// A demand names a run no spelling holds, and a carried node joins no run, so every pass carries at least one more node. function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const carried = new Set() for (;;) { @@ -262,6 +262,7 @@ function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange for (const node of nodes) { if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range }) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path) + if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path) text += node.text } if (/[\n\r]/.test(text)) return success({ carry: range }) diff --git a/todo.md b/todo.md index f17a9fd..e0ed60a 100644 --- a/todo.md +++ b/todo.md @@ -109,7 +109,7 @@ detail is settled at its own milestone. which leaves the refusals a container's own spelling owns. The flanking trigger 2e2 added to that list is the odd one out: `unspellableMark` finds it after assembly and names a mark type against the line's path, so the failing run needs identifying before - the carry can replace the refusal `corpus/unspellable/mark-inside-word` pins. + the carry can replace the refusal `mark-inside-word` pinned. - [ ] **2e5 — Combined documents and the collision property.** Documents combining nodes rather than isolating one, and the gate's collision property: no two corpus documents may emit the same bytes — one spelling for two documents is a round-trip break no parser can undo,