From dfbb92b7abea47b7b46710f4352a0e76a93a1911 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 6 Sep 2026 23:09:01 +0200 Subject: [PATCH] Pin refusal codes and divergences; fix the text oracle and count by heading level --- AGENTS.md | 9 +- corpus/README.md | 10 +- corpus/commonmark-spec/exceptions.json | 140 +++++++++- corpus/commonmark-spec/refusals.json | 346 +++++++++++++++++++++++++ src/commonmark-spec.test.ts | 202 ++++++++++----- todo.md | 8 +- 6 files changed, 626 insertions(+), 89 deletions(-) create mode 100644 corpus/commonmark-spec/refusals.json diff --git a/AGENTS.md b/AGENTS.md index b5228d8..320609f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,10 @@ When losslessness and readability conflict, losslessness wins. The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way back yields the library's canonical spelling, and that spelling round-trips byte-identically — -where there is a way back. CommonMark spells link destinations the flavour has no escape for, so a -parse succeeding does not imply a spellable document; `todo.md` 3k's exception list names those. +where there is a way back. CommonMark spells some things the flavour has no escape for — a link +destination or title holding a backslash or newline, a paragraph opening with a code span whose +backticks read back as a fence — so a parse succeeding does not imply a spellable document; +`corpus/commonmark-spec/exceptions.json` names those. "Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks merged, JSON number semantics, an empty attrs object, marks array or content array the absent @@ -202,7 +204,8 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s `node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor. A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js` -over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey +over HTTP and converts the round-trip, normalization and error fixtures — the `commonmark-spec` +sort is the Node suite's to check — which is §6's browser half and the only SpiderMonkey there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's. A WebDriver session is what carries a verdict back out, the driver and the page's server sharing one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such diff --git a/corpus/README.md b/corpus/README.md index 91b1f01..e2492a7 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -12,9 +12,13 @@ One directory per contract kind, each landing with its milestone: pins which error. - `real-payloads/` — `.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No expected markdown. -- `commonmark-spec/` — `spec.json` (the CommonMark 0.31.2 suite, vendored byte-exact) and - `exceptions.json` (the examples a known divergence exempts, keyed by `check` and `example` with - a `reason`). +- `commonmark-spec/` — the CommonMark suite run against `markdownToAdf` by three checks. `spec.json` + (0.31.2, vendored byte-exact) is the suite; `refusals.json` pins each refusing example to its + error `code`; `exceptions.json` pins each known divergence by `check`, `example`, `kind` and the + exact `divergence`, with a `reason`. `kind` is `mark-model` (the permanent count divergence from + ADF's mark-per-text-node model), `unspellable` (parses but the flavour has no spelling) or + `pending` (a parser gap a later milestone may close). The two lists are derived and labelled, + not hand-picked — regenerate them rather than hand-edit when re-pinning. JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. `spec.json` is the vendored, upstream machine-readable suite (CommonMark 0.31.2, CC-BY-SA-4.0, © John MacFarlane) and is not diff --git a/corpus/commonmark-spec/exceptions.json b/corpus/commonmark-spec/exceptions.json index ed4ce3e..95b5dfa 100644 --- a/corpus/commonmark-spec/exceptions.json +++ b/corpus/commonmark-spec/exceptions.json @@ -1,322 +1,436 @@ [ - { - "check": "text", - "example": 39, - "reason": "` ` decodes to a newline kept literal in text; CommonMark reads it a soft break." - }, { "check": "fixpoint", + "divergence": "unspellable-link", "example": 196, + "kind": "unspellable", "reason": "The link title holds literal newlines no escape spells." }, { "check": "fixpoint", + "divergence": "unspellable-link", "example": 202, + "kind": "unspellable", "reason": "The link destination holds a backslash the flavour cannot escape." }, - { - "check": "text", - "example": 300, - "reason": "A heading and the block after it join with no space where CommonMark breaks them." - }, { "check": "count", + "divergence": "ul 2/1", "example": 301, + "kind": "pending", "reason": "A list continuing past a marker change renders as two lists, the parser opens one." }, { "check": "count", + "divergence": "ol 2/1", "example": 302, + "kind": "pending", "reason": "A list continuing past a marker change renders as two lists, the parser opens one." }, { "check": "fixpoint", + "divergence": "unspellable-line-start", "example": 330, + "kind": "unspellable", "reason": "A paragraph opens with a code span whose backticks read back as a fence." }, { "check": "fixpoint", + "divergence": "unspellable-line-start", "example": 331, + "kind": "unspellable", "reason": "A paragraph opens with a code span whose backticks read back as a fence." }, { "check": "fixpoint", + "divergence": "unspellable-line-start", "example": 340, + "kind": "unspellable", "reason": "A paragraph opens with a code span whose backticks read back as a fence." }, { "check": "count", + "divergence": "em 2/1", "example": 369, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 2/1", "example": 373, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 2/1", "example": 389, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 1/3", "example": 393, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/5", "example": 394, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/3", "example": 395, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/3", "example": 399, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 404, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/3", "example": 406, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 2/1", "example": 407, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 2/1", "example": 408, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 2/1", "example": 409, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 1/3", "example": 410, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/3", "example": 411, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 413, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 414, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 415, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 3/1", "example": 417, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 2/5 strong 1/3", "example": 418, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/2", "example": 422, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/3", "example": 424, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 2/1", "example": 425, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 2/1", "example": 426, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 2/1", "example": 427, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 1/3", "example": 428, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/3", "example": 429, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/2", "example": 430, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/2", "example": 431, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/3 strong 2/5", "example": 432, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "strong 1/2", "example": 433, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 2/1", "example": 461, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 2/1", "example": 463, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 2/1", "example": 464, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 2/1", "example": 465, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 3/1", "example": 466, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "strong 2/1", "example": 468, + "kind": "mark-model", "reason": "CommonMark nests same-kind elements; the single mark collapses them to one." }, { "check": "count", + "divergence": "em 1/3", "example": 470, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 478, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 479, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "a 1/0", "example": 484, - "reason": "The empty link text stays literal text; CommonMark renders an empty link." + "kind": "pending", + "reason": "An empty link text stays literal text; CommonMark renders an empty link." }, { "check": "text", + "divergence": "\"\" against \"[](./target.md)\"", "example": 484, + "kind": "pending", "reason": "An empty link text stays literal text; CommonMark renders an empty link." }, { "check": "count", + "divergence": "a 1/0", "example": 487, - "reason": "The empty link text stays literal text; CommonMark renders an empty link." + "kind": "pending", + "reason": "An empty link text stays literal text; CommonMark renders an empty link." }, { "check": "text", + "divergence": "\"\" against \"[]()\"", "example": 487, + "kind": "pending", "reason": "An empty link text stays literal text; CommonMark renders an empty link." }, { "check": "fixpoint", + "divergence": "unspellable-link", "example": 502, + "kind": "unspellable", "reason": "The link destination holds a backslash the flavour cannot escape." }, { "check": "count", + "divergence": "a 1/5 em 1/4", "example": 516, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/3", "example": 519, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "a 1/5 em 1/4", "example": 530, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 533, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "a 1/0", "example": 540, + "kind": "pending", "reason": "The case-folding shortcut reference is unresolved; CommonMark folds case and links." }, { "check": "text", + "divergence": "\"ẞ\" against \"[ẞ]\"", "example": 540, - "reason": "A case-folding shortcut reference is unresolved text; CommonMark folds case and links." + "kind": "pending", + "reason": "A case-folding shortcut reference is unresolved; CommonMark folds case and links." }, { "check": "count", + "divergence": "a 1/2", "example": 554, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "a 1/2", "example": 558, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "a 1/2", "example": 559, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 638, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." }, { "check": "count", + "divergence": "em 1/2", "example": 639, + "kind": "mark-model", "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." } ] diff --git a/corpus/commonmark-spec/refusals.json b/corpus/commonmark-spec/refusals.json new file mode 100644 index 0000000..083420f --- /dev/null +++ b/corpus/commonmark-spec/refusals.json @@ -0,0 +1,346 @@ +[ + { + "code": "unmappable-html", + "example": 21 + }, + { + "code": "unmappable-html", + "example": 31 + }, + { + "code": "unmappable-html", + "example": 148 + }, + { + "code": "unmappable-html", + "example": 149 + }, + { + "code": "unmappable-html", + "example": 150 + }, + { + "code": "unmappable-html", + "example": 151 + }, + { + "code": "unmappable-html", + "example": 152 + }, + { + "code": "unmappable-html", + "example": 153 + }, + { + "code": "unmappable-html", + "example": 154 + }, + { + "code": "unmappable-html", + "example": 155 + }, + { + "code": "unmappable-html", + "example": 156 + }, + { + "code": "unmappable-html", + "example": 157 + }, + { + "code": "unmappable-html", + "example": 158 + }, + { + "code": "unmappable-html", + "example": 159 + }, + { + "code": "unmappable-html", + "example": 160 + }, + { + "code": "unmappable-html", + "example": 161 + }, + { + "code": "unmappable-html", + "example": 162 + }, + { + "code": "unmappable-html", + "example": 163 + }, + { + "code": "unmappable-html", + "example": 164 + }, + { + "code": "unmappable-html", + "example": 165 + }, + { + "code": "unmappable-html", + "example": 166 + }, + { + "code": "unmappable-html", + "example": 167 + }, + { + "code": "unmappable-html", + "example": 168 + }, + { + "code": "unmappable-html", + "example": 169 + }, + { + "code": "unmappable-html", + "example": 170 + }, + { + "code": "unmappable-html", + "example": 171 + }, + { + "code": "unmappable-html", + "example": 172 + }, + { + "code": "unmappable-html", + "example": 173 + }, + { + "code": "unmappable-html", + "example": 174 + }, + { + "code": "unmappable-html", + "example": 175 + }, + { + "code": "unmappable-html", + "example": 176 + }, + { + "code": "unmappable-html", + "example": 177 + }, + { + "code": "unmappable-html", + "example": 178 + }, + { + "code": "unmappable-html", + "example": 179 + }, + { + "code": "unmappable-html", + "example": 180 + }, + { + "code": "unmappable-html", + "example": 181 + }, + { + "code": "unmappable-html", + "example": 182 + }, + { + "code": "unmappable-html", + "example": 183 + }, + { + "code": "unmappable-html", + "example": 184 + }, + { + "code": "unmappable-html", + "example": 185 + }, + { + "code": "unmappable-html", + "example": 186 + }, + { + "code": "unmappable-html", + "example": 187 + }, + { + "code": "unmappable-html", + "example": 188 + }, + { + "code": "unmappable-html", + "example": 189 + }, + { + "code": "unmappable-html", + "example": 190 + }, + { + "code": "unmappable-html", + "example": 191 + }, + { + "code": "unmappable-html", + "example": 201 + }, + { + "code": "unmappable-html", + "example": 308 + }, + { + "code": "unmappable-html", + "example": 309 + }, + { + "code": "unmappable-html", + "example": 344 + }, + { + "code": "unmappable-html", + "example": 475 + }, + { + "code": "unmappable-html", + "example": 476 + }, + { + "code": "unmappable-html", + "example": 477 + }, + { + "code": "unmappable-html", + "example": 491 + }, + { + "code": "unmappable-html", + "example": 494 + }, + { + "code": "unmappable-image", + "example": 517 + }, + { + "code": "unmappable-html", + "example": 524 + }, + { + "code": "unmappable-image", + "example": 531 + }, + { + "code": "unmappable-html", + "example": 536 + }, + { + "code": "unmappable-image", + "example": 572 + }, + { + "code": "unmappable-image", + "example": 573 + }, + { + "code": "unmappable-image", + "example": 576 + }, + { + "code": "unmappable-image", + "example": 577 + }, + { + "code": "unmappable-image", + "example": 579 + }, + { + "code": "unmappable-image", + "example": 584 + }, + { + "code": "unmappable-image", + "example": 585 + }, + { + "code": "unmappable-image", + "example": 586 + }, + { + "code": "unmappable-image", + "example": 587 + }, + { + "code": "unmappable-image", + "example": 588 + }, + { + "code": "unmappable-image", + "example": 589 + }, + { + "code": "unmappable-image", + "example": 591 + }, + { + "code": "unmappable-html", + "example": 613 + }, + { + "code": "unmappable-html", + "example": 614 + }, + { + "code": "unmappable-html", + "example": 615 + }, + { + "code": "unmappable-html", + "example": 616 + }, + { + "code": "unmappable-html", + "example": 617 + }, + { + "code": "unmappable-html", + "example": 623 + }, + { + "code": "unmappable-html", + "example": 625 + }, + { + "code": "unmappable-html", + "example": 626 + }, + { + "code": "unmappable-html", + "example": 627 + }, + { + "code": "unmappable-html", + "example": 628 + }, + { + "code": "unmappable-html", + "example": 629 + }, + { + "code": "unmappable-html", + "example": 630 + }, + { + "code": "unmappable-html", + "example": 631 + }, + { + "code": "unmappable-html", + "example": 642 + }, + { + "code": "unmappable-html", + "example": 643 + } +] diff --git a/src/commonmark-spec.test.ts b/src/commonmark-spec.test.ts index 9ed996d..0930fc2 100644 --- a/src/commonmark-spec.test.ts +++ b/src/commonmark-spec.test.ts @@ -5,17 +5,19 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import type { AdfNode } from './adf/document.ts' -import { readEntityReference } from './markdown/entity-references.ts' import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus', 'commonmark-spec') type Check = 'count' | 'fixpoint' | 'text' +type ExceptionKind = 'mark-model' | 'pending' | 'unspellable' type SpecExample = { example: number; html: string; markdown: string; section: string } -type Exception = { check: Check; example: number; reason: string } +type Exception = { check: Check; divergence: string; example: number; kind: ExceptionKind; reason: string } + +type Refusal = { code: string; example: number } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -25,38 +27,50 @@ function isCheck(value: unknown): value is Check { return value === 'count' || value === 'fixpoint' || value === 'text' } +function isKind(value: unknown): value is ExceptionKind { + return value === 'mark-model' || value === 'pending' || value === 'unspellable' +} + function isSpecExample(value: unknown): value is SpecExample { if (!isRecord(value)) return false return typeof value['example'] === 'number' && typeof value['html'] === 'string' && typeof value['markdown'] === 'string' && typeof value['section'] === 'string' } function isException(value: unknown): value is Exception { - return isRecord(value) && isCheck(value['check']) && typeof value['example'] === 'number' && typeof value['reason'] === 'string' + if (!isRecord(value)) return false + return ( + isCheck(value['check']) && + typeof value['divergence'] === 'string' && + value['divergence'].length > 0 && + typeof value['example'] === 'number' && + isKind(value['kind']) && + typeof value['reason'] === 'string' && + value['reason'].length > 0 + ) } -function readSpec(): SpecExample[] { - const parsed: unknown = JSON.parse(readFileSync(join(root, 'spec.json'), 'utf8')) - assert.ok(Array.isArray(parsed), 'spec.json is not an array of examples') - return parsed.map((value) => { - assert.ok(isSpecExample(value), 'spec.json holds an example with the wrong shape') - return value - }) +function isRefusal(value: unknown): value is Refusal { + if (!isRecord(value)) return false + return typeof value['code'] === 'string' && value['code'].length > 0 && typeof value['example'] === 'number' } -function readExceptions(): Exception[] { - const parsed: unknown = JSON.parse(readFileSync(join(root, 'exceptions.json'), 'utf8')) - assert.ok(Array.isArray(parsed), 'exceptions.json is not an array') +function readJson(name: string, guard: (value: unknown) => value is T, shape: string): T[] { + const parsed: unknown = JSON.parse(readFileSync(join(root, name), 'utf8')) + assert.ok(Array.isArray(parsed), `${name} is not an array`) return parsed.map((value, index) => { - assert.ok(isException(value), `exception ${index} is not an exception`) + assert.ok(guard(value), `${name} holds a ${shape} with the wrong shape at ${index}`) return value }) } -const spec = readSpec() -const exceptions = readExceptions() -const exceptionIndex = new Map(exceptions.map((entry) => [`${entry.example}:${entry.check}`, entry.reason])) +const spec = readJson('spec.json', isSpecExample, 'spec example') +const exceptions = readJson('exceptions.json', isException, 'exception') +const refusals = readJson('refusals.json', isRefusal, 'refusal') -test('the CommonMark spec suite is the pinned 0.31.2 run', () => { +const exampleToRefusal = new Map(refusals.map((refusal) => [refusal.example, refusal.code])) +const exceptionIndex = new Map(exceptions.map((entry) => [`${entry.example}:${entry.check}`, entry])) + +test('the CommonMark spec suite holds the full 652-example 0.31.2 run', () => { assert.equal(spec.length, 652) }) @@ -64,14 +78,20 @@ test('the exception list is unique per example and check', () => { assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds') }) -// A mark is counted once per text node it touches: 3e's collapse makes `*(*a*)*` one `em` against two ``. -const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'heading', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul'] +test('the refusal list is unique per example and names real examples', () => { + assert.equal(exampleToRefusal.size, refusals.length, 'one refusal repeats an example another holds') + for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`) +}) + +// The reference HTML is corpus data, never converted (AGENTS.md §1): its element names are counted against +// the nodes and marks the CommonMark subset maps to. A mark is counted once per text node it touches — 3e +// collapses a spelling nested inside its own kind, so `*(*a*)*` is one `em` mark against two `` elements. +const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul'] const nodeElement: Record = { blockquote: 'blockquote', bulletList: 'ul', codeBlock: 'pre', hardBreak: 'br', - heading: 'heading', listItem: 'li', media: 'img', mediaInline: 'img', @@ -108,8 +128,6 @@ function referenceCounts(html: string): Record { counts['pre'] = (counts['pre'] ?? 0) + 1 } else if (name === 'code' && inPre) { // A code block's `` is the `
`'s body, already counted.
-    } else if (/^h[1-6]$/.test(name)) {
-      counts['heading'] = (counts['heading'] ?? 0) + 1
     } else if (countKeys.includes(name)) {
       counts[name] = (counts[name] ?? 0) + 1
     }
@@ -133,6 +151,12 @@ function nodeCounts(document: AdfNode): Record {
       for (const element of seen) counts[element] = (counts[element] ?? 0) + 1
       continue
     }
+    if (node.type === 'heading') {
+      const level = node.attrs?.['level']
+      if (typeof level === 'number') counts[`h${level}`] = (counts[`h${level}`] ?? 0) + 1
+      pending.push(...(node.content ?? []))
+      continue
+    }
     const element = nodeElement[node.type]
     if (element !== undefined) counts[element] = (counts[element] ?? 0) + 1
     pending.push(...(node.content ?? []))
@@ -140,19 +164,45 @@ function nodeCounts(document: AdfNode): Record {
   return counts
 }
 
+// The reference HTML's own entity set is the four cmark emits; this decoder is independent of the library's
+// (finding: a bug in the shared decoder would corrupt both sides of the comparison). Named and numeric cover a
+// future re-pin; the current suite holds only the named four.
+const namedEntity: Record = { amp: '&', apos: "'", gt: '>', lt: '<', quot: '"' }
+
+function decodeHtmlEntity(text: string, index: number): { length: number; text: string } | undefined {
+  if (text[index] !== '&') return undefined
+  const end = text.indexOf(';', index)
+  if (end === -1 || end - index > 8) return undefined
+  const reference = text.slice(index, end + 1)
+  const named = namedEntity[reference.slice(1, -1)]
+  if (named !== undefined) return { length: reference.length, text: named }
+  const decimal = /^&#(\d+)$/.exec(reference)
+  if (decimal?.[1] !== undefined) return { length: reference.length, text: characterOf(Number(decimal[1])) }
+  const hexadecimal = /^&#[xX]([0-9A-Fa-f]+)$/.exec(reference)
+  if (hexadecimal?.[1] !== undefined) return { length: reference.length, text: characterOf(Number.parseInt(hexadecimal[1], 16)) }
+  return undefined
+}
+
+function characterOf(codePoint: number): string {
+  if (codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return '\ufffd'
+  return String.fromCodePoint(codePoint)
+}
+
 function referenceText(html: string): string {
-  let out = ''
+  const parts: string[] = []
   let preDepth = 0
+  let atBoundary = true
   let skipNewline = false
   for (let index = 0; index < html.length; index += 1) {
-    const character = html[index]
+    const character = html.charAt(index)
     if (character === '<') {
       const close = html.indexOf('>', index)
       if (close === -1) break
       const tag = html.slice(index, close + 1)
       const name = tagName(tag)
       if (name === 'br') {
-        out += '\n'
+        parts.push(' ')
+        atBoundary = false
         skipNewline = true
         index = close
         continue
@@ -160,10 +210,13 @@ function referenceText(html: string): string {
       if (name === 'pre') {
         if (tag.startsWith(' 0) {
-        out += '\n'
+        parts.push('\n')
         continue
       }
-      let next = index + 1
-      while (next < html.length && (html[next] === '\n' || html[next] === ' ' || html[next] === '\t')) next += 1
-      if (next >= html.length) continue
-      if (html[next] === '<') {
-        const close = html.indexOf('>', next)
-        if (close === -1) continue
-        if (!blockTags.has(tagName(html.slice(next, close + 1)))) out += ' '
-      } else {
-        out += ' '
-      }
+      if (!atBoundary && !followedByBlock(html, index + 1)) parts.push(' ')
       continue
     }
-    skipNewline = false
-    const reference = readEntityReference(html, index)
+    const reference = decodeHtmlEntity(html, index)
     if (reference !== undefined) {
-      out += reference.text
+      parts.push(reference.text)
+      atBoundary = false
       index += reference.length - 1
       continue
     }
-    out += character
+    parts.push(character)
+    atBoundary = false
   }
-  return out
+  return parts.join('')
+}
+
+function trimTrailingNewline(parts: string[]): void {
+  const last = parts[parts.length - 1]
+  if (last === undefined) return
+  parts[parts.length - 1] = last.endsWith('\n') ? last.slice(0, -1) : last
+}
+
+// A newline is a soft break only between inline content on both sides; one beside a block open/close
+// (a nested list, a following heading) is a block boundary and contributes nothing.
+function followedByBlock(html: string, index: number): boolean {
+  let next = index
+  while (next < html.length && (html[next] === '\n' || html[next] === ' ' || html[next] === '\t')) next += 1
+  if (next >= html.length) return true
+  if (html[next] !== '<') return false
+  const close = html.indexOf('>', next)
+  return close !== -1 && blockTags.has(tagName(html.slice(next, close + 1)))
 }
 
 function concatenatedText(document: AdfNode): string {
   const parts: string[] = []
-  const pending: AdfNode[] = [document]
+  const pending: { inCode: boolean; node: AdfNode }[] = [{ inCode: false, node: document }]
   while (pending.length > 0) {
-    const node = pending.pop()
-    if (node === undefined) continue
+    const frame = pending.pop()
+    if (frame === undefined) continue
+    const { inCode, node } = frame
     if (node.text !== undefined) {
-      parts.push(node.text)
+      parts.push(inCode ? node.text : node.text.replace(/\n/g, ' '))
       continue
     }
     if (node.type === 'hardBreak') {
-      parts.push('\n')
+      parts.push(' ')
       continue
     }
+    const childInCode = inCode || node.type === 'codeBlock'
     const content = node.content ?? []
     for (let index = content.length - 1; index >= 0; index -= 1) {
       const child = content[index]
-      if (child !== undefined) pending.push(child)
+      if (child !== undefined) pending.push({ inCode: childInCode, node: child })
     }
   }
   return parts.join('')
@@ -226,18 +290,18 @@ function concatenatedText(document: AdfNode): string {
 
 function fixpointRefused(example: SpecExample): string | undefined {
   const parsed = markdownToAdf(example.markdown)
-  assert.ok(parsed.ok, `example ${example.example} parsed to no document, not an error`)
+  assert.ok(parsed.ok, `example ${example.example} parsed to no document`)
   const emitted = adfToMarkdown(parsed.value)
   if (!emitted.ok) return emitted.error.code
   const again = markdownToAdf(emitted.value)
   assert.ok(again.ok, `example ${example.example} emits markdown it cannot read back`)
-  assert.deepStrictEqual(again.value, parsed.value, `example ${example.example} does not hold its own round-trip`)
+  assert.deepEqual(again.value, parsed.value, `example ${example.example} does not hold its own round-trip`)
   return undefined
 }
 
 function textMismatch(example: SpecExample): string | undefined {
   const parsed = markdownToAdf(example.markdown)
-  assert.ok(parsed.ok)
+  assert.ok(parsed.ok, `example ${example.example} parsed to no document`)
   const expected = referenceText(example.html)
   const actual = concatenatedText(parsed.value)
   return expected === actual ? undefined : `${JSON.stringify(expected)} against ${JSON.stringify(actual)}`
@@ -245,7 +309,7 @@ function textMismatch(example: SpecExample): string | undefined {
 
 function countMismatch(example: SpecExample): string | undefined {
   const parsed = markdownToAdf(example.markdown)
-  assert.ok(parsed.ok)
+  assert.ok(parsed.ok, `example ${example.example} parsed to no document`)
   const expected = referenceCounts(example.html)
   const actual = nodeCounts(parsed.value)
   const names = countKeys.filter((key) => expected[key] !== actual[key])
@@ -255,34 +319,38 @@ function countMismatch(example: SpecExample): string | undefined {
 for (const example of spec) {
   test(`CommonMark example ${example.example} => ${example.section}`, () => {
     const parse = markdownToAdf(example.markdown)
-    if (!parse.ok) {
-      assert.equal(exceptionIndex.get(`${example.example}:fixpoint`), undefined, `example ${example.example} is a named error but files a fixpoint exception`)
+    const refused = exampleToRefusal.get(example.example)
+    if (refused !== undefined) {
+      assert.ok(!parse.ok, `example ${example.example} was expected to refuse with ${refused} but parsed`)
+      assert.equal(parse.error.code, refused, `example ${example.example} refused with a different code`)
       return
     }
+    if (!parse.ok) assert.fail(`example ${example.example} was expected to parse but refused with ${parse.error.code}`)
 
-    const failures: Record = {
+    const divergences: Record = {
+      count: countMismatch(example),
       fixpoint: fixpointRefused(example),
       text: textMismatch(example),
-      count: countMismatch(example),
     }
-
-    for (const check of ['fixpoint', 'text', 'count'] as const) {
-      const filed = exceptionIndex.get(`${example.example}:${check}`)
-      const result = failures[check]
-      if (result === undefined) {
-        assert.equal(filed, undefined, `example ${example.example} passes its ${check} check but files an exception`)
+    for (const check of ['count', 'fixpoint', 'text'] as const) {
+      const entry = exceptionIndex.get(`${example.example}:${check}`)
+      const divergence = divergences[check]
+      if (divergence === undefined) {
+        assert.equal(entry, undefined, `example ${example.example} passes its ${check} check but files an exception`)
       } else {
-        assert.equal(typeof filed, 'string', `example ${example.example} ${check} check fails: ${result}`)
+        assert.ok(entry !== undefined, `example ${example.example} ${check} check fails: ${divergence}`)
+        assert.equal(entry.divergence, divergence, `example ${example.example} ${check} diverged differently than filed`)
       }
     }
   })
 }
 
 for (const entry of exceptions) {
-  test(`exception ${entry.example} ${entry.check} still diverges`, () => {
+  test(`exception ${entry.example} ${entry.check} still diverges as filed`, () => {
     const example = spec.find((candidate) => candidate.example === entry.example)
     assert.ok(example !== undefined, `exception ${entry.example} names no example in the suite`)
-    const failure = entry.check === 'fixpoint' ? fixpointRefused(example) : entry.check === 'text' ? textMismatch(example) : countMismatch(example)
-    assert.equal(typeof failure, 'string', `exception ${entry.example} ${entry.check} no longer diverges: ${entry.reason}`)
+    assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
+    const divergence = entry.check === 'fixpoint' ? fixpointRefused(example) : entry.check === 'text' ? textMismatch(example) : countMismatch(example)
+    assert.equal(divergence, entry.divergence, `exception ${entry.example} ${entry.check} changed from ${entry.divergence} to ${divergence ?? 'no divergence'}`)
   })
 }
diff --git a/todo.md b/todo.md
index c726567..4a073b2 100644
--- a/todo.md
+++ b/todo.md
@@ -67,9 +67,11 @@ The numbering is the order the work was planned in, not the order it ships.
         counts close both. The exception list stays the maintainer's, and one entry is owed
         already: 3h continues a list across the marker change CommonMark splits on, so an example
         the reference HTML gives two `
    ` counts one `bulletList`. One outcome is no - exception and must not be filed as one: valid CommonMark parsing to a document - `adfToMarkdown` refuses is a §2 hole, which is what `corpus/unspellable/` held until 3c, - 3e and 3h landed their answers and emptied it. + exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a + document `adfToMarkdown` refuses — which is what `corpus/unspellable/` held until 3c, 3e + and 3h landed their answers and emptied it. The permanent ones — a link destination or + title no escape spells, a paragraph opening with a code span — are the exceptions, named + by AGENTS.md §2. - [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the documents a human wrote — the thing that proves 2 and 3 beyond them. Editor-normal (§2) is finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip