From 7a5a4f06c11a570de68d92cb0f4660fa6fca5947 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 19:03:37 +0000 Subject: [PATCH] fix(pm): dispatch-gates derives the CLI command-id, ADR anchor and error-code provenance gates from the diff that can break them Three lint steps judge populations no path trigger reached, so --commands never handed them to a card and each cost a CI round trip: - check:cli-command-ids reads a quoted binary-name literal in any package source and every CLI command module. A content predicate reaches the literal (a net held against the gate's own reader in the self-test) and a path predicate reaches the command surface and manifest. - check:adr-symbol-anchors reds when an anchored declaration is removed or an anchored file moves, from a diff touching no ADR. The predicate reads the anchored files from the gate's own corpus registration. - check:error-code-provenance reads the same stamp sites as the vocabulary gate and was filed under the artifact rosters; it rides that entry now. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5mUdL1EUGghY7nGHww --- scripts/pm/dispatch-gates.data.mjs | 24 +++ scripts/pm/dispatch-gates.mjs | 202 ++++++++++++++++++++++++ scripts/pm/dispatch-gates.self-test.mjs | 198 ++++++++++++++++++++++- 3 files changed, 422 insertions(+), 2 deletions(-) diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index 28a36f387e1..6d27a6cfc98 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -1062,6 +1062,30 @@ export const CHANGE_KIND_ROWS = [ name: 'check:dispatcher-error-vocabulary', why: 'it sweeps the non-test TypeScript sources under the package root for every site that stamps an error code, and reports each value the registered vocabulary (StandardErrorCode joined with ERROR_CODE_LEDGER) does not contain — so a code arriving through a quoted literal, a SCREAMING_SNAKE constant, a typeof reference to one, or a template moves it. This is the gate no path derivation can name: it computes its own population from a bare top-level root, which the bare-root ledger records as REFUSE-WIDE at 39% of the tracked tree, so it scores the same quiet silence for every card and #12843 paid a CI round trip for that silence. It needs NO build — a source scan, one pass, whole tree, and it names the file and line. Repair by REGISTERING the code where the vocabulary is declared, never by widening a consumer to tolerate it; reconciliation runs BOTH ways, so a table row whose site is gone fails too, and a pending-registration row whose code has since been registered fails as the discharge it is. ⚠ This lead is deliberately WIDE — it fires on a file that merely carries a code-shaped value, not only one that adds a new one — because the wasted run is one cheap gate and the miss is a CI round trip', }, + { + name: 'check:error-code-provenance', + why: "the vocabulary gate's population-disjoint sibling: it reads the SAME stamp sites in every package's non-test src, keeps only the codes the vocabulary DOES register, and fails when the stamping package's own owner key in the ADR-0112 ledger does not list the code and no PROVENANCE_WAIVERS entry names the owner that holds it instead. So a registered code stamped in a package that never stamped it before moves it, and so does a constant BOUND to one (a constant is read at its definition, whatever its name, even one that is only ever compared against). No path derivation names it: its only declared literals are the ledger file, so the derivation files it under the artifact rosters and scores it silent for every card, and PR #31214 paid a CI round trip for that silence on a scenarios-package constant. It needs NO build, being one source scan over the whole tree, and it names the package, the code and the site. Repair by listing the code under the stamping package's owner key, or by not stamping it (compare against the catalogue's spelling instead of holding it in a constant); a waiver is a recorded ruling, never a quick fix. ⚠ The lead is this entry's predicate, wide for the vocabulary gate's reason: at fe97d85b it reached all 162 files holding one of the gate's 513 registered-code sites", + }, + ], + }, + { + kind: 'adds or edits a file carrying a CLI command-id literal outside the CLI package, or a CLI command module or manifest (judged from CONTENT — no path derivation can name this gate)', + matches: 'cli-command-literal', + gates: [ + { + name: 'check:cli-command-ids', + why: "every quoted run that opens with a CLI binary name and a space (a command id such as the migrate-apply one, and just as much the Portuguese word for administrators after that binary name in a translated message) in non-test source outside the CLI package must resolve to a real oclif command id or topic; and every module the CLI emits under its commands directory must default-export a command class. So a new or edited literal anywhere in package source moves it, and so does renaming, adding or deleting a command module. No path derivation names it: the literal population is a `src/` path SEGMENT in any package, a shape no subtree spelling covers, and its only declared root is `scripts/**`, so it scored silent for package source and for the commands directory alike, and PR #30734 paid a CI round trip for that silence on a pt-BR string fragment. It needs NO build, about 22 seconds, and it names the file, the line and the literal. Repair by spelling the real command id (or, for prose that merely starts with the binary's word, rephrasing so the binary name is not the first thing in the quotes); the exemption ledger is for a checker's own fixtures and filed real defects, never a quick fix. Derived by CONTENT rather than for every diff: the gate is unconditional in CI but not cheap enough to name on a card that cannot move it", + }, + ], + }, + { + kind: 'edits a file an ADR anchors by symbol, or moves or deletes a file an ADR anchors at all (read from the ADR corpus — no path derivation can name this gate)', + matches: 'adr-anchored-file', + gates: [ + { + name: 'check:adr-symbol-anchors', + why: "every `path#Symbol` anchor in docs/adr/** must still resolve to a declaration (or a literal) of that name in that file, and every file-level anchor to a tracked file. So removing or renaming an anchored declaration reds it, and so does moving or deleting any anchored file, from a diff that never touches an ADR. No path derivation names it for those diffs: its declared population is docs/adr/** alone, and PR #30749 paid a CI round trip and a landing tier for that silence when it retired a constant ADR-0012 anchored. It needs NO build, about 7 seconds, and it names the record, the line and the anchor. Repair by re-anchoring the ADR where the mechanism now lives, or by keeping the symbol; ⚠ if the ADR SENTENCE has become untrue, that is its own card, and ⛔ an `anchor-exempt` marker is MAINTAINER-ONLY. ⚠ The symbol limb fires on any edit of a symbol-anchored file, not only one that removes the declaration, because the wasted run is one cheap gate and the miss is a CI round trip", + }, ], }, { diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 3a2d6d29f42..098f38af9ac 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -456,6 +456,12 @@ import { GOVERNED_REPOS, HUMAN_MERGE_LINE_THRESHOLD, SELF_REPO_ID, parseNumstat, // published source and which are its tests — never respelled here (#19936). See // SUSPECT_TIER_GLOBS for why this predicate and not one of the repo's others. import { isTestPath } from '../check-undeclared-dep-imports.mjs'; +// The ADR anchor gate's corpus registration and the shared extractor it sweeps +// with: the change kind that reads them (#30763) reads exactly the anchors that +// gate judges, never a respelling of them. Both modules are dependency-free, so +// this tool still runs in a checkout with no install. +import { CORPUS as ADR_ANCHOR_CORPUS } from '../check-adr-symbol-anchors.mjs'; +import { extractAnchors } from '../symbol-anchors.mjs'; // The hand-written tables — ledgers, marker grammars, the change-kind roster, the tier globs — are // DATA, and live beside this file; this engine loads them, matches paths against them and derives. // Every table is re-exported under the name it always had, so no consumer moved. @@ -10801,6 +10807,200 @@ export function guardsADurabilityCriticalCall(path, readSource = readTrackedSour return new RegExp(`\\b(?:${names.map((n) => n.replace(/\$/g, '\\$')).join('|')})\\b`).test(masked); } +/** + * Does this path carry a CLI command-id literal outside the CLI package, or sit + * on a CLI's own command surface? This is the content trigger for + * `check:cli-command-ids` (#30763), the fourth CONTENT predicate. + * + * ## Why no path derivation names this gate + * + * The gate judges two populations. Its literal duty reads every non-test JS/TS + * source outside each oclif package whose path has a `src` SEGMENT anywhere, + * plus the whole `scripts` root. Only the second is spelled as a root it can + * declare, so a card touching package source scored silent. Its command duty + * reads each CLI's commands directory, which it spells as a package-relative + * constant no hint can anchor, so a card renaming a command scored silent too. + * PR #30734 paid a CI round trip for the first silence: a pt-BR string fragment + * opening with the binary's word, in plugin-auth source. + * + * ## Why by content, and not for every diff + * + * The step is unconditional in lint.yml, so "always" would be true. It is not + * cheap: about 22 seconds at fe97d85b, against about 7 for the ADR anchor gate + * and 6 for the provenance gate. So this predicate reads what the gate reads: + * + * - LITERAL: a non-test JS/TS file outside every CLI package holding a quote + * followed by a declared binary name, a space and a lowercase word or digit. + * That is a net around the gate's own literal reader, never a copy of it: + * it skips no comment line and resolves no id, so a shape the gate adds + * later is already inside it. + * - SURFACE: any path under a CLI package's commands source directory, and + * the CLI manifest itself. Judged from the PATH, so a deleted or renamed + * command module still counts. + * + * ⛔ The gate's own reader is NOT imported here: its module loads `typescript` + * at import time, and this tool runs in checkouts with no install. The + * self-test holds the net against that reader instead, in a child process: the + * binary names must agree, and every file in which the gate's reader finds a + * literal must be inside this net. A narrowing that loses one fails there. + * + * Measured at fe97d85b over 7002 non-test JS/TS files outside the CLI package: + * the gate's population holds a literal in 390 of them, and this net reaches + * all 390 and 34 more (19 under the `scripts` root the path derivation already + * names, 15 test helpers and lint scripts outside the gate's population). + * + * ## What it cannot see + * + * A file with nothing on disk answers false on the literal limb, as in the + * three predicates above. Deleting a literal can only stale an exemption, and + * every exempted file sits under the `scripts` root the path derivation + * already matches. + */ +const OCLIF_COMMANDS_SOURCE = ['src', 'commands'].join('/'); +const CLI_POPULATION_EXTENSION = /\.(?:ts|tsx|js|mjs|cjs)$/; +const CLI_POPULATION_TEST = /\.(?:test|spec)\.[tj]sx?$/; +const COMMENT_LINE_START = /^\s*(?:\/\/|\*|\/\*)/; + +/** + * The binary names a CLI manifest declares for itself: `oclif.bin` plus every + * `bin` key. The gate's own rule as its header states it, read wide (no name + * shape filter); the self-test holds it equal to the gate's reader. + */ +export function cliBinaryNames(pkg) { + const bin = pkg?.oclif?.bin; + if (typeof bin !== 'string' || bin === '') return []; + const keys = pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin) ? Object.keys(pkg.bin) : []; + return [...new Set([bin, ...keys])]; +} + +let cliSurfaces = null; +/** + * Every CLI package in the tree as `{ dir, bins }`, read from the tracked + * manifests that declare `oclif.bin` (the gate's rule for what a CLI is), + * memoised. A manifest that does not parse is skipped, as the gate skips one. + */ +export function cliCommandSurfaces(files = null) { + if (!files && cliSurfaces) return cliSurfaces; + const surfaces = []; + for (const file of files ?? trackedFiles()) { + if (!/(?:^|\/)package\.json$/.test(file)) continue; + let pkg; + try { + pkg = JSON.parse(readFileSync(nodePath.join(ROOT, file), 'utf8')); + } catch { + continue; + } + const bins = cliBinaryNames(pkg); + if (bins.length > 0) surfaces.push({ dir: nodePath.posix.dirname(file), bins }); + } + if (!files) cliSurfaces = surfaces; + return surfaces; +} + +const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export function carriesACliCommandLiteral(path, readSource = readTrackedSource, surfaces = cliCommandSurfaces) { + const rel = path.replace(/^\.\//, ''); + const clis = surfaces(); + const own = clis.find((c) => c.dir !== '.' && rel.startsWith(`${c.dir}/`)); + if (own) return rel === `${own.dir}/package.json` || rel.startsWith(`${own.dir}/${OCLIF_COMMANDS_SOURCE}/`); + if (!CLI_POPULATION_EXTENSION.test(rel)) return false; + if (CLI_POPULATION_TEST.test(rel) || rel.includes('/__tests__/')) return false; + const bins = [...new Set(clis.flatMap((c) => c.bins))]; + // An empty alternation would match a quote and a space anywhere, so no + // declared binary must answer false rather than reach every file. + if (bins.length === 0) return false; + const source = readSource(rel); + if (source === null || source === undefined) return false; + const literal = new RegExp(`['"\`](?:${bins.map(escapeRegExp).join('|')}) [a-z0-9]`); + if (!literal.test(source)) return false; + // A line that OPENS as a comment is prose the gate does not read, and jsdoc + // that cites a command in backticks is most of what the net would catch + // otherwise. A trailing comment on a code line stays in, as it does there. + return source.split('\n').some((line) => !COMMENT_LINE_START.test(line) && literal.test(line)); +} + +/** + * Would a diff on this path break an ADR anchor? This is the trigger for + * `check:adr-symbol-anchors` (#30763), read from the ADR corpus rather than + * from the path's own content. + * + * ## Why no path derivation names this gate + * + * The gate's declared population is the ADR directory, and that is all a card + * touching an ADR needs. But an anchor reds from the OTHER end too: a + * `path#Symbol` anchor reds when its file stops declaring the symbol, and any + * anchor reds when its file moves or goes. PR #30749 retired a constant an ADR + * anchored and learned of it in CI, because its diff touched no ADR. + * + * ## What it matches + * + * The anchors are extracted by the shared extractor, with the gate's own corpus + * registration (its roots, document pattern, projection and grammar options), + * so this reads exactly the anchors the gate sweeps. Cross-repo anchors are + * left out, as the gate leaves them out of its red verdicts. + * + * - SYMBOL: a path some `path#Symbol` anchor names. Any edit counts, not only + * one that removes the declaration: deciding that needs the old and new + * text, and the wasted run is one seven-second gate. + * - FILE: a path any anchor names that has nothing on disk. That is a move or + * a delete, the only way a file-level anchor can red. + * + * Measured at fe97d85b: 3204 anchors across 150 records, naming 292 files by + * symbol and 1281 more at file level only. + */ +let adrTargets = null; +export function adrAnchorTargets(corpus = ADR_ANCHOR_CORPUS, root = ROOT) { + if (corpus === ADR_ANCHOR_CORPUS && root === ROOT && adrTargets) return adrTargets; + const symbolFiles = new Set(); + const anchoredFiles = new Set(); + const docs = []; + const walkDocs = (rel) => { + let entries; + try { + entries = readdirSync(nodePath.join(root, rel), { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const child = `${rel}/${e.name}`; + if (e.isDirectory()) { + if (!(corpus.excludeDirs ?? []).includes(e.name)) walkDocs(child); + } else if (corpus.docPattern.test(e.name)) docs.push(child); + } + }; + for (const docRoot of corpus.docRoots) walkDocs(docRoot); + if (docs.length === 0) { + throw new Error( + `dispatch-gates: the ADR anchor corpus (${corpus.docRoots.join(', ')}) yielded no document, so the anchored files ` + + 'cannot be read. The corpus moved: re-point this reader with it', + ); + } + for (const doc of docs) { + const raw = readFileSync(nodePath.join(root, doc), 'utf8'); + const { anchors } = extractAnchors(corpus.docProjection ? corpus.docProjection(raw) : raw, { + unspannedAnchors: corpus.unspannedAnchors ?? false, + pathlessLineCitations: corpus.pathlessLineCitations ?? false, + tildeRangeCitations: corpus.tildeRangeCitations ?? false, + }); + for (const a of anchors) { + if (a.repo || !a.path) continue; + anchoredFiles.add(a.path); + if (a.symbol) symbolFiles.add(a.path); + } + } + const targets = { symbolFiles, anchoredFiles, docs: docs.length }; + if (corpus === ADR_ANCHOR_CORPUS && root === ROOT) adrTargets = targets; + return targets; +} + +export function breaksAnAdrAnchor(path, targets = adrAnchorTargets, onDisk = (p) => existsSync(nodePath.join(ROOT, p))) { + const rel = path.replace(/^\.\//, ''); + const { symbolFiles, anchoredFiles } = targets(); + if (symbolFiles.has(rel)) return true; + return anchoredFiles.has(rel) && !onDisk(rel); +} + /** * Is this path inside the ROOT package's tsc program — the population the * root's `typecheck:root` script checks (until the root graduated, the @@ -11117,6 +11317,8 @@ const CHANGE_KIND_PREDICATES = Object.freeze({ 'error-code-literal': stampsAnErrorCodeLiteral, 'http-status-emit': emitsAnHttpStatus, 'durability-seam': guardsADurabilityCriticalCall, + 'cli-command-literal': (path) => carriesACliCommandLiteral(path), + 'adr-anchored-file': (path) => breaksAnAdrAnchor(path), 'ui-scripts-suite-input': (path) => readByUiScriptsSuite(path), }); diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index e87f545e40d..90f572e06da 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -286,7 +286,13 @@ import { zeroSegmentForms, readByUiScriptsSuite, UI_SCRIPTS_SUITE_ROOTS, + adrAnchorTargets, + breaksAnAdrAnchor, + carriesACliCommandLiteral, + cliBinaryNames, + cliCommandSurfaces, } from './dispatch-gates.mjs'; +import { CORPUS as ADR_ANCHOR_CORPUS } from '../check-adr-symbol-anchors.mjs'; import { COMPOUND_ANCHOR_LEDGER, GOVERNED_READ_FLOOR, @@ -4651,7 +4657,7 @@ export function selfTest({ tier = 'full' } = {}) { const codeEntry = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === 'check:dispatcher-error-vocabulary')); t('exactly one entry in the table names the vocabulary gate', codeEntry.length === 1); const codeKind = changeKindLines([CODE_SPECIMEN], resolved, codeEntry); - t('a code-carrying path emits the convention section', codeKind.length === 2 && codeKind[0].includes('judged from CONTENT')); + t('a code-carrying path emits the convention section, with both code gates', codeKind.length === 3 && codeKind[0].includes('judged from CONTENT')); t('and it names the vocabulary gate runnably, anchored on the delimiter', codeKind.some((l) => l.includes('- pnpm check:dispatcher-error-vocabulary —'))); const codeLine = codeKind.find((l) => l.includes('- pnpm check:dispatcher-error-vocabulary —')) ?? ''; @@ -4667,6 +4673,20 @@ export function selfTest({ tier = 'full' } = {}) { // narrowing that drops this sentence fails here. t('…and writes the false-positive trade down, so nobody assumes narrowing is free', /deliberately WIDE/.test(codeLine) && /CI round trip/.test(codeLine)); + // #30763's third reading: the provenance gate reads the same stamp sites and + // was filed under the artifact rosters, so no card was ever handed it. PR + // #31214 went red on a scenarios-package constant bound to a registered code. + t('the provenance gate rides the same entry, so it is reached by the same predicate', + codeEntry[0]?.gates.some((g) => g.name === 'check:error-code-provenance') && codeEntry[0]?.matches === stampsAnErrorCodeLiteral); + const provLine = codeKind.find((l) => l.includes('- pnpm check:error-code-provenance —')) ?? ''; + t('…and names it runnably, anchored on the delimiter', provLine !== ''); + t('…and pushes the repair to the owner key, never a waiver as a quick fix', /owner key/.test(provLine) && /never a quick fix/.test(provLine)); + t('…and says it needs no build', /needs NO build/.test(provLine)); + t('the #31214 shape — a constant bound to a registered code, in a scenarios-package source — is a hit', + stamps("export const VALIDATION_FAILED_CODE: ErrorCode = 'VALIDATION_FAILED';", 'packages/qa/scenarios/src/score/scenario-operation-grade.ts')); + t('…and through the whole table it derives the provenance gate', changeKindGates(['packages/qa/scenarios/src/score/scenario-operation-grade.ts'], resolved, + CHANGE_KIND_GATES.map((k) => (k.matches === stampsAnErrorCodeLiteral ? { ...k, matches: (p) => stampsAnErrorCodeLiteral(p, codeSrc("export const VALIDATION_FAILED_CODE: ErrorCode = 'VALIDATION_FAILED';")) } : k))) + .some((g) => g.gates.some((x) => x.name === 'check:error-code-provenance' && x.command === 'pnpm check:error-code-provenance'))); // ── The HTTP-status CONTENT kind (#22320) ──────────────────────────────── // @@ -4850,6 +4870,174 @@ export function selfTest({ tier = 'full' } = {}) { t('an undiscoverable durability guard renders STALE for this entry', seamStale.filter((l) => l.includes(`⚠ ${DURABILITY_SEAM_GATE}: STALE`)).length === 1); + // ── The CLI command-id CONTENT kind (#30763) ───────────────────────────── + // + // The fourth content entry, in the first three's shape: limbs through an + // injected reader and an injected CLI surface, then the live tree, then the + // gate's own reader held against the net in a child process, then the + // rendered section. Every fixture literal is ASSEMBLED at runtime: this file + // sits in the gate's own `scripts` population, and a quoted binary name with + // an id after it spelled here would be a literal that gate judges. + const Q = "'"; + const cliLit = (bin, words) => `${Q}${bin} ${words}${Q}`; + const CLI_SURFACE = () => [{ dir: 'packages/cli', bins: ['os', 'objectstack'] }]; + const cliHit = (text, path = 'packages/x/src/a.ts', surfaces = CLI_SURFACE) => carriesACliCommandLiteral(path, codeSrc(text), surfaces); + t('a quoted command id in package source is a hit', cliHit(`export const FIX = ${cliLit('os', 'migrate apply')};`)); + // The PR #30734 shape: a translated sentence fragment whose first word after + // the quote is the binary's word. The gate reads it as an unresolved id. + const PT_BR = `const msg = 'apenas ' +\n ${cliLit('os', 'administradores. Mantenha')} + ' x';`; + t('the #30734 shape — a pt-BR fragment opening with the binary word — is a hit', cliHit(PT_BR)); + t('a literal on the second declared binary name is a hit', cliHit(`run(${cliLit('objectstack', 'serve')});`)); + t('a template literal opening with the binary is a hit', cliHit(`const c = \`${'os'} validate \${dir}\`;`)); + t('a trailing comment on a code line still counts, as the gate reads it', cliHit(`f(); // ${cliLit('os', 'start')}`)); + t('a line that opens as a comment is not a hit', !cliHit(`// run ${cliLit('os', 'migrate apply')} first\nexport const x = 1;`)); + t('…nor a jsdoc line', !cliHit(`/**\n * see ${cliLit('os', 'validate')}\n */\nexport const x = 1;`)); + t('the binary word in unquoted prose is not a hit', !cliHit("const s = 'the os module';")); + t('the binary word glued to more letters is not a hit', !cliHit(`const s = ${Q}osx build${Q};`)); + t('a test file carrying a literal is not a hit', !cliHit(PT_BR, 'packages/x/src/a.test.ts')); + t('…nor a file under __tests__', !cliHit(PT_BR, 'packages/x/src/__tests__/helper.ts')); + t('a non-JS file carrying a literal is not a hit', !cliHit(PT_BR, 'packages/x/README.md')); + t('a path with nothing to read is not a hit, and does not throw', !carriesACliCommandLiteral('packages/x/src/a.ts', () => null, CLI_SURFACE)); + t('no declared CLI reaches nothing, so the binary names are consulted', !cliHit(PT_BR, 'packages/x/src/a.ts', () => [])); + // The SURFACE limb is judged from the path, so a deleted module still counts. + t('a command module of the CLI is a hit with nothing to read — a deleted or renamed command', + carriesACliCommandLiteral('packages/cli/src/commands/migrate/apply.ts', () => null, CLI_SURFACE)); + t('the CLI manifest is a hit: it declares the binary names', carriesACliCommandLiteral('packages/cli/package.json', () => null, CLI_SURFACE)); + t('CLI source outside its commands directory is not a hit, even carrying a literal: the gate never scans its own CLI', + !cliHit(PT_BR, 'packages/cli/src/utils/x.ts')); + t('cliBinaryNames: oclif.bin plus every bin key', JSON.stringify(cliBinaryNames({ oclif: { bin: 'os' }, bin: { objectstack: 'x', os: 'x' } })) === '["os","objectstack"]'); + t('cliBinaryNames: a manifest with bin keys but no oclif.bin is no CLI', cliBinaryNames({ bin: { tool: 'x' } }).length === 0); + + // The live tree: the CLI is discovered from its manifest, its command surface + // is reached, and the literal net discriminates. + const liveClis = cliCommandSurfaces(); + t(`the live tree declares a CLI the net can read (${liveClis.map((c) => `${c.dir}: ${c.bins.join('/')}`).join('; ') || 'none'})`, + liveClis.some((c) => c.bins.length > 0)); + const CLI_SURFACE_SPECIMEN = 'packages/cli/src/commands/migrate/apply.ts'; + t('a live command module is reached', carriesACliCommandLiteral(CLI_SURFACE_SPECIMEN)); + const cliCorpus = trackedFiles().filter((f) => /\.(?:ts|tsx|js|mjs|cjs)$/.test(f) && !/\.(?:test|spec)\.[tj]sx?$/.test(f) && !f.includes('/__tests__/') + && !liveClis.some((c) => f.startsWith(`${c.dir}/`))); + const cliHits = cliCorpus.filter((f) => carriesACliCommandLiteral(f)); + t(`the CLI literal trigger discriminates: ${cliHits.length} of ${cliCorpus.length} non-test JS/TS files outside the CLI (neither vacuous nor tree-wide)`, + cliHits.length > 50 && cliHits.length < cliCorpus.length / 4); + // The gate's own reader, held against the net. Spawned: selfTest is + // synchronous, and the gate's module loads `typescript` at import time, + // which is exactly why the engine does not import it. + const cliProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `const fs = await import('node:fs'); + const m = await import(${JSON.stringify(pathToFileURL(nodePath.join(ROOT, 'scripts/check-cli-command-ids.mjs')).href)}); + const { root, clis, corpus } = JSON.parse(fs.readFileSync(0, 'utf8')); + const bins = {}; + for (const c of clis) bins[c.dir] = m.binNamesOf(JSON.parse(fs.readFileSync(root + '/' + c.dir + '/package.json', 'utf8'))); + const all = [...new Set(Object.values(bins).flat())]; + const files = corpus.filter((f) => fs.readFileSync(root + '/' + f, 'utf8').split('\\n').some((l) => m.literalsOn(l, all).length > 0)); + console.log(JSON.stringify({ bins, files }));`, + ], + { encoding: 'utf8', cwd: ROOT, input: JSON.stringify({ root: ROOT, clis: liveClis, corpus: cliCorpus }), maxBuffer: 1 << 26 }, + ); + let cliTruth = null; + try { + cliTruth = JSON.parse((cliProbe.stdout ?? '').trim()); + } catch { + /* cliTruth stays null and the cases below fail loudly */ + } + t(`the gate's own reader ran over the same corpus (exit ${cliProbe.status}${cliProbe.status === EXIT_PREREQUISITE_NOT_MET ? ': install the workspace first' : ''})`, + cliProbe.status === 0 && cliTruth !== null && cliTruth.files.length > 50); + t("the net reads the same binary names the gate's reader does, per CLI", + cliTruth !== null && liveClis.every((c) => JSON.stringify([...(cliTruth.bins[c.dir] ?? [])].sort()) === JSON.stringify([...c.bins].sort()))); + const cliMissed = (cliTruth?.files ?? []).filter((f) => !cliHits.includes(f)); + t(`every file the gate's reader finds a literal in is inside the net (missed: ${cliMissed.slice(0, 5).join(', ') || 'none'})`, + cliTruth !== null && cliMissed.length === 0); + + // The rendered section, through this entry alone and then the whole table. + const cliEntry = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === 'check:cli-command-ids')); + t('exactly one entry in the table names the CLI command-id gate', cliEntry.length === 1 && cliEntry[0].gates.length === 1); + const cliKind = changeKindLines([CLI_SURFACE_SPECIMEN], resolved, cliEntry); + t('a command-module path emits the CLI convention section', cliKind.length === 2 && cliKind[0].includes('CLI command-id literal')); + const cliLine = cliKind.find((l) => l.includes('- pnpm check:cli-command-ids —')) ?? ''; + t('and it names the gate runnably, anchored on the delimiter', cliLine !== ''); + t('…and states why it is derived by content rather than for every diff', /unconditional in CI but not cheap/.test(cliLine)); + t('…and keeps the exemption ledger off the repair path', /exemption ledger/.test(cliLine) && /never a quick fix/.test(cliLine)); + const throughTableCli = (p) => changeKindLines([p], resolved).some((l) => l.includes('- pnpm check:cli-command-ids —')); + t('through the whole table, a live command module derives the gate', throughTableCli(CLI_SURFACE_SPECIMEN)); + t('…a live literal-carrying source does too', cliHits.some((f) => !f.startsWith('scripts/') && throughTableCli(f))); + t('…and a docs-only page does not', !throughTableCli('content/docs/ai/agents.mdx')); + t('the runnable union carries the gate for the command module', + commandsFor({ kindGroups: changeKindGates([CLI_SURFACE_SPECIMEN], resolved) }).includes('pnpm check:cli-command-ids')); + t('an undiscoverable CLI command-id gate renders STALE for this entry', + changeKindLines([CLI_SURFACE_SPECIMEN], () => null, cliEntry).filter((l) => l.includes('⚠ check:cli-command-ids: STALE')).length === 1); + + // ── The ADR-anchored-file kind (#30763) ────────────────────────────────── + // + // Read from the ADR corpus, not from the path's content: the anchors are the + // gate's own, extracted with its corpus registration. Limbs through injected + // targets and an injected disk, the reader against a scratch corpus, then + // the live tree and the rendered section. + const ADR_TARGETS = () => ({ + symbolFiles: new Set(['packages/a/src/anchored.ts']), + anchoredFiles: new Set(['packages/a/src/anchored.ts', 'packages/b/src/file-level.ts']), + }); + const onDisk = (present) => (p) => present.includes(p); + t('an edit to a symbol-anchored file is a hit', breaksAnAdrAnchor('packages/a/src/anchored.ts', ADR_TARGETS, onDisk(['packages/a/src/anchored.ts']))); + t('…and so is deleting it', breaksAnAdrAnchor('packages/a/src/anchored.ts', ADR_TARGETS, onDisk([]))); + t('an edit to a file anchored only at file level is not a hit: it cannot red that anchor', + !breaksAnAdrAnchor('packages/b/src/file-level.ts', ADR_TARGETS, onDisk(['packages/b/src/file-level.ts']))); + t('…but moving or deleting it is', breaksAnAdrAnchor('packages/b/src/file-level.ts', ADR_TARGETS, onDisk([]))); + t('a file no ADR anchors is not a hit, present or gone', + !breaksAnAdrAnchor('packages/c/src/x.ts', ADR_TARGETS, onDisk(['packages/c/src/x.ts'])) && !breaksAnAdrAnchor('packages/c/src/x.ts', ADR_TARGETS, onDisk([]))); + t('a leading ./ is tolerated', breaksAnAdrAnchor('./packages/a/src/anchored.ts', ADR_TARGETS, onDisk(['packages/a/src/anchored.ts']))); + const adrFx = mkdtempSync(nodePath.join(tmpdir(), 'dispatch-gates-adr-anchors-')); + try { + const adrDir = nodePath.join(adrFx, ADR_ANCHOR_CORPUS.docRoots[0]); + mkdirSync(adrDir, { recursive: true }); + writeFileSync( + nodePath.join(adrDir, '0001-x.md'), + ['# X', '', 'See `packages/a/src/x.ts#makeThing`, `#otherThing` and `packages/b/README.md`.', '', + 'Upstream `objectui:packages/c/src/y.ts` too.', '', '```ts', 'packages/d/src/quoted.ts#notAnAnchor', '```', ''].join('\n'), + ); + const fx = adrAnchorTargets(ADR_ANCHOR_CORPUS, adrFx); + t('adrAnchorTargets: a symbol anchor and its continuation name the file by symbol', fx.symbolFiles.has('packages/a/src/x.ts') && fx.symbolFiles.size === 1); + t('adrAnchorTargets: a file-level anchor names its file at file level only', fx.anchoredFiles.has('packages/b/README.md') && !fx.symbolFiles.has('packages/b/README.md')); + t('adrAnchorTargets: a cross-repo anchor and a fenced sample are left out', + ![...fx.anchoredFiles].some((p) => p.includes('packages/c/') || p.includes('packages/d/'))); + t('adrAnchorTargets: a corpus with no document THROWS rather than reading as no anchors', + throwsNaming(() => adrAnchorTargets(ADR_ANCHOR_CORPUS, nodePath.join(adrFx, 'nowhere')), 'yielded no document')); + } finally { + rmSync(adrFx, { recursive: true, force: true }); + } + + // The live tree: the corpus is read, and the PR #30749 file is anchored by + // symbol (ADR-0012 anchors the push plugin's declarations). + const liveAdr = adrAnchorTargets(); + t(`the live ADR corpus is read: ${liveAdr.docs} records, ${liveAdr.symbolFiles.size} files anchored by symbol, ${liveAdr.anchoredFiles.size} anchored at all`, + liveAdr.docs > 100 && liveAdr.symbolFiles.size > 100 && [...liveAdr.symbolFiles].every((p) => liveAdr.anchoredFiles.has(p))); + t('…and a second ask is the memoised read', adrAnchorTargets() === liveAdr); + const ADR_SPECIMEN = 'packages/services/service-push/src/push-plugin.ts'; + t('the live tree still anchors the #30749 file by symbol, and the predicate reaches it', breaksAnAdrAnchor(ADR_SPECIMEN)); + t('a live source no ADR anchors is not reached', !breaksAnAdrAnchor('packages/services/service-storage/src/index.ts') + && !liveAdr.anchoredFiles.has('packages/services/service-storage/src/index.ts')); + t(`the symbol limb discriminates: ${liveAdr.symbolFiles.size} of ${codeCorpus.length} non-test TS files at most`, + liveAdr.symbolFiles.size < codeCorpus.length / 10); + + const adrEntry = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === 'check:adr-symbol-anchors')); + t('exactly one entry in the table names the ADR anchor gate', adrEntry.length === 1 && adrEntry[0].gates.length === 1); + const adrKind = changeKindLines([ADR_SPECIMEN], resolved, adrEntry); + t('the anchored path emits the ADR convention section', adrKind.length === 2 && adrKind[0].includes('ADR anchors')); + const adrLine = adrKind.find((l) => l.includes('- pnpm check:adr-symbol-anchors —')) ?? ''; + t('and it names the gate runnably, anchored on the delimiter', adrLine !== ''); + t('…and keeps the exemption marker MAINTAINER-ONLY', /MAINTAINER-ONLY/.test(adrLine)); + const throughTableAdr = (p) => changeKindLines([p], resolved).some((l) => l.includes('- pnpm check:adr-symbol-anchors —')); + t('through the whole table, the specimen derives the ADR gate', throughTableAdr(ADR_SPECIMEN)); + t('…and an unanchored source does not', !throughTableAdr('packages/services/service-storage/src/index.ts')); + t('the runnable union carries the ADR gate for the specimen', + commandsFor({ kindGroups: changeKindGates([ADR_SPECIMEN], resolved) }).includes('pnpm check:adr-symbol-anchors')); + t('an undiscoverable ADR anchor gate renders STALE for this entry', + changeKindLines([ADR_SPECIMEN], () => null, adrEntry).filter((l) => l.includes('⚠ check:adr-symbol-anchors: STALE')).length === 1); + // ── The ui/scripts/__tests__ suite (#26318) ───────────────────────────────── // // The 178 files run only in CI's Test UI shards, so no family named them and a @@ -6006,6 +6194,11 @@ export function selfTest({ tier = 'full' } = {}) { // #22320's entry, the second content-reached gate, pinned for the same reason. t('check:error-status-conformance is a live family, so naming it is not a guess', liveFamilies.has('check:error-status-conformance')); + // #30763's three entries, the families a diff could break and no derivation + // handed to --commands, pinned for the same reason. + for (const name of ['check:error-code-provenance', 'check:cli-command-ids', 'check:adr-symbol-anchors']) { + t(`${name} is a live family, so naming it is not a guess`, liveFamilies.has(name)); + } // ── The test-file entry's deletion criterion, MEASURED (#11199) ─────────── // @@ -7598,7 +7791,8 @@ export function selfTest({ tier = 'full' } = {}) { // 133 -> 137 (#25867): the header gained four lines on the ui jobs' queue-diff outputs. 'scripts/pm/check-expected-skips.mjs:137 self-test-reads', // 736 -> 738 (#26318): the data import and its re-export each gained UI_SCRIPTS_SUITE_ROOTS. - 'scripts/pm/dispatch-gates.mjs:738 inherited-population', + // 738 -> 744 (#30763): the ADR anchor corpus import and its comment. + 'scripts/pm/dispatch-gates.mjs:744 inherited-population', // Ninth row, added with the declaration it names: the recogniser's mailbox // doorbell battery pins the seven key templates verbatim in the pm-dispatch // references' project-forms.md, a population claim on a governed file, so