diff --git a/AGENTS.md b/AGENTS.md index 21a55cb..f7972af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -306,15 +306,12 @@ someone spells it or pins it. `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 the whole point of. -- A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe - table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only - where the general form fails on the same node. Refusing there refuses a document the general - form spells, so a refusal the general form does not share belongs in the general form or - nowhere — save the nested list a tight spelling would swallow, whose refusal the - tight-versus-blank answer owns (`todo.md` 2b). A readable spelling that must spell its subtree - 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). +- A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the + general form fails on the same node (20): refusing there refuses a document the general form + spells, so a refusal the general form does not share belongs in the general form or nowhere. A + readable spelling that must spell its subtree 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 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 diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 6eb0da3..299f248 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -26,7 +26,7 @@ type Walk = { blocks: readonly PlacedBlock[]; headroom: number } type WalkedItem = { node: AdfNode; walk: Walk } 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, '') export function adfToMarkdown(document: AdfDocument): Result { @@ -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 | undefined { - if (node.type === 'blockquote') return emitBlockquote(node, path, depth, memo) - if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth, memo) - if (node.type === 'codeBlock') return emitCodeBlock(node, path) - if (node.type === 'heading') return emitHeading(node, path) + if (node.type === 'blockquote') return tryBlockquote(node, path, depth, memo) + if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, memo) + if (node.type === 'codeBlock') return tryCodeBlock(node, path) + if (node.type === 'heading') return tryHeading(node, path) if (node.type === 'mediaSingle') return readableText(tryImage(node, path)) - if (node.type === 'paragraph') return emitParagraph(node, path) - if (node.type === 'rule') return emitRule(node) + if (node.type === 'paragraph') return tryParagraph(node, path) + if (node.type === 'rule') return readableText(tryRule(node)) if (node.type === 'table') return readableText(tryPipeTable(node, path)) 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)) } -function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result | undefined { +function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result | undefined { if (!carriesOnly(node, [])) return undefined const inner = walkBlocks(nodeContent(node), path, depth + 1, memo) 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)) } -function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined { +function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined { if (!carriesOnly(node, ['language'])) return undefined const slot = languageSlot(nodeAttrs(node)['language']) if (slot.kind === 'attribute') return undefined @@ -214,7 +214,7 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result { return success(text) } -function emitHeading(node: AdfNode, path: ConvertErrorPath): Result | undefined { +function tryHeading(node: AdfNode, path: ConvertErrorPath): Result | undefined { if (!carriesOnly(node, ['level'])) return undefined const level = nodeAttrs(node)['level'] if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined @@ -226,7 +226,7 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result | undefined { +function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result | undefined { const ordered = node.type === 'orderedList' if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined const items = nodeContent(node) @@ -243,7 +243,7 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: Sp } const lines: string[] = [] 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 (headroom < 1) return tooDeep(path) 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 } -function listItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined { +function tryListItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined { const inner = joinBlocks(blocks, 'list-item') if (inner === '') return marker.trimEnd() const body = inner.split('\n') @@ -276,7 +276,7 @@ function listItemLines(blocks: readonly PlacedBlock[], marker: string): string | return lines.join('\n') } -function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result | undefined { +function tryParagraph(node: AdfNode, path: ConvertErrorPath): Result | undefined { const content = nodeContent(node) if (content.length === 0 || !carriesOnly(node, [])) return undefined const line = emitInlineLine(content, 'paragraph', path) @@ -284,7 +284,7 @@ function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result | undefined { +function tryRule(node: AdfNode): string | undefined { if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined - return success(commonMarkText('---')) + return '---' } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 5872a9a..1c2034c 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -258,7 +258,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, if (attributes === undefined) return success({ carry: range }) if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) 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 const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) if (!inner.ok) return inner @@ -300,8 +300,7 @@ function needsPadding(text: string): boolean { return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) } -// `undefined` where the link takes the directive form the caller spells. -function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result | undefined { +function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result | undefined { const href = linkHref(nodeAttrs(mark)) if (href === undefined) return success({ carry: range }) const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective diff --git a/todo-history.md b/todo-history.md index db888f2..8ab98bd 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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`) and the external-image read (`markdown/emit/image.ts`), both pure ADF attribute reads, move 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 | 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 `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 diff --git a/todo.md b/todo.md index 603e7ea..d0cc238 100644 --- a/todo.md +++ b/todo.md @@ -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.` 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 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 @@ -20,7 +22,7 @@ Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next ## Milestones 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`; 8, 9 → TBD; 5e last. 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. 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. +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`, - `emitHeading`, `emitList`, `emitParagraph` and `emitRule` return - `Result | 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. +- [ ] **31 — The branch figure the floor is read against is stable (`0.2.0`).** Three Node test + legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and 96.23%, + and the total at 98.80%, 98.84% and 98.84% (2026-09-21). `--experimental-test-coverage` + counts branches off V8's own coverage, which the runner's parallel files and V8's + optimization make run-dependent, so the number the floor is read against is not the code's + alone. The floor of 98 holds today on 0.8 points of slack and §10 says it only ever moves + 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 `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 @@ -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] **18 — The subtree the directive spelling asks about (`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] **29 — The README reads raw HTML as refused for good (`0.2.0`).** - [x] **30 — AGENTS.md says each thing once (`0.2.0`).**