diff --git a/AGENTS.md b/AGENTS.md index f3017b0..aaaa56a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,8 @@ deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev dependency — CommonJS-only, some fifty packages with React among them, and a release most days for Renovate to automerge — re-pinned by hand when a payload or a report shows the need. -`devDependencies`: few, each earning its keep; they never reach a consumer. +`devDependencies`: few, each earning its keep; they never reach a consumer. `fast-check` earns its +place shrinking a failing generated document to the nodes that break it. ## 6. The package contract @@ -225,6 +226,10 @@ compared against `undefined` — have a half no valid document reaches. The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from live Atlassian APIs; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`. +Beside the corpus, properties run over documents generated from the node tables, on a fixed seed in +the gate; `PROPERTY_RUNS=` raises the runs and randomizes the seed for local digging, and a +counterexample found becomes a round-trip fixture. + `spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes named before its first em dash, with the attributes following `Attributes: ` — a parenthesized diff --git a/ci.sh b/ci.sh index 5ab9650..dd3d712 100755 --- a/ci.sh +++ b/ci.sh @@ -16,7 +16,7 @@ if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then exit 1 fi -in_image "$deno_image" deno test --allow-read --no-check src/ +in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/ in_image "$bun_image" bun test src/ in_image "$node_image" npm run build diff --git a/package-lock.json b/package-lock.json index 54054ed..d7eeb19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@types/node": "24.13.3", + "fast-check": "4.10.0", "typescript": "7.0.2" }, "engines": { @@ -366,6 +367,46 @@ "node": ">=16.20.0" } }, + "node_modules/fast-check": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz", + "integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index 7a6670c..a188be2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@types/node": "24.13.3", + "fast-check": "4.10.0", "typescript": "7.0.2" } } diff --git a/src/adf-property.test.ts b/src/adf-property.test.ts new file mode 100644 index 0000000..bdc8eda --- /dev/null +++ b/src/adf-property.test.ts @@ -0,0 +1,154 @@ +import fc from 'fast-check' +import assert from 'node:assert/strict' +import { env } from 'node:process' +import test from 'node:test' + +import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' +import type { Arbitrary } from 'fast-check' +import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts' +import type { JsonValue } from './json-value.ts' +import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' +import { blockArgument } from './markdown/block-directive-arguments.ts' +import { blockDirectives } from './adf/block-directives.ts' +import { inlineDirectives } from './adf/inline-directives.ts' +import { isJsonValue } from './json-value.ts' +import { markAttributes } from './adf/mark-attributes.ts' +import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' +import { toEditorNormal } from './adf/editor-normal.ts' + +type Positions = { block: AdfNode; inline: AdfNode } + +const deepRunsVariable = 'PROPERTY_RUNS' +const gateRuns = 3000 +const gateSeed = 20260914 + +const depthIdentifier = fc.createDepthIdentifier() +const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } +const markdownCharacters = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é 🎉') +const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)]) + +function textOf(minLength: number): Arbitrary { + return fc.oneof( + { arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownCharacters }), weight: 4 }, + { arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 }, + ) +} + +const jsonValue = fc.jsonValue({ maxDepth: 2 }).filter(isJsonValue) +const text = textOf(1) +const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) + +const valueByKind: Readonly>> = { + boolean: fc.boolean(), + json: jsonValue, + number: fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }), + string: textOf(0), +} + +function attributes(vocabulary: AttributeVocabulary): Arbitrary { + const model = Object.fromEntries( + Object.entries(vocabulary).map(([key, kind]) => [key, fc.oneof({ arbitrary: fc.constant(undefined), weight: 2 }, { arbitrary: valueByKind[kind], weight: 1 })]), + ) + return fc.record(model).map(heldAttributes) +} + +function heldAttributes(held: Readonly>): AdfAttributes { + const attrs: AdfAttributes = {} + for (const [key, value] of Object.entries(held)) if (value !== undefined) attrs[key] = value + return attrs +} + +function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { + const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))] + return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' } +} + +const mark: Arbitrary = fc.oneof( + { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, + { arbitrary: fc.record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, +) +const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) + +const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) + +const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) => + fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })), +) + +const positions = fc.letrec((tie) => { + const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 }) + const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }) + const contentByModel = { + block: blockContent, + code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }), + inline: inlineContent, + none: fc.constant([]), + } + const blockMarks = fc.oneof({ arbitrary: fc.constant([]), weight: 4 }, { arbitrary: marks, weight: 1 }) + const blockNodes = Object.entries(blockDirectives).map(([type, directive]) => { + const argument = blockArgument(type) + const vocabulary: AttributeVocabulary = argument === undefined ? directive.attributes : { ...directive.attributes, [argument]: 'string' } + const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[directive.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })) + return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node } + }) + const unknownNode = fc + .record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) + .map((held): AdfNode => held) + const leafBlocks = blockNodes.filter((entry) => entry.leaf).map((entry) => entry.node) + const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node) + const misplacedWeight = 7 + const paragraph = inlineContent.map((content): AdfNode => ({ content, type: 'paragraph' })) + const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type })) + const listItems = fc.array( + blockContent.map((content): AdfNode => ({ content, type: 'listItem' })), + { depthIdentifier, maxLength: 3, minLength: 1 }, + ) + const commonMarkShapes = [ + blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), + listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), + fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), + fc + .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) + .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), + paragraph, + fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), + ] + return { + block: fc.oneof( + { depthIdentifier, depthSize: 'small', maxDepth: 4 }, + { arbitrary: fc.oneof(...leafBlocks), weight: leafBlocks.length * 2 }, + { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, + { arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, + { arbitrary: fc.oneof(...commonMarkShapes), weight: blockNodes.length * 2 + misplacedWeight }, + ), + inline: fc.oneof( + { depthIdentifier, depthSize: 'small', maxDepth: 4 }, + { arbitrary: textNode, weight: 12 }, + { arbitrary: fc.oneof(...inlineNodes), weight: 7 }, + { arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 }, + ), + } +}) + +const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 })) + +function runParameters(): { numRuns: number; seed?: number } { + const deepRuns = env[deepRunsVariable] + if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed } + const numRuns = Number(deepRuns) + assert.ok(Number.isSafeInteger(numRuns) && numRuns > 0, `${deepRunsVariable} holds a whole number of runs: found ${deepRuns}`) + return { numRuns } +} + +test('a generated document refuses to emit, or its markdown reads back to it', () => { + fc.assert( + fc.property(adfDocument, (document) => { + const emitted = adfToMarkdown(document) + if (!emitted.ok) return + const read = markdownToAdf(emitted.value) + assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) + assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`) + }), + runParameters(), + ) +}) diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 31a7f1d..33d752d 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -107,18 +107,6 @@ function roundTripFixtures(): { name: string; path: string }[] { ) } -test('no two round-trip documents share one markdown spelling', () => { - const spellings = new Map() - for (const fixture of roundTripFixtures()) { - const parsed: unknown = JSON.parse(readFileSync(fixture.path, 'utf8')) - assert.ok(isAdfDocument(parsed), `${fixture.name} is not an ADF document`) - const result = adfToMarkdown(parsed) - assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) - assert.equal(spellings.get(result.value), undefined, `${fixture.name} and ${spellings.get(result.value)} share one markdown spelling`) - spellings.set(result.value, fixture.name) - } -}) - test('no round-trip fixture repeats the document another holds', () => { const documents = new Map() for (const fixture of roundTripFixtures()) {