Skip to content

Commit 44564d2

Browse files
os-elonclaude
andauthored
feat(pm): derive check:dispatcher-error-vocabulary from a diff's CONTENT, the gate no path can name (#12875)
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. Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93a554d commit 44564d2

1 file changed

Lines changed: 215 additions & 0 deletions

File tree

scripts/pm/dispatch-gates.mjs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3562,6 +3562,116 @@ export function isTestFilePath(path) {
35623562
return /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(path);
35633563
}
35643564

3565+
/**
3566+
* Does this file carry a value shaped like an ADR-0112 error/notice CODE — the
3567+
* content trigger for `check:dispatcher-error-vocabulary` (#12850)?
3568+
*
3569+
* ## Why this one is judged from CONTENT, when every other kind reads a PATH
3570+
*
3571+
* The four predicates around this one answer questions about a path: is it a
3572+
* test file, does its package own an extract config, is it a gate script, is it
3573+
* in the root program. This one cannot, and the reason is recorded elsewhere in
3574+
* the tree rather than argued here. The vocabulary gate computes its own
3575+
* population by walking a bare top-level root, and `scripts/pm/bare-root-
3576+
* worklist.mjs` already carries the verdict for that spelling: REFUSE-WIDE,
3577+
* "non-test sources plus manifests, 1898 of 4903 (39%) — same trade" as the
3578+
* sibling it is grouped with, whose note spells the trade out — a declaration
3579+
* that "would name this gate for every card in the repo that touches a
3580+
* package".
3581+
*
3582+
* So there is no path prefix to give this entry. Inventing one would not merely
3583+
* be imprecise: it would mirror, inside this file, a population the ledger next
3584+
* door has already refused to spell — and mirroring a fact another file states
3585+
* is the drift this whole script is written against. A trigger that looks right
3586+
* and covers a third of the tree is strictly worse than today's honest silence,
3587+
* because a lead that fires on every card is one a reader learns to skip.
3588+
*
3589+
* What IS derivable from a diff is the thing the gate actually bites on: a code
3590+
* value entering the tree. That is content, so this predicate reads content.
3591+
*
3592+
* ## What it matches, and why it is deliberately broader than the gate
3593+
*
3594+
* Two limbs, both anchored on the shapes an error code is written in here:
3595+
*
3596+
* - STAMP POSITION — the token `code` bound to a quoted literal or to a
3597+
* SCREAMING_SNAKE identifier, through `:` or `=`, optional or not, with an
3598+
* optional `typeof` between. That last part is not decoration: the specimen
3599+
* that cost the CI round trip is `code: typeof CONVERSION_NOTICE_CODE`, and
3600+
* a matcher without it misses the very case this entry exists for.
3601+
* - CONSTANT BINDING — a SCREAMING_SNAKE binding whose value is a quoted
3602+
* SCREAMING_SNAKE string, which is how this repo declares a code before any
3603+
* `code` token is anywhere near it (`const CONVERSION_NOTICE_CODE =
3604+
* 'OS_METADATA_CONVERTED'`). The specimen file matches on both limbs; a
3605+
* tree sweep found no file that only the second reaches, so neither limb is
3606+
* carrying the other.
3607+
*
3608+
* ⛔ This is NOT a copy of the gate's own `SHAPES` table and must never become
3609+
* one. A copy would be a second spelling of a fact that file owns, and it would
3610+
* go stale in the SILENT direction the day `SHAPES` grows an indirection — that
3611+
* table has grown twice for exactly that reason. Being broader than `SHAPES` is
3612+
* what makes staleness impossible in the expensive direction: a shape added
3613+
* there is already inside this predicate's wider net.
3614+
*
3615+
* ## The false-positive trade, stated so nobody assumes narrowing is free
3616+
*
3617+
* This predicate over-matches on purpose. It fires for a file that merely
3618+
* CONTAINS a code, not only one that adds a new one, and it does not ask
3619+
* whether the value is registered — both would need the gate's own resolver,
3620+
* which is the thing this must not import. Cost of a false positive: one extra
3621+
* gate run, and this gate needs no build and answers for the whole tree in one
3622+
* pass. Cost of a false negative, measured on #12843: a full CI round trip,
3623+
* because the derived union reads green locally and the gate reds in `Lint &
3624+
* Repo Gates`. The two costs are not close, so the wide side is the correct
3625+
* one. ⚠ Narrowing it later is therefore not a tidy-up — it is a trade against
3626+
* a measured price, and it needs the same kind of measurement to justify.
3627+
*
3628+
* Measured on this tree when written, over 7169 tracked files: 196 of the 2281
3629+
* non-test TypeScript files match (8.6%, 2.7% of the tree) — two orders of
3630+
* discrimination away from the 39% a path spelling would have named. 194 of
3631+
* those 196 are inside the gate's own scanned population; the other two are one
3632+
* file each under the app and example roots, which the gate does not scan. Two
3633+
* wasted runs across the whole tree is the entire cost of leaving the
3634+
* population half out, and leaving it out is what keeps this file from spelling
3635+
* a pathy literal it would then match cards through — see the note on `why`
3636+
* prose in the table's docblock.
3637+
*
3638+
* ## What it cannot see, said out loud rather than discovered later
3639+
*
3640+
* A file that does not exist has no content, so this returns false for one —
3641+
* and at DISPATCH time the card's file surface is a hypothesis, which is where
3642+
* a brand-new file carrying a brand-new code lives. The trigger therefore fires
3643+
* for the dev's re-derivation off the merge base (where the file is real and
3644+
* where the missed gate actually costs the round trip) and stays quiet for the
3645+
* PM's hypothetical surface. That asymmetry is the honest one: firing on a path
3646+
* whose content nobody can read would be the path-shaped trigger this entry
3647+
* exists to refuse, wearing a different name. ⛔ Do not close it by falling back
3648+
* to the path half.
3649+
*/
3650+
const CODE_STAMP_POSITION = /\bcode\s*\??\s*[:=]\s*(?:typeof\s+)?(?:['"`]|[A-Z][A-Z0-9_]*\b)/;
3651+
const CODE_CONSTANT_BINDING =
3652+
/\b(?:const|readonly|static|let)\s+[A-Z][A-Z0-9_]*\s*(?::[^=;\n]+)?=\s*['"`][A-Z][A-Z0-9_]*['"`]/;
3653+
3654+
/** The file's text, or null when there is nothing on disk to read. */
3655+
function readTrackedSource(path) {
3656+
try {
3657+
return readFileSync(join(ROOT, path), 'utf8');
3658+
} catch {
3659+
return null;
3660+
}
3661+
}
3662+
3663+
export function stampsAnErrorCodeLiteral(path, readSource = readTrackedSource) {
3664+
if (!/\.[cm]?tsx?$/.test(path) || /\.d\.[cm]?ts$/.test(path)) return false;
3665+
if (isTestFilePath(path)) return false;
3666+
const source = readSource(path);
3667+
if (source === null || source === undefined) return false;
3668+
// Comments are masked for the reason the gate masks them: a code DISCUSSED in
3669+
// prose is not a code stamped in source. This narrows nothing the gate would
3670+
// have reported, so it costs no recall in the expensive direction.
3671+
const masked = maskComments(source);
3672+
return CODE_STAMP_POSITION.test(masked) || CODE_CONSTANT_BINDING.test(masked);
3673+
}
3674+
35653675
/**
35663676
* Is this path inside the ROOT package's tsc program — the population behind
35673677
* the `@objectstack/spec-monorepo` entry of `check:type-check-debt`?
@@ -4109,6 +4219,15 @@ export function reachesMetadataFormModule(path, modulePaths) {
41094219
* day the entry stops firing by itself (its `matches` reads the flags), so
41104220
* delete it only once the opt-out is the permanent shape rather than a
41114221
* transient one.
4222+
* - error-code entry: when the vocabulary gate's own source declares the
4223+
* population it walks in a form this derivation can read — which today
4224+
* means the bare-root ledger row for it moving off REFUSE-WIDE to a
4225+
* recorded subtree spelling — the ordinary path match names it and this
4226+
* entry is redundant. ⛔ Growing the gate's own SHAPES table does NOT
4227+
* qualify: more stamp positions make the gate see more, and change nothing
4228+
* about whether a dispatch brief can NAME it. ⛔ Nor does this predicate
4229+
* going quiet on a given card: it reads content, so silence about a file
4230+
* nobody can read yet is not evidence in either direction.
41124231
* - root-program entry: when the gate's own source names its root population
41134232
* in a form this derivation can read — a positive literal, or a generated
41144233
* manifest of the resolved program — the ordinary path match names it and
@@ -4213,6 +4332,16 @@ export const CHANGE_KIND_GATES = [
42134332
},
42144333
],
42154334
},
4335+
{
4336+
kind: 'adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)',
4337+
matches: stampsAnErrorCodeLiteral,
4338+
gates: [
4339+
{
4340+
name: 'check:dispatcher-error-vocabulary',
4341+
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',
4342+
},
4343+
],
4344+
},
42164345
];
42174346

42184347
/**
@@ -7548,6 +7677,86 @@ function selfTest() {
75487677
t('the i18n section also names check:i18n-stale-fill, runnably', i18nHit.some((l) => l.includes('- pnpm check:i18n-stale-fill —')));
75497678
t('a path outside every owning package emits no i18n section', !changeKindLines(['packages/objectql/src/engine.ts'], resolved).some((l) => l.includes('check:i18n')));
75507679

7680+
// ── The error-code CONTENT kind (#12850) ─────────────────────────────────
7681+
//
7682+
// The only entry in this table judged from a file's CONTENT rather than its
7683+
// path, so its cases are shaped differently: the limbs are driven through an
7684+
// INJECTED reader (offline, no tree), and the tree itself is used only for
7685+
// the two properties a fixture cannot pin — that the predicate still reaches
7686+
// the real specimen, and that it still DISCRIMINATES.
7687+
const codeSrc = (text) => (_path) => text;
7688+
const stamps = (text, path = 'packages/x/src/a.ts') => stampsAnErrorCodeLiteral(path, codeSrc(text));
7689+
t('a quoted code literal in a stamp position is a hit', stamps("const e = { code: 'NOT_CREATABLE' };"));
7690+
t('a SCREAMING_SNAKE constant in a stamp position is a hit', stamps('const e = { code: NOT_CREATABLE };'));
7691+
t('an assigned code is a hit', stamps("err.code = 'FLOW_FAILED';"));
7692+
t('an optional code FIELD TYPE is a hit', stamps("interface E { code?: 'FLOW_FAILED' }"));
7693+
// The specimen shape from #12843, spelled out: without the `typeof` limb this
7694+
// case is the one that fails, and it is the exact form that cost the round
7695+
// trip — a literal `code` type reached through a named constant.
7696+
t('a typeof reference to a code constant is a hit — the #12843 shape',
7697+
stamps('interface N { code: typeof CONVERSION_NOTICE_CODE; }'));
7698+
// The declaration half of that same shape, which carries no `code` token at
7699+
// all and is therefore invisible to every `code`-anchored limb.
7700+
t('a SCREAMING_SNAKE constant bound to a SCREAMING_SNAKE string is a hit',
7701+
stamps("export const CONVERSION_NOTICE_CODE = 'OS_METADATA_CONVERTED' as const;"));
7702+
t('a file with neither shape is not a hit', !stamps('export function add(a: number, b: number) { return a + b; }'));
7703+
// Masking is load-bearing in the cheap direction only: a code the gate would
7704+
// never report because it is not in source cannot cost a run here either.
7705+
t('a code discussed only in a comment is not a hit', !stamps("// code: 'NOT_CREATABLE' is stamped elsewhere\nexport const x = 1;"));
7706+
t('a lowercase constant binding is not a hit', !stamps("const notACode = 'lowercase';"));
7707+
// Population: the gate does not read tests, declaration files or non-TS, so
7708+
// neither does the lead. Each is driven with content that WOULD hit, so the
7709+
// case fails if the population half stops being consulted.
7710+
t('a test file carrying a stamp is not a hit', !stamps("const e = { code: 'X_Y' };", 'packages/x/src/a.test.ts'));
7711+
t('a d.ts carrying a stamp is not a hit', !stamps("const e = { code: 'X_Y' };", 'packages/x/src/a.d.ts'));
7712+
t('a non-TS file carrying a stamp is not a hit', !stamps("const e = { code: 'X_Y' };", 'packages/x/src/a.md'));
7713+
// The unreadable branch, pinned as its own case because it is the one this
7714+
// entry deliberately does NOT close: at dispatch time the card's surface is a
7715+
// hypothesis, and a file with no content on disk answers false rather than
7716+
// falling back to a path match. A regression here would be silent.
7717+
t('a path with nothing to read is not a hit, and does not throw', !stampsAnErrorCodeLiteral('packages/x/src/a.ts', () => null));
7718+
t('…and the live reader answers the same way for a path the tree does not have',
7719+
!stampsAnErrorCodeLiteral('packages/there-is-no-such-package/src/a.ts'));
7720+
7721+
// Anti-vacuity, against the REAL tree: the shape that cost #12843 a CI round
7722+
// trip must still be reached. Spelled rather than discovered because it IS
7723+
// the specimen — a derived probe would answer about some other file.
7724+
const CODE_SPECIMEN = 'packages/spec/src/conversions/types.ts';
7725+
t('the live tree still carries the #12843 specimen shape, and the predicate reaches it',
7726+
stampsAnErrorCodeLiteral(CODE_SPECIMEN));
7727+
// The discrimination pin, and the one case that holds this card's ruling
7728+
// mechanically: a content trigger is only worth having while it names the
7729+
// gate for SOME cards and not for most. The path spelling this entry refuses
7730+
// would have scored 39%; if a future widening pushes this predicate up there,
7731+
// the entry has become the thing it was written against and this case fails.
7732+
const codeCorpus = trackedFiles().filter((f) => /\.[cm]?tsx?$/.test(f) && !/\.d\.[cm]?ts$/.test(f) && !isTestFilePath(f));
7733+
const codeHits = codeCorpus.filter((f) => stampsAnErrorCodeLiteral(f));
7734+
t(`the content trigger discriminates: ${codeHits.length} of ${codeCorpus.length} non-test TS files (neither vacuous nor tree-wide)`,
7735+
codeCorpus.length > 500 && codeHits.length > 20 && codeHits.length < codeCorpus.length / 4);
7736+
7737+
// The rendered section, driven through THIS entry alone so the count is a
7738+
// statement about the entry rather than about which other kinds happen to
7739+
// fire for the specimen path.
7740+
const codeEntry = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === 'check:dispatcher-error-vocabulary'));
7741+
t('exactly one entry in the table names the vocabulary gate', codeEntry.length === 1);
7742+
const codeKind = changeKindLines([CODE_SPECIMEN], resolved, codeEntry);
7743+
t('a code-carrying path emits the convention section', codeKind.length === 2 && codeKind[0].includes('judged from CONTENT'));
7744+
t('and it names the vocabulary gate runnably, anchored on the delimiter',
7745+
codeKind.some((l) => l.includes('- pnpm check:dispatcher-error-vocabulary —')));
7746+
const codeLine = codeKind.find((l) => l.includes('- pnpm check:dispatcher-error-vocabulary —')) ?? '';
7747+
// The `why` owes the three halves a dev cannot re-derive from the command:
7748+
// why no path derivation names it, what the repair direction is, and that it
7749+
// needs no build (unlike the two ratchets in this same table).
7750+
t('the vocabulary line states why no path derivation reaches it', /REFUSE-WIDE/.test(codeLine));
7751+
t('…and pushes the repair to registration rather than to a tolerant consumer',
7752+
/REGISTERING/.test(codeLine) && /never by widening a consumer/.test(codeLine));
7753+
t('…and says it needs no build, unlike the ratchets in this table', /needs NO build/.test(codeLine));
7754+
// The card's second ruling, pinned: the over-broad direction is the chosen
7755+
// one and the trade is written where the next reader will meet it. A silent
7756+
// narrowing that drops this sentence fails here.
7757+
t('…and writes the false-positive trade down, so nobody assumes narrowing is free',
7758+
/deliberately WIDE/.test(codeLine) && /CI round trip/.test(codeLine));
7759+
75517760
// ── The metadata-form edge (#9116) ────────────────────────────────────────
75527761
//
75537762
// The bundles' OTHER producer, and the half no owning-package test can reach:
@@ -7998,6 +8207,12 @@ function selfTest() {
79988207
'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)',
79998208
liveFamilies.has('check:cross-package-test-inputs'),
80008209
);
8210+
// #12850's entry, pinned here for the same reason and with one of its own:
8211+
// this is the only gate in the table reached by a CONTENT predicate, so the
8212+
// census guard above is the only thing standing between a rename and a lead
8213+
// that renders STALE on a card nobody re-reads.
8214+
t('check:dispatcher-error-vocabulary is a live family, so naming it is not a guess',
8215+
liveFamilies.has('check:dispatcher-error-vocabulary'));
80018216

80028217
// ── The test-file entry's deletion criterion, MEASURED (#11199) ───────────
80038218
//

0 commit comments

Comments
 (0)