Check in the CommonMark spec suite and pin its exception list #58

Merged
lilleman merged 7 commits from commonmark-spec-suite into main 2026-09-12 20:32:00 +02:00
2 changed files with 33 additions and 50 deletions
Showing only changes of commit d1331a6612 - Show all commits
+1 -2
View File
@@ -17,8 +17,7 @@ One directory per contract kind, each landing with its milestone:
error `code`; `exceptions.json` pins each known divergence by `check`, `example`, `kind` and the 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 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 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, `pending` (a parser gap a later milestone may close).
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
+31 -47
View File
@@ -1,10 +1,11 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { AdfNode } from './adf/document.ts' import type { AdfDocument, AdfNode } from './adf/document.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'
@@ -70,12 +71,18 @@ const refusals = readJson('refusals.json', isRefusal, 'refusal')
const exampleToRefusal = new Map(refusals.map((refusal) => [refusal.example, refusal.code])) const exampleToRefusal = new Map(refusals.map((refusal) => [refusal.example, refusal.code]))
const exceptionIndex = new Map(exceptions.map((entry) => [`${entry.example}:${entry.check}`, entry])) 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', () => { test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
assert.equal(spec.length, 652) const digest = createHash('sha256').update(readFileSync(join(root, 'spec.json'))).digest('hex')
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
}) })
test('the exception list is unique per example and check', () => { test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => {
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')
for (const entry of exceptions) {
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`)
}
}) })
test('the refusal list is unique per example and names real examples', () => { test('the refusal list is unique per example and names real examples', () => {
@@ -83,8 +90,7 @@ test('the refusal list is unique per example and names real examples', () => {
for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`) for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`)
}) })
// A mark is counted once per text node it touches: nesting inside its own kind names the mark once, so // A mark is counted once per text node it touches (AGENTS.md §14).
// `*(*a*)*` is one `em` against two `<em>` elements (AGENTS.md §14).
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul'] 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',
@@ -163,10 +169,7 @@ 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 const namedEntity: Record<string, string> = { amp: '&', gt: '>', lt: '<', ouml: 'ö', quot: '"' }
// (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 { function decodeHtmlEntity(text: string, index: number): { length: number; text: string } | undefined {
if (text[index] !== '&') return undefined if (text[index] !== '&') return undefined
@@ -174,18 +177,16 @@ function decodeHtmlEntity(text: string, index: number): { length: number; text:
if (end === -1 || end - index > 8) return undefined if (end === -1 || end - index > 8) return undefined
const reference = text.slice(index, end + 1) const reference = text.slice(index, end + 1)
const named = namedEntity[reference.slice(1, -1)] const named = namedEntity[reference.slice(1, -1)]
if (named !== undefined) return { length: reference.length, text: named } return named === undefined ? undefined : { 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 { test('the oracle decodes every entity the reference HTML holds', () => {
if (codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return '\ufffd' for (const example of spec) {
return String.fromCodePoint(codePoint) for (const [reference] of example.html.matchAll(/&#?[0-9A-Za-z]+;/g)) {
assert.ok(decodeHtmlEntity(reference, 0) !== undefined, `example ${example.example} holds ${reference}, which the oracle would leave literal`)
} }
}
})
function referenceText(html: string): string { function referenceText(html: string): string {
const parts: string[] = [] const parts: string[] = []
@@ -251,8 +252,7 @@ function trimTrailingNewline(parts: string[]): void {
parts[parts.length - 1] = last.endsWith('\n') ? last.slice(0, -1) : last 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 newline beside a block open or close is a boundary rather than a soft break, so it spells no space.
// (a nested list, a following heading) is a block boundary and contributes nothing.
function followedByBlock(html: string, index: number): boolean { function followedByBlock(html: string, index: number): boolean {
let next = index let next = index
while (next < html.length && (html[next] === '\n' || html[next] === ' ' || html[next] === '\t')) next += 1 while (next < html.length && (html[next] === '\n' || html[next] === ' ' || html[next] === '\t')) next += 1
@@ -287,30 +287,24 @@ function concatenatedText(document: AdfNode): string {
return parts.join('') return parts.join('')
} }
function fixpointRefused(example: SpecExample): string | undefined { function fixpointRefused(example: SpecExample, document: AdfDocument): string | undefined {
const parsed = markdownToAdf(example.markdown) const emitted = adfToMarkdown(document)
assert.ok(parsed.ok, `example ${example.example} parsed to no document`)
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.deepEqual(again.value, parsed.value, `example ${example.example} does not hold its own round-trip`) assert.deepEqual(again.value, document, `example ${example.example} does not hold its own round-trip`)
return undefined return undefined
} }
function textMismatch(example: SpecExample): string | undefined { function textMismatch(example: SpecExample, document: AdfDocument): string | undefined {
const parsed = markdownToAdf(example.markdown)
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(document)
return expected === actual ? undefined : `${JSON.stringify(expected)} against ${JSON.stringify(actual)}` return expected === actual ? undefined : `${JSON.stringify(expected)} against ${JSON.stringify(actual)}`
} }
function countMismatch(example: SpecExample): string | undefined { function countMismatch(example: SpecExample, document: AdfDocument): string | undefined {
const parsed = markdownToAdf(example.markdown)
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(document)
const names = countKeys.filter((key) => expected[key] !== actual[key]) const names = countKeys.filter((key) => expected[key] !== actual[key])
return names.length === 0 ? undefined : names.map((name) => `${name} ${expected[name]}/${actual[name]}`).join(' ') return names.length === 0 ? undefined : names.map((name) => `${name} ${expected[name]}/${actual[name]}`).join(' ')
} }
@@ -327,9 +321,9 @@ for (const example of spec) {
if (!parse.ok) assert.fail(`example ${example.example} was expected to parse but refused with ${parse.error.code}`) if (!parse.ok) assert.fail(`example ${example.example} was expected to parse but refused with ${parse.error.code}`)
const divergences: Record<Check, string | undefined> = { const divergences: Record<Check, string | undefined> = {
count: countMismatch(example), count: countMismatch(example, parse.value),
fixpoint: fixpointRefused(example), fixpoint: fixpointRefused(example, parse.value),
text: textMismatch(example), text: textMismatch(example, parse.value),
} }
for (const check of ['count', 'fixpoint', 'text'] as const) { for (const check of ['count', 'fixpoint', 'text'] as const) {
const entry = exceptionIndex.get(`${example.example}:${check}`) const entry = exceptionIndex.get(`${example.example}:${check}`)
@@ -343,13 +337,3 @@ for (const example of spec) {
} }
}) })
} }
for (const entry of exceptions) {
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`)
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'}`)
})
}