From 7e53b389709b9f38842b419c4d19bd7dec850ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:22:05 +0000 Subject: [PATCH] feat(pm): derive check:dispatcher-error-vocabulary from a diff's CONTENT, the gate no path can name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vocabulary gate computes its own population by walking a bare top-level root, so no path-derived trigger ever names it in a dispatch prompt's gate list. A dev introducing a new error/notice code runs the derived union green locally and reds on CI — measured once, at the price of a full round trip. Adds a CONTENT-shaped CHANGE_KIND_GATES entry: two limbs over the masked source (a `code` token bound to a quoted literal, a SCREAMING_SNAKE constant or a `typeof` reference to one; and a SCREAMING_SNAKE constant bound to a SCREAMING_SNAKE string), deliberately broader than the gate's own SHAPES table so it cannot go stale in the silent direction. Measured on this tree: 196 of 2281 non-test TS files (8.6%), against the 39% a path spelling would have named — the spelling the bare-root ledger already records as REFUSE-WIDE for this gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- scripts/pm/dispatch-gates.mjs | 215 ++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index c5b484da91..400f9278b6 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3562,6 +3562,116 @@ export function isTestFilePath(path) { return /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(path); } +/** + * Does this file carry a value shaped like an ADR-0112 error/notice CODE — the + * content trigger for `check:dispatcher-error-vocabulary` (#12850)? + * + * ## Why this one is judged from CONTENT, when every other kind reads a PATH + * + * The four predicates around this one answer questions about a path: is it a + * test file, does its package own an extract config, is it a gate script, is it + * in the root program. This one cannot, and the reason is recorded elsewhere in + * the tree rather than argued here. The vocabulary gate computes its own + * population by walking a bare top-level root, and `scripts/pm/bare-root- + * worklist.mjs` already carries the verdict for that spelling: REFUSE-WIDE, + * "non-test sources plus manifests, 1898 of 4903 (39%) — same trade" as the + * sibling it is grouped with, whose note spells the trade out — a declaration + * that "would name this gate for every card in the repo that touches a + * package". + * + * So there is no path prefix to give this entry. Inventing one would not merely + * be imprecise: it would mirror, inside this file, a population the ledger next + * door has already refused to spell — and mirroring a fact another file states + * is the drift this whole script is written against. A trigger that looks right + * and covers a third of the tree is strictly worse than today's honest silence, + * because a lead that fires on every card is one a reader learns to skip. + * + * What IS derivable from a diff is the thing the gate actually bites on: a code + * value entering the tree. That is content, so this predicate reads content. + * + * ## What it matches, and why it is deliberately broader than the gate + * + * Two limbs, both anchored on the shapes an error code is written in here: + * + * - STAMP POSITION — the token `code` bound to a quoted literal or to a + * SCREAMING_SNAKE identifier, through `:` or `=`, optional or not, with an + * optional `typeof` between. That last part is not decoration: the specimen + * that cost the CI round trip is `code: typeof CONVERSION_NOTICE_CODE`, and + * a matcher without it misses the very case this entry exists for. + * - CONSTANT BINDING — a SCREAMING_SNAKE binding whose value is a quoted + * SCREAMING_SNAKE string, which is how this repo declares a code before any + * `code` token is anywhere near it (`const CONVERSION_NOTICE_CODE = + * 'OS_METADATA_CONVERTED'`). The specimen file matches on both limbs; a + * tree sweep found no file that only the second reaches, so neither limb is + * carrying the other. + * + * ⛔ This is NOT a copy of the gate's own `SHAPES` table and must never become + * one. A copy would be a second spelling of a fact that file owns, and it would + * go stale in the SILENT direction the day `SHAPES` grows an indirection — that + * table has grown twice for exactly that reason. Being broader than `SHAPES` is + * what makes staleness impossible in the expensive direction: a shape added + * there is already inside this predicate's wider net. + * + * ## The false-positive trade, stated so nobody assumes narrowing is free + * + * This predicate over-matches on purpose. It fires for a file that merely + * CONTAINS a code, not only one that adds a new one, and it does not ask + * whether the value is registered — both would need the gate's own resolver, + * which is the thing this must not import. Cost of a false positive: one extra + * gate run, and this gate needs no build and answers for the whole tree in one + * pass. Cost of a false negative, measured on #12843: a full CI round trip, + * because the derived union reads green locally and the gate reds in `Lint & + * Repo Gates`. The two costs are not close, so the wide side is the correct + * one. ⚠ Narrowing it later is therefore not a tidy-up — it is a trade against + * a measured price, and it needs the same kind of measurement to justify. + * + * Measured on this tree when written, over 7169 tracked files: 196 of the 2281 + * non-test TypeScript files match (8.6%, 2.7% of the tree) — two orders of + * discrimination away from the 39% a path spelling would have named. 194 of + * those 196 are inside the gate's own scanned population; the other two are one + * file each under the app and example roots, which the gate does not scan. Two + * wasted runs across the whole tree is the entire cost of leaving the + * population half out, and leaving it out is what keeps this file from spelling + * a pathy literal it would then match cards through — see the note on `why` + * prose in the table's docblock. + * + * ## What it cannot see, said out loud rather than discovered later + * + * A file that does not exist has no content, so this returns false for one — + * and at DISPATCH time the card's file surface is a hypothesis, which is where + * a brand-new file carrying a brand-new code lives. The trigger therefore fires + * for the dev's re-derivation off the merge base (where the file is real and + * where the missed gate actually costs the round trip) and stays quiet for the + * PM's hypothetical surface. That asymmetry is the honest one: firing on a path + * whose content nobody can read would be the path-shaped trigger this entry + * exists to refuse, wearing a different name. ⛔ Do not close it by falling back + * to the path half. + */ +const CODE_STAMP_POSITION = /\bcode\s*\??\s*[:=]\s*(?:typeof\s+)?(?:['"`]|[A-Z][A-Z0-9_]*\b)/; +const CODE_CONSTANT_BINDING = + /\b(?:const|readonly|static|let)\s+[A-Z][A-Z0-9_]*\s*(?::[^=;\n]+)?=\s*['"`][A-Z][A-Z0-9_]*['"`]/; + +/** The file's text, or null when there is nothing on disk to read. */ +function readTrackedSource(path) { + try { + return readFileSync(join(ROOT, path), 'utf8'); + } catch { + return null; + } +} + +export function stampsAnErrorCodeLiteral(path, readSource = readTrackedSource) { + if (!/\.[cm]?tsx?$/.test(path) || /\.d\.[cm]?ts$/.test(path)) return false; + if (isTestFilePath(path)) return false; + const source = readSource(path); + if (source === null || source === undefined) return false; + // Comments are masked for the reason the gate masks them: a code DISCUSSED in + // prose is not a code stamped in source. This narrows nothing the gate would + // have reported, so it costs no recall in the expensive direction. + const masked = maskComments(source); + return CODE_STAMP_POSITION.test(masked) || CODE_CONSTANT_BINDING.test(masked); +} + /** * Is this path inside the ROOT package's tsc program — the population behind * the `@objectstack/spec-monorepo` entry of `check:type-check-debt`? @@ -4109,6 +4219,15 @@ export function reachesMetadataFormModule(path, modulePaths) { * day the entry stops firing by itself (its `matches` reads the flags), so * delete it only once the opt-out is the permanent shape rather than a * transient one. + * - error-code entry: when the vocabulary gate's own source declares the + * population it walks in a form this derivation can read — which today + * means the bare-root ledger row for it moving off REFUSE-WIDE to a + * recorded subtree spelling — the ordinary path match names it and this + * entry is redundant. ⛔ Growing the gate's own SHAPES table does NOT + * qualify: more stamp positions make the gate see more, and change nothing + * about whether a dispatch brief can NAME it. ⛔ Nor does this predicate + * going quiet on a given card: it reads content, so silence about a file + * nobody can read yet is not evidence in either direction. * - root-program entry: when the gate's own source names its root population * in a form this derivation can read — a positive literal, or a generated * manifest of the resolved program — the ordinary path match names it and @@ -4213,6 +4332,16 @@ export const CHANGE_KIND_GATES = [ }, ], }, + { + kind: 'adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)', + matches: stampsAnErrorCodeLiteral, + gates: [ + { + 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', + }, + ], + }, ]; /** @@ -7548,6 +7677,86 @@ function selfTest() { t('the i18n section also names check:i18n-stale-fill, runnably', i18nHit.some((l) => l.includes('- pnpm check:i18n-stale-fill —'))); t('a path outside every owning package emits no i18n section', !changeKindLines(['packages/objectql/src/engine.ts'], resolved).some((l) => l.includes('check:i18n'))); + // ── The error-code CONTENT kind (#12850) ───────────────────────────────── + // + // The only entry in this table judged from a file's CONTENT rather than its + // path, so its cases are shaped differently: the limbs are driven through an + // INJECTED reader (offline, no tree), and the tree itself is used only for + // the two properties a fixture cannot pin — that the predicate still reaches + // the real specimen, and that it still DISCRIMINATES. + const codeSrc = (text) => (_path) => text; + const stamps = (text, path = 'packages/x/src/a.ts') => stampsAnErrorCodeLiteral(path, codeSrc(text)); + t('a quoted code literal in a stamp position is a hit', stamps("const e = { code: 'NOT_CREATABLE' };")); + t('a SCREAMING_SNAKE constant in a stamp position is a hit', stamps('const e = { code: NOT_CREATABLE };')); + t('an assigned code is a hit', stamps("err.code = 'FLOW_FAILED';")); + t('an optional code FIELD TYPE is a hit', stamps("interface E { code?: 'FLOW_FAILED' }")); + // The specimen shape from #12843, spelled out: without the `typeof` limb this + // case is the one that fails, and it is the exact form that cost the round + // trip — a literal `code` type reached through a named constant. + t('a typeof reference to a code constant is a hit — the #12843 shape', + stamps('interface N { code: typeof CONVERSION_NOTICE_CODE; }')); + // The declaration half of that same shape, which carries no `code` token at + // all and is therefore invisible to every `code`-anchored limb. + t('a SCREAMING_SNAKE constant bound to a SCREAMING_SNAKE string is a hit', + stamps("export const CONVERSION_NOTICE_CODE = 'OS_METADATA_CONVERTED' as const;")); + t('a file with neither shape is not a hit', !stamps('export function add(a: number, b: number) { return a + b; }')); + // Masking is load-bearing in the cheap direction only: a code the gate would + // never report because it is not in source cannot cost a run here either. + t('a code discussed only in a comment is not a hit', !stamps("// code: 'NOT_CREATABLE' is stamped elsewhere\nexport const x = 1;")); + t('a lowercase constant binding is not a hit', !stamps("const notACode = 'lowercase';")); + // Population: the gate does not read tests, declaration files or non-TS, so + // neither does the lead. Each is driven with content that WOULD hit, so the + // case fails if the population half stops being consulted. + t('a test file carrying a stamp is not a hit', !stamps("const e = { code: 'X_Y' };", 'packages/x/src/a.test.ts')); + t('a d.ts carrying a stamp is not a hit', !stamps("const e = { code: 'X_Y' };", 'packages/x/src/a.d.ts')); + t('a non-TS file carrying a stamp is not a hit', !stamps("const e = { code: 'X_Y' };", 'packages/x/src/a.md')); + // The unreadable branch, pinned as its own case because it is the one this + // entry deliberately does NOT close: at dispatch time the card's surface is a + // hypothesis, and a file with no content on disk answers false rather than + // falling back to a path match. A regression here would be silent. + t('a path with nothing to read is not a hit, and does not throw', !stampsAnErrorCodeLiteral('packages/x/src/a.ts', () => null)); + t('…and the live reader answers the same way for a path the tree does not have', + !stampsAnErrorCodeLiteral('packages/there-is-no-such-package/src/a.ts')); + + // Anti-vacuity, against the REAL tree: the shape that cost #12843 a CI round + // trip must still be reached. Spelled rather than discovered because it IS + // the specimen — a derived probe would answer about some other file. + const CODE_SPECIMEN = 'packages/spec/src/conversions/types.ts'; + t('the live tree still carries the #12843 specimen shape, and the predicate reaches it', + stampsAnErrorCodeLiteral(CODE_SPECIMEN)); + // The discrimination pin, and the one case that holds this card's ruling + // mechanically: a content trigger is only worth having while it names the + // gate for SOME cards and not for most. The path spelling this entry refuses + // would have scored 39%; if a future widening pushes this predicate up there, + // the entry has become the thing it was written against and this case fails. + const codeCorpus = trackedFiles().filter((f) => /\.[cm]?tsx?$/.test(f) && !/\.d\.[cm]?ts$/.test(f) && !isTestFilePath(f)); + const codeHits = codeCorpus.filter((f) => stampsAnErrorCodeLiteral(f)); + t(`the content trigger discriminates: ${codeHits.length} of ${codeCorpus.length} non-test TS files (neither vacuous nor tree-wide)`, + codeCorpus.length > 500 && codeHits.length > 20 && codeHits.length < codeCorpus.length / 4); + + // The rendered section, driven through THIS entry alone so the count is a + // statement about the entry rather than about which other kinds happen to + // fire for the specimen path. + 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('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 —')) ?? ''; + // The `why` owes the three halves a dev cannot re-derive from the command: + // why no path derivation names it, what the repair direction is, and that it + // needs no build (unlike the two ratchets in this same table). + t('the vocabulary line states why no path derivation reaches it', /REFUSE-WIDE/.test(codeLine)); + t('…and pushes the repair to registration rather than to a tolerant consumer', + /REGISTERING/.test(codeLine) && /never by widening a consumer/.test(codeLine)); + t('…and says it needs no build, unlike the ratchets in this table', /needs NO build/.test(codeLine)); + // The card's second ruling, pinned: the over-broad direction is the chosen + // one and the trade is written where the next reader will meet it. A silent + // 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)); + // ── The metadata-form edge (#9116) ──────────────────────────────────────── // // The bundles' OTHER producer, and the half no owning-package test can reach: @@ -7998,6 +8207,12 @@ function selfTest() { 'check:cross-package-test-inputs is a live family (#10542 moved it here from a path derivation that could name it at 49.6% precision at best)', liveFamilies.has('check:cross-package-test-inputs'), ); + // #12850's entry, pinned here for the same reason and with one of its own: + // this is the only gate in the table reached by a CONTENT predicate, so the + // census guard above is the only thing standing between a rename and a lead + // that renders STALE on a card nobody re-reads. + t('check:dispatcher-error-vocabulary is a live family, so naming it is not a guess', + liveFamilies.has('check:dispatcher-error-vocabulary')); // ── The test-file entry's deletion criterion, MEASURED (#11199) ─────────── //