diff --git a/scripts/pm/check-dispatch-gates.mjs b/scripts/pm/check-dispatch-gates.mjs index 30e935183d..cda4603b9a 100644 --- a/scripts/pm/check-dispatch-gates.mjs +++ b/scripts/pm/check-dispatch-gates.mjs @@ -6,8 +6,6 @@ * * node scripts/pm/check-dispatch-gates.mjs # runs the tool's --self-test * - * ⚠️ This header names repo paths UNQUOTED on purpose — see the last section. - * * ## Why the gate exists * * scripts/pm/dispatch-gates.mjs derives the "local gates for this card" line of @@ -53,21 +51,48 @@ * none"), so shipping it as the price of gating the tool would have taken more * from every dispatch prompt than the gate gives back. * + * Those numbers are PRE-MASKING, and the decision they justify survives on + * narrower grounds than they describe. maskSelfTests now blanks the fixture + * half outright: measured on this tree, the tool's own source yields 4 hints, + * not 49 — .github/workflows, which it really reads, and packages/plugins, + * packages/drivers, packages/services, the bases its package resolver probes. + * Those three are real reads and still cover three of the largest directories + * in the tree, so a directly-wired gate would print MATCHED for every card + * under them — a smaller fabrication than the fixture one, of the same kind. + * The spec filter path from the incident above no longer matches at all. + * * A separate gate file is also what the other two pm gates look like * (check-skill-line-ratchet.mjs, check-skill-id-lint.mjs). Its watch hints are * the one constant below: this gate is matched for a card that edits the tool, * and for nothing else — which is the blind spot #8162 is about. * - * ## Why the paths above are unquoted + * ## Why the paths above are unquoted, and why that is no longer required * - * Watch-hint extraction reads any quoted-looking span, backticks included, and - * does not skip comments. Written the ordinary way, with each path in backticks, - * this header alone yielded ten hints — packages/spec/src, packages/objectql, + * The incident is real and worth keeping. Watch-hint extraction reads any + * quoted-looking span, backticks included, and it USED TO read comments as + * well. Written the ordinary way, with each path in backticks, this header + * alone yielded ten hints — packages/spec/src, packages/objectql, * packages/plugins, packages/drivers, .claude/agents, .changeset among them — * and reproduced, from the file explaining the pollution, the exact false * MATCHED leads it exists to avoid (measured, not predicted: the first draft of - * this file did it). So paths are named unquoted here, and the only quoted path - * in this file is the one input this gate genuinely has. + * this file did it). Hence the convention. + * + * The extractor no longer works that way: extractWatchHints opens with + * maskComments, whose own docblock names this file as the specimen it retires. + * Measured on this tree, with every repo path this header names rewritten into + * backticks: 1 hint under today's extractor — scripts/pm/dispatch-gates.mjs, + * the same single hint the file ships with — against 10 under the pre-masking + * one, reaching spec, objectql, plugins, drivers, .claude/agents and .changeset + * exactly as the incident describes. Comment masking alone accounts for the + * difference: masking self-test bodies instead changes nothing here, because + * this file has none. + * + * So the unquoting is no longer load-bearing, and this section is history + * rather than an instruction: quoting a path in a comment here is now free, and + * the paths stay unquoted because rewriting them buys nothing. What is NOT free + * is a path literal in a module body — masking cannot reach one — so the one + * quoted path below is still the one input this gate genuinely has, and that is + * the rule to carry into a new gate's header rather than the unquoting. * * Nothing else belongs in this file. Assertions go in the tool's own self-test, * beside the code they judge; this is the CI invocation and its reason. diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index de8c611dac..bdf5ebe922 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -719,6 +719,31 @@ export function i18nBundlePackageDirs() { * CI does not silently stop being suggested — the run prints it as STALE and * says to fix this table. A hand-written list that reports its own rot is a * different object from one that quietly ages. + * - Every `name` here is an INVOCATION, not a script. One check script can be + * wired into CI under two package scripts that answer different questions, and + * a rationale that names the script instead of the invocation sends a seat to + * a command which cannot reproduce the failure it describes. + * `check:type-check-coverage` and `check:type-check-debt` are one file + * (`scripts/check-type-check-coverage.mjs`); only the second passes + * `--re-measure`, which is the half a new test file's type errors move. This + * entry named the first while explaining the second, so a dev seat ran it in + * good faith, reported the union green, and CI found four new type errors. + * Swept over this tree when that was fixed: the workflows discover 96 + * families resolving to 73 distinct script files, and 8 of those files are + * reached by more than one family — 7 of the 8 in the other shape, a `check:` + * script beside a direct `node scripts/check-x.mjs` step in a second + * workflow, which `derive` discovers as its own family and prints with its + * own runnable invocation. The pair below is the only one where two ROOT + * SCRIPTS differ by a flag, so this is a one-off today and what generalises + * is the rule, not the fix. + * - Prose in a `why` is a MODULE-BODY string, so it is scanned for watch hints + * like any other literal — comment masking cannot reach it. The ratchet + * entry's remedy command therefore spells its `--filter` values unquoted (and + * says to quote them for the shell): measured, the shell-quoted spelling adds + * both of its glob filter values to THIS file's own hint set as hints, inert + * only because `hintCovers` rejects one that collapses to a bare top-level + * directory. A gate list that fabricates hints out of its own explanations is + * the failure this whole script is written against. * - Each entry is deletable, with a stated criterion: * - test-file entry: when a gate on it grows a discoverable path literal, * the ordinary derivation names it and its line becomes redundant. @@ -741,7 +766,11 @@ export const CHANGE_KIND_GATES = [ }, { name: 'check:type-check-coverage', - why: "TEST_DEBT ratchets a package's test-layer type errors, so a new test file that does not typecheck cleanly moves it", + why: "the STRUCTURAL half: a package whose test files sit outside every tsc program accounting for it must carry a TEST_DEBT entry, so a new test file no tsconfig reaches moves this one. It re-measures no count — the ratchet is the invocation below", + }, + { + name: 'check:type-check-debt', + why: "the RATCHET half, and the invocation CI runs for it: `--re-measure` re-runs tsc per ledger entry and fails when a count drifts up, so a new test file that does not typecheck cleanly moves it. Needs the workspace closure BUILT — on an unbuilt worktree it refuses outright, and that throw means NOT MEASURED, never `not applicable to me`. Build first, exactly as lint.yml does: pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/* (quote the filter values for your shell)", }, ], }, @@ -1077,8 +1106,8 @@ function selfTest() { const resolved = (name) => `pnpm ${name}`; const kindHit = changeKindLines(['packages/objectql/src/engine.test.ts'], resolved); - t('a test path emits the convention section', kindHit.length === 3 && kindHit[0].includes('adds or edits a test file')); - // Both halves anchor on the rendered DELIMITERS (`- pnpm x —`), for the + t('a test path emits the convention section', kindHit.length === 4 && kindHit[0].includes('adds or edits a test file')); + // All three halves anchor on the rendered DELIMITERS (`- pnpm x —`), for the // reason the i18n entry's pins below state at length: a bare `includes` is // satisfied by every name that merely STARTS WITH the expected one, so a // prefix-preserving rename is invisible to it — the single rot class the STALE @@ -1087,7 +1116,20 @@ function selfTest() { // `check:type-check-coverage-v2` in CHANGE_KIND_GATES left the substring form // green at 61/61 while the live run printed both as STALE; anchored, the same // rename fails this case. The two conventions in this file now agree. - t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('- pnpm check:query-options-erasure —')) && kindHit.some((l) => l.includes('- pnpm check:type-check-coverage —'))); + // + // The coverage/debt PAIR is pinned as a pair on purpose (#8545): they are two + // invocations of one script, and the anchored form is what tells them apart — + // `includes('pnpm check:type-check-coverage')` is satisfied by the debt line's + // absence AND by a `-v2` rename, which is how a rationale describing the + // ratchet went on naming the invocation that never runs it. + t('the section names all three convention gates, runnably', kindHit.some((l) => l.includes('- pnpm check:query-options-erasure —')) && kindHit.some((l) => l.includes('- pnpm check:type-check-coverage —')) && kindHit.some((l) => l.includes('- pnpm check:type-check-debt —'))); + // The ratchet line's prerequisite is part of the product, not decoration: a + // seat that runs `--re-measure` on an unbuilt worktree gets a throw, and an + // unexplained throw reads as "not applicable to me" — which is a green report + // over a gate that never ran. So the printed line must carry both the + // condition and a command that satisfies it. + const debtLine = kindHit.find((l) => l.includes('- pnpm check:type-check-debt —')) ?? ''; + t('the ratchet line states its built-closure prerequisite', /closure BUILT|BUILT closure/.test(debtLine) && debtLine.includes('turbo run build')); t('a non-test path emits nothing', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0); // i18n change-kind derivation — the pure judgments first, each mirroring one @@ -1287,7 +1329,14 @@ function selfTest() { // The table's own rot detector: a name no live run discovers must say so, // never disappear quietly. const stale = changeKindLines(['a.test.ts'], () => null); - t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 2); + t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 3); + // Per NAME, anchored on both sides of the rendered name (`⚠ x: STALE`), so the + // pair that shares one script is reported apart: a count alone stays green if + // one of the two is dropped from the table and something else is added, and a + // leading substring stays green through a `-v2` rename — the two ways this + // table has actually rotted. + t('the coverage half renders STALE under its own name', stale.some((l) => l.includes('⚠ check:type-check-coverage: STALE'))); + t('the ratchet half renders STALE under its own name', stale.some((l) => l.includes('⚠ check:type-check-debt: STALE'))); const i18nStale = changeKindLines(['packages/services/service-messaging/scripts/i18n-extract.config.ts'], () => null); t('an undiscoverable check:i18n renders as STALE', i18nStale.filter((l) => l.includes('⚠ check:i18n: STALE')).length === 1); t('every declared convention gate carries a reason', CHANGE_KIND_GATES.every((k) => k.gates.every((g) => g.name && g.why)));