diff --git a/.changeset/9194-fence-opener-commonmark-run.md b/.changeset/9194-fence-opener-commonmark-run.md new file mode 100644 index 0000000000..d70ead290c --- /dev/null +++ b/.changeset/9194-fence-opener-commonmark-run.md @@ -0,0 +1,10 @@ +--- +--- + +One authority for "does this line open a fenced code block" (objectui#9194). Both doc +gates carried the same greedy predicate, which read a four-backtick opener as a +three-backtick fence in a language named with a leading backtick: the nesting inverted, +the code inside was read as prose, and two empty non-fences were counted as successfully +parsed in the coverage figures. The rule now lives once, as CommonMark states it — a run +of three or more opens, and only a run of the same character that is at least as long +closes. Tooling only; no package is released by this change. diff --git a/scripts/__tests__/check-doc-expression-carriage.test.ts b/scripts/__tests__/check-doc-expression-carriage.test.ts index 83e5b6f4e3..75431269c2 100644 --- a/scripts/__tests__/check-doc-expression-carriage.test.ts +++ b/scripts/__tests__/check-doc-expression-carriage.test.ts @@ -664,13 +664,20 @@ describe('check-doc-expression-carriage: the real tree, and the posture', () => // having looked at nothing. const orphan = fs.mkdtempSync(path.join(os.tmpdir(), 'carriage-orphan-')); fs.mkdirSync(path.join(orphan, 'scripts')); - // Three files, not two: objectui#7878 made the gate IMPORT its scan surface + // Four files, not two: objectui#7878 made the gate IMPORT its scan surface // from `check-doc-component-types.mjs` rather than carry a fourth copy of it, - // so the orphan needs that module for the import to resolve at all. If this - // list ever falls behind the gate's imports the failure is a module-resolution - // stack trace rather than the message below, which is why the message is - // asserted and not merely the exit code. - for (const file of ['check-doc-expression-carriage.mjs', 'check-doc-component-types.mjs', 'invoked-as.mjs']) { + // and objectui#9194 made both gates IMPORT the opening-fence predicate from + // `markdown-fence-scan.mjs` rather than carry a copy each, so the orphan needs + // those modules for the imports to resolve at all. If this list ever falls + // behind the gate's imports the failure is a module-resolution stack trace + // rather than the message below, which is why the message is asserted and not + // merely the exit code. + for (const file of [ + 'check-doc-expression-carriage.mjs', + 'check-doc-component-types.mjs', + 'invoked-as.mjs', + 'markdown-fence-scan.mjs', + ]) { fs.copyFileSync(path.join(ROOT, 'scripts', file), path.join(orphan, 'scripts', file)); } const run = spawnSync(process.execPath, ['scripts/check-doc-expression-carriage.mjs'], { diff --git a/scripts/__tests__/markdown-fence-scan.test.ts b/scripts/__tests__/markdown-fence-scan.test.ts new file mode 100644 index 0000000000..2fe5593335 --- /dev/null +++ b/scripts/__tests__/markdown-fence-scan.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helpers. Their types are INFERRED from the .mjs sources by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — +// re-adding one is itself an error (TS2578). See objectui#3494. +import { closesFence, openFence, selfTest } from '../markdown-fence-scan.mjs'; +import { maskComments } from '../js-comment-mask.mjs'; +import { scanFences } from '../check-doc-expression-carriage.mjs'; +import { scanDocs } from '../check-doc-component-types.mjs'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); + +/** + * ObjectUI — the fence-opening predicate has ONE authority (objectui#9194) + * + * ## The defect + * + * `check-doc-component-types.mjs` and `check-doc-expression-carriage.mjs` each + * carried the same opening-fence predicate, verbatim, and the same wrong answer + * with it: `(\S*)` greedy over non-space read a FOUR-backtick opener as a + * three-backtick opener in a language named with a leading backtick. The + * nesting then inverted — the four-backtick line opened, the real inner opener + * closed, and the code between them was read as prose by both gates while the + * per-language census grew a column for a language that does not exist and the + * coverage figures counted two empty non-fences as successfully parsed. + * + * ## What this file pins, in the order the failures would arrive + * + * 1. **The rule**, on the module that now owns it — CommonMark's: a run of + * three or more opens, and only a run of the same character that is at least + * as long closes. + * 2. **The shape, not the instance** — an ODD number of stray four-backtick + * markers is what desynchronises pairing for the rest of a file. The fixture + * below carries exactly one, and both gates are driven over it. Pinning only + * the even-count page that this card was filed for would pin the accident, + * not the hazard. + * 3. **The corpus invariant** — no fence language on either gate's real scan + * surface may carry a backtick. + * 4. **The recurrence guard** — neither gate may grow a private fence + * predicate again. That is the half the card's own closing sentence asks + * for: two copies that are separately correct today are two copies that + * drift silently tomorrow, and a fix applied to one can leave the other + * behind. + */ +describe('markdown fence scanning has one authority (objectui#9194)', () => { + it('passes its own controls', () => { + expect(selfTest()).toEqual([]); + }); + + describe('the CommonMark run rule', () => { + it('reads the real language off a four-backtick opener', () => { + expect(openFence('````markdown')).toMatchObject({ marker: '`', run: 4, lang: 'markdown' }); + }); + + it('does not let a shorter run close a longer fence', () => { + const outer = openFence('````markdown')!; + expect(closesFence('```javascript', outer)).toBe(false); + expect(closesFence('```', outer)).toBe(false); + expect(closesFence('````', outer)).toBe(true); + }); + + it('lets a longer run close a shorter fence', () => { + const inner = openFence('```json')!; + expect(closesFence('````', inner)).toBe(true); + expect(closesFence('`````', inner)).toBe(true); + }); + + it('never produces a language carrying a backtick — the error is unrepresentable', () => { + // The greedy capture this replaced would answer '`markdown' here. There is + // no spelling of a backtick fence whose captured language holds a backtick: + // the run is consumed greedily, and an info string containing one is not an + // opener at all (CommonMark). + for (const line of ['```markdown', '````markdown', '`````markdown', '``````js', '```js`x', '``` `x']) { + expect(openFence(line)?.lang ?? '').not.toContain('`'); + } + }); + + it('does not confuse the two marker characters', () => { + const tilde = openFence('~~~json')!; + expect(closesFence('```', tilde)).toBe(false); + expect(closesFence('~~~', tilde)).toBe(true); + }); + }); + + /** + * The lit control. ⛔ The corpus instance this card was filed for has an EVEN + * number of stray markers, so its pairing happens to resynchronise afterwards — + * verifying against it alone cannot show what goes wrong. This fixture carries + * exactly ONE stray four-backtick marker, inside a five-backtick wrapper that + * is legitimately teaching it. + * + * Against the replaced predicate this file read: two phantom languages + * ('``markdown' and '``'), the prose paragraph as a fence BODY, the quoted + * example as a real judged json fence, `omega` as prose, and an unterminated + * fence at end of file. Every one of those is a wrong answer delivered with a + * healthy-looking parse rate. + */ + describe('an odd number of stray four-backtick markers', () => { + const source = [ + '# Odd stray four-backtick markers', + '', + '```json', + '{ "type": "alpha" }', + '```', + '', + '`````markdown', + '````markdown', + '```json', + '{ "type": "quoted-inside-the-wrapper" }', + '```', + '`````', + '', + 'Prose paragraph that is not code.', + '', + '```json', + '{ "type": "omega" }', + '```', + '', + ].join('\n'); + + const withFixture = (run: (root: string) => T): T => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'objectui-9194-')); + try { + fs.mkdirSync(path.join(dir, 'content', 'docs'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'content', 'docs', 'odd.mdx'), source, 'utf8'); + return run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }; + + it('carries exactly one such marker, which is what makes it odd', () => { + const strays = source.split('\n').filter((line) => /^````(?:[^`]|$)/.test(line)); + expect(strays).toHaveLength(1); + expect(strays[0]).toBe('````markdown'); + }); + + it('does not desynchronise the expression-carriage gate', () => { + const fences = withFixture((root) => scanFences(root).fences); + expect(fences.map((f) => [f.line, f.lang, f.scanned])).toEqual([ + [3, 'json', true], + [7, 'markdown', false], + [16, 'json', true], + ]); + // The stray marker and the quoted example are BODY of the wrapper, not + // boundaries — so the fence after them still pairs correctly... + expect(fences[2].body).toEqual(['{ "type": "omega" }']); + // ...and nothing runs off the end of the file. + expect(fences.filter((f) => f.reason === 'unterminated-fence')).toEqual([]); + expect(fences.filter((f) => f.lang.includes('`'))).toEqual([]); + }); + + it('does not desynchronise the component-types gate', () => { + const docs = withFixture((root) => scanDocs(root)); + expect(docs.sites.map((s) => [s.line, s.lang, s.value])).toEqual([ + [4, 'json', 'alpha'], + [10, 'markdown', 'quoted-inside-the-wrapper'], + [17, 'json', 'omega'], + ]); + expect(docs.counters.codeBlocks).toBe(3); + expect(docs.sites.some((s) => s.unterminated)).toBe(false); + }); + }); + + describe('the real scan surface', () => { + it('carries no fence language holding a backtick', () => { + const languages = new Set(scanFences(ROOT).fences.map((f) => f.lang)); + expect([...languages].filter((lang) => lang.includes('`'))).toEqual([]); + }); + + it('reads a page that teaches nested fences as one fence, not three', () => { + const page = 'content/docs/plugins/plugin-markdown.mdx'; + const fences = scanFences(ROOT).fences.filter((f) => f.file === page); + const nested = fences.filter((f) => f.body.some((line) => line.trim().startsWith('```'))); + // Whatever else the page grows, a block quoted INSIDE a longer fence is + // that fence's body: it never becomes a fence of its own, and it never + // leaves a zero-length phantom behind. + expect(nested.every((f) => f.body.length > 0)).toBe(true); + expect(fences.filter((f) => f.body.length === 0 && f.lang.includes('markdown'))).toEqual([]); + }); + }); + + /** + * The recurrence guard. A gate that re-spells the predicate locally is the + * defect coming back, whatever the new spelling gets right — that is why this + * looks for the SHAPE of an anchored fence-matching regex literal rather than + * for the one wrong pattern that was removed. + * + * Comments are masked first: these files describe the old predicate at length, + * and prose about a regex is not a regex (`js-comment-mask.mjs`, objectui#8560's + * family). + */ + describe('neither gate re-spells the predicate', () => { + const GATES = ['scripts/check-doc-component-types.mjs', 'scripts/check-doc-expression-carriage.mjs']; + /** An anchored regex literal that matches a run of fence markers. */ + const LOCAL_PREDICATE = /\/\^[^\n/]*(?:`{3}|`\{\d|~{3}|~\{\d)/; + + it.each(GATES)('%s imports the shared authority', (gate) => { + const source = fs.readFileSync(path.join(ROOT, gate), 'utf8'); + expect(source).toContain("from './markdown-fence-scan.mjs'"); + }); + + it.each(GATES)('%s spells no fence predicate of its own', (gate) => { + const source = maskComments(fs.readFileSync(path.join(ROOT, gate), 'utf8')); + expect(LOCAL_PREDICATE.test(source)).toBe(false); + }); + + it('the guard would catch the predicate that was removed', () => { + // The positive control, so a guard that stopped matching anything at all + // cannot pass by describing nothing. + expect(LOCAL_PREDICATE.test('const fence = /^\\s*```(\\S*)\\s*$/.exec(lines[i]);')).toBe(true); + expect(LOCAL_PREDICATE.test('const fence = /^([ \\t]*)(`{3,}|~{3,})([^\\n]*)\\n/gm;')).toBe(true); + }); + }); +}); diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index d56263a6b0..47a9397b40 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -206,6 +206,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { closesFence, openFence } from './markdown-fence-scan.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -1142,22 +1143,30 @@ export function scanDocs(root) { for (const abs of files) { const rel = relative(root, abs).split(sep).join('/'); const lines = readFileSync(abs, 'utf8').split('\n'); - let inFence = false; + /** @type {import('./markdown-fence-scan.mjs').OpenFence | null} */ + let open = null; let lang = null; for (let i = 0; i < lines.length; i++) { - const fence = /^\s*```(\S*)\s*$/.exec(lines[i]); - if (fence) { - if (inFence) { - inFence = false; + // ⛔ Never re-spell the fence predicate here. `markdown-fence-scan.mjs` is + // its one authority, and it is one because the local spelling this line + // used to hold read a four-backtick opener as a three-backtick fence in a + // language named with a leading backtick (objectui#9194). + if (open) { + if (closesFence(lines[i], open)) { + open = null; lang = null; - } else { - inFence = true; - lang = fence[1] || 'plaintext'; + continue; + } + } else { + const opened = openFence(lines[i]); + if (opened) { + open = opened; + lang = opened.lang || 'plaintext'; counters.codeBlocks++; + continue; } - continue; } - if (!inFence) { + if (!open) { if (KEY_TABLE_HEADER.test(lines[i]) && TABLE_DELIMITER.test(lines[i + 1] ?? '')) { counters.keyTables++; const header = i + 1; @@ -1190,7 +1199,7 @@ export function scanDocs(root) { sites.push({ file: rel, line: i + 1, lang, value, text: lines[i].trim() }); } } - if (inFence) { + if (open) { // An unclosed fence means the rest of the file was read as code. Report it // rather than guessing, because the alternative is a silently truncated scan. sites.push({ file: rel, line: lines.length, lang: 'unterminated', value: null, unterminated: true }); diff --git a/scripts/check-doc-expression-carriage.mjs b/scripts/check-doc-expression-carriage.mjs index cf846c5576..124e36311c 100644 --- a/scripts/check-doc-expression-carriage.mjs +++ b/scripts/check-doc-expression-carriage.mjs @@ -216,6 +216,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { APP_DOCS, appDocsDirs, ROOT_PAGES } from './check-doc-component-types.mjs'; import { isEntrypoint } from './invoked-as.mjs'; +import { closesFence, openFence } from './markdown-fence-scan.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, '..'); @@ -677,9 +678,13 @@ export function scanFences(root) { let open = null; let body = []; for (let i = 0; i < lines.length; i++) { - const fence = /^\s*```(\S*)\s*$/.exec(lines[i]); - if (fence) { - if (open) { + // ⛔ Never re-spell the fence predicate here. `markdown-fence-scan.mjs` is + // its one authority, and it is one because the local spelling this line + // used to hold read a four-backtick opener as a three-backtick fence in a + // language named with a leading backtick, which then counted two phantom + // fences as successfully parsed (objectui#9194). + if (open) { + if (closesFence(lines[i], open)) { const scanned = JSON_FENCE_LANGUAGES.includes(open.lang); fences.push({ file: rel, @@ -693,10 +698,14 @@ export function scanFences(root) { }); open = null; body = []; - } else { - open = { lang: (fence[1] || 'plaintext').toLowerCase(), line: i + 1 }; + continue; + } + } else { + const opened = openFence(lines[i]); + if (opened) { + open = { ...opened, lang: (opened.lang || 'plaintext').toLowerCase(), line: i + 1 }; + continue; } - continue; } if (open) body.push(lines[i]); } diff --git a/scripts/markdown-fence-scan.mjs b/scripts/markdown-fence-scan.mjs new file mode 100644 index 0000000000..7d22048db8 --- /dev/null +++ b/scripts/markdown-fence-scan.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * markdown-fence-scan -- the ONE answer to "does this line OPEN a fenced code + * block, and what is allowed to close it?" (objectui#9194) + * + * node scripts/markdown-fence-scan.mjs --self-test + * + * ## The defect this module exists to make unrepresentable + * + * Two doc gates -- `check-doc-component-types.mjs` and + * `check-doc-expression-carriage.mjs` -- each carried this predicate, verbatim: + * + * const fence = /^\s*```(\S*)\s*$/.exec(line); + * + * `(\S*)` is greedy over non-space, so a FOUR-backtick opener matched as a + * three-backtick opener whose *language* was the leftover backtick plus the real + * language. Three things then went wrong at once, on a page that is legitimately + * teaching nested fences (`content/docs/plugins/plugin-markdown.mdx`): + * + * 1. a language that does not exist -- a backtick-bearing string -- got its + * own column in the per-language census; + * 2. two PHANTOM fences with empty bodies, both reporting a successful parse, + * because the object-BODY retry wraps `''` into `{}` and `{}` parses. An + * empty non-fence was therefore counted as a successfully parsed fence in + * the coverage figures; + * 3. the nesting INVERTED -- the four-backtick line opened, the real inner + * opener CLOSED, and the code between them sat outside any fence and was + * invisible to both gates while the parse rate still read healthy. + * + * The bounded instance was two markers in one file. The hazard is the SHAPE: an + * ODD number of stray markers desynchronises fence pairing for the rest of the + * file, so both gates judge the wrong bodies and report a clean coverage figure + * over a region the instrument mis-read (the objectui#8334 family). + * + * ## The rule, which is CommonMark's and not this repo's invention + * + * A fenced code block opens on a RUN of at least three backticks or at least + * three tildes, and closes only on a run of the SAME character that is AT LEAST + * AS LONG, carrying no info string. That is what makes a shorter run inside a + * longer one ordinary body text, which is precisely how a page teaches nested + * fences. The info string of a BACKTICK fence may not contain a backtick -- + * which is what structurally prevents a language capture from ever holding one + * again, rather than detecting it after the fact. + * + * ## Two deliberate divergences from strict CommonMark, both measured + * + * - **Indentation is not capped at three spaces.** CommonMark says a marker + * indented four or more spaces is indented code, not a fence. The replaced + * predicates accepted any leading whitespace, and tightening that here would + * silently DROP fences the gates read today -- a behaviour change this card + * did not ask for. Measured over every tracked `.md`/`.mdx` file in the tree + * at the time of writing: 12 marker lines are indented past three spaces and + * every one of them is in `.github/prompts/component.prompt.md`, which is on + * neither gate's scan surface. Tightening is a separate card. + * - **Blank-info and multi-token info are both accepted as openers.** The + * replaced predicates required the whole info string to be one non-space + * token (`\s*$` after the capture), so a ```` ```ts title="a.ts" ```` line + * was not a fence at all and its body was read as prose. Accepting it is + * CommonMark and is behaviour-neutral on today's corpus: the same sweep + * found ZERO marker lines with a multi-token info string, and zero tilde + * fences. + * + * ## Why this is a MODULE and not two patched copies + * + * The two gates already share a module boundary -- one imports the other's doc + * surface constants -- so there was no extraction cost to weigh, and "N + * separately-correct copies" is the family this tree keeps paying for + * (objectstack#17681, the objectui#7448 family). A copy that is separately + * correct today is a copy that drifts silently tomorrow: the card's own closing + * sentence is that a fix applied to one gate can leave the other behind. + * `scripts/__tests__/markdown-fence-scan.test.ts` is the recurrence guard -- it + * fails if either gate grows a private fence predicate again. + * + * Prior art, deliberately named rather than re-derived: `body-dialect-census.mjs` + * (`keepFencedCodeOnly`) and `check-doc-fence-languages.mjs` were already + * run-aware and already carried the "closes on a run at least as long" comment. + * They are offset-based and self-contained respectively; this module is the + * line-based spelling the two doc gates need, and it is the one a third gate + * should import instead of writing a fourth. + */ + +import { isEntrypoint } from './invoked-as.mjs'; + +/** A run of three or more backticks or tildes, with whatever follows it. */ +const MARKER_RUN = /^\s*(`{3,}|~{3,})(.*)$/; + +/** A closing marker carries a run and nothing else but whitespace. */ +const CLOSING_RUN = /^\s*(`{3,}|~{3,})\s*$/; + +/** + * @typedef {object} OpenFence + * @property {string} marker the fence character, '`' or '~' + * @property {number} run how many of it opened the fence + * @property {string} info the full info string, trimmed + * @property {string} lang the first token of the info string ('' if none) + */ + +/** + * The opening fence this line is, or `null` if it is not one. + * + * ⛔ Never re-spell this test locally. A local spelling is how the two doc gates + * ended up with one wrong answer each. + * + * @param {string} line one line of a markdown/MDX document, newline removed + * @returns {OpenFence | null} + */ +export function openFence(line) { + const m = MARKER_RUN.exec(line); + if (!m) return null; + const marker = m[1][0]; + const info = m[2].trim(); + // CommonMark: a backtick fence's info string may not contain a backtick. + // This is what makes a backtick-bearing "language" unrepresentable, instead + // of detectable after it has already been counted. + if (marker === '`' && info.includes('`')) return null; + return { marker, run: m[1].length, info, lang: info.split(/\s+/)[0] ?? '' }; +} + +/** + * Does this line close the fence `open` opened? Same marker character, a run at + * least as long, and no info string. + * + * @param {string} line + * @param {OpenFence} open the fence returned by `openFence` + * @returns {boolean} + */ +export function closesFence(line, open) { + const m = CLOSING_RUN.exec(line); + if (!m) return false; + return m[1][0] === open.marker && m[1].length >= open.run; +} + +/** + * Controls. Every one of them is a shape this module got WRONG before it + * existed, or a shape a future simplification would break. + * + * @returns {string[]} failures, empty when the instrument is sound + */ +export function selfTest() { + /** @type {string[]} */ + const failures = []; + const t = (name, actual, expected) => { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) failures.push(`${name}: expected ${e}, got ${a}`); + }; + + // The instance objectui#9194 was filed for. + t('a four-backtick opener keeps its real language', openFence('````markdown')?.lang, 'markdown'); + t('...and records the run that opened it', openFence('````markdown')?.run, 4); + t('a three-backtick opener still reads its language', openFence('```json')?.lang, 'json'); + t('a bare opener has an empty language', openFence('```')?.lang, ''); + + // The inversion: a shorter run inside a longer one is BODY, not a boundary. + const four = openFence('````markdown'); + t('a three-backtick line does NOT close a four-backtick fence', closesFence('```javascript', four), false); + t('...nor does a bare three-backtick line', closesFence('```', four), false); + t('a four-backtick line closes it', closesFence('````', four), true); + t('a LONGER run closes it too', closesFence('`````', four), true); + const three = openFence('```json'); + t('a four-backtick line closes a three-backtick fence', closesFence('````', three), true); + + // No language may ever carry a backtick again -- structurally, not by report. + t('an info string holding a backtick is not an opener', openFence('```js`x'), null); + t('...and neither is a five-backtick run read as a language', openFence('`````markdown')?.lang, 'markdown'); + + // A closer carries no info string. + t('a run with an info string never closes', closesFence('```json', three), false); + t('trailing whitespace still closes', closesFence('``` ', three), true); + t('an indented closer still closes', closesFence(' ```', three), true); + + // Markers do not cross. + const tilde = openFence('~~~json'); + t('a tilde fence reads its language', tilde?.lang, 'json'); + t('a backtick run does not close a tilde fence', closesFence('```', tilde), false); + t('a tilde run does not close a backtick fence', closesFence('~~~', three), false); + + // Not fences. + t('inline code is not a fence', openFence('use `x` here'), null); + t('two backticks are not a fence', openFence('``x'), null); + t('an info string may hold several tokens', openFence('```ts title="a.ts"')?.lang, 'ts'); + t('...and the full info string is kept', openFence('```ts title="a.ts"')?.info, 'ts title="a.ts"'); + + return failures; +} + +if (isEntrypoint(import.meta.url)) { + const failures = selfTest(); + if (failures.length > 0) { + console.error(`❌ markdown-fence-scan failed its own controls:\n${failures.map((f) => ` ${f}`).join('\n')}`); + process.exit(1); + } + console.log('✅ markdown-fence-scan: all controls pass.'); +} diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 978c158754..dc69a30f1e 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -796,6 +796,16 @@ export const ADJUDICATED = new Map([ reads: ['content/docs/guide/ci-cd-pipeline.md'], }, ], + // objectui#9194. Names one page -- the one that legitimately teaches nested + // fences -- to pin that a block quoted inside a longer run stays that run's + // body. The whole scan surface is reached too, but through the two gates' own + // scanners rather than a walk of its own, which is why no `walker` is declared. + // Its odd-marker control is written into a temp directory and is ⛔ not a + // document in this tree. + [ + 'scripts/__tests__/markdown-fence-scan.test.ts', + { reads: ['content/docs/plugins/plugin-markdown.mdx'] }, + ], // Reads no markdown: drives the decision step against fixture repositories it // writes in a temp directory, so every document literal here is a fixture path // rather than a file in this tree -- `AGENTS.md` included, which is the