Check in the CommonMark spec suite and pin its exception list #58
Reference in New Issue
Block a user
Delete Branch "commonmark-spec-suite"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Check in the CommonMark 0.31.2 spec suite as corpus data and run it against
markdownToAdfwith three checks (canonical fixpoint, reference-HTML text, element counts), gated by a maintainer-curated exception list.64 exceptions: 6 fixpoint (the §2 holes the flavour has no escape for), 5 text, 53 count (the mark model nests marks per text node where CommonMark nests HTML elements). The suite is vendored, not a dependency (AGENTS.md §5); the spec examples are CC-BY-SA-4.0.
Stability review
The three-check design is sound, the vendoring rationale holds up (
spec.jsonsha256d431b29d…c1b2a20matchesspec.commonmark.org/0.31.2/spec.jsonbyte for byte;commonmark-spec@0.31.2from npm is indeed CommonJS-only,require/exports, notype: "module"), and the exception list is a real pin rather than a mute in the directions it covers — adding an entry for a passing example fails twice, an entry naming no example fails, a duplicate(example, check)fails. No index drift: everything is keyed on the spec's ownexamplefield. No CI cost — 1277 tests in 878 ms on Node, 432 ms on Bun, ~1 s on Deno; green on all three plus the Firefox leg.Positive control confirms the suite adds real signal: corrupting code-span text in the parser produces 2 CommonMark failures the entire hand corpus misses.
Findings below are from fault injection against the branch, each reverted.
🔴 1. The refusal path asserts nothing — 86/652 examples are checked by nothing
src/commonmark-spec.test.ts:257-261The early return makes no assertion about the conversion. It checks only that no
fixpointexception is filed — and none is filed for any refusing example, so it is vacuously true for all of them, and stays vacuously true for any example that starts refusing. 86 of 652 examples take this path today (72unmappable-html, 14unmappable-image). The suite advertises 652 examples under three checks; 566 get them.todo.md3k says the three checks exist to prevent ("The fixpoint alone is self-consistency a parser returning the empty document passes…"), reintroduced one level up: a parser returning an error skips all three.foo, 391 examples, 334 previously converting — produces no failing test for 339 of them.It also contradicts the settled definition at
todo.md:59-60: "§2's canonical fixpoint: a named error, or markdown that parses and emits to itself byte for byte." The implementation names none of the 86.Smallest fix: treat a refusal as a fourth outcome that must be named — an
errorsmap ofexample → codecompared as onedeepEqual, so both an example that starts refusing and one that stops are failures, and the code is pinned too. Cheaper than 86 more exception entries.🟠 2. An exception pins that an example diverges, never how
src/commonmark-spec.test.ts:275and:286both assert onlytypeof … === 'string'. All three checks compute the exact divergence —countMismatchreturnsem 2/1,fixpointRefusedreturnsunspellable-link,textMismatchreturns both strings — and every caller discards it.reasonis free text compared to nothing.Two demonstrations:
markdownToAdfso[](./target.md)returns a paragraph reading literallyTOTAL GARBAGE— arbitrarily wrong output for example 484 — leaves all 718 tests passing. The same mutation on a non-excepted example ([link](/uri "title")) correctly fails 2 tests. So the suite is a genuine gate for the 588 unexcepted examples and blind for the 64 excepted example/check pairs.markTypeto always return'strong', so everyembecomesstrong— a catastrophic regression to precisely the mark-collapse logic these exceptions record — leaves all 53 count-excepted examples green and 0 of the 64 pin tests failing. Those 53 are the suite's only coverage of the*(*a*)*carve-out, so it gives zero regression protection for the rule most likely to regress.Fix: put the divergence in the entry (
"divergence": "em 2/1", the error code for fixpoint) and compare it. 53 short strings, and each entry becomes self-checking.🟠 3. Two
textexceptions record a defect in the test's own oracle, not in the libraryreferenceTextandconcatenatedTextdisagree structurally on whitespace, and in both cases the parser output is correct:foo bar): reference HTML is<p>foo\n\nbar</p>— CommonMark keeps the newlines literal — and the parser yields exactly"foo\n\nbar".referenceTextcollapses\n\nto one space, producing the mismatch. The filed reason ("CommonMark reads it a soft break") is not what the reference HTML shows.<h2>Bar</h2>\nbaz.referenceTextinserts a space at that boundary;concatenatedTextjoins every text node with''and inserts no block separator anywhere. The whole divergence is that one space.Both entries disappear if the two flatteners agree on block separation. As filed they read as library defects and will be chased as such.
🟠 4. The text oracle shares its entity decoder with the code under test
src/commonmark-spec.test.ts:8importsreadEntityReferencefrom./markdown/entity-references.ts— the same function the parse path uses atsrc/markdown/commonmark-grammar.ts:111. A bug in it corrupts both sides of the comparison identically and the check still passes. The reference HTML is meant to be the independent oracle; decoding it with the library's own decoder makes the text check blind to exactly the entity handling it appears to cover.🟡 5. The list conflates categories that deserve different fates
Nothing in the schema separates "accepted by design, will never change" from "known bug, not yet fixed". Grouping the 64:
todo.md3k anticipates. Permanent.[](./target.md)and[]()stay literal text; CommonMark renders an empty link. Unimplemented behaviour.[ẞ]against[SS]: /url. Narrow: ASCII case folding works (539, 541, 555, 561 all pass unexcepted), only Unicode full folding (ẞ→ss) fails. The reason reads as though case folding fails generally.484/487 are parser gaps carrying the same weight as 48 entries that will never move. A
kindfield keeps the second group visible.🟡 6. Heading level is invisible to the suite
src/commonmark-spec.test.ts:74, 111-112collapseh1–h6and everyheadingnode into one bucket. Injectinglevel 6 → 5in the parser produces zero CommonMark failures — caught only by the pre-existingcommonmark-subset/headingfixture and one unit test. Keying onh${level}on both sides costs one template literal.🟡 7.
AGENTS.md:205is now falsebrowser-tests/run.js:75excludes the new kind, while §10 still says the browser leg "converts the whole corpus". A reader concludes the spec suite is proved on SpiderMonkey; it is not — the only non-V8, non-JSC engine never sees it. Per §12 (a false claim in a doc is a bug, fixed where found) this belongs in the diff: name the three kinds it does convert, or drop "whole".🟡 8. Two decision records disagree, and the PR resolves it silently
todo.md:69-72, marked Settled (the maintainer, 2026-08-27): "One outcome is no exception and must not be filed as one: valid CommonMark parsing to a documentadfToMarkdownrefuses is a §2 hole." All sixfixpointentries (196, 202, 330, 331, 340, 502) are exactly that — they parse (:229assertsparsed.ok) and the emit refuses (:231).AGENTS.md:57, newer, says the opposite for one sub-case: "CommonMark spells link destinations the flavour has no escape for …todo.md3k's exception list names those." That blesses 196/202/502 but not 330/331/340 (unspellable-line-start). AGENTS.md §2 also still points attodo.mdfor the list, which now lives atcorpus/commonmark-spec/exceptions.json.Per §15 the resolution belongs in the diff — strike the stale
todo.mdsentence or drop the six entries. As it stands the next reader of 3k treats all six as bugs to fix.todo.md3k is also still unticked, though this PR implements it.🟢 Minor
src/commonmark-spec.test.ts:59-61— the test is named "the pinned 0.31.2 run" but asserts onlyspec.length === 652, and 0.30 also has 652 examples. The real pin holds (swapping 0.30'sspec.jsonin gives 79 failures), so this is naming, not a hole — but hashing the file would pin whatcorpus/README.mdactually claims ("vendored byte-exact").:234is the repo's onlyassert.deepStrictEqual; every other test file usesassert.deepEqual(8 incorpus.test.ts, 17 indirective-syntax.test.ts). Undernode:assert/strictthey are the same function — two spellings, one goal.:240, :248— bareassert.ok(parsed.ok)with no message, while:229has one. These fire from the pin loop when an excepted example stops parsing and report "The expression evaluated to a falsy value" with no example number.reasonis never checked non-empty;""satisfies every assertion.:67cites bare3e; AGENTS.md §14 already states the rule and is the stabler reference —3eis a ticked item whose text moves totodo-history.md.corpus/README.md:15, 19-21repeat "vendored" and "CommonMark 0.31.2"; only the licence and the gate carve-out are new information.Questions
todo.md:67says "The exception list stays the maintainer's." Were these 64 curated by hand, or derived and labelled so? That decides whether finding 8 is the author's call to make.mainalready carried the slack.Stability review — second pass
Every finding from the first pass was re-verified by re-running the original fault injections, not by reading the diff. Seven of eight are genuinely closed, one was renamed rather than fixed and is downgraded to non-blocking. The suite is 717 tests green in ~0.7 s; Node, Deno 2.9.6 and Bun 1.4.0 all pass 1277/1277; coverage holds at 100 lines / 100 functions / 98.25 branches.
The fixes are real, not restructuring:
unmappable-htmlpins matching the injected code — so the code is compared, not just the fact of refusalunmappable-image→unmappable-htmlat all four sites fails exactly 14 tests, precisely the image pinsmarkType→'strong'left 0 failures<h6>)(39,'text')and(300,'text')and adding nothingreadEntityReferencenamedEntitymap; the import is gonekind: 48mark-model, 6unspellable, 8pendingrefusals.jsonpinning each of the 86 refusing examples to its code is stronger than the singledeepEqualsuggested, and closes both directions — an example that starts refusing and one that stops.Three things the fix commits introduced, all fixed by deletion.
🔴 1. The numeric entity decoder cannot match — it is dead code that would also not work if reached
src/commonmark-spec.test.ts:171-188.referenceistext.slice(index, end + 1), so it includes the trailing;— while both numeric patterns anchor with$immediately after the digits:Exercised directly:
So
characterOfis unreachable, and the comment's claim that "named and numeric cover a future re-pin" is the opposite of true: at that re-pindecodeHtmlEntityreturnsundefinedandreferenceTextfalls through to pushing&,#,1,0,;as literal characters — a silently wrong oracle rather than an error. Keeping this is strictly worse than deleting it, because it reads as coverage that does not exist.Nothing will ever catch it:
--test-coverage-exclude="src/**/*.test.ts"puts the test file's own dead branches outside the 100% floor. AGENTS.md §11 — "no speculative generality — a second consumer, or it goes."Fix: delete
characterOf, both numeric branches and the unusedaposentry.decodeHtmlEntitycollapses to the lookup plus an early return.🔴 2.
corpus/README.md:20-21instructs a regeneration that does not exist, and the same PR says the oppositeThere is no generator — the repo's only scripts are
ci.sh,docker-runner.sh,publish.sh, andpackage.jsonhas justbuild,test,typecheck. Meanwhile this same PR addsAGENTS.md:66: "A spec bump is a deliberate re-pin, exceptions re-derived by hand beside it", andtodo.md:67still reads "The exception list stays the maintainer's."The lists are also visibly hand-written: example 540's two entries word the same reason differently —
"The case-folding shortcut reference is unresolved…"oncount,"A case-folding shortcut reference is unresolved…"ontext. A generator does not produce that drift.At the 0.32 re-pin a reader hunts for the regenerator, finds none, and either writes one unasked or hand-edits in defiance of the instruction they just read. AGENTS.md §12: a false claim in a doc is a bug, fixed where found. Fix: delete the sentence; the preceding one already gives each list's shape.
🔴 3.
src/commonmark-spec.test.ts:166-168— three comment lines that are false about the code below them(finding: …)narrates a review finding — self-justification by contrast with what it replaced. That is commit-message material, and the commit already carries it.namedEntityon the next line holds five keys, not four (aposnever occurs). And the suite does not hold "only the named four":öappears 3 times, in examples 31 and 630. That is harmless only because both are on the refusal list, so the oracle never reads their text — an invariant the comment does not state and nothing enforces. If either stops refusing,ödecodes to the literal textö, the same silent-wrong-oracle failure as finding 1.Fix: delete all three lines. With finding 1 applied, a four-entry map named
namedEntityneeds no comment.🟡 4. The 62 exception-pin tests detect nothing the per-example loop misses
:347-354duplicates:334-343. Under themarkType→'strong'injection, 39 pin tests failed and every one of their examples was already failing its own per-example test — zero unique detections. They cost 8.6% of the suite, each re-runningmarkdownToAdfup to four more times.Two assertions there are unique, both list hygiene:
:350(an exception naming an example absent fromspec.json) and:351(an exception whose example is also on the refusal list — a genuinely good catch). Fix: fold both into the meta test at:77as loops overexceptions, and drop the per-entry loop.🟡 5. Nothing enforces the rule todo.md 3k was just corrected to state
kindis shape-validated at:46and never read again. The rule "a fixable §2 hole must not be filed as an exception" now lives only in prose, so a later milestone can file{check:'fixpoint', kind:'pending'}for valid CommonMark the emitter refuses and the suite goes green — the exact outcome 3k forbids. The invariant already holds in the data (all 6 fixpoint entries areunspellable), so pinning it costs one line in the meta test.🟢 Minor
:73— the test is named "holds the full 652-example 0.31.2 run" but still asserts only the length, and 0.30 also has 652 examples. Downgraded from the last round: swapping 0.30's suite in wholesale (276 of 652 examples differ) leaves that test passing but fails 131 others, so the version is de-facto pinned by the refusal and exception data. Naming defect, not a coverage hole — drop "0.31.2" or pin something version-bearing.:86-87and:254-255are two-line comments;:86-87restates AGENTS.md §14, which it then cites.markdownToAdfcalls per non-refusing example (:320, plus one each incountMismatch/textMismatchand two infixpointRefused) — the same result computed five times. Not a problem at 2.9 s; parsing once and passing the document to the three checks would remove it.❓ Are 301/302 really
pending?corpus/README.md:20definespendingas "a parser gap a later milestone may close", buttodo.md:67-69presents the marker-change list split as the settled consequence of 3h ("one entry is owed already"). If the library will never split a list on marker change these are permanent and mislabelled. Since nothing assertskind, a mislabel is silent either way — finding 5 would catch this class.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.