Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/9194-fence-opener-commonmark-run.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 13 additions & 6 deletions scripts/__tests__/check-doc-expression-carriage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'], {
Expand Down
219 changes: 219 additions & 0 deletions scripts/__tests__/markdown-fence-scan.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T,>(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);
});
});
});
31 changes: 20 additions & 11 deletions scripts/check-doc-component-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
21 changes: 15 additions & 6 deletions scripts/check-doc-expression-carriage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -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,
Expand All @@ -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]);
}
Expand Down
Loading
Loading