From 7b8e29c6aa1ba85b1d0541a9af60683592272399 Mon Sep 17 00:00:00 2001 From: os-steve Date: Mon, 24 Aug 2026 23:44:41 +0000 Subject: [PATCH 1/4] feat(gates): ratchet AGENTS.md's published spellings list to the constant `scripts/check-cross-package-test-inputs.mjs` is a source scan: a path spelling it does not know produces no flag, so a test whose reads escape its package goes undeclared silently. That is why its recognised set is published in AGENTS.md rather than left in the implementation -- and nothing compared the two copies. The mirror drifted three times (#10163, #10854, and this one). Twice the stale line was the stated REASON FOR A PROHIBITION, so a rotting mirror does not merely misinform: it launders an obsolete rule into a live one. And one claim was already false BEFORE the PR that supposedly staled it, so a lag-only check would not have caught it either. Adds `scripts/check-published-list-mirrors.mjs`, which asserts line-for-line EQUALITY between a declared constant and the block a document publishes: - equality, not containment -- a comment-only drift is invisible to containment, and the comments are where the prohibitions live; - the block is located by heading + fence, never by line number (this card was filed against `AGENTS.md:96-106`; the block sat at `:92-104` three days later); - every unreadable state REFUSES: renamed heading, duplicate heading, re-tagged fence, unterminated fence, empty block, two candidate fences, and a constant that is missing, renamed, empty or not a string list; - it can only ever go RED. AGENTS.md is governed, human-merge-only, so the gate never repairs -- it prints the exact block to paste. Stated in its header. Repairs the published block in the same change: it was short by 13 of the constant's 24 lines, including the two `findUp` ANCHOR seeds PR #10852 added, and the lead-in prose named only two of the three seed kinds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- .github/workflows/lint.yml | 21 + AGENTS.md | 40 +- scripts/check-cross-package-test-inputs.mjs | 8 + scripts/check-published-list-mirrors.mjs | 402 ++++++++++++++++++++ 4 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 scripts/check-published-list-mirrors.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1879d81fc4..309be66a29 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2233,6 +2233,27 @@ jobs: node scripts/check-declaration-mirrors.mjs --self-test node scripts/check-declaration-mirrors.mjs + # Published list mirrors (#10855). The cross-package gate above is a SOURCE + # SCAN: a path spelling it does not know yields no flag, so the escaping read + # goes undeclared SILENTLY. That is why its recognised set is published in + # AGENTS.md instead of living only in the implementation — and nothing held the + # two copies in step. It drifted three times (#10163, #10854, #10855); measured + # on 1a47a5368 the published block was short by 13 of the 24 lines, the two + # findUp ANCHOR seeds among them. ⭐ Twice the stale line was the stated REASON + # FOR A PROHIBITION, so a rotting mirror does not merely misinform — it launders + # an obsolete rule into a live one, and the fix has to derive a new true reason. + # This asserts line-for-line EQUALITY (not containment: a comment-only drift is + # invisible to containment, and the comments are where the prohibitions live), + # locates the block by heading + fence rather than line number, and REFUSES + # rather than passing empty when it cannot find it. ⛔ It can only ever go RED: + # AGENTS.md is governed (human-merge-only), so it prints the block to paste and + # never repairs. Invoked as `node` rather than a `pnpm check:*` alias for the + # same reason as the step above (#9465). Reads two files; milliseconds. + - name: Published list mirrors + run: | + node scripts/check-published-list-mirrors.mjs --self-test + node scripts/check-published-list-mirrors.mjs + # The inventory of `packages/**` tests coupled to `examples/**` (#8754). # Sibling of the cross-package gate above, on the axis that gate does not # own. ⚠️ The line that used to stand here — "that one detects tests whose diff --git a/AGENTS.md b/AGENTS.md index 7785a2f80b..12ed1b590c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,20 +87,34 @@ deliberately: a detector with no dependencies cannot itself fail to resolve in C The price of a source scan is that it sees only the spellings it knows, and an unrecognised one produces no flag — which means no declaration, **silently**. So the recognised list is published rather than left inside the implementation. Seed from -`import.meta.url` or `__dirname`, and write the escaping path as one of: +`import.meta.url`, `__dirname`, or a `findUp` walk, and write the escaping path as +one of: ```ts const HERE = dirname(fileURLToPath(import.meta.url)); // seed (ESM) -const HERE = __dirname; // seed (CJS) -const HERE = import.meta.dirname; // and dirname(import.meta.filename) -const HERE = resolve(fileURLToPath(import.meta.url), '..'); // seed walked from the - // FILE rather than named; - // import.meta.filename too -const P = resolve(HERE, ''); // join() and path.* too +const HERE = __dirname; // seed (CJS) +const HERE = import.meta.dirname; // and dirname(import.meta.filename) +const HERE = resolve(fileURLToPath(import.meta.url), '..'); // seed, walked + // from the FILE instead of named; + // import.meta.filename works too +const P = resolve(HERE, ''); // join() and the path.* forms too const P = fileURLToPath(new URL('', import.meta.url)); const P = new URL('', import.meta.url); -readFileSync(resolve(HERE, '')) // the same expressions -readFileSync(new URL('', import.meta.url)) // in argument position +readFileSync(resolve(HERE, '')) // the same expressions in argument +readFileSync(new URL('', import.meta.url)) // position + +// Any call above may be BROKEN ACROSS LINES -- a formatter does that to every +// argument list past the print width, so it is the DEFAULT spelling for a long +// relative literal, and it is read whole, trailing comma and all (#11093). + +// ANCHOR seeds -- a findUp walk, for a CJS-typed package where import.meta +// is a TS1470. Both compose with every expression above (#10029). +const PKG = findUp((dir) => JSON.parse(readFileSync(join(dir, 'package.json'))).name + === ''); // -> package root +const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml'))); + // -> repo root + ⛔ NOT a manifest name belonging to some OTHER package -- that root cannot + be located from here, so the escape is flagged and the path is NOT named ``` The gate prints this list in its failure text too, and `--self-test` pins every entry. @@ -108,6 +122,14 @@ Reaching for a spelling that is not here? **Extend the detector and add a `--sel case in the same edit** — never route around it. An unseen read is the defect above, not a style question, and a newly recognised shape with no pin is the next silent regression. +That block is **byte-identical** to the detector's `RECOGNISED_PATH_SPELLINGS`, and +`node scripts/check-published-list-mirrors.mjs` holds it that way — line for line, +comments included, because twice the stale line here was the stated *reason for a +prohibition* (#10163, #10854), which is how an obsolete rule gets laundered into a +live one. Extending the detector therefore means editing this block in the **same +PR**. ⛔ The gate can only ever go RED — this file is governed, human-merge-only, so +nothing may repair it for you; it prints the exact block to paste (#10855). + Two things it deliberately does not flag: a path that climbs out and lands in `node_modules` (an installed dependency is not a repo source input, and no turbo glob can name it), and a path that climbs out and comes straight back in. What it *does* flag is diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 2566c26164..ed19eb52bc 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -312,6 +312,14 @@ const PATH_ARG_READS = ['readFileSync', 'readdirSync', 'statSync', 'lstatSync', * a source scan: a spelling that is not on this list yields no flag, so a read * written that way goes undeclared silently. Anything added here needs a * `--self-test` case in the same edit, or the next refactor drops it unnoticed. + * + * The AGENTS.md copy is held BYTE-IDENTICAL to this array by + * `scripts/check-published-list-mirrors.mjs` (#10855), comments included: twice the + * stale line over there was the stated REASON FOR A PROHIBITION (#10163, #10854), so + * a containment check would have missed exactly the drift that cost the most. Editing + * this array therefore means editing that block in the SAME PR -- and AGENTS.md is + * governed, human-merge-only, so that gate can only ever go RED. It prints the block + * to paste. */ export const RECOGNISED_PATH_SPELLINGS = [ "const HERE = dirname(fileURLToPath(import.meta.url)); // seed (ESM)", diff --git a/scripts/check-published-list-mirrors.mjs b/scripts/check-published-list-mirrors.mjs new file mode 100644 index 0000000000..b5112704e4 --- /dev/null +++ b/scripts/check-published-list-mirrors.mjs @@ -0,0 +1,402 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-published-list-mirrors (#10855) -- a list a document PUBLISHES must be + * the list the code actually holds, line for line. + * + * node scripts/check-published-list-mirrors.mjs # judge the checked-in tree + * node scripts/check-published-list-mirrors.mjs --self-test # prove the battery can go red + * + * ## The defect this exists to make impossible + * + * `scripts/check-cross-package-test-inputs.mjs` is a SOURCE SCAN: it sees only + * the path spellings it knows, and a spelling it does not know produces no + * flag -- so a test whose reads escape its package goes undeclared SILENTLY. + * That is why the recognised set is published rather than left inside the + * implementation, and why the constant's own header says it is "printed in the + * failure text and mirrored in AGENTS.md". + * + * Nothing compared the two. The detector could grow a spelling and the + * published copy could stay short indefinitely; the only signal was a human + * noticing. It drifted three times: + * + * round 1 #10163 -- three files said the detector recognised TWO seeds; it + * recognised five (widened by #8995 / #9763). closed by #10690 + * round 2 #10854 -- three files said the detector could not resolve a + * `findUp` walk; after PR #10852 it can. closed by #11891 + * round 3 #10855 -- this one. Measured on `1a47a5368`: `findUp` occurs 22 + * times in the detector and 0 times in AGENTS.md, while the control + * spelling `__dirname` occurs twice there -- so the zero was a + * reading and not a dead grep. The published block was short by 13 + * of the constant's 24 lines, the two `findUp` ANCHOR seeds among + * them. + * + * ## Why a gate rather than a fourth fix + * + * In rounds 1 and 2 the stale sentence was the stated REASON FOR A + * PROHIBITION -- #10163's wrong "only two seeds" line was the justification for + * "this seed may not change". A rotting mirror does not merely misinform: it + * launders an obsolete rule into a live one, and the next reader obeys it. + * Round 2's fix (PR #11891) had to keep the prohibition and derive a NEW true + * reason for it, which is the expensive shape this closes. + * + * And one of round 2's three claims was already false BEFORE the PR that + * supposedly staled it. So this is not only "the code moved and the doc + * lagged": a published claim can be wrong on arrival, and nothing caught that + * either. + * + * ## Equality, not "every entry appears" + * + * Containment -- every entry of the constant appears SOMEWHERE in the block -- + * is the cheaper assertion, and it is not enough, in two directions that both + * occur in the history above: + * + * - a line the DOC publishes that the constant does not hold tells authors a + * spelling is recognised while the scanner is blind to it. That is the + * original defect with a published byline, and it is the "wrong on + * arrival" case; + * - a COMMENT-ONLY drift is invisible to containment, and the comments are + * where the prohibitions live. Both rounds above were comment prose, not + * code. + * + * So the block must equal the constant line for line. The only slack is + * trailing whitespace, stripped on both sides: an invisible character is not + * worth an unreadable red, and no meaning in either copy rides on it. + * + * ⛔ This gate can only ever go RED. It never repairs, and it never will: + * `AGENTS.md` is a GOVERNED surface (`scripts/pm/check-governed-merges.mjs`), + * human-merge-only, so an auto-fix here would write the one file no seat may + * land on its own. The failure text prints the exact block to paste instead, + * and the repair rides in the same PR as the code change that caused it. + * + * ## What it deliberately does NOT assert + * + * Only the fenced block is judged. The prose AROUND it is not comparable to + * anything mechanically, and a gate that implied otherwise would overstate its + * coverage -- worse than one that states its limit. Round 2's false claim lived + * in prose of exactly that kind, in three files; this gate would have caught it + * in the block and not in the sentence. + * + * `RECOGNISED_IMPORT_SPELLINGS` (the same detector's escaping-IMPORT list, + * #10452) is NOT in the table below, because AGENTS.md does not publish it at + * all -- there is no mirror to hold in step. Whether it should be published is + * a governed content decision, not this gate's to make. + * + * ## Why every unreadable state is a REFUSAL + * + * A gate that locates a block by its heading and fence is exactly the kind that + * can pass while reading nothing: a renamed heading, a re-tagged fence, a + * second candidate fence, a block emptied to a placeholder -- each produces an + * empty comparison, and an empty comparison has no violations in it. Every one + * of them exits 1 naming what could not be read (#4690). The same rule covers + * the code side: a constant that is missing, renamed, empty, or no longer an + * array of strings is a refusal, never a quiet pass -- which matters here + * because the recognised-set constant has already survived one refactor that + * moved its neighbours (#11871 moved the declaration table and the glob helpers + * to `scripts/cross-package-test-inputs.mjs` and `scripts/glob-match.mjs`). + * + * `--self-test` pins every refusal AND pins that the ordinary case is green, so + * a red under ablation is a reading rather than a battery that cannot pass. + * Nothing here is best-effort, so there is no UNRECOGNISED census (#9747) to + * print: every input is read or refused. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import process from 'node:process'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); + +/** + * The mirrors, DECLARED. One row per (constant, published block) pair. + * + * A row is located STRUCTURALLY -- by its heading and the fence inside that + * heading's section -- never by line number. Line numbers are the first thing + * to rot here: this card was filed against `AGENTS.md:96-106` and the block sat + * at `:92-104` by the time it was implemented, three days later. + */ +export const MIRRORS = [ + { + id: 'cross-package-path-spellings', + module: 'scripts/check-cross-package-test-inputs.mjs', + constant: 'RECOGNISED_PATH_SPELLINGS', + doc: 'AGENTS.md', + heading: '### A test that reads outside its own package must be spelled so the gate can see it', + lang: 'ts', + }, +]; + +/** A fence line: three or more backticks, then an optional info string. */ +const FENCE = /^(`{3,})[ \t]*([^\s`]*)[ \t]*$/; + +/** Trailing whitespace is the one difference this gate forgives. See the header. */ +const stripEnd = (s) => s.replace(/[ \t]+$/, ''); + +/** + * The lines of `spec`'s published block, or a REFUSAL naming what could not be + * read. Pure: takes the document text, so the self-test drives it on fixtures. + */ +export function locateBlock(docText, spec) { + const level = (/^(#{1,6})\s/.exec(spec.heading) || [])[1]?.length; + if (!level) return { refusal: `mirror \`${spec.id}\`: the declared heading is not a markdown heading: ${JSON.stringify(spec.heading)}` }; + + const lines = docText.replace(/\r\n/g, '\n').split('\n'); + const at = []; + for (let i = 0; i < lines.length; i++) if (stripEnd(lines[i]) === stripEnd(spec.heading)) at.push(i); + if (at.length === 0) { + return { refusal: `mirror \`${spec.id}\`: heading not found in ${spec.doc} -- ${JSON.stringify(spec.heading)}\n A renamed or deleted heading is a REFUSAL, never a pass: the block cannot be located, so nothing was compared.` }; + } + if (at.length > 1) { + return { refusal: `mirror \`${spec.id}\`: heading occurs ${at.length} times in ${spec.doc} (lines ${at.map((i) => i + 1).join(', ')}); which section holds the mirror is ambiguous.` }; + } + + // The section runs to the next heading of the same or higher rank. + let end = lines.length; + for (let i = at[0] + 1; i < lines.length; i++) { + const m = /^(#{1,6})\s/.exec(lines[i]); + if (m && m[1].length <= level) { end = i; break; } + } + + const fences = []; + let open = null; + for (let i = at[0] + 1; i < end; i++) { + const m = FENCE.exec(lines[i]); + if (!m) continue; + if (!open) { open = { ticks: m[1].length, info: m[2], start: i }; continue; } + if (m[1].length >= open.ticks && m[2] === '') { fences.push({ ...open, end: i }); open = null; } + } + if (open) { + return { refusal: `mirror \`${spec.id}\`: an unterminated \`${'`'.repeat(open.ticks)}${open.info}\` fence opens at ${spec.doc}:${open.start + 1} and never closes inside the section; the block boundaries are unknowable.` }; + } + + const candidates = fences.filter((f) => f.info === spec.lang); + if (candidates.length === 0) { + return { refusal: `mirror \`${spec.id}\`: no \`${'```'}${spec.lang}\` fence in that section of ${spec.doc} (${fences.length} fence(s) of other kinds: ${fences.map((f) => f.info || '').join(', ') || 'none'}).\n A re-tagged or moved block is a REFUSAL: an empty comparison has no violations in it.` }; + } + if (candidates.length > 1) { + return { refusal: `mirror \`${spec.id}\`: ${candidates.length} \`${'```'}${spec.lang}\` fences in that section of ${spec.doc} (lines ${candidates.map((f) => f.start + 1).join(', ')}); which one is the mirror is ambiguous. Give the section one, or teach this table how to tell them apart.` }; + } + + const block = lines.slice(candidates[0].start + 1, candidates[0].end); + if (block.every((l) => stripEnd(l) === '')) { + return { refusal: `mirror \`${spec.id}\`: the \`${'```'}${spec.lang}\` block at ${spec.doc}:${candidates[0].start + 1} holds no content; an empty block passes every containment check ever written.` }; + } + return { lines: block, at: candidates[0].start + 2 }; +} + +/** + * The constant's entries, or a REFUSAL. Pure over the imported value, so the + * self-test can drive every shape without writing a module to disk. + */ +export function validateEntries(value, spec) { + const where = `${spec.module} -> ${spec.constant}`; + if (value === undefined) return { refusal: `mirror \`${spec.id}\`: ${where} is not exported (renamed, moved, or deleted). A vanished constant must be loud -- an absent list compares equal to nothing.` }; + if (!Array.isArray(value)) return { refusal: `mirror \`${spec.id}\`: ${where} is ${typeof value}, not an array of strings.` }; + if (value.length === 0) return { refusal: `mirror \`${spec.id}\`: ${where} is EMPTY; an empty list matches an empty block and reads as a pass.` }; + const bad = value.findIndex((v) => typeof v !== 'string'); + if (bad >= 0) return { refusal: `mirror \`${spec.id}\`: ${where}[${bad}] is ${typeof value[bad]}, not a string.` }; + return { entries: value }; +} + +/** Line-for-line disagreements between the constant and the published block. */ +export function judge(entries, blockLines) { + const want = entries.map(stripEnd); + const got = blockLines.map(stripEnd); + const problems = []; + for (let i = 0; i < Math.max(want.length, got.length); i++) { + if (want[i] === got[i]) continue; + if (i >= want.length) problems.push(`line ${i + 1}: PUBLISHED but not in the constant: ${JSON.stringify(got[i])}`); + else if (i >= got.length) problems.push(`line ${i + 1}: in the constant but NOT PUBLISHED: ${JSON.stringify(want[i])}`); + else problems.push(`line ${i + 1}: differs\n constant : ${JSON.stringify(want[i])}\n published: ${JSON.stringify(got[i])}`); + } + return problems; +} + +/** Import `spec.module` and hand back its constant, or a refusal. */ +async function loadEntries(spec) { + const modPath = join(REPO_ROOT, spec.module); + if (!existsSync(modPath)) return { refusal: `mirror \`${spec.id}\`: ${spec.module} does not exist; the constant it publishes cannot be read.` }; + let mod; + try { + mod = await import(pathToFileURL(modPath).href); + } catch (err) { + return { refusal: `mirror \`${spec.id}\`: ${spec.module} could not be imported -- ${err?.message ?? err}` }; + } + return validateEntries(mod[spec.constant], spec); +} + +/** Read `spec.doc` from the repo root, or refuse. */ +function readDoc(spec) { + const docPath = join(REPO_ROOT, spec.doc); + if (!existsSync(docPath)) return { refusal: `mirror \`${spec.id}\`: ${spec.doc} does not exist.` }; + return { text: readFileSync(docPath, 'utf8') }; +} + +async function main() { + if (MIRRORS.length === 0) { + console.error('REFUSE: the mirror table is empty, so this gate compared nothing. Absence must be loud (AGENTS.md, Route & surface ownership §3).'); + process.exit(1); + } + + const refusals = []; + const failures = []; + + for (const spec of MIRRORS) { + const doc = readDoc(spec); + if (doc.refusal) { refusals.push(doc.refusal); continue; } + const loaded = await loadEntries(spec); + if (loaded.refusal) { refusals.push(loaded.refusal); continue; } + const block = locateBlock(doc.text, spec); + if (block.refusal) { refusals.push(block.refusal); continue; } + + const problems = judge(loaded.entries, block.lines); + if (problems.length) failures.push({ spec, problems, entries: loaded.entries, at: block.at }); + } + + if (refusals.length) { + console.error('REFUSE: a published-list mirror could not be READ, so it was not judged.\n'); + for (const r of refusals) console.error(` - ${r}\n`); + console.error( + 'A gate that cannot find its block must never pass: an empty comparison has no\n' + + 'violations in it, which is the dormant-gate shape this repo keeps paying for\n' + + '(#4690). Fix the document, or update the table in this file (#10855).\n', + ); + process.exit(1); + } + + if (failures.length) { + console.error('FAIL: a published list has drifted from the constant it mirrors.\n'); + for (const f of failures) { + console.error(` ${f.spec.doc} (block at :${f.at}) vs ${f.spec.module} -> ${f.spec.constant}:\n`); + for (const p of f.problems) console.error(` ${p}`); + console.error('\n The block below is what the constant holds today. Paste it between the fences:\n'); + console.error(f.entries.map((l) => ` | ${l}`).join('\n')); + console.error(''); + } + console.error( + 'Why this gate exists: the detector is a SOURCE SCAN, so a spelling it does not\n' + + 'know produces no flag and a cross-package read goes undeclared SILENTLY. The\n' + + 'published list is the only thing telling authors what it can see, and it has\n' + + 'drifted three times (#10163, #10854, #10855) -- twice while being the stated\n' + + 'reason for a prohibition.\n' + + '\n' + + '⛔ This gate cannot repair the document: AGENTS.md is GOVERNED, human-merge-only.\n' + + 'Edit it by hand, in the same PR as the change that moved the constant.\n', + ); + process.exit(1); + } + + const rows = MIRRORS.map((m) => `${m.doc} <- ${m.module} -> ${m.constant}`); + console.log( + `OK: ${MIRRORS.length} published list mirror(s) match their constants line for line.\n ${rows.join('\n ')}\n` + + ` (Only the fenced block is judged — the prose around it is not machine-comparable; see this file's header.)`, + ); +} + +async function selfTest() { + const cases = []; + const ok = (label, cond) => cases.push({ label, cond }); + + const SPEC = { id: 'fixture', module: 'scripts/nonexistent.mjs', constant: 'FIXTURE', doc: 'FIXTURE.md', heading: '### The mirror heading', lang: 'ts' }; + const doc = (body) => [ + '# Title', + '', + '## Another section', + '', + 'prose that mentions const A = 1; in passing', + '', + SPEC.heading, + '', + 'lead-in prose:', + '', + ...body, + '', + 'trailing prose', + '', + '### A later section', + '', + '```ts', + 'const DECOY = 0;', + '```', + '', + ].join('\n'); + const FENCED = ['```ts', 'const A = 1; // seed', ' ⛔ NOT a manifest name belonging to some OTHER package', '```']; + const ENTRIES = ['const A = 1; // seed', ' ⛔ NOT a manifest name belonging to some OTHER package']; + + // ── the positive control: the ordinary case is GREEN, and it reads the right block ── + const good = locateBlock(doc(FENCED), SPEC); + ok('the ordinary case locates a block', Array.isArray(good.lines)); + ok('and it is the block under the declared heading, not the decoy in a later section', !good.lines?.includes('const DECOY = 0;')); + ok('and prose OUTSIDE the fence is not collected', !good.lines?.some((l) => l.includes('in passing'))); + ok('and an exact copy judges clean', judge(ENTRIES, good.lines ?? []).length === 0); + + // ── direction 1: the constant grew and the doc did not (the card's own case) ── + ok( + 'a spelling in the constant and absent from the doc is RED', + judge([...ENTRIES, "const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml')));"], good.lines ?? []) + .some((p) => p.includes('NOT PUBLISHED')), + ); + + // ── direction 2: the doc publishes a spelling the scanner cannot see (wrong on arrival) ── + ok( + 'a spelling published that the constant does not hold is RED', + judge(ENTRIES, [...(good.lines ?? []), 'const P = process.cwd();']).some((p) => p.includes('PUBLISHED but not in the constant')), + ); + + // ── the nastiest drift: comment/prohibition prose only, which containment cannot see ── + ok( + "a COMMENT-only drift is RED (round 1 and round 2 were both comment prose)", + judge(['const A = 1; // seed (ESM)', ENTRIES[1]], good.lines ?? []).some((p) => p.includes('differs')), + ); + ok( + 'a reworded ⛔ PROHIBITION is RED — a stale prohibition reason is what launders an obsolete rule into a live one', + judge([ENTRIES[0], ' ⛔ NOT a manifest name belonging to ANOTHER package'], good.lines ?? []).some((p) => p.includes('differs')), + ); + ok('trailing whitespace alone is NOT a difference (the one stated slack)', judge(ENTRIES, [`${ENTRIES[0]} `, `${ENTRIES[1]}\t`]).length === 0); + + // ── every unreadable state REFUSES rather than passing empty ── + ok('a renamed heading REFUSES', locateBlock(doc(FENCED).replace(SPEC.heading, '### A different heading entirely'), SPEC).refusal?.includes('heading not found')); + ok('a duplicated heading REFUSES as ambiguous', locateBlock(`${doc(FENCED)}\n${SPEC.heading}\n`, SPEC).refusal?.includes('occurs 2 times')); + ok('a re-tagged fence REFUSES', locateBlock(doc(['```js', ...FENCED.slice(1)]), SPEC).refusal?.includes('no ````ts` fence')); + ok('an unterminated fence REFUSES', locateBlock(doc(FENCED.slice(0, 3)), SPEC).refusal?.includes('unterminated')); + ok('an empty fence REFUSES rather than matching an empty list', locateBlock(doc(['```ts', '```']), SPEC).refusal?.includes('holds no content')); + ok('a blank-only fence REFUSES too', locateBlock(doc(['```ts', ' ', '```']), SPEC).refusal?.includes('holds no content')); + ok('two candidate fences in one section REFUSE as ambiguous', locateBlock(doc([...FENCED, '', ...FENCED]), SPEC).refusal?.includes('2 ````ts` fences')); + ok('a block that moved to a LATER section is not silently accepted', locateBlock(doc(['prose only, no fence at all']), SPEC).refusal?.includes('no ````ts` fence')); + ok('a heading spec that is not a heading REFUSES', locateBlock(doc(FENCED), { ...SPEC, heading: 'not a heading' }).refusal?.includes('not a markdown heading')); + + // ── the code side refuses just as loudly (the #11871 refactor shape) ── + ok('a renamed or vanished constant REFUSES', validateEntries(undefined, SPEC).refusal?.includes('not exported')); + ok('an EMPTY constant REFUSES', validateEntries([], SPEC).refusal?.includes('EMPTY')); + ok('a non-array constant REFUSES', validateEntries('a string', SPEC).refusal?.includes('not an array')); + ok('a non-string entry REFUSES', validateEntries(['fine', 42], SPEC).refusal?.includes('not a string')); + + // ── the live table, which is what actually rots ── + ok('the mirror table is not empty', MIRRORS.length >= 1); + for (const spec of MIRRORS) { + const live = readDoc(spec); + ok(`live: ${spec.doc} exists`, !live.refusal); + const loaded = await loadEntries(spec); + ok(`live: ${spec.module} still exports ${spec.constant} as a non-empty string list`, !loaded.refusal); + const located = live.text ? locateBlock(live.text, spec) : { refusal: 'unreadable' }; + ok(`live: the block still LOCATES in ${spec.doc} (heading + fence, never a line number)`, Array.isArray(located.lines)); + } + + const failed = cases.filter((c) => !c.cond); + for (const c of cases) console.log(`${c.cond ? 'ok ' : 'FAIL'} ${c.label}`); + if (failed.length) { + console.error(`\n${failed.length}/${cases.length} self-test case(s) failed.`); + process.exit(1); + } + console.log(`\nAll ${cases.length} self-test cases passed.`); +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) await selfTest(); + else await main(); +} From 22312aa9e4af7081fe10f1a6bd1506a880fbcec7 Mon Sep 17 00:00:00 2001 From: os-steve Date: Mon, 24 Aug 2026 23:58:46 +0000 Subject: [PATCH 2/4] fix(gates): publish the spellings, not the failure box's notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two standing rulings on AGENTS.md govern what a mirror may put there, and a byte-identical copy of the whole constant breaks one of them outright: - the shrink-only line ceiling (`check-skill-line-ratchet`), and - "operative text carries lessons self-contained ... numbers go" (maintainer ruling 2026-08-12, `check-skill-id-lint`). The constant is the text of a CLI failure box: spellings (code, each with the comment that annotates it) plus free-standing NOTE PARAGRAPHS set off by blank entries, two of which carry issue-ID citations. Publishing all 24 lines put those citations into the instruction surface and check:pm-skill-id-lint went red -- the gate meant to keep the document honest would have broken the document's own prose standard. So the mirror judges a projection with no judgement in it: an entry is PUBLISHED unless it is blank, or it is a comment line that no published entry directly precedes. A comment continuing a spelling travels with that spelling; a note paragraph standing alone stays in the failure box. Every spelling is code, so no spelling can hide from it. 24 entries project to 17 published lines, zero issue-ID citations, and the published block grows 11 -> 17: the two `findUp` anchor seeds, the ⛔ manifest prohibition that qualifies them, and the `-> repo root` annotation. A self-test case now pins the citation property against the live constant, so an issue number added to a spelling line goes red HERE, naming the ruling, instead of landing in AGENTS.md. check:pm-skill-id-lint: 22 file(s) clean. check:pm-skill-ratchet is red by 8 lines (969 vs 961) and needs a maintainer ruling -- see the PR body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- AGENTS.md | 28 ++----- scripts/check-published-list-mirrors.mjs | 95 +++++++++++++++++++++++- 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12ed1b590c..7792373771 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,7 @@ deliberately: a detector with no dependencies cannot itself fail to resolve in C The price of a source scan is that it sees only the spellings it knows, and an unrecognised one produces no flag — which means no declaration, **silently**. So the recognised list is published rather than left inside the implementation. Seed from -`import.meta.url`, `__dirname`, or a `findUp` walk, and write the escaping path as -one of: +`import.meta.url`, `__dirname` or a `findUp` walk, and write the escaping path as one of: ```ts const HERE = dirname(fileURLToPath(import.meta.url)); // seed (ESM) @@ -102,13 +101,6 @@ const P = fileURLToPath(new URL('', import.meta.url)); const P = new URL('', import.meta.url); readFileSync(resolve(HERE, '')) // the same expressions in argument readFileSync(new URL('', import.meta.url)) // position - -// Any call above may be BROKEN ACROSS LINES -- a formatter does that to every -// argument list past the print width, so it is the DEFAULT spelling for a long -// relative literal, and it is read whole, trailing comma and all (#11093). - -// ANCHOR seeds -- a findUp walk, for a CJS-typed package where import.meta -// is a TS1470. Both compose with every expression above (#10029). const PKG = findUp((dir) => JSON.parse(readFileSync(join(dir, 'package.json'))).name === ''); // -> package root const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml'))); @@ -117,18 +109,12 @@ const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml'))); be located from here, so the escape is flagged and the path is NOT named ``` -The gate prints this list in its failure text too, and `--self-test` pins every entry. -Reaching for a spelling that is not here? **Extend the detector and add a `--self-test` -case in the same edit** — never route around it. An unseen read is the defect above, not -a style question, and a newly recognised shape with no pin is the next silent regression. - -That block is **byte-identical** to the detector's `RECOGNISED_PATH_SPELLINGS`, and -`node scripts/check-published-list-mirrors.mjs` holds it that way — line for line, -comments included, because twice the stale line here was the stated *reason for a -prohibition* (#10163, #10854), which is how an obsolete rule gets laundered into a -live one. Extending the detector therefore means editing this block in the **same -PR**. ⛔ The gate can only ever go RED — this file is governed, human-merge-only, so -nothing may repair it for you; it prints the exact block to paste (#10855). +The gate prints this list in its failure text, where the notes that go with the spellings +live too, and `check-published-list-mirrors` holds the block above equal to it. That gate +can only ever go RED — this file is governed, so nothing repairs it for you. Reaching for +a spelling that is not here? **Extend the detector, add a `--self-test` case, and correct +the block in the same edit** — never route around it. An unseen read is the defect above, +and a newly recognised shape with no pin is the next silent regression. Two things it deliberately does not flag: a path that climbs out and lands in `node_modules` (an installed dependency is not a repo source input, and no turbo glob can diff --git a/scripts/check-published-list-mirrors.mjs b/scripts/check-published-list-mirrors.mjs index b5112704e4..30fb41bf0c 100644 --- a/scripts/check-published-list-mirrors.mjs +++ b/scripts/check-published-list-mirrors.mjs @@ -46,7 +46,29 @@ * lagged": a published claim can be wrong on arrival, and nothing caught that * either. * - * ## Equality, not "every entry appears" + * ## What is mirrored: the SPELLINGS, not the failure box's notes + * + * The constant is the text of a CLI failure box, and it carries two kinds of + * line: the spellings themselves (code, each with the comment that annotates + * it) and free-standing NOTE PARAGRAPHS set off by blank entries. Only the + * first kind is published, by a rule with no judgement in it: + * + * an entry is PUBLISHED unless it is blank, or it is a comment line that no + * published entry directly precedes. + * + * So a comment CONTINUING the spelling above it travels with that spelling, + * and a note paragraph standing alone does not. That is not a convenience: the + * document on the other side is a GOVERNED instruction surface under two + * standing maintainer rulings — a shrink-only line ceiling + * (`scripts/pm/check-skill-line-ratchet.mjs`) and "operative text carries + * lessons self-contained ... numbers go" (2026-08-12, + * `scripts/pm/check-skill-id-lint.mjs`). A verbatim dump of the whole constant + * would import the failure box's narrative AND its issue-ID citations into that + * surface, breaking the second ruling outright. The projection is what lets one + * mechanism serve both: the doc publishes what an author must be able to read + * there, and the failure box keeps the commentary that belongs beside a red. + * + * ## Equality over that projection, not "every entry appears" * * Containment -- every entry of the constant appears SOMEWHERE in the block -- * is the cheaper assertion, and it is not enough, in two directions that both @@ -72,6 +94,13 @@ * * ## What it deliberately does NOT assert * + * A prohibition written as a free-standing NOTE PARAGRAPH is not published, so + * this gate does not hold the document to it. That is the projection's price, + * stated rather than discovered: a prohibition that must bind the reader of the + * document belongs on a spelling line or in the document's own prose, not in a + * note paragraph. The `⛔ NOT a manifest name ...` lines below the anchor seeds + * are published today precisely because they sit on the spelling. + * * Only the fenced block is judged. The prose AROUND it is not comparable to * anything mechanically, and a gate that implied otherwise would overstate its * coverage -- worse than one that states its limit. Round 2's false claim lived @@ -203,6 +232,28 @@ export function validateEntries(value, spec) { return { entries: value }; } +/** + * The entries a document is expected to publish, from the whole constant. + * + * PUBLISHED unless the entry is blank, or it is a comment line that no + * published entry directly precedes. A blank entry breaks the chain, which is + * what separates a note paragraph from a spelling's own continuation. + * + * A spelling can never hide from this: a spelling is code, and code is + * published wherever it sits. + */ +export function publishedEntries(entries) { + const out = []; + let continues = false; + for (const e of entries) { + const t = e.trim(); + if (t === '') { continues = false; continue; } + if (!t.startsWith('//')) { out.push(e); continues = true; continue; } + if (continues) out.push(e); + } + return out; +} + /** Line-for-line disagreements between the constant and the published block. */ export function judge(entries, blockLines) { const want = entries.map(stripEnd); @@ -254,8 +305,13 @@ async function main() { const block = locateBlock(doc.text, spec); if (block.refusal) { refusals.push(block.refusal); continue; } - const problems = judge(loaded.entries, block.lines); - if (problems.length) failures.push({ spec, problems, entries: loaded.entries, at: block.at }); + const expected = publishedEntries(loaded.entries); + if (expected.length === 0) { + refusals.push(`mirror \`${spec.id}\`: ${spec.module} -> ${spec.constant} projects to NO publishable entries; an empty expectation matches nothing and reads as a pass.`); + continue; + } + const problems = judge(expected, block.lines); + if (problems.length) failures.push({ spec, problems, entries: expected, at: block.at }); } if (refusals.length) { @@ -294,7 +350,7 @@ async function main() { const rows = MIRRORS.map((m) => `${m.doc} <- ${m.module} -> ${m.constant}`); console.log( `OK: ${MIRRORS.length} published list mirror(s) match their constants line for line.\n ${rows.join('\n ')}\n` + - ` (Only the fenced block is judged — the prose around it is not machine-comparable; see this file's header.)`, + ` (The spellings are judged; the failure box's own note paragraphs are not published — see this file's header.)`, ); } @@ -359,6 +415,31 @@ async function selfTest() { ); ok('trailing whitespace alone is NOT a difference (the one stated slack)', judge(ENTRIES, [`${ENTRIES[0]} `, `${ENTRIES[1]}\t`]).length === 0); + // ── the projection: a spelling keeps its comments, a note paragraph is not published ── + const RAW = [ + 'const A = 1; // seed', + ' // continued here', + '', + '// a NOTE paragraph, set off by a blank, carrying provenance (#1234)', + '// and a second line of it', + '', + 'const B = 2;', + ' ⛔ NOT the thing next door', + ]; + const proj = publishedEntries(RAW); + ok('a spelling is published', proj.includes('const A = 1; // seed')); + ok('a comment CONTINUING a spelling travels with it', proj.includes(' // continued here')); + ok('a note paragraph set off by a blank is NOT published — its provenance stays in the failure box', !proj.some((l) => l.includes('#1234'))); + ok('and neither is the rest of that paragraph', !proj.some((l) => l.includes('second line of it'))); + ok('a blank entry is never published', !proj.includes('')); + ok('a spelling AFTER a note paragraph is still published — a spelling cannot hide in one', proj.includes('const B = 2;')); + ok('a ⛔ prohibition sitting on a spelling IS published', proj.includes(' ⛔ NOT the thing next door')); + ok('so the projection of those 8 entries is exactly the 4 spelling-bearing lines', proj.length === 4); + ok( + 'and a doc that publishes a note-paragraph line anyway is RED', + judge(proj, [...proj, '// a NOTE paragraph, set off by a blank, carrying provenance (#1234)']).some((p) => p.includes('PUBLISHED but not in the constant')), + ); + // ── every unreadable state REFUSES rather than passing empty ── ok('a renamed heading REFUSES', locateBlock(doc(FENCED).replace(SPEC.heading, '### A different heading entirely'), SPEC).refusal?.includes('heading not found')); ok('a duplicated heading REFUSES as ambiguous', locateBlock(`${doc(FENCED)}\n${SPEC.heading}\n`, SPEC).refusal?.includes('occurs 2 times')); @@ -383,6 +464,12 @@ async function selfTest() { ok(`live: ${spec.doc} exists`, !live.refusal); const loaded = await loadEntries(spec); ok(`live: ${spec.module} still exports ${spec.constant} as a non-empty string list`, !loaded.refusal); + const pub = loaded.entries ? publishedEntries(loaded.entries) : []; + ok(`live: ${spec.constant} projects to a non-empty published list`, pub.length > 0); + ok( + `live: no line ${spec.doc} must publish carries an issue-ID citation — an instruction surface keeps its lessons self-contained (maintainer ruling 2026-08-12, \`check-skill-id-lint\`)`, + !pub.some((l) => /#\d{3,}/.test(l)), + ); const located = live.text ? locateBlock(live.text, spec) : { refusal: 'unreadable' }; ok(`live: the block still LOCATES in ${spec.doc} (heading + fence, never a line number)`, Array.isArray(located.lines)); } From d41ef56bc02f4217455f159aa7e9cefd8c2ebdb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 10:52:31 +0000 Subject: [PATCH 3/4] chore(ratchet): raise the AGENTS.md ceiling 961 -> 969 per the maintainer ruling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-line follow-up PR #11908's body pre-wrote: the published-spellings mirror re-sync costs +8 lines, lossless rewrap headroom measured 0, and the mirror is now mechanically enforced. Maintainer ruling 2026-08-25, verbatim: 「同意,帮我合并,然后继续」 (option A as presented in the PM-chat batch review; provenance on PR #11908). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H9StxQgG2DPA26XzZZqnJB --- scripts/pm/check-skill-line-ratchet.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 988badbe8d..2e44699a72 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -186,7 +186,18 @@ export const CEILINGS = new Map([ // 2026-08-20, verbatim and untranslated: 「A — 抬上限到 961 (Recommended)」 // (issue #10126, comment 5353111732). Headroom is 0 again by construction, and // the next author needing a line is back to compressing. - ['AGENTS.md', 961], + // + // 961 → 969 (#10855 / PR #11908): the published-spellings mirror re-sync. The + // AGENTS.md copy of check-cross-package-test-inputs' recognised-spellings list + // had drifted (the findUp anchor seeds missing), the honest re-sync costs +8 + // lines, and lossless rewrap headroom across the section measured 0 — so it + // could not be paid in place. The mirror is now mechanically enforced by + // check-published-list-mirrors.mjs, which prices any later drift at the moment + // it is incurred. Maintainer ruling 2026-08-25, verbatim and untranslated: + // 「同意,帮我合并,然后继续」 — accepting option A (raise to 969) as presented + // in the PM-chat batch review; provenance recorded on PR #11908. Headroom is 0 + // again by construction. + ['AGENTS.md', 969], // #9965: root CLAUDE.md is the other repo-root instruction file — same read // path (every seat session), same governance (Prime Directive #14). It is // structurally growth-prone in the way the ratchet is built for: it exists to From 97d431daec66540a4fa28ad2642c9f9ca14cfe00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 14:33:14 +0000 Subject: [PATCH 4/4] chore(ratchet): re-measure the AGENTS.md ceiling on the reflowed layout, 1150 -> 1158 The branch's original raise (961 -> 969) was measured against the pre-#11948 layout and was dissolved by the merge. Re-measured on current main: AGENTS.md is 1150 there and 1158 with the mirror re-sync applied, so the honest cost is again +8 -- published block 11 -> 17 lines, prose paragraph 4 -> 6. The maintainer's option-C1 ruling is quoted verbatim and untranslated beside the value, per the map's convention. Gate reads at the new value: check-skill-line-ratchet: AGENTS.md is 1158 lines (ceiling 1158; headroom 0). check-skill-line-ratchet self-test: 71 cases pass. --- scripts/pm/check-skill-line-ratchet.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 781c416f6f..aff4edeb28 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -289,7 +289,22 @@ export const CEILINGS = new Map([ // rewrap headroom, so the correction costs one line. Maintainer ruling // 2026-08-25, verbatim and untranslated, accepting the measured +1/D1 option: // 「我看到了,你分析过了,接受你的建议」. Headroom is 0 again by construction. - ['AGENTS.md', 1150], + // + // 1150 → 1158 (#10855 / PR #11908): the published-spellings mirror re-sync. + // AGENTS.md's copy of check-cross-package-test-inputs' RECOGNISED_PATH_SPELLINGS + // had drifted — the two findUp ANCHOR seeds and the ⛔ manifest-name prohibition + // qualifying them were missing. Twice before, the stale line over there was the + // stated REASON FOR A PROHIBITION (#10163, #10854), so a rotting mirror does not + // merely misinform: it launders an obsolete rule into a live one. The honest + // re-sync measures +8 on the reflowed (#11948) layout — published block 11 → 17 + // lines, prose paragraph 4 → 6 — and lossless rewrap headroom across that + // section measures 0, so it cannot be paid in place. From here the mirror is + // mechanically enforced by check-published-list-mirrors.mjs, which prices any + // later drift at the moment it is incurred rather than letting it accrue. + // Maintainer ruling 2026-08-25 (option C1), verbatim and untranslated: + // 「我看到了,你分析过了,接受你的建议」 — recorded on PR #11908. Headroom is 0 + // again by construction. + ['AGENTS.md', 1158], // #9965: root CLAUDE.md is the other repo-root instruction file — same read // path (every seat session), same governance (Prime Directive #14). It is // structurally growth-prone in the way the ratchet is built for: it exists to