From ec5c92d4fddfff99e31f9269ddfc8f1db975b2f8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:03:36 +0200 Subject: [PATCH 01/12] 4.3: the markdown property --- AGENTS.md | 6 +- package.json | 2 +- src/adf-property.test.ts | 164 +------------- src/markdown-property.test.ts | 394 ++++++++++++++++++++++++++++++++++ src/property-generators.ts | 166 ++++++++++++++ tsconfig.build.json | 2 +- 6 files changed, 567 insertions(+), 167 deletions(-) create mode 100644 src/markdown-property.test.ts create mode 100644 src/property-generators.ts diff --git a/AGENTS.md b/AGENTS.md index 9786496..a1c5beb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,9 +227,9 @@ compared against `undefined` โ€” have a half no valid document reaches. The corpus, all checked in: hand-built fixtures per node and combination; real ADF Atlassian's editor wrote; 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. +Beside the corpus, properties run over documents generated from the node tables and over generated +markdown, 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 diff --git a/package.json b/package.json index a188be2..5b86fde 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", - "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", + "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=src/property-generators.ts --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" }, "devDependencies": { diff --git a/src/adf-property.test.ts b/src/adf-property.test.ts index 4343cb1..4e54660 100644 --- a/src/adf-property.test.ts +++ b/src/adf-property.test.ts @@ -1,173 +1,13 @@ 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 { adfDocument, propertyRuns, propertyTimeout } from './property-generators.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 { 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 = 1600 -const gateSeed = 20260914 -// Bun's test runner stops a test after five seconds unless the test sets its own timeout. -const propertyTimeout = 600000 - -const depthIdentifier = fc.createDepthIdentifier() -const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } -const flatCommonMarkShapeWeight = 4 -const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~รฉ\xa0๐ŸŽ‰', ':a[', ':a{', 'ab:', 'http://') -const nestingCommonMarkShapeWeight = 21 -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: markdownPieces }), weight: 4 }, - { arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 }, - ) -} - -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 numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) - -// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected. -const keyPiece = fc - .oneof({ arbitrary: markdownPieces, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 }) - .filter((piece) => !/[\\"\x00-\x1f]/.test(piece)) -const jsonKey = fc.string({ maxLength: 8, unit: keyPiece }) - -const { jsonValue } = fc.letrec<{ jsonValue: JsonValue }>((tie) => ({ - jsonValue: fc.oneof( - { depthSize: 'small', maxDepth: 2 }, - fc.oneof(fc.constant(null), fc.boolean(), numberValue, textOf(0)), - fc.array(tie('jsonValue'), { maxLength: 3 }), - fc.dictionary(jsonKey, tie('jsonValue'), { maxKeys: 3, noNullPrototype: true }), - ), -})) - -const valueByKind: Readonly>> = { - boolean: fc.boolean(), - json: jsonValue, - number: numberValue, - 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(jsonKey, 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 autolinkTextNode = fc - .record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks }) - .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' })) - -const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) => - fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })), -) - -function weighted(arbitraries: readonly Arbitrary[], weight: number): { arbitrary: Arbitrary; weight: number }[] { - return arbitraries.map((arbitrary) => ({ arbitrary, weight })) -} - -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(jsonKey, 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 flatCommonMarkShapes = [ - fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), - 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), - ] - const nestingCommonMarkShapes = [ - blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), - listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), - 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' })), - ] - const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)] - return { - block: fc.oneof( - { depthIdentifier, depthSize: 'small', maxDepth: 4 }, - { arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) }, - { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, - { arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, - { arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight }, - ), - inline: fc.oneof( - { depthIdentifier, depthSize: 'small', maxDepth: 4 }, - { arbitrary: textNode, weight: 12 }, - { arbitrary: autolinkTextNode, weight: 2 }, - { 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 } - assert.ok(/^[1-9]\d*$/.test(deepRuns), `${deepRunsVariable} is a run count in digits, such as ${deepRunsVariable}=10000: found ${JSON.stringify(deepRuns)}`) - return { numRuns: Number(deepRuns) } -} test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => { fc.assert( @@ -178,6 +18,6 @@ test('a generated document refuses to emit, or its markdown reads back to it', { 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(), + propertyRuns(gateRuns), ) }) diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts new file mode 100644 index 0000000..b0cd732 --- /dev/null +++ b/src/markdown-property.test.ts @@ -0,0 +1,394 @@ +import fc from 'fast-check' +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { Arbitrary, DepthIdentifier } from 'fast-check' +import type { AttributeVocabulary } from './adf/attribute-vocabulary.ts' +import type { JsonValue } from './json-value.ts' +import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-generators.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 { carryName } from './markdown/opaque-carry.ts' +import { fencedCodeBlock } from './markdown/backtick-runs.ts' +import { inlineDirectives } from './adf/inline-directives.ts' +import { listBreakName } from './markdown/list-break.ts' +import { markAttributes } from './adf/mark-attributes.ts' +import { markSpelling } from './markdown/mark-spellings.ts' +import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' +import { marksAttribute } from './markdown/block-directive-marks.ts' +import { serializeCanonicalJson } from './canonical-json.ts' +import { spellAttributes, spellJsonAttribute, spellLeafDirective, spellStringAttribute, spellVocabulary } from './markdown/directive-syntax.ts' +import { textDirectiveName } from './markdown/text-directive.ts' +import { toEditorNormal } from './adf/editor-normal.ts' +import { vocabularyPairs } from './adf/attribute-vocabulary.ts' + +type Choice = { arbitrary: Arbitrary; hostile?: true; weight: number } + +type Edit = [at: number, removed: number, inserted: string] + +const gateRuns = 1000 + +const vocabularies = [...Object.values(blockDirectives).map((directive) => directive.attributes), ...Object.values(inlineDirectives).map((directive) => directive.attributes), ...Object.values(markAttributes)] +const attributeKeys = [ + ...new Set([...vocabularies.flatMap((vocabulary) => Object.keys(vocabulary)), ...Object.keys(blockDirectives).flatMap((type) => blockArgument(type) ?? []), marksAttribute, 'json', textDirectiveName]), +] +const directiveNames = [...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes), carryName, listBreakName, textDirectiveName] + +// Hostile generation reaches refusals; clean generation holds none a single piece would trip, so a whole document reaches the emitter. +function choose(hostile: boolean, choices: readonly Choice[], depth?: { depthIdentifier: DepthIdentifier; maxDepth: number }): Arbitrary { + const held = choices.filter((choice) => hostile || choice.hostile !== true).map(({ arbitrary, weight }) => ({ arbitrary, weight })) + return depth === undefined ? fc.oneof(...held) : fc.oneof({ ...depth, depthSize: 'small' }, ...held) +} + +const bareToken = fc.stringMatching(/^[A-Za-z0-9_-]{1,8}$/) +const prose = fc.stringMatching(/^[A-Za-z][a-z]{0,6}(?: [a-z]{1,6}){0,3}$/) +const cleanText = fc.string({ maxLength: 12, unit: fc.constantFrom(...'aZ09 \t!"#$%&\'()*+,-./;=?@[\\]^_`{}~รฉ\xa0๐ŸŽ‰') }) +const syntaxTokens = fc.constantFrom( + ...[...String.fromCodePoint(0x0, 0xb, 0xc, 0x85, 0xa0, 0x200b, 0x2028, 0x3000, 0xfeff)], + '\n', + '\r\n', + '\r', + '\t', + ' ', + ' \n', + '\\\n', + '**', + '__', + '~~', + '***', + '```', + '~~~', + '![', + '](', + '{}', + '::', + ':::', + '> ', + '- ', + '* ', + '1. ', + '2) ', + '# ', + '---', + '===', + '| ', + ' |', + '', + '&', + '&#', +) +const piece = fc.oneof(markdownPieces, syntaxTokens) + +const namedEntity = fc.constantFrom('&', '<', '"', '©', ' ', 'ö', '&bogus;', '&', '&#;', '&', '≧̸') +const numericEntity = fc + .tuple(fc.oneof(fc.integer({ max: 0x7f, min: 0 }), fc.integer({ max: 0xffff, min: 0 }), fc.integer({ max: 0x110000, min: 0 })), fc.boolean()) + .map(([code, hex]) => (hex ? `&#x${code.toString(16)};` : `&#${code};`)) +const escape = fc.constantFrom(...'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~a \n').map((escaped) => `\\${escaped}`) +const backticks = fc.integer({ max: 3, min: 1 }).map((count) => '`'.repeat(count)) +const codeSpan = fc + .tuple(backticks, fc.oneof(prose, textOf(0)), fc.oneof({ arbitrary: fc.constant(undefined), weight: 4 }, { arbitrary: backticks, weight: 1 })) + .map(([opener, body, closer]) => `${opener}${body}${closer ?? opener}`) +const autolink = fc.oneof( + fc.tuple(fc.constantFrom('http://', 'https://', 'mailto:', 'ab:', 'x+y.z-:'), prose).map(([scheme, rest]) => `<${scheme}${rest.replaceAll(' ', '/')}>`), + fc.stringMatching(/^<[a-z.+]{1,6}@[a-z-]{1,6}(?:\.[a-z]{1,4})?>$/), +) +const hostileAutolink = fc.tuple(fc.constantFrom('http://', 'ab:', 'a:'), textOf(0)).map(([scheme, rest]) => `<${scheme}${rest}>`) +const inlineHtml = fc.constantFrom('', '', '', "", '', '', '', '', '', '
', '') +const hardBreak = fc.constantFrom('\\\n', ' \n', '\n', ':hardBreak{}') +const textDirective = fc.constantFrom(' ', ' ', '\t', '\n', '\n\n').map((held) => `:${textDirectiveName}{${textDirectiveName}=${spellStringAttribute(held)}}`) +const hostileTextDirective = fc.constantFrom(' \n', 'a', '').map((held) => `:${textDirectiveName}{${textDirectiveName}=${spellStringAttribute(held)}}`) + +const url = fc.stringMatching(/^https?:\/\/[a-z]{1,6}\.[a-z]{2,3}(?:\/[a-z0-9()]{0,5})?$/) +const title = (text: Arbitrary) => fc.oneof(fc.constant(''), text.map((held) => ` "${held}"`), text.map((held) => ` '${held}'`), text.map((held) => ` (${held})`)) + +const attributeValue = fc.oneof( + { arbitrary: bareToken, weight: 3 }, + { arbitrary: fc.constantFrom('true', 'false', '0', '1', '3', '-1', '1.5', '1e2', '"1"', '01', 'null', '"[]"', '"{}"', '"#deebff"'), weight: 2 }, + { arbitrary: textOf(0).map(spellStringAttribute), weight: 3 }, + { arbitrary: jsonValue.map(spellJsonAttribute), weight: 2 }, + { arbitrary: textOf(0).map((held) => JSON.stringify(held)), weight: 1 }, + { arbitrary: textOf(0).map((held) => `"${held}`), weight: 1 }, +) +const attributePairs = fc.uniqueArray(fc.tuple(fc.oneof({ arbitrary: fc.constantFrom(...attributeKeys), weight: 4 }, { arbitrary: bareToken, weight: 1 }), attributeValue), { + maxLength: 3, + minLength: 1, + selector: ([key]) => key, +}) +const hostileAttributes = fc.oneof( + { arbitrary: fc.constant(''), weight: 3 }, + { + arbitrary: fc.tuple(attributePairs, fc.boolean()).map(([pairs, sorted]) => { + const ordered = sorted ? pairs.toSorted(([left], [right]) => (left < right ? -1 : 1)) : pairs + return `{${ordered.map(([key, value]) => `${key}=${value}`).join(' ')}}` + }), + weight: 6, + }, + { arbitrary: fc.constantFrom('{}', '{ }', '{a}', '{a=}', '{=b}', '{a=b', '{a=b c=d}', '{a="}"}'), weight: 1 }, +) + +function tableAttributes(vocabulary: AttributeVocabulary, slot?: string): Arbitrary { + return attributes(vocabulary).map((attrs) => spellAttributes(spellVocabulary(vocabularyPairs(attrs, vocabulary, slot === undefined ? [] : [slot]) ?? []))) +} + +const directiveName = fc.oneof({ arbitrary: fc.constantFrom(...directiveNames), weight: 8 }, { arbitrary: fc.stringMatching(/^[A-Za-z0-9-]{1,6}$/), weight: 1 }) +const hostileArgument = fc.oneof( + { arbitrary: fc.constant(''), weight: 3 }, + { arbitrary: fc.constantFrom(' info', ' warning', ' custom', ' DONE', ' TODO'), weight: 2 }, + { arbitrary: fc.oneof(bareToken.map((held) => ` ${held}`), fc.constantFrom(' info', ' a b', ' "a"')), weight: 1 }, +) + +function directiveHeader(colons: number, name: string, argument: string, attrs: string): string { + return `${':'.repeat(colons)}${name}${argument}${attrs === '' ? '' : ` ${attrs}`}` +} + +function container(header: (colons: number) => string, body: string, closer: number | null): string { + const colons = Math.max(2, ...[...body.matchAll(/:{2,}/g)].map(([run]) => run.length)) + 1 + return [header(colons), ...(body === '' ? [] : [body]), ':'.repeat(closer ?? colons)].join('\n') +} + +const carriedNode = fc.oneof( + fc.tuple(fc.constantFrom('mention', 'paragraph', 'status', 'widget'), fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true })).map(([type, attrs]): JsonValue => ({ attrs, type })), + textOf(1).map((held): JsonValue => ({ text: held, type: 'text' })), +) +const inlineCarry = carriedNode.map((node) => `:${carryName}{json=${spellStringAttribute(serializeCanonicalJson(node, 'compact'))}}`) +const hostileInlineCarry = fc.oneof(carriedNode, jsonValue).map((node) => `:${carryName}{json=${spellStringAttribute(JSON.stringify(node, null, 1))}}`) +const blockCarry = carriedNode.map((node) => fencedCodeBlock(carryName, serializeCanonicalJson(node, 'two-space'))) +const hostileBlockCarry = fc.oneof(carriedNode, jsonValue).map((node) => fencedCodeBlock(carryName, JSON.stringify(node))) + +function prefixLines(body: string, first: string, rest: (index: number) => string): string { + return body + .split('\n') + .map((line, index) => (index === 0 ? `${first}${line}` : line === '' ? rest(index).trimEnd() : `${rest(index)}${line}`)) + .join('\n') +} + +const separator = fc.oneof({ arbitrary: fc.constant('\n\n'), weight: 4 }, { arbitrary: fc.constant('\n'), weight: 3 }, { arbitrary: fc.constantFrom('\n\n\n', '\n \n', '\n\t\n'), weight: 1 }) +const quotePrefix = fc.oneof({ arbitrary: fc.constant('> '), weight: 6 }, { arbitrary: fc.constantFrom('>', ' > ', '> ', '>\t', ''), weight: 1 }) +const listMarker = fc.oneof( + { arbitrary: fc.constantFrom('-', '*', '+'), weight: 3 }, + { + arbitrary: fc + .tuple(fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 1000000000, min: 0 }), weight: 1 }), fc.constantFrom('.', ')')) + .map(([start, delimiter]) => `${start}${delimiter}`), + weight: 2, + }, +) +const indentDrift = fc.oneof({ arbitrary: fc.constant(0), weight: 6 }, { arbitrary: fc.integer({ max: 2, min: -2 }), weight: 1 }) +const closerDrift = fc.option(fc.integer({ max: 5, min: 2 }), { freq: 6 }) + +function markdownOf(hostile: boolean): Arbitrary { + const blockDepth = fc.createDepthIdentifier() + const inlineDepth = fc.createDepthIdentifier() + const text = hostile ? fc.oneof(cleanText, textOf(0)) : cleanText + const word = fc.oneof({ arbitrary: prose, weight: 3 }, { arbitrary: text.filter((held) => held !== ''), weight: 2 }) + const destination = fc.oneof(url, text, fc.stringMatching(/^<[0-9.#][a-z0-9 ]{0,6}>$/), fc.constant(''), ...(hostile ? [text.map((held) => `<${held}>`)] : [])) + const label = fc.oneof(prose, word) + + const { inlines } = fc.letrec<{ inline: string; inlines: string }>((tie) => ({ + inline: choose( + hostile, + [ + { arbitrary: word, weight: 12 }, + { arbitrary: fc.oneof(codeSpan, autolink, namedEntity, numericEntity, escape, hardBreak, textDirective, inlineCarry), weight: 8 }, + { arbitrary: fc.oneof(hostileAutolink, hostileInlineCarry, hostileTextDirective, inlineHtml, piece), hostile: true, weight: 4 }, + { + arbitrary: fc + .tuple(fc.constantFrom('*', '_', '**', '__', '***', '~~', '~'), fc.constantFrom('', '', ' '), tie('inlines'), fc.constantFrom('', '', ' '), fc.option(fc.constantFrom('*', '_', '**', '~~'), { freq: 4 })) + .map(([opener, inside, body, closing, closer]) => `${opener}${inside}${body}${closing}${closer ?? opener}`), + weight: 4, + }, + { + arbitrary: fc + .tuple(fc.constantFrom('', '', hostile ? '!' : ''), tie('inlines'), fc.oneof(fc.tuple(destination, title(text)).map(([target, titled]) => `(${target}${titled})`), label.map((held) => `[${held}]`), fc.constantFrom('', '[]'))) + .map(([image, content, target]) => `${image}[${content}]${target}`), + weight: 3, + }, + { + arbitrary: fc.oneof( + ...Object.entries(inlineDirectives).map(([name, directive]) => + fc + .tuple(directive.textAttribute === undefined ? fc.constant(null) : fc.option(hostile ? word : prose), tableAttributes(directive.attributes, directive.textAttribute)) + .map(([slot, attrs]) => (slot === null ? spellLeafDirective(name, attrs) : `:${name}[${slot}]${attrs}`)), + ), + ...Object.entries(markAttributes) + .filter(([name]) => hostile || markSpelling(name)?.kind === 'directive') + .map(([name, vocabulary]) => fc.tuple(tie('inlines'), tableAttributes(vocabulary)).map(([content, attrs]) => `:${name}[${content}]${attrs}`)), + ), + weight: 2, + }, + { + arbitrary: fc.tuple(directiveName, fc.option(tie('inlines'), { freq: 3 }), hostileAttributes).map(([name, content, attrs]) => `:${name}${content === null ? '' : `[${content}]`}${attrs}`), + hostile: true, + weight: 2, + }, + ], + { depthIdentifier: inlineDepth, maxDepth: 3 }, + ), + inlines: fc.array(tie('inline'), { depthIdentifier: inlineDepth, maxLength: 4, minLength: 1 }).map((parts) => parts.join('')), + })) + + const oneLine = inlines.map((held) => held.replace(/[\n\r]/g, ' ')) + const fencedCode = fc + .tuple( + fc.constantFrom('```', '```', '~~~', '````', '``'), + fc.oneof(fc.constant(''), bareToken, text), + fc.array(fc.oneof(prose, text, fc.constantFrom('```', '~~~', ':::', ' x')), { maxLength: 3 }), + fc.constantFrom('', '', '`', '~', 'none'), + ) + .map(([fence, info, lines, closer]) => [`${fence}${info}`, ...lines, ...(closer === 'none' ? [] : [`${fence}${closer}`])].join('\n')) + const pipeTable = fc + .record({ + body: fc.array(fc.array(oneLine, { maxLength: 3 }), { maxLength: 2 }), + delimiter: fc.array(fc.constantFrom('---', '-', ':--', '--:', ':-:', '', '==='), { maxLength: 3, minLength: 1 }), + header: fc.array(oneLine, { maxLength: 3, minLength: 1 }), + leading: fc.boolean(), + regular: hostile ? fc.boolean() : fc.constant(true), + trailing: fc.boolean(), + }) + .map(({ body, delimiter, header, leading, regular, trailing }) => { + const row = (cells: readonly string[]) => (regular || leading ? `| ${cells.join(' | ')}` : cells.join(' | ')) + (regular || trailing ? ' |' : '') + const width = (cells: readonly string[]) => (regular ? header.map((_, index) => cells[index] ?? '') : cells) + return [row(header), row(regular ? header.map(() => '---') : delimiter), ...body.map((cells) => row(width(cells)))].join('\n') + }) + + const leafBlock = choose(hostile, [ + { + arbitrary: fc + .tuple(fc.integer({ max: 7, min: 1 }), fc.constantFrom(' ', ' ', '', '\t'), oneLine, fc.constantFrom('', '', ' #', '#', ' ## ')) + .map(([level, gap, content, closer]) => `${'#'.repeat(level)}${gap}${content}${closer}`), + weight: 3, + }, + { + arbitrary: fc.tuple(inlines, fc.constantFrom('=', '-'), fc.integer({ max: 4, min: 1 }), fc.constantFrom('', ' ')).map(([content, underline, length, trailing]) => `${content}\n${underline.repeat(length)}${trailing}`), + weight: 2, + }, + { arbitrary: fc.constantFrom('---', '***', '___', '- - -', ' * * *', '_____', '--', '*-*'), weight: 1 }, + { arbitrary: fencedCode, weight: 2 }, + { arbitrary: blockCarry, weight: 1 }, + { + arbitrary: fc.tuple(fc.constantFrom(' ', '\t', ' '), fc.array(fc.oneof(prose, text), { maxLength: 3, minLength: 1 })).map(([indent, lines]) => lines.map((line) => `${indent}${line}`).join('\n')), + weight: 1, + }, + { arbitrary: pipeTable, weight: 2 }, + { arbitrary: fc.tuple(label, destination, title(text)).map(([name, target, titled]) => `[${name}]: ${target}${titled}`), weight: 1 }, + { arbitrary: fc.tuple(word, fc.oneof(url, fc.constant(''))).map(([alt, target]) => `![${alt}](${target})`), weight: 1 }, + { arbitrary: hostileBlockCarry, hostile: true, weight: 1 }, + { + arbitrary: fc.constantFrom('
\ntext\n
', '', '
\nx\n
', '', '', '', '', '', ''), + hostile: true, + weight: 1, + }, + { + arbitrary: fc.tuple(fc.constantFrom(2, 2, 3, 1, 4), directiveName, hostileArgument, hostileAttributes).map(([colons, name, argument, attrs]) => directiveHeader(colons, name, argument, attrs)), + hostile: true, + weight: 1, + }, + { arbitrary: fc.constantFrom(':::', '::', '::::', ':::panel', '::: panel', ':::panel info extra'), hostile: true, weight: 1 }, + ]) + + const { blocks } = fc.letrec<{ block: string; blocks: string }>((tie) => { + const bodyByModel = { block: fc.oneof(tie('blocks'), fc.constant('')), code: fencedCode, inline: fc.oneof(oneLine, fc.constant('')) } + const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => { + const argument = + blockArgument(name) === undefined + ? fc.constant('') + : fc.oneof({ arbitrary: fc.constantFrom(' DONE', ' TODO', ' custom', ' info', ' warning'), weight: 3 }, { arbitrary: bareToken.map((held) => ` ${held}`), weight: 1 }) + const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes) + if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => directiveHeader(2, name, held, spelled)) + return fc + .tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null)) + .map(([held, spelled, body, closer]) => container((colons) => directiveHeader(colons, name, held, spelled), body, closer)) + }) + return { + block: choose( + hostile, + [ + { arbitrary: fc.array(inlines, { maxLength: 3, minLength: 1 }).map((lines) => lines.join('\n')), weight: 10 }, + { arbitrary: leafBlock, weight: 10 }, + { + arbitrary: fc.tuple(tie('blocks'), fc.array(quotePrefix, { maxLength: 3, minLength: 1 })).map(([body, prefixes]) => prefixLines(body, '> ', (index) => prefixes[index % prefixes.length] ?? '')), + weight: 3, + }, + { + arbitrary: fc + .tuple(listMarker, fc.array(fc.tuple(fc.option(listMarker, { freq: 4 }), fc.constantFrom(' ', ' ', ' ', ' ', '\t', '', ' '), tie('blocks'), indentDrift), { maxLength: 3, minLength: 1 }), separator) + .map(([listed, items, gap]) => + items + .map(([own, space, body, drift]) => { + const marker = own ?? listed + return prefixLines(body, `${marker}${space}`, () => ' '.repeat(Math.max(0, marker.length + space.length + drift))) + }) + .join(gap === '\n\n' ? '\n\n' : '\n'), + ), + weight: 4, + }, + { arbitrary: fc.oneof(...tableDirectives), weight: 3 }, + { + arbitrary: fc + .tuple(directiveName, hostileArgument, hostileAttributes, tie('blocks'), fc.option(fc.integer({ max: 5, min: 3 }), { freq: 2 }), closerDrift) + .map(([name, argument, attrs, body, opener, closer]) => container((colons) => directiveHeader(opener ?? colons, name, argument, attrs), body, closer)), + hostile: true, + weight: 1, + }, + ], + { depthIdentifier: blockDepth, maxDepth: 3 }, + ), + blocks: fc + .array(fc.tuple(separator, tie('block')), { depthIdentifier: blockDepth, maxLength: 3, minLength: 1 }) + .map((entries) => entries.map(([gap, block], index) => (index === 0 ? block : `${gap}${block}`)).join('')), + } + }) + return blocks +} + +const cleanMarkdown = markdownOf(false) +const hostileMarkdown = markdownOf(true) + +const canonical = adfDocument.map((document) => { + const emitted = adfToMarkdown(document) + return emitted.ok ? emitted.value : '' +}) + +function edited(markdown: string, edits: readonly Edit[]): string { + let text = markdown + for (const [at, removed, inserted] of edits) { + const index = at % (text.length + 1) + text = `${text.slice(0, index)}${inserted}${text.slice(index + removed)}` + } + return text +} + +const edit: Arbitrary = fc.tuple(fc.nat(), fc.nat({ max: 3 }), fc.oneof(piece, fc.constant(''))) + +const markdown = fc.oneof( + { arbitrary: cleanMarkdown, weight: 4 }, + { arbitrary: hostileMarkdown, weight: 2 }, + { arbitrary: canonical, weight: 2 }, + { arbitrary: fc.tuple(fc.oneof(cleanMarkdown, canonical), fc.array(edit, { maxLength: 3, minLength: 1 })).map(([held, edits]) => edited(held, edits)), weight: 3 }, + { arbitrary: fc.string({ maxLength: 40, unit: piece }), weight: 1 }, +) + +const document = fc + .tuple(markdown, fc.oneof({ arbitrary: fc.constant('\n'), weight: 8 }, { arbitrary: fc.constantFrom('\r\n', '\r'), weight: 1 }), fc.constantFrom('', '', '\n', ' \n')) + .map(([held, ending, trailing]) => `${held}${trailing}`.replaceAll('\n', ending)) + +test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => { + fc.assert( + fc.property(document, (input) => { + const parsed = markdownToAdf(input) + if (!parsed.ok) return + const emitted = adfToMarkdown(parsed.value) + 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), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`) + const respelled = adfToMarkdown(read.value) + assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} โ€” spelling ${JSON.stringify(emitted.value)} again`) + assert.equal(respelled.value, emitted.value) + }), + propertyRuns(gateRuns), + ) +}) + diff --git a/src/property-generators.ts b/src/property-generators.ts new file mode 100644 index 0000000..4e38daf --- /dev/null +++ b/src/property-generators.ts @@ -0,0 +1,166 @@ +import fc from 'fast-check' +import assert from 'node:assert/strict' +import { env } from 'node:process' + +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 { blockArgument } from './markdown/block-directive-arguments.ts' +import { blockDirectives } from './adf/block-directives.ts' +import { inlineDirectives } from './adf/inline-directives.ts' +import { markAttributes } from './adf/mark-attributes.ts' +import { toEditorNormal } from './adf/editor-normal.ts' + +type Positions = { block: AdfNode; inline: AdfNode } + +const deepRunsVariable = 'PROPERTY_RUNS' +const gateSeed = 20260914 +// Bun's test runner stops a test after five seconds unless the test sets its own timeout. +export const propertyTimeout = 600000 + +const depthIdentifier = fc.createDepthIdentifier() +const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } +const flatCommonMarkShapeWeight = 4 +export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~รฉ\xa0๐ŸŽ‰', ':a[', ':a{', 'ab:', 'http://') +const nestingCommonMarkShapeWeight = 21 +const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)]) + +export function textOf(minLength: number): Arbitrary { + return fc.oneof( + { arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownPieces }), weight: 4 }, + { arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 }, + ) +} + +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 numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) + +// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected. +const keyPiece = fc + .oneof({ arbitrary: markdownPieces, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 }) + .filter((piece) => !/[\\"\x00-\x1f]/.test(piece)) +export const jsonKey = fc.string({ maxLength: 8, unit: keyPiece }) + +export const { jsonValue } = fc.letrec<{ jsonValue: JsonValue }>((tie) => ({ + jsonValue: fc.oneof( + { depthSize: 'small', maxDepth: 2 }, + fc.oneof(fc.constant(null), fc.boolean(), numberValue, textOf(0)), + fc.array(tie('jsonValue'), { maxLength: 3 }), + fc.dictionary(jsonKey, tie('jsonValue'), { maxKeys: 3, noNullPrototype: true }), + ), +})) + +const valueByKind: Readonly>> = { + boolean: fc.boolean(), + json: jsonValue, + number: numberValue, + string: textOf(0), +} + +export 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(jsonKey, 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 autolinkTextNode = fc + .record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks }) + .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' })) + +const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) => + fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })), +) + +function weighted(arbitraries: readonly Arbitrary[], weight: number): { arbitrary: Arbitrary; weight: number }[] { + return arbitraries.map((arbitrary) => ({ arbitrary, weight })) +} + +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(jsonKey, 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 flatCommonMarkShapes = [ + fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), + 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), + ] + const nestingCommonMarkShapes = [ + blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), + listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), + 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' })), + ] + const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)] + return { + block: fc.oneof( + { depthIdentifier, depthSize: 'small', maxDepth: 4 }, + { arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) }, + { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, + { arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, + { arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight }, + ), + inline: fc.oneof( + { depthIdentifier, depthSize: 'small', maxDepth: 4 }, + { arbitrary: textNode, weight: 12 }, + { arbitrary: autolinkTextNode, weight: 2 }, + { arbitrary: fc.oneof(...inlineNodes), weight: 7 }, + { arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 }, + ), + } +}) + +export const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 })) + +export function propertyRuns(gateRuns: number): { numRuns: number; seed?: number } { + const deepRuns = env[deepRunsVariable] + if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed } + assert.ok(/^[1-9]\d*$/.test(deepRuns), `${deepRunsVariable} is a run count in digits, such as ${deepRunsVariable}=10000: found ${JSON.stringify(deepRuns)}`) + return { numRuns: Number(deepRuns) } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 3f6e2c7..6b17c1b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -27,6 +27,6 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "exclude": ["src/**/*.test.ts"], + "exclude": ["src/**/*.test.ts", "src/property-generators.ts"], "include": ["src"] } -- 2.52.0 From 4735933d61b6f46ecf7870bf7a26e43c6cb74069 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:45:07 +0200 Subject: [PATCH 02/12] 4.3: a paragraph line reading as a link reference definition escapes its opening bracket --- .../combinations/link-definition-line.json | 76 +++++++++++++++++++ .../combinations/link-definition-line.md | 6 ++ spec/flavour.md | 2 + src/markdown/emit/line-escaping.ts | 7 +- .../{parse => }/link-reference-definitions.ts | 8 +- src/markdown/parse/blocks.ts | 2 +- 6 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 corpus/round-trip/combinations/link-definition-line.json create mode 100644 corpus/round-trip/combinations/link-definition-line.md rename src/markdown/{parse => }/link-reference-definitions.ts (90%) diff --git a/corpus/round-trip/combinations/link-definition-line.json b/corpus/round-trip/combinations/link-definition-line.json new file mode 100644 index 0000000..fd16f9f --- /dev/null +++ b/corpus/round-trip/combinations/link-definition-line.json @@ -0,0 +1,76 @@ +{ + "content": [ + { + "content": [ + { + "text": "[", + "type": "text" + }, + { + "attrs": { + "timestamp": "]:a" + }, + "type": "date" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "content": [ + { + "text": "[", + "type": "text" + }, + { + "marks": [ + { + "type": "code" + } + ], + "text": "]: a", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "blockquote" + }, + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": "[", + "type": "text" + }, + { + "attrs": { + "timestamp": "]:a" + }, + "type": "date" + }, + { + "type": "hardBreak" + }, + { + "text": "b", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/link-definition-line.md b/corpus/round-trip/combinations/link-definition-line.md new file mode 100644 index 0000000..b5c5a28 --- /dev/null +++ b/corpus/round-trip/combinations/link-definition-line.md @@ -0,0 +1,6 @@ +\[:date{timestamp="]:a"} + +> \[`]: a` + +- \[:date{timestamp="]:a"}\ + b diff --git a/spec/flavour.md b/spec/flavour.md index c0f802b..e80d8fb 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -54,6 +54,8 @@ normalizes to it through the round-trip. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape the leading delimiter of a construct that would otherwise open, re-scan from there, and repeat. + A paragraph's opening `[` escapes wherever its line reads as a link reference definition, which + resolves before any inline construct binds: a `]` inside a code span or `{attrs}` counts. An emphasis delimiter run in text escapes where CommonMark can open **or** close with it, so `*not emphasis*` is `\*not emphasis\*` โ€” no delimiter the emitter did not write reaches the matching below, which is what lets the emitter decide its own pairings. diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index eeaa7ec..4671702 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -3,6 +3,7 @@ import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../em import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark-grammar.ts' import { isBareDelimiterRow } from '../pipe-table-syntax.ts' import { opensInlineDirective } from '../directive-syntax.ts' +import { opensLinkDefinition } from '../link-reference-definitions.ts' import { readEntityReference } from '../entity-references.ts' export type EmphasisRole = 'close' | 'open' @@ -27,8 +28,7 @@ type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; deli const delimiters = ['*', '_', '`', '~'] -// The `:` keeps a `[label]: url` line escaped: unescaped, the parser swallows it as a link reference definition. -const followsLinkText = /[([:]/ +const followsLinkText = /[([]/ export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { return escape(resolveEmphasis(segments), container) @@ -90,7 +90,8 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A placements.push(output.length) output += scan.charAt(index) } - return { line: output, unspellableRun: unspellableRun(segments, output, placements) } + const spelled = container === 'paragraph' && opensLinkDefinition(output) ? `\\${output}` : output + return { line: spelled, unspellableRun: unspellableRun(segments, output, placements) } } function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined { diff --git a/src/markdown/parse/link-reference-definitions.ts b/src/markdown/link-reference-definitions.ts similarity index 90% rename from src/markdown/parse/link-reference-definitions.ts rename to src/markdown/link-reference-definitions.ts index 8edbefd..d69d90f 100644 --- a/src/markdown/parse/link-reference-definitions.ts +++ b/src/markdown/link-reference-definitions.ts @@ -1,10 +1,14 @@ -import type { LinkDefinition, LinkPart } from '../link-syntax.ts' -import { normalizeLabel, readDestination, readLabel, readTitle, skipLinkWhitespace } from '../link-syntax.ts' +import type { LinkDefinition, LinkPart } from './link-syntax.ts' +import { normalizeLabel, readDestination, readLabel, readTitle, skipLinkWhitespace } from './link-syntax.ts' type ReadDefinition = { definition: LinkDefinition; label: string; length: number } const restOfLine = /^[ \t]*(?:\n|$)/ +export function opensLinkDefinition(text: string): boolean { + return readDefinition(text) !== undefined +} + export function readLinkDefinitions(definitions: Map, text: string): string { let rest = text let read = readDefinition(rest) diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 7527d38..db40412 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -18,7 +18,7 @@ import { } from '../commonmark-grammar.ts' import { directiveLineEscape, malformedDirective, readDirectiveLine } from '../directive-syntax.ts' import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts' -import { readLinkDefinitions } from './link-reference-definitions.ts' +import { readLinkDefinitions } from '../link-reference-definitions.ts' export type Block = { position: SourcePosition } & ( | { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string } -- 2.52.0 From aeb54d98d1f46c038e2667a85bc2da0d11726168 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:52:29 +0200 Subject: [PATCH 03/12] 4.3: an escaped backtick run escapes whole, and a lone backtick before one escapes too --- .../commonmark-subset/text-backtick-run.json | 31 ++++++++++++++++++ .../commonmark-subset/text-backtick-run.md | 4 +++ spec/flavour.md | 2 ++ src/markdown/emit/line-escaping.ts | 32 +++++++++++++++---- 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 corpus/round-trip/commonmark-subset/text-backtick-run.json create mode 100644 corpus/round-trip/commonmark-subset/text-backtick-run.md diff --git a/corpus/round-trip/commonmark-subset/text-backtick-run.json b/corpus/round-trip/commonmark-subset/text-backtick-run.json new file mode 100644 index 0000000..1274ab0 --- /dev/null +++ b/corpus/round-trip/commonmark-subset/text-backtick-run.json @@ -0,0 +1,31 @@ +{ + "content": [ + { + "content": [ + { + "text": "`a ``b``", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "`a", + "type": "text" + }, + { + "type": "hardBreak" + }, + { + "text": "```b``", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/commonmark-subset/text-backtick-run.md b/corpus/round-trip/commonmark-subset/text-backtick-run.md new file mode 100644 index 0000000..ca8ca5c --- /dev/null +++ b/corpus/round-trip/commonmark-subset/text-backtick-run.md @@ -0,0 +1,4 @@ +\`a \`\`b`` + +\`a\ +\`\`\`b`` diff --git a/spec/flavour.md b/spec/flavour.md index e80d8fb..a98ec2e 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -54,6 +54,8 @@ normalizes to it through the round-trip. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape the leading delimiter of a construct that would otherwise open, re-scan from there, and repeat. + A backtick run escapes whole, and a lone backtick escapes wherever an escaped one follows it in + the same inline content: CommonMark reads no escape inside a code span, so `` \` `` closes one. A paragraph's opening `[` escapes wherever its line reads as a link reference definition, which resolves before any inline construct binds: a `]` inside a code span or `{attrs}` counts. An emphasis delimiter run in text escapes where CommonMark can open **or** close with it, so diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 4671702..4f51118 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -67,9 +67,20 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A const scan = segments.map((segment) => segment.text).join('') const escapings: InlineEscaping[] = [] for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping) - const escaped = new Set() + const escaped = escapedIndexes(scan, escapings, container) const placements: number[] = [] let output = '' + for (let index = 0; index < scan.length; index += 1) { + if (escaped.has(index)) output += '\\' + placements.push(output.length) + output += scan.charAt(index) + } + const line = container === 'paragraph' && opensLinkDefinition(output) ? `\\${output}` : output + return { line, unspellableRun: unspellableRun(segments, output, placements) } +} + +function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): Set { + const escaped = new Set() const linkClose = lastLinkClose(scan, escapings) let line = scanLine(scan, 0) for (let index = 0; index < scan.length; index += 1) { @@ -84,14 +95,21 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A (escaping === 'bracketed-link-target' && ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || (scan.charAt(index) === ':' && opensInlineDirective(scan, index)))) ) { - output += '\\' escaped.add(index) } - placements.push(output.length) - output += scan.charAt(index) } - const spelled = container === 'paragraph' && opensLinkDefinition(output) ? `\\${output}` : output - return { line: spelled, unspellableRun: unspellableRun(segments, output, placements) } + escapeLoneBackticks(scan, escapings, escaped) + return escaped +} + +// CommonMark reads no escape inside a code span, so an escaped backtick still closes one a bare lone backtick before it opens. +function escapeLoneBackticks(scan: string, escapings: readonly InlineEscaping[], escaped: Set): void { + let escapedAfter = false + for (let index = scan.length - 1; index >= 0; index -= 1) { + if (scan.charAt(index) !== '`') continue + if (escapedAfter && escapings[index] !== 'none' && scan.charAt(index - 1) !== '`' && scan.charAt(index + 1) !== '`') escaped.add(index) + if (escaped.has(index)) escapedAfter = true + } } function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined { @@ -248,6 +266,8 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef } function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet): boolean { + // A run escapes whole: a rest left bare would be a raw run of another length for a closer. + if (scan.charAt(index - 1) === '`' && escaped.has(index - 1)) return true if (!startsRun(scan, index, escaped)) return false const opener = backtickRun(scan, index) return closingBacktickRun(scan, index + opener, opener) !== undefined -- 2.52.0 From fe47e6bc7ba4f3aebe2caaf6a906d0e68ec38e7a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:22:34 +0200 Subject: [PATCH 04/12] 4.3: a link opening a paragraph that reads as a link reference definition rides the carry --- .../combinations/link-definition-carry.json | 45 +++++++++++++++++++ .../combinations/link-definition-carry.md | 3 ++ spec/flavour.md | 5 ++- src/markdown/emit/inline-line.ts | 2 +- src/markdown/emit/line-escaping.ts | 11 +++-- 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 corpus/round-trip/combinations/link-definition-carry.json create mode 100644 corpus/round-trip/combinations/link-definition-carry.md diff --git a/corpus/round-trip/combinations/link-definition-carry.json b/corpus/round-trip/combinations/link-definition-carry.json new file mode 100644 index 0000000..ad3fd23 --- /dev/null +++ b/corpus/round-trip/combinations/link-definition-carry.json @@ -0,0 +1,45 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "/u" + }, + "type": "link" + }, + { + "type": "code" + } + ], + "text": "]: a", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "attrs": { + "timestamp": "]:a" + }, + "marks": [ + { + "attrs": { + "href": "/u" + }, + "type": "link" + } + ], + "type": "date" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/link-definition-carry.md b/corpus/round-trip/combinations/link-definition-carry.md new file mode 100644 index 0000000..a06706f --- /dev/null +++ b/corpus/round-trip/combinations/link-definition-carry.md @@ -0,0 +1,3 @@ +:adf{json="{\"marks\":[{\"attrs\":{\"href\":\"/u\"},\"type\":\"link\"},{\"type\":\"code\"}],\"text\":\"]: a\",\"type\":\"text\"}"} + +:adf{json="{\"attrs\":{\"timestamp\":\"]:a\"},\"marks\":[{\"attrs\":{\"href\":\"/u\"},\"type\":\"link\"}],\"type\":\"date\"}"} diff --git a/spec/flavour.md b/spec/flavour.md index a98ec2e..e407647 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -56,8 +56,9 @@ normalizes to it through the round-trip. the leading delimiter of a construct that would otherwise open, re-scan from there, and repeat. A backtick run escapes whole, and a lone backtick escapes wherever an escaped one follows it in the same inline content: CommonMark reads no escape inside a code span, so `` \` `` closes one. - A paragraph's opening `[` escapes wherever its line reads as a link reference definition, which - resolves before any inline construct binds: a `]` inside a code span or `{attrs}` counts. + Where a paragraph opens with what reads as a link reference definition, which resolves before + any inline construct binds (a `]` inside a code span or `{attrs}` counts), an opening text `[` + escapes and an opening link's nodes ride the carry. An emphasis delimiter run in text escapes where CommonMark can open **or** close with it, so `*not emphasis*` is `\*not emphasis\*` โ€” no delimiter the emitter did not write reaches the matching below, which is what lets the emitter decide its own pairings. diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 6c0105b..d9c91cb 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -292,6 +292,6 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner const spelledTarget: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(target.value, '[', ']') } : syntax(target.value) - return success({ segments: [syntax('['), ...inner.value.segments, syntax(']('), spelledTarget, syntax(')')] }) + return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), spelledTarget, syntax(')')] }) } diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 4f51118..66d2879 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -14,7 +14,8 @@ export type NodeRange = { first: number; last: number } export type InlineSegment = | { emphasis: EmphasisRole; escaping: 'none'; nodes: NodeRange; text: string } - | { emphasis?: undefined; escaping: InlineEscaping; text: string } + | { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string } + | { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string } export type AssembledLine = { line: string; unspellableRun: NodeRange | undefined } @@ -75,8 +76,12 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A placements.push(output.length) output += scan.charAt(index) } - const line = container === 'paragraph' && opensLinkDefinition(output) ? `\\${output}` : output - return { line, unspellableRun: unspellableRun(segments, output, placements) } + if (container === 'paragraph' && opensLinkDefinition(output)) { + const opener = segments[0]?.nodes + if (opener !== undefined) return { line: output, unspellableRun: opener } + return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) } + } + return { line: output, unspellableRun: unspellableRun(segments, output, placements) } } function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): Set { -- 2.52.0 From 755b5cbe0182966359126c94046c9228c6ef5657 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:28:32 +0200 Subject: [PATCH 05/12] 4.3: the markdown generator splits into inline, leaf-block and block builders --- src/markdown-property.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts index b0cd732..4213794 100644 --- a/src/markdown-property.test.ts +++ b/src/markdown-property.test.ts @@ -27,6 +27,10 @@ type Choice = { arbitrary: Arbitrary; hostile?: true; weight: number } type Edit = [at: number, removed: number, inserted: string] +type InlineMarkdown = { destination: Arbitrary; inlines: Arbitrary; label: Arbitrary; oneLine: Arbitrary; text: Arbitrary; word: Arbitrary } + +type LeafMarkdown = { fencedCode: Arbitrary; leafBlock: Arbitrary } + const gateRuns = 1000 const vocabularies = [...Object.values(blockDirectives).map((directive) => directive.attributes), ...Object.values(inlineDirectives).map((directive) => directive.attributes), ...Object.values(markAttributes)] @@ -179,7 +183,11 @@ const indentDrift = fc.oneof({ arbitrary: fc.constant(0), weight: 6 }, { arbitra const closerDrift = fc.option(fc.integer({ max: 5, min: 2 }), { freq: 6 }) function markdownOf(hostile: boolean): Arbitrary { - const blockDepth = fc.createDepthIdentifier() + const inline = inlineMarkdown(hostile) + return blockMarkdown(hostile, inline, leafBlocks(hostile, inline)) +} + +function inlineMarkdown(hostile: boolean): InlineMarkdown { const inlineDepth = fc.createDepthIdentifier() const text = hostile ? fc.oneof(cleanText, textOf(0)) : cleanText const word = fc.oneof({ arbitrary: prose, weight: 3 }, { arbitrary: text.filter((held) => held !== ''), weight: 2 }) @@ -228,8 +236,10 @@ function markdownOf(hostile: boolean): Arbitrary { ), inlines: fc.array(tie('inline'), { depthIdentifier: inlineDepth, maxLength: 4, minLength: 1 }).map((parts) => parts.join('')), })) + return { destination, inlines, label, oneLine: inlines.map((held) => held.replace(/[\n\r]/g, ' ')), text, word } +} - const oneLine = inlines.map((held) => held.replace(/[\n\r]/g, ' ')) +function leafBlocks(hostile: boolean, { destination, inlines, label, oneLine, text, word }: InlineMarkdown): LeafMarkdown { const fencedCode = fc .tuple( fc.constantFrom('```', '```', '~~~', '````', '``'), @@ -287,7 +297,11 @@ function markdownOf(hostile: boolean): Arbitrary { }, { arbitrary: fc.constantFrom(':::', '::', '::::', ':::panel', '::: panel', ':::panel info extra'), hostile: true, weight: 1 }, ]) + return { fencedCode, leafBlock } +} +function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, { fencedCode, leafBlock }: LeafMarkdown): Arbitrary { + const blockDepth = fc.createDepthIdentifier() const { blocks } = fc.letrec<{ block: string; blocks: string }>((tie) => { const bodyByModel = { block: fc.oneof(tie('blocks'), fc.constant('')), code: fencedCode, inline: fc.oneof(oneLine, fc.constant('')) } const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => { -- 2.52.0 From 8e7d429379ff7429a3985c4ba412fa85a5607b70 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:31:41 +0200 Subject: [PATCH 06/12] 4.3: the shared property module is property-harness.ts, and the canonical arm filters emit refusals --- AGENTS.md | 3 ++- package.json | 2 +- src/adf-property.test.ts | 2 +- src/markdown-property.test.ts | 11 ++++++----- src/{property-generators.ts => property-harness.ts} | 0 tsconfig.build.json | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) rename src/{property-generators.ts => property-harness.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index a1c5beb..b7489a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,7 +229,8 @@ editor wrote; the CommonMark spec suite against `markdownToAdf` and `markdownToH Beside the corpus, properties run over documents generated from the node tables and over generated markdown, 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. +seed for local digging, and a counterexample found becomes a round-trip fixture. The generators and +run parameters properties share live in `src/property-harness.ts`, outside the build and coverage. `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 diff --git a/package.json b/package.json index 5b86fde..0d696e2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", - "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=src/property-generators.ts --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", + "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=src/property-harness.ts --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" }, "devDependencies": { diff --git a/src/adf-property.test.ts b/src/adf-property.test.ts index 4e54660..41e4410 100644 --- a/src/adf-property.test.ts +++ b/src/adf-property.test.ts @@ -2,7 +2,7 @@ import fc from 'fast-check' import assert from 'node:assert/strict' import test from 'node:test' -import { adfDocument, propertyRuns, propertyTimeout } from './property-generators.ts' +import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts' import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { toEditorNormal } from './adf/editor-normal.ts' diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts index 4213794..66a8f3f 100644 --- a/src/markdown-property.test.ts +++ b/src/markdown-property.test.ts @@ -5,7 +5,8 @@ import test from 'node:test' import type { Arbitrary, DepthIdentifier } from 'fast-check' import type { AttributeVocabulary } from './adf/attribute-vocabulary.ts' import type { JsonValue } from './json-value.ts' -import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-generators.ts' +import type { Result } from './result.ts' +import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.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' @@ -360,10 +361,10 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, { const cleanMarkdown = markdownOf(false) const hostileMarkdown = markdownOf(true) -const canonical = adfDocument.map((document) => { - const emitted = adfToMarkdown(document) - return emitted.ok ? emitted.value : '' -}) +const canonical = adfDocument + .map((document) => adfToMarkdown(document)) + .filter((emitted): emitted is Extract, { ok: true }> => emitted.ok) + .map((emitted) => emitted.value) function edited(markdown: string, edits: readonly Edit[]): string { let text = markdown diff --git a/src/property-generators.ts b/src/property-harness.ts similarity index 100% rename from src/property-generators.ts rename to src/property-harness.ts diff --git a/tsconfig.build.json b/tsconfig.build.json index 6b17c1b..98a04c3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -27,6 +27,6 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "exclude": ["src/**/*.test.ts", "src/property-generators.ts"], + "exclude": ["src/**/*.test.ts", "src/property-harness.ts"], "include": ["src"] } -- 2.52.0 From a3073c69b2544422444f30db7d23d3643c6016eb Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:36:55 +0200 Subject: [PATCH 07/12] 4.3: under the gate seed the markdown property asserts floors on fixpoint and directive-shaped runs --- src/markdown-property.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts index 66a8f3f..1db438a 100644 --- a/src/markdown-property.test.ts +++ b/src/markdown-property.test.ts @@ -32,6 +32,9 @@ type InlineMarkdown = { destination: Arbitrary; inlines: Arbitrary; leafBlock: Arbitrary } +const directiveShapedFloor = 350 +const directiveSpelling = /(?*+.)0-9-]*::+[a-z]/m +const fixpointFloor = 600 const gateRuns = 1000 const vocabularies = [...Object.values(blockDirectives).map((directive) => directive.attributes), ...Object.values(inlineDirectives).map((directive) => directive.attributes), ...Object.values(markAttributes)] @@ -390,12 +393,17 @@ const document = fc .map(([held, ending, trailing]) => `${held}${trailing}`.replaceAll('\n', ending)) test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => { + const parameters = propertyRuns(gateRuns) + let directiveShaped = 0 + let fixpoints = 0 fc.assert( fc.property(document, (input) => { const parsed = markdownToAdf(input) if (!parsed.ok) return const emitted = adfToMarkdown(parsed.value) if (!emitted.ok) return + fixpoints += 1 + if (directiveSpelling.test(emitted.value)) directiveShaped += 1 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), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`) @@ -403,7 +411,10 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} โ€” spelling ${JSON.stringify(emitted.value)} again`) assert.equal(respelled.value, emitted.value) }), - propertyRuns(gateRuns), + parameters, ) + if (parameters.seed === undefined) return + assert.ok(fixpoints >= fixpointFloor, `${fixpoints} of ${gateRuns} runs reached the fixpoint, under the floor of ${fixpointFloor}`) + assert.ok(directiveShaped >= directiveShapedFloor, `${directiveShaped} runs reaching the fixpoint spelled a directive, under the floor of ${directiveShapedFloor}`) }) -- 2.52.0 From ef71d55c136a2688c78cd421068b01ec7c3a5823 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:36:55 +0200 Subject: [PATCH 08/12] 4.3: todo.md records the settled harness, floors and breaks, and 12's and 13b's follow-ups --- todo.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/todo.md b/todo.md index 41b7dd1..7e4a412 100644 --- a/todo.md +++ b/todo.md @@ -59,6 +59,18 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c - [ ] **4.3 โ€” The markdown property.** Generated markdown through `markdownToAdf` never throws, and the runs fit the budget; where it parses and `adfToMarkdown` spells the result, that spelling parses and emits to itself byte for byte (ยง2). + **Settled** (the maintainer, 2026-09-15): the generators and run parameters 4.2 and 4.3 + share live in one test-only module in `src/`, kept out of the build and coverage, with + 10c's properties as its third user. Under the gate seed the property asserts floors on the + runs reaching the fixpoint and on the directive-shaped ones. It lands when hunts of several + hundred thousand runs per engine pass clean, since the breaks hit once per ~150,000 runs, + past a 10,000-run bar. Two breaks, both shipped in `0.1.0`, are fixed inside it. An escaped + backtick closed an earlier lone backtick's code span, since CommonMark reads no escape + inside one: a backtick run now escapes whole, and a lone backtick escapes wherever an + escaped one follows it in the same inline content. A paragraph's opening read as a link + reference definition across a `]` the emitter spelled: the emitter now escapes the opening + `[` exactly when the parser's own definition reader accepts the paragraph, and a link + opening it rides the carry until 13b. - [x] **4.4 โ€” The real payloads.** - [ ] **4b โ€” The block walk's retry (`0.2.0`).** `emitBlock` walks a subtree twice wherever `readableBlock` reads it whole and then gives up โ€” a list item whose first line reads back @@ -237,7 +249,7 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c `src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s `directive-syntax.ts`, `opaque-carry.ts` and the `emit/` + `parse/` readers, every corpus fixture (round-trip, normalization and `errors/`), the prose reader over `spec/flavour.md`, - and the README's examples. + the markdown property's generator, and the README's examples. **Settled** (the maintainer, 2026-09-13): - A line opening `!adf:name` is a block line when a space or the line's end follows the name, and a paragraph when `[` or `{` does. Claiming stays syntactic and structure comes from the @@ -292,7 +304,9 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c exceptions it cures re-derived, and the README's code table and its "not every document converts back" guarantee following; the gap list is empty. A round-trip fixture holds the shape 4.2's review left refused until then: an autolink-shaped link under a directive mark - whose href holds `\:name{`. + whose href holds `\:name{`. A link opening a paragraph whose opening reads as a link + reference definition, which 4.3 leaves riding the carry, takes the directive link too, with + its round-trip fixture (the maintainer, 2026-09-15). ## The ADF inventory to cover -- 2.52.0 From 5fe2e6ec42f341ba7fd108ebf30b8970c8d2561a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:44:49 +0200 Subject: [PATCH 09/12] 4.3: the directive floor counts directive-only and carried types in the parsed document, and run parameters name the gate --- src/markdown-property.test.ts | 22 +++++++++++++++++----- src/property-harness.ts | 6 +++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts index 1db438a..f5909b4 100644 --- a/src/markdown-property.test.ts +++ b/src/markdown-property.test.ts @@ -2,6 +2,7 @@ import fc from 'fast-check' import assert from 'node:assert/strict' import test from 'node:test' +import type { AdfDocument } from './adf/document.ts' import type { Arbitrary, DepthIdentifier } from 'fast-check' import type { AttributeVocabulary } from './adf/attribute-vocabulary.ts' import type { JsonValue } from './json-value.ts' @@ -18,6 +19,7 @@ import { markAttributes } from './adf/mark-attributes.ts' import { markSpelling } from './markdown/mark-spellings.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { marksAttribute } from './markdown/block-directive-marks.ts' +import { nodeContent, nodeMarks } from './adf/document.ts' import { serializeCanonicalJson } from './canonical-json.ts' import { spellAttributes, spellJsonAttribute, spellLeafDirective, spellStringAttribute, spellVocabulary } from './markdown/directive-syntax.ts' import { textDirectiveName } from './markdown/text-directive.ts' @@ -32,10 +34,11 @@ type InlineMarkdown = { destination: Arbitrary; inlines: Arbitrary; leafBlock: Arbitrary } -const directiveShapedFloor = 350 -const directiveSpelling = /(?*+.)0-9-]*::+[a-z]/m +const commonMarkTypes = new Set(['blockquote', 'bulletList', 'codeBlock', 'hardBreak', 'heading', 'listItem', 'orderedList', 'paragraph', 'rule', 'text']) +const directiveShapedFloor = 330 const fixpointFloor = 600 const gateRuns = 1000 +const markdownMarkTypes = new Set(Object.keys(markAttributes).filter((type) => markSpelling(type)?.kind !== 'directive')) const vocabularies = [...Object.values(blockDirectives).map((directive) => directive.attributes), ...Object.values(inlineDirectives).map((directive) => directive.attributes), ...Object.values(markAttributes)] const attributeKeys = [ @@ -392,6 +395,15 @@ const document = fc .tuple(markdown, fc.oneof({ arbitrary: fc.constant('\n'), weight: 8 }, { arbitrary: fc.constantFrom('\r\n', '\r'), weight: 1 }), fc.constantFrom('', '', '\n', ' \n')) .map(([held, ending, trailing]) => `${held}${trailing}`.replaceAll('\n', ending)) +function holdsDirectiveShape(document: AdfDocument): boolean { + const pending = [...nodeContent(document)] + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + if (!commonMarkTypes.has(node.type) || nodeMarks(node).some((mark) => !markdownMarkTypes.has(mark.type))) return true + pending.push(...nodeContent(node)) + } + return false +} + test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => { const parameters = propertyRuns(gateRuns) let directiveShaped = 0 @@ -403,7 +415,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s const emitted = adfToMarkdown(parsed.value) if (!emitted.ok) return fixpoints += 1 - if (directiveSpelling.test(emitted.value)) directiveShaped += 1 + if (holdsDirectiveShape(parsed.value)) directiveShaped += 1 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), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`) @@ -413,8 +425,8 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s }), parameters, ) - if (parameters.seed === undefined) return + if (!parameters.gate) return assert.ok(fixpoints >= fixpointFloor, `${fixpoints} of ${gateRuns} runs reached the fixpoint, under the floor of ${fixpointFloor}`) - assert.ok(directiveShaped >= directiveShapedFloor, `${directiveShaped} runs reaching the fixpoint spelled a directive, under the floor of ${directiveShapedFloor}`) + assert.ok(directiveShaped >= directiveShapedFloor, `${directiveShaped} runs reaching the fixpoint held a node or mark only a directive or the carry spells, under the floor of ${directiveShapedFloor}`) }) diff --git a/src/property-harness.ts b/src/property-harness.ts index 4e38daf..226f0f3 100644 --- a/src/property-harness.ts +++ b/src/property-harness.ts @@ -158,9 +158,9 @@ const positions = fc.letrec((tie) => { export const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 })) -export function propertyRuns(gateRuns: number): { numRuns: number; seed?: number } { +export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } { const deepRuns = env[deepRunsVariable] - if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed } + if (deepRuns === undefined) return { gate: true, numRuns: gateRuns, seed: gateSeed } assert.ok(/^[1-9]\d*$/.test(deepRuns), `${deepRunsVariable} is a run count in digits, such as ${deepRunsVariable}=10000: found ${JSON.stringify(deepRuns)}`) - return { numRuns: Number(deepRuns) } + return { gate: false, numRuns: Number(deepRuns) } } -- 2.52.0 From 7d20f3e895e3ffe82ef51b56e8e2e10eeced2a62 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 22:22:46 +0200 Subject: [PATCH 10/12] 4.3: the ADF generator draws backtick runs beside code spans, and the directive floor's message names what it counts --- src/markdown-property.test.ts | 2 +- src/property-harness.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts index f5909b4..1778594 100644 --- a/src/markdown-property.test.ts +++ b/src/markdown-property.test.ts @@ -427,6 +427,6 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s ) if (!parameters.gate) return assert.ok(fixpoints >= fixpointFloor, `${fixpoints} of ${gateRuns} runs reached the fixpoint, under the floor of ${fixpointFloor}`) - assert.ok(directiveShaped >= directiveShapedFloor, `${directiveShaped} runs reaching the fixpoint held a node or mark only a directive or the carry spells, under the floor of ${directiveShapedFloor}`) + assert.ok(directiveShaped >= directiveShapedFloor, `${directiveShaped} runs reaching the fixpoint held a node or mark outside CommonMark's own types, under the floor of ${directiveShapedFloor}`) }) diff --git a/src/property-harness.ts b/src/property-harness.ts index 226f0f3..1f714ec 100644 --- a/src/property-harness.ts +++ b/src/property-harness.ts @@ -85,6 +85,10 @@ const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) +const backtickRunNode = fc + .record({ marks: fc.oneof(fc.constant([]), fc.constant([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) }) + .map((held): AdfNode => ({ ...held, type: 'text' })) + const autolinkTextNode = fc .record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks }) .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' })) @@ -119,7 +123,7 @@ const positions = fc.letrec((tie) => { 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 paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).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' })), @@ -150,6 +154,7 @@ const positions = fc.letrec((tie) => { { depthIdentifier, depthSize: 'small', maxDepth: 4 }, { arbitrary: textNode, weight: 12 }, { arbitrary: autolinkTextNode, weight: 2 }, + { arbitrary: backtickRunNode, weight: 3 }, { arbitrary: fc.oneof(...inlineNodes), weight: 7 }, { arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 }, ), -- 2.52.0 From b6ba4404108f7f58797d5e8b1942a46e4138276c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 22:28:51 +0200 Subject: [PATCH 11/12] 4.3: a bare backtick run escapes wherever a later string of its length forms around an escape --- .../combinations/code-span-joined-run.json | 73 +++++++++++++++++++ .../combinations/code-span-joined-run.md | 5 ++ spec/flavour.md | 6 +- src/markdown/emit/line-escaping.ts | 32 ++++++-- 4 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 corpus/round-trip/combinations/code-span-joined-run.json create mode 100644 corpus/round-trip/combinations/code-span-joined-run.md diff --git a/corpus/round-trip/combinations/code-span-joined-run.json b/corpus/round-trip/combinations/code-span-joined-run.json new file mode 100644 index 0000000..6d8351a --- /dev/null +++ b/corpus/round-trip/combinations/code-span-joined-run.json @@ -0,0 +1,73 @@ +{ + "content": [ + { + "content": [ + { + "text": "`` ``", + "type": "text" + }, + { + "marks": [ + { + "type": "code" + } + ], + "text": "a", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + } + ], + "text": "``", + "type": "text" + }, + { + "text": "``", + "type": "text" + }, + { + "marks": [ + { + "type": "code" + } + ], + "text": "a", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "``\\`", + "type": "text" + }, + { + "marks": [ + { + "type": "code" + } + ], + "text": "`", + "type": "text" + }, + { + "text": "`", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/code-span-joined-run.md b/corpus/round-trip/combinations/code-span-joined-run.md new file mode 100644 index 0000000..a89d1f6 --- /dev/null +++ b/corpus/round-trip/combinations/code-span-joined-run.md @@ -0,0 +1,5 @@ +\`\` \`\``a` + +:underline[\`\`]\`\``a` + +\`\`\\\``` ` ``\` diff --git a/spec/flavour.md b/spec/flavour.md index e407647..ebdf7e4 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -54,8 +54,10 @@ normalizes to it through the round-trip. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape the leading delimiter of a construct that would otherwise open, re-scan from there, and repeat. - A backtick run escapes whole, and a lone backtick escapes wherever an escaped one follows it in - the same inline content: CommonMark reads no escape inside a code span, so `` \` `` closes one. + A backtick run escapes whole, and a bare one escapes wherever a later backtick string of its + length forms around an escape in the same inline content โ€” an escaped backtick alone, one joined + to the bare run after it, or a bare run an escape splits off: CommonMark reads no escape inside a + code span, so such a string still closes one. Where a paragraph opens with what reads as a link reference definition, which resolves before any inline construct binds (a `]` inside a code span or `{attrs}` counts), an opening text `[` escapes and an opening link's nodes ride the carry. diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 66d2879..50ab2ce 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -103,17 +103,33 @@ function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], cont escaped.add(index) } } - escapeLoneBackticks(scan, escapings, escaped) + escapeClosedRuns(scan, escapings, escaped) return escaped } -// CommonMark reads no escape inside a code span, so an escaped backtick still closes one a bare lone backtick before it opens. -function escapeLoneBackticks(scan: string, escapings: readonly InlineEscaping[], escaped: Set): void { - let escapedAfter = false - for (let index = scan.length - 1; index >= 0; index -= 1) { - if (scan.charAt(index) !== '`') continue - if (escapedAfter && escapings[index] !== 'none' && scan.charAt(index - 1) !== '`' && scan.charAt(index + 1) !== '`') escaped.add(index) - if (escaped.has(index)) escapedAfter = true +// CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens. +function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], escaped: Set): void { + const formed = new Set() + let end = scan.length - 1 + while (end >= 0) { + if (scan.charAt(end) !== '`') { + end -= 1 + continue + } + let start = end + while (scan.charAt(start - 1) === '`') start -= 1 + let segmentEnd = end + for (let index = end; index > start; index -= 1) { + if (!escaped.has(index)) continue + formed.add(segmentEnd - index + 1) + segmentEnd = index - 1 + } + if (segmentEnd !== end || escaped.has(start)) formed.add(segmentEnd - start + 1) + else if (escapings[start] !== 'none' && formed.has(end - start + 1)) { + for (let index = start; index <= end; index += 1) escaped.add(index) + formed.add(1) + } + end = start - 1 } } -- 2.52.0 From ad1f3e996b13e6d9bb0d8a19d5904d0b9b0fa267 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 22:35:48 +0200 Subject: [PATCH 12/12] 4.3: todo.md records the joined-run family and the generalized backtick pass --- todo.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/todo.md b/todo.md index 7e4a412..0fb2312 100644 --- a/todo.md +++ b/todo.md @@ -64,10 +64,12 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c 10c's properties as its third user. Under the gate seed the property asserts floors on the runs reaching the fixpoint and on the directive-shaped ones. It lands when hunts of several hundred thousand runs per engine pass clean, since the breaks hit once per ~150,000 runs, - past a 10,000-run bar. Two breaks, both shipped in `0.1.0`, are fixed inside it. An escaped - backtick closed an earlier lone backtick's code span, since CommonMark reads no escape - inside one: a backtick run now escapes whole, and a lone backtick escapes wherever an - escaped one follows it in the same inline content. A paragraph's opening read as a link + past a 10,000-run bar. Two breaks, both shipped in `0.1.0`, are fixed inside it. A backtick + string an escape formed closed an earlier bare run's code span, since CommonMark reads no + escape inside one: an escaped backtick alone or, as the review found, one joined to the bare + run after it. A backtick run now escapes whole, and a bare run escapes wherever a later + string of its length forms around an escape in the same inline content: the generalized pass + the maintainer chose (2026-09-15). A paragraph's opening read as a link reference definition across a `]` the emitter spelled: the emitter now escapes the opening `[` exactly when the parser's own definition reader accepts the paragraph, and a link opening it rides the carry until 13b. -- 2.52.0