1 Commits

Author SHA1 Message Date
lilleman e964abaa4b 20 - every spelling tried ahead of a directive form takes the try prefix
CI / gate (push) Successful in 30s
CI / publish (push) Successful in 5s
2026-09-21 21:20:47 +02:00
5 changed files with 59 additions and 37 deletions
+6 -9
View File
@@ -306,15 +306,12 @@ someone spells it or pins it.
`matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and `matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and
opener search stay whole: broken into named steps they drift from the algorithm being faithful is opener search stay whole: broken into named steps they drift from the algorithm being faithful is
the whole point of. the whole point of.
- A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe - A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only general form fails on the same node (20): refusing there refuses a document the general form
where the general form fails on the same node. Refusing there refuses a document the general spells, so a refusal the general form does not share belongs in the general form or nowhere. A
form spells, so a refusal the general form does not share belongs in the general form or readable spelling that must spell its subtree before it can give way — the list, whose
nowhere — save the nested list a tight spelling would swallow, whose refusal the thematic-break first line and blank lines exist only spelled — hands that one walk to the general
tight-versus-blank answer owns (`todo.md` 2b). A readable spelling that must spell its subtree form instead: giving way after the walk walks again at every level, doubling per level (4b).
before it can give way — the list, whose thematic-break first line and blank lines exist only
spelled — hands that one walk to the general form instead: giving way after the walk walks
again at every level, doubling per level (4b).
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
`Result` rather than the stack overflow that waits near 2000. A level is one block-list `Result` rather than the stack overflow that waits near 2000. A level is one block-list
+16 -16
View File
@@ -26,7 +26,7 @@ type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
type WalkedItem = { node: AdfNode; walk: Walk } type WalkedItem = { node: AdfNode; walk: Walk }
const largestListMarker = 999999999 const largestListMarker = 999999999
// Bare because emitList admits no item carrying attributes, marks or text. // Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '') const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
export function adfToMarkdown(document: AdfDocument): Result<string> { export function adfToMarkdown(document: AdfDocument): Result<string> {
@@ -115,13 +115,13 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, mem
} }
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined { function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
if (node.type === 'blockquote') return emitBlockquote(node, path, depth, memo) if (node.type === 'blockquote') return tryBlockquote(node, path, depth, memo)
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth, memo) if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, memo)
if (node.type === 'codeBlock') return emitCodeBlock(node, path) if (node.type === 'codeBlock') return tryCodeBlock(node, path)
if (node.type === 'heading') return emitHeading(node, path) if (node.type === 'heading') return tryHeading(node, path)
if (node.type === 'mediaSingle') return readableText(tryImage(node, path)) if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
if (node.type === 'paragraph') return emitParagraph(node, path) if (node.type === 'paragraph') return tryParagraph(node, path)
if (node.type === 'rule') return emitRule(node) if (node.type === 'rule') return readableText(tryRule(node))
if (node.type === 'table') return readableText(tryPipeTable(node, path)) if (node.type === 'table') return readableText(tryPipeTable(node, path))
return undefined return undefined
} }
@@ -164,7 +164,7 @@ function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: str
return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom)) return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom))
} }
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined { function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined if (!carriesOnly(node, [])) return undefined
const inner = walkBlocks(nodeContent(node), path, depth + 1, memo) const inner = walkBlocks(nodeContent(node), path, depth + 1, memo)
if (!inner.ok) return inner if (!inner.ok) return inner
@@ -175,7 +175,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, me
return success(commonMarkText(text, inner.value.headroom)) return success(commonMarkText(text, inner.value.headroom))
} }
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['language'])) return undefined if (!carriesOnly(node, ['language'])) return undefined
const slot = languageSlot(nodeAttrs(node)['language']) const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined if (slot.kind === 'attribute') return undefined
@@ -214,7 +214,7 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
return success(text) return success(text)
} }
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return undefined if (!carriesOnly(node, ['level'])) return undefined
const level = nodeAttrs(node)['level'] const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
@@ -226,7 +226,7 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock
return success(commonMarkText(`${hashes} ${line.value}`)) return success(commonMarkText(`${hashes} ${line.value}`))
} }
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined { function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList' const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
const items = nodeContent(node) const items = nodeContent(node)
@@ -243,7 +243,7 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: Sp
} }
const lines: string[] = [] const lines: string[] = []
for (const [offset, item] of walked.entries()) { for (const [offset, item] of walked.entries()) {
const line = listItemLines(item.walk.blocks, ordered ? `${start + offset}. ` : '- ') const line = tryListItemLines(item.walk.blocks, ordered ? `${start + offset}. ` : '- ')
if (line === undefined) { if (line === undefined) {
if (headroom < 1) return tooDeep(path) if (headroom < 1) return tooDeep(path)
return emitDirectiveBlock(node, ordered ? blockDirectives.orderedList : blockDirectives.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 })) return emitDirectiveBlock(node, ordered ? blockDirectives.orderedList : blockDirectives.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 }))
@@ -265,7 +265,7 @@ function listStart(node: AdfNode, items: number): number | undefined {
return start + items - 1 > largestListMarker ? undefined : start return start + items - 1 > largestListMarker ? undefined : start
} }
function listItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined { function tryListItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined {
const inner = joinBlocks(blocks, 'list-item') const inner = joinBlocks(blocks, 'list-item')
if (inner === '') return marker.trimEnd() if (inner === '') return marker.trimEnd()
const body = inner.split('\n') const body = inner.split('\n')
@@ -276,7 +276,7 @@ function listItemLines(blocks: readonly PlacedBlock[], marker: string): string |
return lines.join('\n') return lines.join('\n')
} }
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
const content = nodeContent(node) const content = nodeContent(node)
if (content.length === 0 || !carriesOnly(node, [])) return undefined if (content.length === 0 || !carriesOnly(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path) const line = emitInlineLine(content, 'paragraph', path)
@@ -284,7 +284,7 @@ function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
return success(commonMarkText(line.value)) return success(commonMarkText(line.value))
} }
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined { function tryRule(node: AdfNode): string | undefined {
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
return success(commonMarkText('---')) return '---'
} }
+2 -3
View File
@@ -258,7 +258,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
if (attributes === undefined) return success({ carry: range }) if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
const link = spelling.kind === 'link' ? emitLink(nodes, mark, depth, range, context) : undefined const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
if (link !== undefined) return link if (link !== undefined) return link
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
if (!inner.ok) return inner if (!inner.ok) return inner
@@ -300,8 +300,7 @@ function needsPadding(text: string): boolean {
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
} }
// `undefined` where the link takes the directive form the caller spells. function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
const href = linkHref(nodeAttrs(mark)) const href = linkHref(nodeAttrs(mark))
if (href === undefined) return success({ carry: range }) if (href === undefined) return success({ carry: range })
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
+19
View File
@@ -922,6 +922,25 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
moved: a construct rises on its second consumer, so `linkHref` (`markdown/mark-spellings.ts`) moved: a construct rises on its second consumer, so `linkHref` (`markdown/mark-spellings.ts`)
and the external-image read (`markdown/emit/image.ts`), both pure ADF attribute reads, move and the external-image read (`markdown/emit/image.ts`), both pure ADF attribute reads, move
when `html/` reads them (7). when `html/` reads them (7).
- [x] **20 — The give-way channel is unmistakable (`0.2.0`).** `emitBlockquote`, `emitCodeBlock`,
`emitHeading`, `emitList`, `emitParagraph` and `emitRule` return
`Result<EmittedBlock> | undefined`, where `undefined` gives way to the directive form and an
error refuses the document. Give the six the `try` prefix the repo already uses for
`tryImage`, `tryPipeTable` and `tryPipeCell`, or a return type that cannot hold both, so a
newcomer meeting an unspellable shape cannot reach for `failure` and silently narrow what
converts. Named as the first thing a new senior would break, and it lands on §1.
**Done** (2026-09-21): both answers, each where it fits. Five of the six reach a refusal the
directive form shares — a walk, `codeBlockText`, `emitInlineLine` — so moving it out is the
second walk 4b removed, and they take the prefix alone. `tryRule` refuses nothing, so it
takes the narrower type as well, `string | undefined` through `readableText`, which is what
`tryImage` and `tryPipeTable` already return. `emitLink` and `listItemLines` are the same
channel outside the six — the CommonMark link and the tight list item, each tried ahead of a
directive form — so they take the prefix too, and the comment naming `emitLink`'s `undefined`
arm goes with the rename. §11's readable-spelling rule carries the prefix and gave up the set
it enumerated: the prefix is the set now, which is what stops the next spelling being named
by the eight beside it. The same rule's nested-list carve-out went with it, naming a refusal
2b's tight-versus-blank answer had already removed — `unspellable-adjacent-lists` is gone from
`result.ts` and `separationBetween` returns a `string`.
- [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** `emit/inline-line.ts:67` is a - [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** `emit/inline-line.ts:67` is a
`for (;;)` that re-emits the line until every unspellable node has been carried, and its `for (;;)` that re-emits the line until every unspellable node has been carried, and its
termination rests on a comment: each pass carries at least one more node, or flips termination rests on a comment: each pass carries at least one more node, or flips
+16 -9
View File
@@ -8,7 +8,9 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next session" says.` Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next session" says.`
1. `git fetch origin` first and read this file at `origin/main`, then branch off it, not the 1. `git fetch origin` first and read this file at `origin/main`, then branch off it, not the
worktree left behind: a checkout behind the remote reads a merged item as unchecked. worktree left behind: a checkout behind the remote reads a merged item as unchecked. `tea pr
list` in the same breath — an open PR is a chunk already in flight whatever line 3 says, and
finishing it is the session.
2. The first unchecked item in shipping order, per AGENTS.md §15 — the order the Milestones line 2. The first unchecked item in shipping order, per AGENTS.md §15 — the order the Milestones line
states, which wins over where an item's bullet sits: a newly filed item is written beside the states, which wins over where an item's bullet sits: a newly filed item is written beside the
one it came in with, not at its own place in the order. Where that item has no release, the one it came in with, not at its own place in the order. Where that item has no release, the
@@ -20,7 +22,7 @@ Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next
## Milestones ## Milestones
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b,
4c, 14, 15, 16, 18, 4d, 28, 17, 29, 19, 20, 21, 22, 23, 24, 25, 30, 26, 27, 10, 6, 7, 5f, 5g → 4c, 14, 15, 16, 18, 4d, 28, 17, 29, 19, 20, 21, 22, 23, 24, 25, 30, 26, 27, 10, 6, 7, 31, 5f, 5g →
`0.2.0`; `0.2.0`;
8, 9 → TBD; 5e last. 8, 9 → TBD; 5e last.
The numbering is the order the work was planned in, not the order it ships. Everything known and The numbering is the order the work was planned in, not the order it ships. Everything known and
@@ -41,14 +43,18 @@ HTML doubles the importers and the file count they touch, and 25 to 27 because t
panel says the next reader pays for. panel says the next reader pays for.
29 and 30 come from 17's prose pass (2026-09-20). 29 reads first because every goal is what a later 29 and 30 come from 17's prose pass (2026-09-20). 29 reads first because every goal is what a later
ask is settled against, 19's included; 30 sits beside 25, the other chunk rereading AGENTS.md. ask is settled against, 19's included; 30 sits beside 25, the other chunk rereading AGENTS.md.
31 comes from 20's gate runs (2026-09-21) and reads beside 5f, the other chunk putting a measured
number under the pipeline.
- [ ] **20 — The give-way channel is unmistakable (`0.2.0`).** `emitBlockquote`, `emitCodeBlock`, - [ ] **31 — The branch figure the floor is read against is stable (`0.2.0`).** Three Node test
`emitHeading`, `emitList`, `emitParagraph` and `emitRule` return legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and 96.23%,
`Result<EmittedBlock> | undefined`, where `undefined` gives way to the directive form and an and the total at 98.80%, 98.84% and 98.84% (2026-09-21). `--experimental-test-coverage`
error refuses the document. Give the six the `try` prefix the repo already uses for counts branches off V8's own coverage, which the runner's parallel files and V8's
`tryImage`, `tryPipeTable` and `tryPipeCell`, or a return type that cannot hold both, so a optimization make run-dependent, so the number the floor is read against is not the code's
newcomer meeting an unspellable shape cannot reach for `failure` and silently narrow what alone. The floor of 98 holds today on 0.8 points of slack and §10 says it only ever moves
converts. Named as the first thing a new senior would break, and it lands on §1. upward, so the first raise to the measured figure reddens a run that changed nothing. Make
the measurement repeatable, or state the number the floor may be raised to and why it is not
the measured one.
- [ ] **21 — The ADF tables carry ADF's nouns (`0.2.0`).** `adf/block-directives.ts` and - [ ] **21 — The ADF tables carry ADF's nouns (`0.2.0`).** `adf/block-directives.ts` and
`adf/inline-directives.ts` hold the ADF node tables — `paragraph`, `heading`, `blockquote` `adf/inline-directives.ts` hold the ADF node tables — `paragraph`, `heading`, `blockquote`
and `rule` among them — under the markdown flavour's word, inside the directory §11 forbids and `rule` among them — under the markdown flavour's word, inside the directory §11 forbids
@@ -280,6 +286,7 @@ ask is settled against, 19's included; 30 sits beside 25, the other chunk reread
- [x] **17 — A machine-enforced size ratchet (`0.2.0`).** - [x] **17 — A machine-enforced size ratchet (`0.2.0`).**
- [x] **18 — The subtree the directive spelling asks about (`0.2.0`).** - [x] **18 — The subtree the directive spelling asks about (`0.2.0`).**
- [x] **19 — A home for what both formats read (`0.2.0`).** - [x] **19 — A home for what both formats read (`0.2.0`).**
- [x] **20 — The give-way channel is unmistakable (`0.2.0`).**
- [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** - [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).**
- [x] **29 — The README reads raw HTML as refused for good (`0.2.0`).** - [x] **29 — The README reads raw HTML as refused for good (`0.2.0`).**
- [x] **30 — AGENTS.md says each thing once (`0.2.0`).** - [x] **30 — AGENTS.md says each thing once (`0.2.0`).**