From 41ac4510f570565ed15fe59848bb524e2985a919 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 09:28:06 +0200 Subject: [PATCH] Answer architecture round 1: name a non-string by typeof alone, name and comment what the seats could not read, file items 81 and 82 --- MIGRATION.md | 6 ++++-- src/adf/document.ts | 2 +- src/markdown/emit/adf-to-markdown.ts | 10 +++++----- src/markdown/emit/line-escaping.ts | 2 ++ src/markdown/opaque-carry.ts | 3 +-- src/markdown/parse/inline-content.ts | 2 +- src/markdown/parse/markdown-to-adf.test.ts | 4 +++- src/markdown/parse/markdown-to-adf.ts | 12 ++++++++++-- src/markdown/plain/adf-to-plain-markdown.ts | 5 +++-- src/markdown/plain/inline-reduction.ts | 5 +++-- src/nesting.ts | 2 +- todo.md | 18 +++++++++++++++++- 12 files changed, 51 insertions(+), 20 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 661d748..2c47c28 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -65,8 +65,6 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`: a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now converts; markdown the parser refused with one of them now refuses with the code the table gives. -`not-a-string` joins it: a `switch` naming every code needs its case. The markdown readers return -it for a value that is not a string, which threw or read as an empty document before. | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | @@ -83,3 +81,7 @@ it for a value that is not a string, which threw or read as an empty document be | a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` | | an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` | | an empty node the `::taskItem` spelling row names, written with a closer | `unsupported-node-shape` | parses | + +`ConvertErrorCode` gains `not-a-string`: a `switch` naming every code needs its case. The markdown +readers return it for a value that is not a string, which threw or read as an empty document +before. diff --git a/src/adf/document.ts b/src/adf/document.ts index 2d120bb..5945edb 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -208,7 +208,7 @@ function notADocument(message: string): ConvertFault { return { code: 'not-an-adf-document', message } } -export function describe(value: unknown): string { +function describe(value: unknown): string { if (typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value) if (typeof value === 'function') return 'a function' if (typeof value === 'object' && value !== null) return Array.isArray(value) ? 'an array' : 'an object' diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 03d2a9e..5b2a9c7 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -96,7 +96,7 @@ function interruptsParagraph(node: AdfNode): boolean { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result { const model = blockNodeModel(node.type) - if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth)) + if (model === undefined) return carriedFence(carriedBlock(node, path, depth)) const readable = readableBlock(node, path, depth, writing) if (readable !== undefined) return readable return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing)) @@ -207,7 +207,7 @@ function readableText(text: string | undefined): Result | undefine return text === undefined ? undefined : success(commonMarkText(text)) } -function commonMarkLine(carried: Result<{ headroom: number; text: string }>): Result { +function carriedFence(carried: Result<{ headroom: number; text: string }>): Result { if (!carried.ok) return carried return success({ ...carried.value, spelling: 'commonmark' }) } @@ -225,7 +225,7 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth) const opener = spellBlockDirectiveOpener(node, model, path) - if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) + if (opener === undefined) return carriedFence(carriedBlock(node, path, depth)) if (!opener.ok) return opener return emitDirectiveBody(node, model, opener.value, path, walkBody) } @@ -263,10 +263,10 @@ function tryCodeBlock(node: AdfNode): string | undefined { // spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does. function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result { const texts = fencedTexts(node) - if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth)) + if (texts === undefined) return carriedFence(carriedBlock(node, path, depth)) const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language']) const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language']) - if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) + if (opener === undefined) return carriedFence(carriedBlock(node, path, depth)) if (!opener.ok) return opener const info = slot.kind === 'fence' ? slot.info : '' return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n'))) diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index e7b39ae..ba6014a 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -11,6 +11,7 @@ import { readEntityReference } from '../commonmark/entity-references.ts' export type DelimiterRole = 'close' | 'open' +// A segment's kind: `backslash` text, `bracketed` text inside `[…]` (link text, alt, a directive's content), `bracketed-link-target` a link target inside `[…]`, `none` syntax written as is. export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none' export type NodeRange = { first: number; last: number } @@ -143,6 +144,7 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], cl if (segmentEnd !== end || claimed.has(start)) formed.add(segmentEnd - start + 1) else if (escapings[start] !== 'none' && formed.has(end - start + 1)) { for (let index = start; index <= end; index += 1) escaped.add(index) + // Each escaped backtick then stands alone, a string of length one. formed.add(1) } end = start - 1 diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 3941cf3..55e88cf 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -2,13 +2,13 @@ import type { AdfNode } from '../adf/document.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' import type { JsonValue } from '../json-value.ts' +import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { fencedCodeBlock } from './commonmark/backtick-runs.ts' import { infoStringCarries } from './commonmark/grammar.ts' import { isAdfNode } from '../adf/document.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { largestNesting } from '../nesting.ts' -import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export const carryFencePrefix = 'adf:' @@ -32,7 +32,6 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { if (!isNode(node)) return node diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index d6e5d20..108d852 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -85,7 +85,9 @@ function table(...rows: AdfNode[]): AdfNode { } test('refuses a value that is not a string, in both readers, at the start of the input', () => { - const found: [unknown, string][] = [[null, 'null'], [undefined, 'undefined'], [42, '42'], [{}, 'an object'], [['x'], 'an array']] + const revoked = Proxy.revocable({}, {}) + revoked.revoke() + const found: [unknown, string][] = [[null, 'null'], [undefined, 'undefined'], [42, 'a number'], [10n, 'a bigint'], [['x'], 'an object'], [revoked.proxy, 'an object']] for (const read of [markdownToAdf, plainMarkdownToAdf]) { for (const [value, described] of found) { assert.deepEqual(Reflect.apply(read, undefined, [value]), { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 3f80ac5..86149a0 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -8,7 +8,7 @@ import type { LinkDefinitions } from './inline-content.ts' import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts' -import { describe, emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts' +import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts' import { inlineLeaves } from '../plain/inline-reduction.ts' import { languageSlot } from '../code-language.ts' @@ -39,7 +39,7 @@ export function plainMarkdownToAdf(markdown: string): Result { - if (typeof markdown !== 'string') return positioned(failure('not-a-string', `markdown is a string: found ${describe(markdown)}`, []), documentStart) + if (typeof markdown !== 'string') return positioned(failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, []), documentStart) const parsed = parseBlocks(markdown) const [only] = parsed.blocks if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) { @@ -54,6 +54,14 @@ function readDocument(markdown: string, flavour: Flavour): Result { if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path) const content: AdfNode[] = [] diff --git a/src/markdown/plain/adf-to-plain-markdown.ts b/src/markdown/plain/adf-to-plain-markdown.ts index 256f97c..9e99636 100644 --- a/src/markdown/plain/adf-to-plain-markdown.ts +++ b/src/markdown/plain/adf-to-plain-markdown.ts @@ -1,7 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' -import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' +import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './inline-reduction.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' @@ -51,7 +51,7 @@ export function reduceToPlain(document: AdfDocument): Result { const fault = adfDocumentFault(document) if (fault !== undefined) return faulted(fault, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) - // docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF. + // Two documents the editor holds equal write the same plain markdown (docs/decisions.md §Equality is deep). const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] }) if (!blocks.ok) { // Merging text renumbers siblings, so a refusal's path comes from the caller's document. @@ -122,6 +122,7 @@ function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'plain', memo: reduction.memo })?.ok === true if (spelled) continue sequence = spliced(sequence, index, block === listed ? nodeContent(block).flatMap(nodeContent) : [block]) + // Step back to the merged neighbour, which may now take the directive form itself. index = Math.max(0, index - 1) - 1 } return success(sequence) diff --git a/src/markdown/plain/inline-reduction.ts b/src/markdown/plain/inline-reduction.ts index 10acb39..2647966 100644 --- a/src/markdown/plain/inline-reduction.ts +++ b/src/markdown/plain/inline-reduction.ts @@ -17,7 +17,7 @@ const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link'] export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { const leaves = inlineLeaves(nodes, container, path, depth) if (!leaves.ok) return leaves - return spellableLine(trimmedEdges(highlighted(trimmedEdges(leaves.value))), container, path) + return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path) } export function isBlockNodeType(type: string): boolean { @@ -167,7 +167,7 @@ export function writableHref(href: string): string { } // The marks a whole highlight run shares go outside the highlight, so its delimiters open and close inside them. -function highlighted(leaves: readonly AdfNode[]): AdfNode[] { +function highlightsInsideSharedMarks(leaves: readonly AdfNode[]): AdfNode[] { const spelled: AdfNode[] = [] let run: AdfNode[] = [] let shared: AdfMark[] = [] @@ -237,6 +237,7 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | return edges } +// How many of the leaf's marks the edge whitespace keeps, `undefined` where it goes at a line edge. function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined { if (whitespace === '') return marks.length const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak' diff --git a/src/nesting.ts b/src/nesting.ts index 419965b..01ddad5 100644 --- a/src/nesting.ts +++ b/src/nesting.ts @@ -1,2 +1,2 @@ -// A level is one block-list recursion in either direction: a readable list's items sit one below it, its directive spelling's two. +// A level is one block-list recursion in either direction: a readable list's items sit one level below the list, a directive-spelled list's two. export const largestNesting = 500 diff --git a/todo.md b/todo.md index b1ef9ce..230fb3e 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 81` +`Next ID = 83` | Goal | W | |---|---| @@ -35,6 +35,7 @@ | 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 | | 75 | 0.2.0 | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 2 | 2 | 3 | 7 | 1 | 17.5 | | 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 4, 5, 7 | 17.1 | +| 81 | 0.2.0 | | **Fail the gate when the README's error tables list other codes than `ConvertErrorCode`.** | 1 | 2 | 5 | 5 | 3 | 16.3 | | 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 | | 46 | 0.2.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 3, 8 | 14.8 | | 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 | @@ -56,6 +57,7 @@ | 33 | 0.3.0 | defect | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 | | 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 | | 76 | 0.3.0 | principle | **State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart.** | 2 | 2 | 1 | 3 | 1 | 5.5 | +| 82 | 0.3.0 | principle | **Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.** | 1 | 3 | 1 | 2 | 1 | 4.2 | | 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 8 | 0.6 | | 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 8 | -2.6 | | 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 3 | 18.6 | @@ -163,6 +165,12 @@ its paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's ` alone in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A writer panel chose this reading 3 of 3 (2026-10-04), by Goal 6, which outranks Goal 7. +### 81. Fail the gate when the README's error tables list other codes than `ConvertErrorCode`. + +`src/result.test.ts` checks the union against the call sites only, so a code added or removed +leaves the README's tables a false contract under a green gate. Assert that the tables list exactly +the union. Found by the technical-principles audit of item 78, 2026-10-05. + ### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`. Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads @@ -325,6 +333,14 @@ assumes they agree. A helper added to one list lints test code under the shipped shipped code from coverage, silently. Derive two from the third, or assert in a test that the three agree. Found by the technical-principles audit, 2026-10-04. +### 82. Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`. + +Technical principle: tests use random UUID v7 ids. The placeholders sit in +`markdown-to-adf.test.ts`, `adf-to-markdown.test.ts`, `blocks.test.ts` and +`corpus/errors/directive-value-uncanonical.md`, so no lossless directive test reads the digit-led hex +id an editor writes; `plain-markdown-to-adf.test.ts` already uses v7s. Found by the +technical-principles audit of item 78, 2026-10-05. + ### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree. Technical principle "Compose, do not entangle": Deno, Bun and the tests read the install leg's