Pin refusal codes and divergences; fix the text oracle and count by heading level
CI / gate (push) Successful in 18s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-06 23:09:01 +02:00
parent 12b57a06a9
commit dfbb92b7ab
6 changed files with 626 additions and 89 deletions
+6 -3
View File
@@ -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 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 — 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 where there is a way back. CommonMark spells some things the flavour has no escape for — a link
parse succeeding does not imply a spellable document; `todo.md` 3k's exception list names those. 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 "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 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. `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` 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. 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 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 one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
+7 -3
View File
@@ -12,9 +12,13 @@ One directory per contract kind, each landing with its milestone:
pins which error. pins which error.
- `real-payloads/``<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No - `real-payloads/``<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
expected markdown. expected markdown.
- `commonmark-spec/``spec.json` (the CommonMark 0.31.2 suite, vendored byte-exact) and - `commonmark-spec/`the CommonMark suite run against `markdownToAdf` by three checks. `spec.json`
`exceptions.json` (the examples a known divergence exempts, keyed by `check` and `example` with (0.31.2, vendored byte-exact) is the suite; `refusals.json` pins each refusing example to its
a `reason`). 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, 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 upstream machine-readable suite (CommonMark 0.31.2, CC-BY-SA-4.0, © John MacFarlane) and is not
+127 -13
View File
@@ -1,322 +1,436 @@
[ [
{
"check": "text",
"example": 39,
"reason": "`&#10;` decodes to a newline kept literal in text; CommonMark reads it a soft break."
},
{ {
"check": "fixpoint", "check": "fixpoint",
"divergence": "unspellable-link",
"example": 196, "example": 196,
"kind": "unspellable",
"reason": "The link title holds literal newlines no escape spells." "reason": "The link title holds literal newlines no escape spells."
}, },
{ {
"check": "fixpoint", "check": "fixpoint",
"divergence": "unspellable-link",
"example": 202, "example": 202,
"kind": "unspellable",
"reason": "The link destination holds a backslash the flavour cannot escape." "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", "check": "count",
"divergence": "ul 2/1",
"example": 301, "example": 301,
"kind": "pending",
"reason": "A list continuing past a marker change renders as two lists, the parser opens one." "reason": "A list continuing past a marker change renders as two lists, the parser opens one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "ol 2/1",
"example": 302, "example": 302,
"kind": "pending",
"reason": "A list continuing past a marker change renders as two lists, the parser opens one." "reason": "A list continuing past a marker change renders as two lists, the parser opens one."
}, },
{ {
"check": "fixpoint", "check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 330, "example": 330,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence." "reason": "A paragraph opens with a code span whose backticks read back as a fence."
}, },
{ {
"check": "fixpoint", "check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 331, "example": 331,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence." "reason": "A paragraph opens with a code span whose backticks read back as a fence."
}, },
{ {
"check": "fixpoint", "check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 340, "example": 340,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence." "reason": "A paragraph opens with a code span whose backticks read back as a fence."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/1",
"example": 369, "example": 369,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/1",
"example": 373, "example": 373,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 2/1",
"example": 389, "example": 389,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3",
"example": 393, "example": 393,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/5",
"example": 394, "example": 394,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/3",
"example": 395, "example": 395,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3",
"example": 399, "example": 399,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 404, "example": 404,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3",
"example": 406, "example": 406,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/1",
"example": 407, "example": 407,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/1",
"example": 408, "example": 408,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/1",
"example": 409, "example": 409,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3",
"example": 410, "example": 410,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3",
"example": 411, "example": 411,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 413, "example": 413,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 414, "example": 414,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 415, "example": 415,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 3/1",
"example": 417, "example": 417,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/5 strong 1/3",
"example": 418, "example": 418,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/2",
"example": 422, "example": 422,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/3",
"example": 424, "example": 424,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 2/1",
"example": 425, "example": 425,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 2/1",
"example": 426, "example": 426,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 2/1",
"example": 427, "example": 427,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/3",
"example": 428, "example": 428,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/3",
"example": 429, "example": 429,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/2",
"example": 430, "example": 430,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/2",
"example": 431, "example": 431,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3 strong 2/5",
"example": 432, "example": 432,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 1/2",
"example": 433, "example": 433,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/1",
"example": 461, "example": 461,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 2/1",
"example": 463, "example": 463,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 2/1",
"example": 464, "example": 464,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 2/1",
"example": 465, "example": 465,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 3/1",
"example": 466, "example": 466,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "strong 2/1",
"example": 468, "example": 468,
"kind": "mark-model",
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one." "reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3",
"example": 470, "example": 470,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 478, "example": 478,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 479, "example": 479,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "a 1/0",
"example": 484, "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", "check": "text",
"divergence": "\"\" against \"[](./target.md)\"",
"example": 484, "example": 484,
"kind": "pending",
"reason": "An empty link text stays literal text; CommonMark renders an empty link." "reason": "An empty link text stays literal text; CommonMark renders an empty link."
}, },
{ {
"check": "count", "check": "count",
"divergence": "a 1/0",
"example": 487, "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", "check": "text",
"divergence": "\"\" against \"[]()\"",
"example": 487, "example": 487,
"kind": "pending",
"reason": "An empty link text stays literal text; CommonMark renders an empty link." "reason": "An empty link text stays literal text; CommonMark renders an empty link."
}, },
{ {
"check": "fixpoint", "check": "fixpoint",
"divergence": "unspellable-link",
"example": 502, "example": 502,
"kind": "unspellable",
"reason": "The link destination holds a backslash the flavour cannot escape." "reason": "The link destination holds a backslash the flavour cannot escape."
}, },
{ {
"check": "count", "check": "count",
"divergence": "a 1/5 em 1/4",
"example": 516, "example": 516,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/3",
"example": 519, "example": 519,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "a 1/5 em 1/4",
"example": 530, "example": 530,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 533, "example": 533,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "a 1/0",
"example": 540, "example": 540,
"kind": "pending",
"reason": "The case-folding shortcut reference is unresolved; CommonMark folds case and links." "reason": "The case-folding shortcut reference is unresolved; CommonMark folds case and links."
}, },
{ {
"check": "text", "check": "text",
"divergence": "\"ẞ\" against \"[ẞ]\"",
"example": 540, "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", "check": "count",
"divergence": "a 1/2",
"example": 554, "example": 554,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "a 1/2",
"example": 558, "example": 558,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "a 1/2",
"example": 559, "example": 559,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 638, "example": 638,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
}, },
{ {
"check": "count", "check": "count",
"divergence": "em 1/2",
"example": 639, "example": 639,
"kind": "mark-model",
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element." "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
} }
] ]
+346
View File
@@ -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
}
]
+135 -67
View File
@@ -5,17 +5,19 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { AdfNode } from './adf/document.ts' import type { AdfNode } from './adf/document.ts'
import { readEntityReference } from './markdown/entity-references.ts'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus', 'commonmark-spec') const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus', 'commonmark-spec')
type Check = 'count' | 'fixpoint' | 'text' type Check = 'count' | 'fixpoint' | 'text'
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable'
type SpecExample = { example: number; html: string; markdown: string; section: string } 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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) 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' 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 { function isSpecExample(value: unknown): value is SpecExample {
if (!isRecord(value)) return false if (!isRecord(value)) return false
return typeof value['example'] === 'number' && typeof value['html'] === 'string' && typeof value['markdown'] === 'string' && typeof value['section'] === 'string' return typeof value['example'] === 'number' && typeof value['html'] === 'string' && typeof value['markdown'] === 'string' && typeof value['section'] === 'string'
} }
function isException(value: unknown): value is Exception { 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[] { function isRefusal(value: unknown): value is Refusal {
const parsed: unknown = JSON.parse(readFileSync(join(root, 'spec.json'), 'utf8')) if (!isRecord(value)) return false
assert.ok(Array.isArray(parsed), 'spec.json is not an array of examples') return typeof value['code'] === 'string' && value['code'].length > 0 && typeof value['example'] === 'number'
return parsed.map((value) => {
assert.ok(isSpecExample(value), 'spec.json holds an example with the wrong shape')
return value
})
} }
function readExceptions(): Exception[] { function readJson<T>(name: string, guard: (value: unknown) => value is T, shape: string): T[] {
const parsed: unknown = JSON.parse(readFileSync(join(root, 'exceptions.json'), 'utf8')) const parsed: unknown = JSON.parse(readFileSync(join(root, name), 'utf8'))
assert.ok(Array.isArray(parsed), 'exceptions.json is not an array') assert.ok(Array.isArray(parsed), `${name} is not an array`)
return parsed.map((value, index) => { 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 return value
}) })
} }
const spec = readSpec() const spec = readJson('spec.json', isSpecExample, 'spec example')
const exceptions = readExceptions() const exceptions = readJson('exceptions.json', isException, 'exception')
const exceptionIndex = new Map(exceptions.map((entry) => [`${entry.example}:${entry.check}`, entry.reason])) 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) 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') 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 `<em>`. test('the refusal list is unique per example and names real examples', () => {
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'heading', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul'] 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 `<em>` elements.
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul']
const nodeElement: Record<string, string> = { const nodeElement: Record<string, string> = {
blockquote: 'blockquote', blockquote: 'blockquote',
bulletList: 'ul', bulletList: 'ul',
codeBlock: 'pre', codeBlock: 'pre',
hardBreak: 'br', hardBreak: 'br',
heading: 'heading',
listItem: 'li', listItem: 'li',
media: 'img', media: 'img',
mediaInline: 'img', mediaInline: 'img',
@@ -108,8 +128,6 @@ function referenceCounts(html: string): Record<string, number> {
counts['pre'] = (counts['pre'] ?? 0) + 1 counts['pre'] = (counts['pre'] ?? 0) + 1
} else if (name === 'code' && inPre) { } else if (name === 'code' && inPre) {
// A code block's `<code>` is the `<pre>`'s body, already counted. // A code block's `<code>` is the `<pre>`'s body, already counted.
} else if (/^h[1-6]$/.test(name)) {
counts['heading'] = (counts['heading'] ?? 0) + 1
} else if (countKeys.includes(name)) { } else if (countKeys.includes(name)) {
counts[name] = (counts[name] ?? 0) + 1 counts[name] = (counts[name] ?? 0) + 1
} }
@@ -133,6 +151,12 @@ function nodeCounts(document: AdfNode): Record<string, number> {
for (const element of seen) counts[element] = (counts[element] ?? 0) + 1 for (const element of seen) counts[element] = (counts[element] ?? 0) + 1
continue 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] const element = nodeElement[node.type]
if (element !== undefined) counts[element] = (counts[element] ?? 0) + 1 if (element !== undefined) counts[element] = (counts[element] ?? 0) + 1
pending.push(...(node.content ?? [])) pending.push(...(node.content ?? []))
@@ -140,19 +164,45 @@ function nodeCounts(document: AdfNode): Record<string, number> {
return counts 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<string, string> = { 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 { function referenceText(html: string): string {
let out = '' const parts: string[] = []
let preDepth = 0 let preDepth = 0
let atBoundary = true
let skipNewline = false let skipNewline = false
for (let index = 0; index < html.length; index += 1) { for (let index = 0; index < html.length; index += 1) {
const character = html[index] const character = html.charAt(index)
if (character === '<') { if (character === '<') {
const close = html.indexOf('>', index) const close = html.indexOf('>', index)
if (close === -1) break if (close === -1) break
const tag = html.slice(index, close + 1) const tag = html.slice(index, close + 1)
const name = tagName(tag) const name = tagName(tag)
if (name === 'br') { if (name === 'br') {
out += '\n' parts.push(' ')
atBoundary = false
skipNewline = true skipNewline = true
index = close index = close
continue continue
@@ -160,10 +210,13 @@ function referenceText(html: string): string {
if (name === 'pre') { if (name === 'pre') {
if (tag.startsWith('</')) { if (tag.startsWith('</')) {
preDepth -= 1 preDepth -= 1
out = out.replace(/\n$/, '') trimTrailingNewline(parts)
} else { } else {
preDepth += 1 preDepth += 1
} }
atBoundary = true
} else {
atBoundary = blockTags.has(name)
} }
index = close index = close
continue continue
@@ -174,51 +227,62 @@ function referenceText(html: string): string {
continue continue
} }
if (preDepth > 0) { if (preDepth > 0) {
out += '\n' parts.push('\n')
continue continue
} }
let next = index + 1 if (!atBoundary && !followedByBlock(html, index + 1)) parts.push(' ')
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 += ' '
}
continue continue
} }
skipNewline = false const reference = decodeHtmlEntity(html, index)
const reference = readEntityReference(html, index)
if (reference !== undefined) { if (reference !== undefined) {
out += reference.text parts.push(reference.text)
atBoundary = false
index += reference.length - 1 index += reference.length - 1
continue 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 { function concatenatedText(document: AdfNode): string {
const parts: string[] = [] const parts: string[] = []
const pending: AdfNode[] = [document] const pending: { inCode: boolean; node: AdfNode }[] = [{ inCode: false, node: document }]
while (pending.length > 0) { while (pending.length > 0) {
const node = pending.pop() const frame = pending.pop()
if (node === undefined) continue if (frame === undefined) continue
const { inCode, node } = frame
if (node.text !== undefined) { if (node.text !== undefined) {
parts.push(node.text) parts.push(inCode ? node.text : node.text.replace(/\n/g, ' '))
continue continue
} }
if (node.type === 'hardBreak') { if (node.type === 'hardBreak') {
parts.push('\n') parts.push(' ')
continue continue
} }
const childInCode = inCode || node.type === 'codeBlock'
const content = node.content ?? [] const content = node.content ?? []
for (let index = content.length - 1; index >= 0; index -= 1) { for (let index = content.length - 1; index >= 0; index -= 1) {
const child = content[index] const child = content[index]
if (child !== undefined) pending.push(child) if (child !== undefined) pending.push({ inCode: childInCode, node: child })
} }
} }
return parts.join('') return parts.join('')
@@ -226,18 +290,18 @@ function concatenatedText(document: AdfNode): string {
function fixpointRefused(example: SpecExample): string | undefined { function fixpointRefused(example: SpecExample): string | undefined {
const parsed = markdownToAdf(example.markdown) 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) const emitted = adfToMarkdown(parsed.value)
if (!emitted.ok) return emitted.error.code if (!emitted.ok) return emitted.error.code
const again = markdownToAdf(emitted.value) const again = markdownToAdf(emitted.value)
assert.ok(again.ok, `example ${example.example} emits markdown it cannot read back`) 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 return undefined
} }
function textMismatch(example: SpecExample): string | undefined { function textMismatch(example: SpecExample): string | undefined {
const parsed = markdownToAdf(example.markdown) 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 expected = referenceText(example.html)
const actual = concatenatedText(parsed.value) const actual = concatenatedText(parsed.value)
return expected === actual ? undefined : `${JSON.stringify(expected)} against ${JSON.stringify(actual)}` 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 { function countMismatch(example: SpecExample): string | undefined {
const parsed = markdownToAdf(example.markdown) 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 expected = referenceCounts(example.html)
const actual = nodeCounts(parsed.value) const actual = nodeCounts(parsed.value)
const names = countKeys.filter((key) => expected[key] !== actual[key]) const names = countKeys.filter((key) => expected[key] !== actual[key])
@@ -255,34 +319,38 @@ function countMismatch(example: SpecExample): string | undefined {
for (const example of spec) { for (const example of spec) {
test(`CommonMark example ${example.example} => ${example.section}`, () => { test(`CommonMark example ${example.example} => ${example.section}`, () => {
const parse = markdownToAdf(example.markdown) const parse = markdownToAdf(example.markdown)
if (!parse.ok) { const refused = exampleToRefusal.get(example.example)
assert.equal(exceptionIndex.get(`${example.example}:fixpoint`), undefined, `example ${example.example} is a named error but files a fixpoint exception`) 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 return
} }
if (!parse.ok) assert.fail(`example ${example.example} was expected to parse but refused with ${parse.error.code}`)
const failures: Record<Check, string | undefined> = { const divergences: Record<Check, string | undefined> = {
count: countMismatch(example),
fixpoint: fixpointRefused(example), fixpoint: fixpointRefused(example),
text: textMismatch(example), text: textMismatch(example),
count: countMismatch(example),
} }
for (const check of ['count', 'fixpoint', 'text'] as const) {
for (const check of ['fixpoint', 'text', 'count'] as const) { const entry = exceptionIndex.get(`${example.example}:${check}`)
const filed = exceptionIndex.get(`${example.example}:${check}`) const divergence = divergences[check]
const result = failures[check] if (divergence === undefined) {
if (result === undefined) { assert.equal(entry, undefined, `example ${example.example} passes its ${check} check but files an exception`)
assert.equal(filed, undefined, `example ${example.example} passes its ${check} check but files an exception`)
} else { } 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) { 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) const example = spec.find((candidate) => candidate.example === entry.example)
assert.ok(example !== undefined, `exception ${entry.example} names no example in the suite`) 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(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
assert.equal(typeof failure, 'string', `exception ${entry.example} ${entry.check} no longer diverges: ${entry.reason}`) 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'}`)
}) })
} }
+5 -3
View File
@@ -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 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 already: 3h continues a list across the marker change CommonMark splits on, so an example
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
exception and must not be filed as one: valid CommonMark parsing to a document exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a
`adfToMarkdown` refuses is a §2 hole, which is what `corpus/unspellable/` held until 3c, document `adfToMarkdown` refuses which is what `corpus/unspellable/` held until 3c, 3e
3e and 3h landed their answers and emptied it. 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 - [ ] **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 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 finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip