Answer the second stability pass: the emitter's own refusal, and the comparator's third arm
CI / gate (push) Successful in 5s

This commit is contained in:
2026-09-01 14:34:02 +02:00
parent dcb4d67b6c
commit fba826ed89
7 changed files with 24 additions and 13 deletions
+2 -1
View File
@@ -172,7 +172,8 @@ both answer to the round-trip corpus and to nothing else where a node has no fix
directions must answer alike — whether a list marker interrupts a paragraph — is one function directions must answer alike — whether a list marker interrupts a paragraph — is one function
there too, never a copy per direction, however conservative the copy would be. Where the rule is there too, never a copy per direction, however conservative the copy would be. Where the rule is
the emitter's own choice, input consults it rather than restating it: the parser asks the emitter's own choice, input consults it rather than restating it: the parser asks
`spellsCommonMark` which form the emitter picks, so no fixture the emitter writes can be refused. `commonMarkSpelling` which form the emitter picks, so no fixture the emitter writes can be
refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
format spells the narrowed value. A spelling that re-checks the type is the check's second copy. format spells the narrowed value. A spelling that re-checks the type is the check's second copy.
Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so
+2 -2
View File
@@ -157,8 +157,8 @@ included, is an error result naming it. The flavour never emits raw HTML.
The directive name is always the ADF node type. A container's body is the node's `content`; a The directive name is always the ADF node type. A container's body is the node's `content`; a
leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is
written; validity against ADF's content models stays the author's business (AGENTS.md §14). It written; validity against ADF's content models stays the author's business (AGENTS.md §14). It
parses only in the form the emitter picks, though: a directive spelling a node CommonMark holds parses only in the form the emitter picks, though: a directive spelling a node the emitter would
is a named error, the per-node plain-versus-directive choice below read backwards. have written as CommonMark is a named error.
Each section lists attributes as `name (type)`. A parenthesized value set documents what real Each section lists attributes as `name (type)`. A parenthesized value set documents what real
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
+2 -1
View File
@@ -107,7 +107,8 @@ export function unknownDirectiveFault(name: string): ConvertFault {
} }
function keyOrder(left: string, right: string): number { function keyOrder(left: string, right: string): number {
return left < right ? -1 : 1 if (left < right) return -1
return left > right ? 1 : 0
} }
function quote(text: string): string { function quote(text: string): string {
+4 -3
View File
@@ -86,9 +86,10 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
return emitDirectiveBlock(node, directive, path, depth) return emitDirectiveBlock(node, directive, path, depth)
} }
// The plain-versus-directive choice is the emitter's; input reads it back rather than restating it (AGENTS.md §11). export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number): Result<null> | undefined {
export function spellsCommonMark(node: AdfNode, path: ConvertErrorPath, depth: number): boolean { const readable = readableBlock(node, path, depth)
return readableBlock(node, path, depth) !== undefined if (readable === undefined) return undefined
return readable.ok ? success(null) : readable
} }
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
+6 -1
View File
@@ -148,7 +148,6 @@ test('reads the three directive forms into the nodes the tables name', () => {
assert.deepEqual(content(markdownToAdf('Part:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('Part:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }])
}) })
// The emitter's plain-versus-directive choice, read backwards: only the form it picks parses.
test('names the directive form a node CommonMark spells refuses', () => { test('names the directive form a node CommonMark spells refuses', () => {
const named = (type: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form` const named = (type: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form`
assert.equal(content(markdownToAdf('::rule\n')), named('rule')) assert.equal(content(markdownToAdf('::rule\n')), named('rule'))
@@ -160,6 +159,12 @@ test('names the directive form a node CommonMark spells refuses', () => {
assert.deepEqual(content(markdownToAdf('::::bulletList\n:::listItem\n---\n:::\n::::\n')), [bulletList(item({ type: 'rule' }))]) assert.deepEqual(content(markdownToAdf('::::bulletList\n:::listItem\n---\n:::\n::::\n')), [bulletList(item({ type: 'rule' }))])
}) })
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
test('gives back the refusal the CommonMark spelling itself raises', () => {
const nested = '::::::::bulletList\n:::::::listItem\n---\n\n::::::bulletList\n:::::listItem\n---\n\n::::bulletList\n:::listItem\n---\n:::\n::::\n:::::\n::::::\n:::::::\n::::::::\n'
assert.equal(code(markdownToAdf(nested)), 'unspelled-block-separation')
})
test('names the directive name no node reads back to', () => { test('names the directive name no node reads back to', () => {
assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name') assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name')
assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node') assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node')
+4 -4
View File
@@ -3,12 +3,12 @@ import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts' import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryName } from '../opaque-carry.ts' import { carryName } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
import { spellsCommonMark } from '../emit/adf-to-markdown.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> { export function markdownToAdf(markdown: string): Result<AdfDocument> {
const parsed = parseBlocks(markdown) const parsed = parseBlocks(markdown)
@@ -58,10 +58,10 @@ function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path
if (!read.ok) return read if (!read.ok) return read
const built = directiveBody(read.value, block.blocks, definitions, path, depth) const built = directiveBody(read.value, block.blocks, definitions, path, depth)
if (!built.ok) return built if (!built.ok) return built
if (spellsCommonMark(built.value, path, depth)) { const readable = commonMarkSpelling(built.value, path, depth)
if (readable === undefined) return built
if (!readable.ok) return readable
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path) return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
}
return built
} }
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
+3
View File
@@ -335,6 +335,9 @@ detail is settled at its own milestone.
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here. `corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
The emitter's other refusals survive on causes no fixture in that directory covers, so The emitter's other refusals survive on causes no fixture in that directory covers, so
3k's one-list pass is where they get fixtures or the directory goes. 3k's one-list pass is where they get fixtures or the directory goes.
Losing that cause closes the last shape input accepts and emit refuses — a CommonMark block
beside a directive one inside a list item — so the parse-then-emit fixpoint the README
promises holds from here rather than only for what the emitter wrote.
- [ ] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's - [ ] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's
`text` attribute and the error a slot holding anything but one unmarked text node is; the `text` attribute and the error a slot holding anything but one unmarked text node is; the
`:text{text="…"}` whitespace spelling; the four directive marks and their nesting order, `:text{text="…"}` whitespace spelling; the four directive marks and their nesting order,