From 44d264ed8a96964f2accfb7905500036fe267f2b Mon Sep 17 00:00:00 2001 From: "objectstack-fleet[bot]" <332303061+objectstack-fleet[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:17:14 +0000 Subject: [PATCH 1/5] test(pm/dispatch-gates): pin every derivation path the self-test did not drive, before the split Condition 1 of the lift: measured with V8 block coverage over a full run of the battery (the engine's functions and branches, self-test excluded), then one pin per path no case reached. No behaviour changes in this commit. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA --- scripts/pm/dispatch-gates.mjs | 144 ++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 1260e61c9e..3d7a1bc3f7 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -29679,6 +29679,150 @@ function selfTest() { ); } + // ── Every derivation path a case drives, measured before the split ──────── + // + // Condition 1 of the lift that let this battery be split: before anything + // moved, V8 block coverage of a full run of this battery was read over the + // engine's own declarations (this self-test excluded) — every one of the 245 + // declarations with code was invoked; 338 of 2,259 blocks inside them were + // not — and the branches below are the derivation paths among those: the + // marker grammars' refusals, the path matcher's floor, the tier renderer's + // two exits, the changed-line reading's three states, the sister-repo tier + // run, the claim-line reader, the argv splitter's joined and valueless + // forms, the repo assertion's three refusals, the absent-path verdict with + // no identity, and every class the run-record reconciliation can land a + // family in. Each case names the function and the branch it drives, so the + // next reading is taken against this block and not against memory. All of + // them are in-process on fixtures: no tree walk, no child. + { + const throwsWith = (fn, needle) => { + try { + fn(); + return false; + } catch (error) { + return String(error.message).includes(needle); + } + }; + // The marker grammars — the refusals a wrong key or form reaches. + t('markerFormKind: an unrecognised comment form throws and names the table it is added to', throwsWith(() => markerFormKind('bogus'), "unrecognised comment form 'bogus'")); + t('populationMarkerPattern: an unknown key throws and lists the known ones', throwsWith(() => populationMarkerPattern('not-a-key'), "unknown population marker key 'not-a-key'")); + const twoKeys = ['// dispatch-gates: no-path-population -- first', '// dispatch-gates: whole-tree-population -- second']; + t('lineFormReason: a NEW key under a declaration is a second declaration, never a cut', lineFormReason(twoKeys, 1, '//', ' first').cut === null); + t('lineFormReason: a declaration on the last line has nothing under it to cut it', lineFormReason(['// dispatch-gates: no-path-population -- only'], 1, '//', ' only').cut === null); + const cutLines = ['// dispatch-gates: no-path-population -- cut', '// off mid-sentence']; + const cut = lineFormReason(cutLines, 1, '//', ' cut').cut; + t('CONTROL: a plain continuation line IS a cut, with its line and text', cut?.kind === 'line' && cut.line === 2 && cut.text === 'off mid-sentence'); + t('markerReasonCutRefusal: an unknown key throws before any text is built', throwsWith(() => markerReasonCutRefusal('bogus', null), "unknown marker key 'bogus'")); + const blockCut = markerReasonCutRefusal('inherited-population', { kind: 'block', line: 3, text: 'tail', file: 'x.mjs' }) ?? ''; + t('markerReasonCutRefusal: a BLOCK cut gets the block repair, located', blockCut.includes('inside a block comment') && blockCut.includes('x.mjs:3') && blockCut.includes('"tail"')); + const lineCut = markerReasonCutRefusal('inherited-population', { kind: 'line', line: 2, text: 'more', file: 'x.mjs' }) ?? ''; + t('…and a LINE cut gets the marker-line repair, byte for byte the older text', lineCut.includes('does not END on the marker line') && lineCut.includes('x.mjs:2')); + t('refuseCutMarkerReason: a read with a cut THROWS, naming the file and the key', throwsWith(() => refuseCutMarkerReason({ cut: { kind: 'line', line: 2, text: 'more' } }, 'local-env', 'y.mjs'), 'y.mjs declares local-env')); + t('…and a read with no cut returns quietly', refuseCutMarkerReason({ cut: null }, 'local-env', 'y.mjs') === undefined); + const lookalike = unparsedPopulationMarkers('// dispatch-gates: no-path-population\n', 'z.mjs'); + t('unparsedPopulationMarkers: a declaration with no reason tail is READ but does not PARSE, and is located', lookalike.length === 1 && lookalike[0].key === 'no-path-population' && lookalike[0].line === 1 && lookalike[0].file === 'z.mjs'); + const lookalikeRefusal = unparsedPopulationMarkerRefusal(lookalike) ?? ''; + t('unparsedPopulationMarkerRefusal: the refusal names the row and the forms the key may be written in', lookalikeRefusal.includes('did not PARSE') && lookalikeRefusal.includes('z.mjs:1') && lookalikeRefusal.includes('no-path-population may be written in')); + t('…and an empty list is no refusal', unparsedPopulationMarkerRefusal([]) === null); + t('readPopulationDeclaration: an unknown key throws and lists the declared fields', throwsWith(() => readPopulationDeclaration({}, '', 'f.mjs', 'bogus'), "unknown population marker key 'bogus'")); + + // The path matcher's floor. + t('hintCovers: a one-character hint covers nothing, even itself', hintCovers('x', 'x') === false); + t('CONTROL: a two-segment hint covers its subtree', hintCovers('scripts/pm', 'scripts/pm/x.mjs') === true); + + // The tier renderer's two one-line-class exits, and the changed-line reading's three states. + t('tierLines: a mandated surface with the one-line exit OPEN says it drops to the default tier', tierLines(deriveTier(['.claude/agents/os-dev.md'])).join('\n').includes('drops to opus execution')); + t('tierLines: a mandated surface whose glob BARS the exit says so, naming the glob', tierLines(deriveTier(['skills/x/SKILL.md'])).join('\n').includes("NOT available for this surface — 'skills/**'")); + t('changedLineLines: no size is NOT MEASURED', changedLineLines(null).join('\n').includes('NOT MEASURED')); + t('changedLineLines: one line is under', changedLineLines({ additions: 1, deletions: 0 }).join('\n').includes('under.')); + t('changedLineLines: one over the threshold is OVER, and the threshold is the gate\'s', changedLineLines({ additions: HUMAN_MERGE_LINE_THRESHOLD + 1, deletions: 0 }).join('\n').includes(`threshold ${HUMAN_MERGE_LINE_THRESHOLD}: ⛔ OVER`)); + + // The sister-repo tier run, in process. + const pinSelf = GOVERNED_REPOS.find((r) => r.id === SELF_REPO_ID).slug; + const pinSister = GOVERNED_REPOS.find((r) => r.id !== SELF_REPO_ID).slug; + t('sisterRepoTierRun: this repo is not a sister — null, so the ordinary assertion answers', sisterRepoTierRun({ asserted: pinSelf, paths: ['x'] }) === null); + const sisterNoPaths = sisterRepoTierRun({ asserted: pinSister, paths: [] }); + t('sisterRepoTierRun: a sister with no paths REFUSES — nothing to derive them from', sisterNoPaths?.ok === false && sisterNoPaths.stderr.join('\n').includes('REFUSING') && sisterNoPaths.stdout.length === 0); + const sisterRun = sisterRepoTierRun({ asserted: pinSister, paths: ['skills/x/SKILL.md'], identity: { head: 'abc1234' } }); + t('sisterRepoTierRun: a sister with paths answers the tier from the globs and the changed lines as NOT MEASURED, naming the commit', sisterRun?.ok === true && sisterRun.stdout[0].startsWith('Model tier') && sisterRun.stdout[sisterRun.stdout.length - 1].includes(`NOT MEASURED`) && sisterRun.stdout[sisterRun.stdout.length - 1].includes(String(HUMAN_MERGE_LINE_THRESHOLD)) && sisterRun.stderr[0].includes('at commit abc1234')); + + // The claim's model line. + t('readContainerModelLine: no key line is absent', readContainerModelLine('no such line here').present === false); + const noSlot = readContainerModelLine('Container & model: `L`, mode:subagent'); + t('readContainerModelLine: a key line with no model slot is present and declares no tier', noSlot.present === true && (noSlot.tier ?? null) === null); + + // The argv splitter's joined and valueless forms. + t('splitArgv: a value flag followed by another flag is malformed, naming the flag', String(splitArgv(['--repo', '--tier']).malformed).includes('--repo needs a value')); + t('splitArgv: the joined spelling binds the value', splitArgv(['--repo=o/r']).assertion === 'o/r' && splitArgv(['--ran=rec.list', 'p']).runRecord === 'rec.list'); + t('splitArgv: the joined spelling with nothing after the sign is malformed', String(splitArgv(['--ran=']).malformed).includes('--ran needs a value')); + + // The repo assertion's three refusals and its case-insensitive pass. + const idHere = { slug: 'o/r', root: '/t' }; + t('repoAssertionVerdict: a slug that is not owner/name is refused as usage', repoAssertionVerdict({ asserted: 'nonsense', identity: idHere }).lines[0].includes('expects an owner and a repository name')); + t('repoAssertionVerdict: an unreadable identity refuses rather than assuming', repoAssertionVerdict({ asserted: 'o/r', identity: { slug: null, root: '/t' } }).lines[0].includes('UNKNOWN')); + const mismatch = repoAssertionVerdict({ asserted: 'not-an-owner/not-a-repo', identity: idHere }); + t('repoAssertionVerdict: a mismatch REFUSES and names both repos, with no tier hint for a stranger', mismatch.ok === false && mismatch.lines[0].includes('REFUSING') && !mismatch.lines.join('\n').includes('The TIER half alone')); + t('…and a mismatch that names a governed sister adds the tier-half hint', repoAssertionVerdict({ asserted: pinSister, identity: idHere }).lines.join('\n').includes('The TIER half alone needs no tree')); + t('repoAssertionVerdict: the comparison ignores case', repoAssertionVerdict({ asserted: 'O/R', identity: idHere }).ok === true); + + // The absent-path verdict with and without an identity. + const noRoot = { root: '/no-such-root-for-this-pin', slug: null }; + const absentNoId = absentPathVerdict({ asserted: null, identity: noRoot, paths: ['docs/x.md'] }); + t('absentPathVerdict: with no readable identity the copy line is marked UNVERIFIABLE', absentNoId.ok === false && absentNoId.lines.join('\n').includes('UNVERIFIABLE') && absentNoId.lines.join('\n').includes('owner/this-repo')); + t('absentPathVerdict: with an identity the copy line names this repo', absentPathVerdict({ asserted: null, identity: { ...noRoot, slug: 'o/r' }, paths: ['docs/x.md'] }).lines.join('\n').includes(`${REPO_FLAG} o/r`)); + t('absentPathVerdict: an assertion settles it', absentPathVerdict({ asserted: 'o/r', identity: noRoot, paths: ['docs/x.md'] }).ok === true); + + // The run record: every class the reconciliation can land a family in. + const floor = RUN_RECORD_KILL_EXITS.SIGNAL_FLOOR; + const namedCodes = Object.keys(RUN_RECORD_KILL_EXITS.SIGNAL_NAMES).map(Number); + const unnamed = Array.from({ length: 60 }, (_, k) => floor + k + 1).find((c) => !namedCodes.includes(c)); + t('runRecordKillLabel: not an integer, and a code under the signal floor, are no kill', runRecordKillLabel('x') === null && runRecordKillLabel(1.5) === null && runRecordKillLabel(1) === null); + t('runRecordKillLabel: the timeout wrapper\'s code is named as such', String(runRecordKillLabel(RUN_RECORD_KILL_EXITS.TIMEOUT)).includes('timeout')); + t('runRecordKillLabel: a named signal is named, an unnamed one is numbered', runRecordKillLabel(namedCodes[0]) === `killed by ${RUN_RECORD_KILL_EXITS.SIGNAL_NAMES[namedCodes[0]]}` && runRecordKillLabel(unnamed) === `killed by signal ${unnamed - floor}`); + t('parseRunRecord: a separator tail that is not an exit code is malformed, read as a command in full', String(parseRunRecord('cmd :: exit abc')[0].malformed).includes('not an exit code') && parseRunRecord('cmd :: 7')[0].command === 'cmd :: 7'); + t('parseRunRecord: a bare line has no exit code and no fault', parseRunRecord('cmd')[0].exitCode === null && parseRunRecord('cmd')[0].malformed === null); + t('parseRunRecord: a claim with no separator, and one with an empty reason, are each malformed by name', String(parseRunRecord('NOT-MEASURED cmd')[0].malformed).includes("no '::'") && parseRunRecord('NOT-MEASURED cmd :: ')[0].malformed === 'an empty reason'); + t('parseRunRecord: comments, blank lines and CRLF are tolerated', parseRunRecord('# note\n\ncmd :: exit 0\r\n').length === 1 && parseRunRecord('# note\n\ncmd :: exit 0\r\n')[0].exitCode === 0); + const recRecord = parseRunRecord([ + 'a :: exit 0', + `a :: exit ${EXIT_PREREQUISITE_NOT_MET}`, + `b :: exit ${namedCodes[0]}`, + 'NOT-MEASURED b :: the cap killed it', + `c :: exit ${namedCodes[0]}`, + `d :: exit ${namedCodes[0]}`, + 'NOT-MEASURED d :: ', + 'NOT-MEASURED e :: never reached — the box has no docker', + 'NOT-MEASURED f', + 'g :: exit zz', + 'ci-only :: exit 0', + 'not-runnable :: exit 0', + 'pending :: exit 0', + 'h :: exit 0', + 'i', + ].join('\n')); + const recon = runReconciliation({ derived: ['a', 'b', 'c', 'd', 'e', 'h', 'i'], record: recRecord, ciOnlyCommands: new Set(['ci-only']), notRunnableCommands: new Set(['not-runnable']), pendingCommands: new Set(['pending']) }); + const landed = (cmd) => (recon.notMeasured.find((x) => x.command === cmd) ? `nm:${recon.notMeasured.find((x) => x.command === cmd).source}` : recon.unrun.find((x) => x.command === cmd) ? 'unrun' : recon.ran.includes(cmd) ? 'ran' : 'none'); + t(`runReconciliation: two codes for one command contradict, and ${EXIT_PREREQUISITE_NOT_MET} wins — NOT-MEASURED from the exit code`, recon.exitContradictions.length === 1 && recon.exitContradictions[0].command === 'a' && landed('a') === 'nm:exit-code'); + t('runReconciliation: a kill code with a stated reason beside it is NOT-MEASURED from the kill claim', landed('b') === `nm:${RUN_RECORD_NOT_MEASURED_KILL_SOURCE}`); + t('runReconciliation: a kill code with no claim is UNRUN, told how to declare it', landed('c') === 'unrun' && recon.unrun.find((x) => x.command === 'c').why.includes('declare it as')); + t('runReconciliation: a kill code with a reasonless claim is UNRUN — a kill without a stated reason', landed('d') === 'unrun' && recon.unrun.find((x) => x.command === 'd').why.includes('a kill without a stated reason')); + t('runReconciliation: a reasoned claim with no exit code is NOT-MEASURED from the claim', landed('e') === 'nm:claim'); + t('runReconciliation: the three explained extras land in their own lists, not as unknown extras', recon.explainedCiOnly?.includes('ci-only') && recon.explainedNotRunnable?.includes('not-runnable') && recon.explainedPending?.includes('pending')); + t('runReconciliation: a recorded command that is derived only once trimmed is a NEAR MISS, and still an extra', recon.nearMiss?.some((n) => n.recorded === 'h ' && n.derived === 'h') && recon.extra.includes('h ') && landed('h') === 'unrun'); + t('runReconciliation: both malformed kinds are carried, by line', recon.malformed.some((m) => m.kind === 'claim') && recon.malformed.some((m) => m.kind === 'exit')); + t('runReconciliation: a bare line is RAN with no code — the silent class', landed('i') === 'ran'); + t('runReconciliation: the classes close over the derived total', recon.ran.length + recon.unrun.length + recon.notMeasured.length === 7); + const rendered = runReconciliationLines(recon).join('\n'); + t('runReconciliationLines: the contradiction, the agreeing kill claim, and both malformed shapes are each rendered', rendered.includes('TWO different exit codes') && rendered.includes('The two AGREE') && rendered.includes('carries') && rendered.includes('claims NOT-MEASURED with')); + t('runReconciliationLines: a mixed record renders the evidence as a FLOOR', rendered.includes('a FLOOR')); + const claimedOnly = runReconciliation({ derived: ['x'], record: parseRunRecord('x') }); + t('runReconciliationLines: a record with no exit codes renders the count as the RUNNER\'S CLAIM', runReconciliationLines(claimedOnly).join('\n').includes("RUNNER'S CLAIM")); + const derivedZero = runReconciliation({ derived: ['x'], record: parseRunRecord('x :: exit 0') }); + t('runReconciliationLines: an all-coded record with nothing refused renders a DERIVED zero', runReconciliationLines(derivedZero).join('\n').includes('a DERIVED zero')); + const nothing = runReconciliation({ derived: [], record: [] }); + t('runReconciliation: nothing derived and nothing recorded closes at zero', nothing.ran.length === 0 && nothing.unrun.length === 0 && nothing.notMeasured.length === 0); + } + // The non-vacuity half of #15539, read at the tail because that is where // every call site passing a reading has already run. The card named six; the // assertion is a FLOOR rather than an equality, so adding a seventh is not a From 3cbaf471b0161957d84cb7e7b1a2814a032eefac Mon Sep 17 00:00:00 2001 From: "objectstack-fleet[bot]" <332303061+objectstack-fleet[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:22:49 +0000 Subject: [PATCH 2/5] refactor(pm/dispatch-gates): the hand-written tables move out as data, the self-test out as a module with two tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate roster was never a table — families are derived from the workflows, the composite actions, package.json and the gate sources on every run — so what moves is what IS hand-written: the marker grammar vocabularies, the ledgers, the change-kind roster, the tier globs and the ladder words, into dispatch-gates.data.mjs with their docblocks and no logic (a predicate or a tier is named, and the engine resolves the name at load, refusing one it does not know). The battery moves into dispatch-gates.self-test.mjs, loaded only by the engine's --self-test branch, and splits into a FAST tier (every in-process case) and a SLOW tier (every CLI spawn, temporary repository and whole-tree sweep), the two pinned to partition the battery. The wrapper check-dispatch-gates.mjs runs the fast tier on a dev box, both under GITHUB_ACTIONS or --slow, and says which before it spawns; lint.yml and the package.json line are untouched, so no new family enters the derivation. The engine keeps roster loading, path matching and derivation; its declared inherited population (.github/workflows, .github/actions) is what every follower reads, so no derivation moved — proven by the replay in the PR. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA --- scripts/pm/check-dispatch-gates.mjs | 79 +- scripts/pm/dispatch-gates.data.mjs | 1258 ++ scripts/pm/dispatch-gates.mjs | 14712 +--------------------- scripts/pm/dispatch-gates.self-test.mjs | 13496 ++++++++++++++++++++ 4 files changed, 15041 insertions(+), 14504 deletions(-) create mode 100644 scripts/pm/dispatch-gates.data.mjs create mode 100644 scripts/pm/dispatch-gates.self-test.mjs diff --git a/scripts/pm/check-dispatch-gates.mjs b/scripts/pm/check-dispatch-gates.mjs index c5864194b2..fb62ab8112 100644 --- a/scripts/pm/check-dispatch-gates.mjs +++ b/scripts/pm/check-dispatch-gates.mjs @@ -53,6 +53,30 @@ * on a quiet box reaches a verdict at all, where before it could only ever be * killed. * + * ## Two tiers, and who runs which + * + * The battery has a FAST tier (every in-process case: fixture derivations, path + * matching, the grammars, the renderings, one memoised discovery of this tree) + * and a SLOW tier (the full replay: every case that spawns the tool's own CLI, + * builds a temporary git repository or sweeps the whole tracked corpus). The + * split is the tool's own — `dispatch-gates.self-test.mjs` names each slow + * section and pins that the two tiers partition the battery — and this file + * only decides which tier a run gets: + * + * a dev box, no flag `--self-test --fast` the fast tier, what a delivery runs + * `--slow` on argv `--self-test` both tiers, on demand + * `GITHUB_ACTIONS` is `true` `--self-test` both tiers, what CI runs + * + * The CI read is the one environment read this file makes, and its failure + * direction is the SAFE one: an unset variable where it should be set buys a + * shorter run that still prints which tier it was, never a quiet one; a set + * variable on a dev box buys the full battery, loud. `lint.yml` runs this gate + * with no argument, so the slow tier stays wired in CI without a workflow edit + * and without a second family for the derivation to name. The tier chosen and + * the reason are printed before the spawn, and the wall clock after it names + * the tier it measured. The fast tier fits a quiet box's foreground cap with + * room to spare; the detached form above stays the form for both tiers. + * * ## The exit contract, and why the kill branch does not keep its old code * * Four endings, four codes, and what fixes them is not this file's taste — it @@ -297,6 +321,21 @@ const SELF_TEST_CHILD_FLAG = '--self-test-child'; /** The flag that runs this file's own battery instead of the tool's. */ const SELF_TEST_FLAG = '--self-test'; +/** The flag that asks for both tiers off CI; without it a dev box runs the fast tier. */ +const SLOW_TIER_FLAG = '--slow'; + +/** + * Which tier this run gets, and why — one read of argv and one of the + * environment, decided here so the spawn below and its announcement cannot + * disagree. Not exported: this file is a CLI, and the self-test drives the + * decision through spawned children that echo the argv they were given. + */ +function tierOfThisRun({ argv = process.argv.slice(2), env = process.env } = {}) { + if (argv.includes(SLOW_TIER_FLAG)) return { args: [], word: 'both tiers', why: `${SLOW_TIER_FLAG} on argv` }; + if (env.GITHUB_ACTIONS === 'true') return { args: [], word: 'both tiers', why: 'GITHUB_ACTIONS is true — the CI run owes the slow tier' }; + return { args: ['--fast'], word: 'the fast tier', why: `the default off CI; ${SLOW_TIER_FLAG} runs both` }; +} + const argv = process.argv.slice(2); if (argv.includes(SELF_TEST_FLAG)) await selfTest(); @@ -315,6 +354,9 @@ if (child !== TOOL) { ); } +const tier = tierOfThisRun({ argv }); +console.error(`check:pm-dispatch-gates: running ${tier.word} (${tier.why}).`); + const started = Date.now(); /** * ⛔ The production spawn names TOOL DIRECTLY, and it has to keep doing so. @@ -327,12 +369,14 @@ const started = Date.now(); * file's self-test was first written that way: five cases of the tool's own * battery red, and a workflows-only derivation stopped naming this gate * entirely. The substituted child therefore gets its OWN call, and the - * production one is byte-for-byte the expression that was here before. + * production one keeps `join(ROOT, TOOL)` as the first element of its argv — + * the component the scan resolves. The tier flags spread after it are not + * paths, so the edge they ride on is the one that was here before. */ const result = childAt < 0 - ? spawnSync(process.execPath, [join(ROOT, TOOL), '--self-test'], { stdio: 'inherit' }) - : spawnSync(process.execPath, [resolve(ROOT, child), '--self-test'], { stdio: 'inherit' }); + ? spawnSync(process.execPath, [join(ROOT, TOOL), '--self-test', ...tier.args], { stdio: 'inherit' }) + : spawnSync(process.execPath, [resolve(ROOT, child), '--self-test', ...tier.args], { stdio: 'inherit' }); /** * What the battery cost on THIS box, printed rather than frozen anywhere. * @@ -377,7 +421,7 @@ if (result.signal) { console.error(' then tail that log until it stops growing. CI runs this step with no such cap.'); process.exit(EXIT_PREREQUISITE_NOT_MET); } -console.error(`check:pm-dispatch-gates: the battery took ${seconds}s on this box.`); +console.error(`check:pm-dispatch-gates: the battery (${tier.word}) took ${seconds}s on this box.`); process.exit(result.status ?? 2); /** @@ -434,13 +478,34 @@ async function selfTest() { const redChild = stub('red.mjs', 'process.exit(1);\n'); const greenChild = stub('green.mjs', 'process.exit(0);\n'); - const drive = (childPath) => - spawnSync(process.execPath, [SELF, SELF_TEST_CHILD_FLAG, childPath], { encoding: 'utf8' }); + const drive = (childPath, { extra = [], env = {} } = {}) => + spawnSync(process.execPath, [SELF, SELF_TEST_CHILD_FLAG, childPath, ...extra], { + encoding: 'utf8', + env: { ...process.env, GITHUB_ACTIONS: '', ...env }, + }); const killed = drive(killedChild); const red = drive(redChild); const green = drive(greenChild); + // The tier decision, driven against a child that echoes the argv it was given. + const echoChild = stub('echo.mjs', "console.log(process.argv.slice(2).join(' '));\nprocess.exit(0);\n"); + const offCi = drive(echoChild); + const onCi = drive(echoChild, { env: { GITHUB_ACTIONS: 'true' } }); + const slowAsked = drive(echoChild, { extra: [SLOW_TIER_FLAG] }); + t( + 'off CI with no flag the child runs the FAST tier — the argv it received says so', + offCi.status === 0 && (offCi.stdout ?? '').trim() === '--self-test --fast' && offCi.stderr.includes('running the fast tier'), + ); + t( + `⭐ under GITHUB_ACTIONS=true the child runs BOTH tiers — the slow tier stays wired in CI without a workflow edit`, + onCi.status === 0 && (onCi.stdout ?? '').trim() === '--self-test' && onCi.stderr.includes('running both tiers'), + ); + t( + `…and ${SLOW_TIER_FLAG} asks for both tiers off CI, on demand`, + slowAsked.status === 0 && (slowAsked.stdout ?? '').trim() === '--self-test' && slowAsked.stderr.includes('running both tiers'), + ); + t( 'CONTROL: the killed stub really died by SIGNAL, so the branch under test is the one that ran', killed.stderr.includes('was killed by SIGTERM'), @@ -495,7 +560,7 @@ async function selfTest() { console.log( failures === 0 - ? '✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions.' + ? '✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions, and the tier decision in both.' : `✗ check:pm-dispatch-gates --self-test: ${failures} case(s) failed.`, ); process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs new file mode 100644 index 0000000000..d4d42b9b95 --- /dev/null +++ b/scripts/pm/dispatch-gates.data.mjs @@ -0,0 +1,1258 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * dispatch-gates.data — the hand-written tables `dispatch-gates.mjs` reads, as DATA and nothing else. + * + * The gate ROSTER is not here, and never was anywhere: which paths feed which `check:*` families is + * derived from `.github/workflows/*.yml`, the composite actions they `uses:`, `package.json` and the + * gate scripts' own sources, on every run of the engine beside this file. What IS hand-written is + * below: the marker grammar vocabularies, the ledgers (whole-tree residue, compound anchors, governed + * reads, escapable literals), the change-kind roster (convention-triggered gates, named by the KIND + * of change rather than by a path), the model-tier globs and the tier ladder words. Each table keeps + * the docblock that governs it; a row's reason is part of the row. + * + * ⛔ No logic. A row that needs a function names it — a predicate by its key (`matches`, `except`), + * a tier by its name (`tier`) — and the engine resolves the name at load, refusing one it does not + * know. A new gate family under a kind is one row here, still only on the maintainer's words. + * + * ⛔ Not a watch surface. No gate family resolves to this file and no gate imports it, so the paths + * its rows spell are never read as a population; the engine declares its own inherited population + * from what it OPENS, which is why moving these rows here moved no derivation. + */ +/** + * The THREE population markers' grammar, in ONE spelling, and the reading of + * whether the reason one of them captures is WHOLE (#18422). + * + * ## The defect this exists for + * + * Every population marker below captured its reason with `(\S.*)$` under the + * `m` flag, so the capture ends at the FIRST NEWLINE. A reason an author wraps + * across two or three comment lines — which is what a comment that long looks + * like in every file in this tree — was captured as line ONE, and nothing + * refused it: `wholeTreePopulationRefusal` checks that a reason EXISTS and that + * a root walk BACKS it, never that it is whole. Measured on card #17472: a + * three-line reason rendered to the seat as "…and the verdict is", a sentence + * that simply stops. The failure mode is the expensive kind — a truncated + * reason reads as a complete sentence that merely ends oddly, and the reason is + * the ONE thing a seat reads off that row when deciding whether a family + * belongs on its card. + * + * ## The contract: the reason is WHOLE, or the declaration is RED + * + * A declaration OWNS the line it is written on and nothing else. It is + * terminated by a blank line, a blank comment line, a non-comment line, EOF, or + * another `dispatch-gates:` declaration. A comment line immediately below it, + * in the SAME comment form, carrying text that is not a new `dispatch-gates:` + * key, is read as a CONTINUATION of the reason — and a continued reason is + * refused, naming the file and the line that continues it. + * + * ⛔ The remedy is NOT a marker that consumes a comment BLOCK. Nothing in the + * text can tell a wrapped reason from an unrelated comment written under the + * declaration, so a block-consuming marker would silently make the next + * paragraph part of a seat-facing reason — the same class of defect pointed the + * other way, and the coin toss this file refuses everywhere else. Refusing is + * decidable; swallowing is a guess. Measured over the tree at the time of + * writing: 27 declarations across 25 gate files, 26 of them already writing the + * whole reason on one line (up to 1215 characters of it), and exactly one + * wrapped — so the one-line spelling is what this convention already IS, and + * the refusal names the one declaration that was being cut. + * + * ## Why the grammar is built here rather than written out three times + * + * The continuation reading has to agree with the capture about what a marker + * line IS, down to the comment form. Two spellings of one grammar drift + * silently — the exact failure `declaredNoCheckFamiliesReason`'s docblock + * prices one level up — so the pattern each marker uses and the pattern the + * continuation reading uses come out of this one function. Group 1 is the + * comment form (`//` or `#`), group 2 is the reason; the form is captured + * rather than discarded because a `#` line under a `//` declaration is not a + * comment in the same language and cannot be continuing it. + */ +export const POPULATION_MARKER_KEYS = Object.freeze(['no-path-population', 'whole-tree-population', 'wide-population']); + +/** + * The COMMENT FORMS a `dispatch-gates:` declaration may be written in, in ONE + * roster — and the HEAD every marker pattern below is built out of it: the + * indent, the form, and the key that follows it. + * + * ## The defect the roster was widened for (#18661) + * + * The alternation listed `//` and `#` and nothing else, so a declaration + * written in the file's own BLOCK-comment idiom parsed as NOTHING: not + * refused, not printed, not counted. Measured on `origin/main` 95b21b33be, + * `declaredNoPathPopulation` read `null` over both of this tree's block-form + * declarations — `scripts/symbol-anchors.mjs` (a slash-star opener) and + * `scripts/release-verify-npm.mjs` (a star-prefixed line inside a docblock) — + * and both families sat in the residue's `undetermined` bucket with `hints=0`, + * the exact bucket the marker exists to split them out of. A dropped + * declaration printed IDENTICALLY to one nobody ever wrote, which is the one + * shape neither side will go and check: its author believes they explained the + * emptiness, its reader believes nobody ever did. + * + * ## Two KINDS of form, because they answer the wholeness question differently + * + * `line` — `//` and `#`. Each line is its OWN comment. The line under a + * declaration is a SEPARATE comment, and nothing in the text says whether it + * belongs to the reason or is an unrelated remark. So the declaration owns + * its line, and a comment line under it is refused as a CUT (#18422) — + * unchanged here, byte for byte, and `populationReasonCutRefusal`'s text + * still states it. + * + * `block` — a slash-star opener (one star or two) and the star-prefixed + * CONTINUATION lines inside it. The whole comment is ONE comment and its + * internal newlines are FORMATTING, not comment boundaries, so a reason that + * wraps is decidable rather than a guess: inside a block, the next + * star-prefixed line is a continuation BY CONSTRUCTION. What the block form + * therefore cannot do is cross any of the three places a block-comment author + * signals a new thought — each one pinned in the self-test: + * + * the CLOSING delimiter the comment is over + * a blank star-only line the author ended the paragraph + * the next star-@tag line the docblock's tag section begins + * + * plus the two the line forms already carry: another `dispatch-gates:` key, + * and EOF. A line INSIDE the block carrying text with no star prefix is none + * of the five — it is reason text this walk cannot read — so it is recorded + * as a CUT and refused, the same direction and for the same reason the line + * forms are refused in. + * + * ⚠️ The residual asymmetry, named here rather than left to be found: a second + * sentence written on the very next star line, with no blank line between it + * and the declaration, IS swallowed into the reason. That is OVER-inclusion, + * and it reaches the seat as a reason that says too much — visible on the row. + * The truncation #18422 refused is UNDER-inclusion, and reaches the seat as a + * sentence that merely ends oddly — invisible. The block idiom's own paragraph + * break is the text that separates the two, and it is what a block-comment + * author already writes; the line forms have no such text, which is why they + * refuse instead. + * + * ## One alternation, one roster + * + * The form alternation is the half a reader has to be able to change in ONE + * place: a form this roster does not list parses as nothing at all, silently, + * and widening it in one builder while the other kept its own copy would fix + * half the markers and leave the other half reading exactly as they did. Both + * builders below and the continuation reading come out of this roster. Group 1 + * of every pattern is the form, because the reading has to know a `#` line is + * not continuing a `//` one — and now also which KIND of form it is reading. + * + * ⚠️ Order is load-bearing: the two-star opener is listed BEFORE the one-star + * opener, so a `/**` docblock opener captures whole instead of matching `/*` + * and stranding its second star in front of the key. + * + * ⛔ Every example in the docblocks below is written with the docblock's OWN + * star prefix AND the example's own form — two openers on one line. That is + * not decoration: exactly ONE opener is what this grammar and the + * unparsed-form probe both accept, so a two-opener line is documentation to + * both of them and can never be read as a live declaration about this file. + */ +export const MARKER_COMMENT_FORMS = Object.freeze([ + Object.freeze({ label: '//', kind: 'line', open: '\\/\\/' }), + Object.freeze({ label: '#', kind: 'line', open: '#' }), + Object.freeze({ label: '/**', kind: 'block', open: '\\/\\*\\*' }), + Object.freeze({ label: '/*', kind: 'block', open: '\\/\\*' }), + Object.freeze({ label: '*', kind: 'block', open: '\\*' }), +]); + +/** + * The marker keys whose files are NOT JavaScript, and the comment forms those + * files really have (#18662). + * + * The roster above is the set of idioms a `dispatch-gates:` declaration may be + * written in across this tree; it is not a claim that every one of them is a + * comment in every LANGUAGE a declaration is read out of. `no-check-families` + * is read out of workflow YAML, where `#` is the only comment there is: a + * `//` or slash-star line in a workflow is document content, and reading a + * declaration off one would be reading it off text the workflow's own parser + * never treats as a remark. So the alternation this key's pattern is built + * from is the roster FILTERED to the forms its language has — never a second + * roster, and never a second pattern. + * + * A key absent from this table gets the whole roster, which is the answer for + * every marker read out of a JavaScript or shell source. + * + * ⛔ This table may only ever NARROW: it names a subset of the labels in + * `MARKER_COMMENT_FORMS`, and a label that is not one of them throws below + * rather than silently contributing nothing to the alternation — a form set + * that quietly emptied would make every declaration of that key parse as + * nothing at all, which is precisely the #18661 failure one level up. + */ +export const MARKER_KEY_FORMS = Object.freeze({ + 'no-check-families': Object.freeze(['#']), +}); + +/** + * The REASON-TAIL markers — the keys whose grammar is head + `-- `, + * with nothing between the key and the separator (#18662). + * + * The three population keys were the whole roster until this card, and the + * builder is still spelled `populationMarkerPattern` because `population*` is + * what this machinery is CALLED everywhere it is exported + * (`populationReasonContinuation`, `populationReasonCutRefusal`) and a rename + * would move the names a reader greps for without moving a single behaviour. + * ⚠️ The ROSTER, not the name, is the authority on which keys it serves: + * `no-check-families` has exactly this grammar and is built here rather than + * out of the fourth hand-written copy of the pattern it used to be — which is + * what left its reason outside the #18422 wholeness reading for two cards. + */ +export const REASON_TAIL_MARKER_KEYS = Object.freeze([...POPULATION_MARKER_KEYS, 'no-check-families']); + +/** + * The PATH-LIST markers' grammar, in one spelling, for the same reason the + * reason-only markers have one (#18673). + * + * A path-list declaration names files BEFORE its reason — + * `dispatch-gates: [ ...] -- ` — so it cannot come + * out of `populationMarkerPattern`, whose tail is a bare reason. What it CAN + * share is the head: the indent, the comment form and the key. Group 1 is the + * comment form, group 2 the path list, group 3 the reason. + * + * The `--` is SPACE-delimited on both sides here (never `[ \t]*`), because a + * path may legitimately contain one and a bare separator would split it. + * + * ⚠️ `local-env` shares this grammar with a list of ENVIRONMENT NAMES in the + * path position (#20278). The grammar is LIST-then-reason and never reads what + * the list holds; each key's own reader does (`declaredLocalEnv` refuses a + * token that is not an env name). The roster keeps its name for the reason + * `REASON_TAIL_MARKER_KEYS` states: a rename would move what a reader greps for + * without moving a behaviour, and a third builder would be the copy of this + * pattern the refusal below forbids. + */ +export const PATH_LIST_MARKER_KEYS = Object.freeze(['inherited-population', 'self-test-reads', 'local-env']); + +/** + * The WHOLE-TREE RESIDUE ledger (#15312) — the families whose own source sweeps + * the repository root, that this derivation can place on NO card, and that are + * deliberately not in the whole-tree bucket above. + * + * ## The defect + * + * `check:driver-memory-census` counts a `vi.mock` of a frozen driver package as + * a module binding wherever in the tree it is written, and names no path + * literal anywhere in its source — so this derivation scored it `undetermined` + * for every card and no `--commands` harvest could contain it. A seat derived + * its family, ran 57 of them with 53 green, and CI's lint job then failed on + * the one gate the derivation could not offer. The gate was right; what was + * missing was a way for a seat to be TOLD about it before the push, which is + * exactly what the whole-tree channel above exists to be. It now declares. + * + * ⚠️ Fixing that one gate is not the fix. The question worth answering is + * "which gates does CI run that no derivation can name", mechanically, so the + * NEXT one reds here rather than in CI a cycle later. This table and the live + * case in `--self-test` are that answer for the class the card names. + * + * ## The population, and why the closure subtraction is load-bearing + * + * A family is IN it when three things hold at once: its own source carries a + * recognised repo-root walk (`repoRootWalkSpelling` — the same predicate that + * vouches for a whole-tree declaration), it declares NEITHER marker, and no + * path in the tree OUTSIDE the gate's own file closure can place it. That last + * subtraction is not a detail: every family matches the card that edits the + * gate itself, through the identity key, so counting that as "the derivation + * can name it" would score this whole population green on a card nobody files. + * + * ## Why this class and not "every gate CI runs" + * + * The wider question was MEASURED for this card rather than guessed at: on + * cd1f8ee96, of the 186 gate scripts CI runs, 44 are named by no derivation at + * all. The bulk of them are SUBTREE walkers — seeded at `packages`, `apps`, + * `examples` through a runtime constant no source scan reads — whose remedy is + * the ordinary `ROOT_DIR_WATCH_HINTS` declaration, one per-gate judgement each. + * A table that swallowed all 44 would be forty rows nobody revisits, which is + * the shape `artifactRosterLines` refuses for its own members. So this holds + * the class the card named, the rest is a filed follow-up, and the boundary is + * STATED here rather than left to be inferred from what the table happens to + * contain. + * + * ## Maintaining this table + * + * A member this table does not list reds `check:pm-dispatch-gates`, and there + * are exactly two honest repairs. If the gate really does read the whole tree, + * give it the `whole-tree-population` marker: it then leaves this population by + * DECLARING, which is the outcome this table exists to push toward. If it does + * not, add a row saying what it reads INSTEAD, so a reader can check the claim + * against the gate. ⛔ Never a row that only says "not whole-tree" — that is + * the reason-less opt-out both markers refuse, and it reads exactly like a + * placeholder nobody will revisit. A listed family that stops being a member + * reds too: a stale exclusion is an exclusion nobody is measuring any more. + */ +export const ROOT_WALK_RESIDUE_LEDGER = [ + [ + 'scripts/check-console-intercept-disarm.mjs', + 'its `scan(REPO_ROOT)` walks `workspacePackageDirs(root)` — every workspace PACKAGE ROOT\'s package.json and ' + + 'vitest.config.*, read off pnpm-workspace.yaml. That is workspace-wide but it is not every file: a new test ' + + 'file under an existing package does not move it, a new PACKAGE does. Declaring the whole tree would put it ' + + 'on every card on a population it does not read.', + ], + [ + 'scripts/check-console-intercept-disarm.mjs --self-test', + 'the same gate file, reached through its self-test invocation; the reading above is the whole of it.', + ], + [ + 'scripts/check-skill-frame-freshness.mjs --self-test', + 'lint.yml runs the SELF-TEST HALF and never the scan — its step is named that, and the step comment states why ' + + '(the pnpm script would drag the scan in with it). The repo-root default parameter belongs to the scan half ' + + 'CI does not schedule, so a whole-tree row for this family would advertise work no workflow performs.', + ], + // ⚖️ `check:pm-half-states` LEFT this population under #16904's D2 and its row + // is gone with it — a listed family that stops being a member reds here, and + // a stale exclusion is an exclusion nobody measures. It is now placeable BY + // PATH, and truthfully: `check-half-states.mjs`'s self-test reads two sibling + // sources as program text — `scripts/pm/sweep-stale-finding.mjs` and + // `scripts/pm/check-prior-rulings.mjs` — to pin that the sweep still ALIASES + // the one stale-`finding` screen rather than re-growing a copy, and that the + // prior-ruling line's writer still prints the key the patrol greps. So a card + // touching either sibling really does owe this gate, which is the opposite of + // the #15753 placement this row was written about: that one came from a + // noise-floor constant and said the opposite of what the constant declares, + // while these two literals are exactly what the self-test reads. + // ⚖️ `scripts/symbol-anchors.mjs --self-test` LEFT this population under + // #18661 and its row is gone with it — a listed family that stops being a + // member reds here, and a stale exclusion is an exclusion nobody measures. + // It left through the OUTCOME this table exists to push toward: it DECLARES. + // The declaration was there the whole time — a `no-path-population` marker + // written in that file's own block-comment idiom, in a form the marker + // grammar did not list, so it read back `null` and the family arrived here + // looking like a gate whose emptiness nobody had examined. This row was the + // price of that silence: a hand-written exclusion, carrying by hand the + // reading the gate's own source already carried, for a family that was never + // a member of this population at all. Widening the form set (see + // `MARKER_COMMENT_FORMS`) is what let the declaration be read; deleting the + // row is the other half of the same landing. +]; + +/** + * Every COMPOUND-name declaration the self-test anchor matches, tree-wide, and + * whether that match is what the anchor MEANT. + * + * ## The defect this ledger answers + * + * `SELF_TEST_DECL` decides "this is a self-test" from the declaration's NAME. + * A name is not a role, so the anchor also fires on production code whose name + * merely spells self-test — and when it does, `maskSelfTests` blanks that + * production body, and `extractWatchHints` never sees the paths in it. The + * failure direction is SILENCE: a hint that is never extracted cannot be + * missed, so a gate family quietly stops being derived for a file it really + * opens. + * + * The specimen that opened this is `maskSelfTests` itself, six lines above: + * `mask` + `Self` + `Test` + `s` matches, so this module's masker blanks its own + * body whenever the module scans itself. + * + * ## The census, re-derived on this tree + * + * 275 code-position matches over the tracked JS/TS corpus. 244 are the bare + * `selfTest`; the remaining 31 carry compound names over 28 distinct spellings, + * and they are the rows below. Twenty are genuine self-test batteries — the + * anchor firing on them is the anchor working. ELEVEN are production code: + * + * scripts/check-self-test-wired.mjs carriesSelfTest + * scripts/check-self-test-workflow-commands.mjs runSelfTest + * scripts/check-step-collectors.mjs selfTestTargets + * scripts/check-step-collectors.mjs selfTestDiscoveries + * scripts/measure-durability-swallow-family.mjs selfTestMode + * scripts/measure-self-test-floor.mjs selfTestDefs + * scripts/pm/dispatch-gates.mjs selfTestOnlyCallables + * scripts/pm/dispatch-gates.mjs maskSelfTests + * scripts/pm/dispatch-gates.mjs selfTestCaseLines + * scripts/pm/dispatch-gates.mjs selfTestOnlyInvocation + * scripts/pm/dispatch-gates.mjs declaredSelfTestReads + * + * Every one of them is a gate that REASONS ABOUT self-tests, which is why they + * cluster: a tool that finds, spawns, counts or masks other scripts' self-tests + * names its functions after the thing it handles, and the anchor cannot tell + * "runs a self-test" from "is one". + * + * ## What it costs today: nothing, MEASURED, and that is the whole point + * + * Neutralising each of the ten one at a time and re-extracting moves no hint + * in any of the six files. The claim is therefore live rather than recalled — + * and it is exactly the kind of claim that stops being true without anything + * going red, which is what the pin in this module's self-test exists to catch. + * + * The same measurement, redone over the table's current twenty genuine rows, + * is still NOT zero, and that asymmetry is what makes the classification + * load-bearing rather than decorative: `fixtureSelfTest` drops + * `packages/spec/spec-changes.json` and `prePushIsArmedSelfTest` drops + * `.githooks/pre-push`, both fixture paths in `scripts/check-regen-pending.mjs`, + * both correctly refused. So "no + * compound-name match may contribute a hint" is FALSE as a blanket invariant; + * the invariant holds only over the accidental half, and only a classification + * can name that half. + * + * ## ⛔ Why the anchor is NOT narrowed, and why nothing is special-cased + * + * The obvious repairs were both refuted by the census rather than judged: + * + * - **Narrowing the name pattern is impossible.** `runSelfTest` is a GENUINE + * entry point in `scripts/check-turbo-task-graph.mjs`, reached only from + * that file's `--self-test` guard, and ACCIDENTAL in + * `scripts/check-self-test-workflow-commands.mjs`, where it is exported and + * spawns other scripts' self-tests from the gate body. One spelling, both + * classes. No predicate over the name can separate them, so any narrowing + * that excludes the accidental one also unmasks a real self-test battery and + * readmits its fixture paths as hints — the fabricated-lead family this + * whole masker exists to refuse, traded for a silence that costs nothing. + * - **Special-casing this module's own path fixes four rows of ten.** The + * other six live in five other files, so the objection that a rename + * "fixes one instance and leaves the class" applies to it too, one file + * wider — and it would make the tool's self-scan differ from every other + * scan, which is a hazard of its own. + * + * ⇒ What ships is neither. The anchor keeps firing on all 31, the mask keeps + * blanking all 31, and the cost of the eleven accidental ones is MEASURED on + * every run instead of asserted in prose. Silence was the defect; the remedy is + * noise on the day it starts costing something. + * + * ## Maintaining this table + * + * A compound-name declaration this table does not list reds + * `check:pm-dispatch-gates`. Classify it and add a row: `accidental: false` if + * it is a self-test battery (its fixtures SHOULD be masked away), `true` if it + * is production code the anchor caught by accident — in which case the pin then + * measures, and keeps measuring, that masking it costs no hint. ⛔ Do not + * "repair" a red by renaming the function to dodge the anchor: the row is the + * record, and the next accidental name is the one nobody will notice. + * + * The TOTAL / GENUINE / ACCIDENTAL counts stated above are pinned the same + * way (#15310): `--self-test` computes them fresh from this table and checks + * the docblock's own prose against that computation, never against a second + * hand-typed constant. Prose that drifts from the table reds there, instead + * of drifting further unnoticed the way it had — twice — by the time #15310 + * measured it. + */ +export const COMPOUND_ANCHOR_LEDGER = [ + ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'specSelfTest', false], + ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'fieldRuleSelfTest', false], + ['scripts/audits/14744-before-update-per-row-value-census.mjs', 'runSelfTest', false], + ['scripts/check-comment-mask-corpus.mjs', 'runSelfTestCases', false], + ['scripts/check-doc-authoring.mjs', 'selfTestRule3', false], + ['scripts/check-doc-authoring.mjs', 'selfTestPackagesProse', false], + ['scripts/check-durability-degradation-log-level.mjs', 'checkSelfTestFloor', false], + ['scripts/check-durability-degradation-log-level.mjs', 'selfTestReadSeams', false], + ['scripts/check-platform-checklist.mjs', 'selfTestTrapVocabulary', false], + ['scripts/check-platform-checklist.mjs', 'selfTestProvisioningUse', false], + ['scripts/check-platform-checklist.mjs', 'selfTestUnreferencedRecipes', false], + ['scripts/check-platform-checklist.mjs', 'selfTestMetaCallSpelling', false], + ['scripts/check-platform-checklist.mjs', 'selfTestLineCitationBinding', false], + ['scripts/check-platform-checklist.mjs', 'selfTestSymbolAnchors', false], + ['scripts/check-platform-checklist.mjs', 'selfTestPlannedStatus', false], + ['scripts/check-regen-pending.mjs', 'fixtureSelfTest', false], + ['scripts/check-regen-pending.mjs', 'prePushIsArmedSelfTest', false], + ['scripts/check-regen-pending.mjs', 'decisionTableSelfTest', false], + ['scripts/check-turbo-task-graph.mjs', 'runSelfTest', false], + ['scripts/check-workspace-manifest-cycles.mjs', 'runSelfTest', false], + ['scripts/check-self-test-wired.mjs', 'carriesSelfTest', true], + ['scripts/check-self-test-workflow-commands.mjs', 'runSelfTest', true], + ['scripts/check-step-collectors.mjs', 'selfTestTargets', true], + ['scripts/check-step-collectors.mjs', 'selfTestDiscoveries', true], + ['scripts/measure-durability-swallow-family.mjs', 'selfTestMode', true], + ['scripts/measure-self-test-floor.mjs', 'selfTestDefs', true], + ['scripts/pm/dispatch-gates.mjs', 'selfTestOnlyCallables', true], + ['scripts/pm/dispatch-gates.mjs', 'maskSelfTests', true], + ['scripts/pm/dispatch-gates.mjs', 'selfTestCaseLines', true], + ['scripts/pm/dispatch-gates.mjs', 'selfTestOnlyInvocation', true], + ['scripts/pm/dispatch-gates.mjs', 'declaredSelfTestReads', true], +]; + +/** + * The governed reads this tree carries, pinned — a FLOOR the next card lowers, + * never a list this derivation reads (#18673). + * + * Three rows at the landing of this card, measured by `governedReadCensus`: + * + * scripts/check-commit-card-trailers.mjs .claude/agents/os-dev.md undeclared + * scripts/pm/check-expected-skips.mjs .claude/skills/pm-dispatch/SKILL.md DECLARED + * scripts/pm/check-settings-deny-roster.mjs .claude/settings.json undeclared + * + * ## Why the two undeclared rows are not declared HERE + * + * Both are already MATCHED for the file they read, through a key this card did + * not add: each spells its governed path in its MODULE BODY as well as in its + * self-test, so `extractWatchHints` sees it and the derivation names the family + * for a card touching it — measured, both ways, at this landing. They cost + * nothing today, and declaring them would widen this PR onto two gates it was + * not dispatched to. They are listed so the next reader inherits the + * measurement rather than re-deriving it. + * + * ## What each column makes fail + * + * A row whose READ disappears reds (the census no longer finds it) — which is + * what makes the pin on this class unsatisfiable by deleting the read. A NEW + * governed read reds until it is classified here. A row whose `declared` flag + * changes reds, in both directions: a declaration added is a floor to lower, + * a declaration deleted is this card's defect coming back. + * + * ## The fourth row: a gate whose SUBJECT is a published skill + * + * `scripts/check-skill-top-level-keys.mjs` reconciles the top-level key + * enumeration in the published platform skill against the stack schema, so + * the governed file is its input, not a fixture; it spells that path in its + * module body and is MATCHED for it the same way the two undeclared rows + * above are, so `declared` is false for the same reason. + */ +export const GOVERNED_READ_FLOOR = Object.freeze([ + Object.freeze({ script: 'scripts/check-commit-card-trailers.mjs', file: '.claude/agents/os-dev.md', declared: false }), + Object.freeze({ + script: 'scripts/check-skill-top-level-keys.mjs', + file: 'skills/objectstack-platform/SKILL.md', + declared: false, + }), + Object.freeze({ + script: 'scripts/pm/check-expected-skips.mjs', + file: '.claude/skills/pm-dispatch/SKILL.md', + declared: true, + }), + Object.freeze({ + script: 'scripts/pm/check-settings-deny-roster.mjs', + file: '.claude/settings.json', + declared: false, + }), +]); + +/** + * ⛔ SHRINK-ONLY. The gates whose declared population is a bare top-level word + * the tree HAS, and which have not declared the subtree spelling for it. + * + * It is a DEBT list, not an exception list, and the same property makes it safe + * that makes `KNOWN_IMPORT_UNSAFE` safe (#10665): every entry has one remedy — + * declare the subtree spelling beside the literal, the `ROOT_DIR_WATCH_HINTS` + * idiom — and no entry records a judgement anyone has to re-make later. There + * is no supported route in the other direction: a family this rule newly + * reaches is a FAILURE with that one remedy, never a new line in here. An entry + * whose gate has since taken the escape fails as STALE and names itself, which + * is what stops the list from rotting into an allowlist nobody re-reads. + * + * Both halves are asserted in this file's self-test, against the live tree, + * and `check:pm-dispatch-gates` runs that self-test on every pull request. So a + * gate written tomorrow that spells a bare root word fails at AUTHORING time + * rather than landing invisible — which is the half of this class the six + * historical instances could not fix, because each of them was archaeology. + * + * ⚠️ Spelling rule for a new row: it must not become a watch hint of THIS file. + * `extractWatchHints` reads any quoted span carrying a separator, so a family + * keyed by a direct script path (`node scripts/check-x.mjs`) would enter this + * file's own declared population as a path it does not read — the same trap + * `DEFAULT_BASE_REF` is assembled in two halves to avoid. Spell such a row so + * it carries no separator, or join it at runtime. A self-test case below holds + * this, so the rule fails rather than needing to be remembered. + */ +export const ESCAPABLE_LITERAL_LEDGER_ROWS = Object.freeze([ +]); + +/** + * Gates that fire on what a change IS, keyed by a mechanically-detectable + * convention. Everything else in this script is derived at runtime and lists + * nothing; this table is the one exception, and it is bounded on purpose. + * + * ## Why these cannot be derived like the rest + * + * The path derivation matches a gate when the gate's own source names a + * directory that covers your file. Every gate here computes its population + * instead of naming it, so no source carries a literal to match: + * + * - the two type-check gates — one lints a glob set that lives in the shared + * ESLint config, the other walks the workspace members — sit permanently + * in the "undetermined" bucket; + * - the three test-file RATCHETS (`check:query-options-erasure`, + * `check:engine-double-contract`, `check:where-matcher`) each walk the tree + * for `*.test.*` files and reconcile the count against a baseline JSON. For + * two of the three, what the source names is that baseline — an artifact + * roster, never the population — so those two score `silent` for every card + * in the tree, and they HAVE hints, so the "undetermined" bucket never sees + * them either. Before this entry named them they were printed in NEITHER + * half of the output for every card in the tree. + * + * ⛔ Their hint sets are NOT transcribed here, and a freshly re-measured + * copy must not be put back. The copy that used to sit here listed all + * three sets and read as measured; by the time anyone re-derived it, + * exactly one of its five claims — the `check:engine-double-contract` row — + * was still true. It named a git ref as a hint for two of the rows, a class + * `isNonPathNamespace` refuses (this file's own self-test pins that + * refusal); it gave `check:query-options-erasure` a one-hint set its source + * has since outgrown; and it drew the conclusion below from both. Not one + * of those drifts touched THIS file, so nothing here could have reported + * them. `--residue` prints every family's live `names:` set and re-derives + * it on every run: that is the authority for this question, and a reader + * who wants the sets should run it rather than trust a paragraph. + * + * ⚠ `check:where-matcher` is the exception to the paragraph above, and it + * stays in this entry anyway. Since #13231 its source declares its + * `*.test.ts` population as a literal, so the ORDINARY path derivation + * MATCHES it for a test file under `packages/` and it is no longer silent. + * That is the shape the `check:cross-package-test-inputs` measurement in + * the deletion criterion below describes, and it is answered the same way: + * the declaration is set-equal to that gate's own walk, which is rooted at + * `packages/`, so it reaches no test file outside that root, while the KIND + * reaches every one — and the KIND reaches a card dispatched BEFORE its + * code exists, which no path derivation can. Two routes to one gate is + * redundancy, not a defect; the KIND is the load-bearing one. That gate's + * own source says the same thing in the docblock above its literal, so + * neither side of the pair asserts it alone. + * + * Every membership claim in the two paragraphs above is re-derived in + * `--self-test` against the live tree rather than restated here, so a tree + * that moves one turns a case RED instead of leaving this prose quietly + * false — which is the failure this entry has now paid for twice; + * - `check:i18n` walks `packages/` at runtime for files NAMED + * `i18n-extract.config.ts` and re-extracts each owning package's bundles. + * Its source names only three hints (measured, post-#9144): the shared + * walk module (SURFACE_MODULE) and the two metadata-registry coupling + * constants below — none of them the OWNING-PACKAGE population this entry + * answers for. So it still matches nothing on an ordinary object/field + * edit AND, having hints, never reaches the "undetermined" bucket either: + * before this entry existed, an edit to + * `packages/services/service-messaging/src/objects/` — which regenerates + * that package's four bundles — printed the gate in NEITHER half of the + * output. A gate the derivation cannot mention at all is the one shape + * this script must not produce; it cost a PR a CI round. + * + * No per-card gate list derived from paths can ever name these, however the + * derivation improves. + * + * ## Why a named table and not a wider heuristic + * + * The tempting generalisation — scan every discovered check script for + * `*.test.ts`-shaped literals and call those the test-sensitive gates — was + * measured against this tree and names 22 families, because a script's source + * mentions test paths in its fixtures, its self-test and its comments. + * "Mentions a test file" is not "counts test files", and 22 leads is the same + * as none. So the pair is written down, and the cost of writing it down is paid + * back by the two properties below. + * + * ## Why the two ratchets below joined this entry (#8632) + * + * `check:engine-double-contract` and `check:where-matcher` were handed to the + * PM's judgment in this file's closing prose instead of being derived. Three + * measured instances, all of them CI rounds, say that boundary was in the wrong + * place — and the deciding evidence is not the incidents but a structural + * identity with an entry that was already here. + * + * All three ratchets discover their population the same way: a walk collecting + * `*.test.*` files (`scripts/check-engine-double-contract.mjs`, `walk` at ~line + * 342, `/\.(test|spec)\.(ts|tsx|mts)$/`; `scripts/check-where-matcher- + * conformance.mjs`, corpus walk at ~line 562, `/\.test\.ts$/`), reconciled + * against a shrink-only baseline. `check:query-options-erasure` has sat in this + * entry for exactly that reason. Naming one of three and calling the other two a + * judgment call was an inconsistency in this table, not a considered line. + * + * The noise objection recorded on the card — a path-level trigger fires on test + * files that contain no fake engine at all — is real and is answered by what + * these gates cost to run rather than by narrowing the trigger. Each is a + * whole-tree shrink-only ratchet: one invocation answers for the entire tree, + * needs no build, and prints the offending file and line when it fails. A seat + * that runs one needlessly loses seconds; a seat that is never prompted loses a + * CI round, which is what all three instances did. The trigger deliberately does + * NOT read the file's contents to confirm a double is present: a card is + * dispatched BEFORE its code exists, so the double the gate will object to is + * usually not on disk at derivation time — the second instance added one to a + * file that already had one, the first added the file itself. + * + * This is the shape the "22 leads" note rejects a heuristic for, and it survives + * that objection because the trigger is the gates' own population test, mirrored + * (`isTestFilePath`), not a guess at which scripts look test-flavoured. + * + * ## What the two i18n entries still refuse to list + * + * Neither `matches` enumerates anything. The first walks for the packages that + * own a bundle, the second for the tree's metadata form modules, and both walks + * are the GATE'S OWN, imported from `scripts/i18n-bundle-surface.mjs` rather + * than mirrored here. That import is the fix for a defect this file used to + * carry in its own comment: `findI18nBundlePackages` was a hand-written copy + * described as mirroring `findConfigs` "exactly", which is a second contract + * with no way to report the day it stopped agreeing. What is written down here + * is the KIND, not its population, so a tenth package growing a bundle — or an + * eighteenth form module — is matched by the next run with nothing to update. + * + * ## Why the SECOND i18n entry exists (#9116) + * + * The owning-package entry answers for one of a bundle's two producers. The + * `objects` half is enumerated by the config's own package, so the owning + * package is the trigger surface. The `metadataForms` half is registry-driven + * and identical for every stack, so exactly ONE package commits that baseline + * (`platform-objects`; every other config passes `--no-metadata-forms`) while + * its source sits in `packages/spec`, which owns no extract config and which + * the gate's walk never reaches. + * + * Measured, and paid for once: PR #9113 added two form entries in + * `packages/spec/src/data/`, four `platform-objects` metadata-form bundles + * moved, `check:i18n` reddened on CI, and the dev's diff-derived gate union + * could not have named the family — the path derivation misses it for the + * reason stated above, and the owning-package entry does not cover + * `packages/spec`. Cost: one CI round trip plus a patch commit. The invariant + * the card states is the one this entry restores — a gate a diff can move must + * be derivable FROM that diff; `undetermined` is an honest unknown, not a + * standing blind spot on a known edge. + * + * Its applicability is read, never assumed: `metadataFormsSurfaceIsExtracted` + * asks the configs' own documented flags whether any package still commits that + * baseline. The day the last one opts out, no form module can move a committed + * bundle and this entry stops firing on its own. + * + * ## Why there is no THIRD i18n entry, for the type-registry edge (#9144) + * + * `walkMetadataForms` has a second edge the SECOND entry above does not reach: + * `DEFAULT_METADATA_TYPE_REGISTRY` (packages/spec/src/kernel/metadata-plugin. + * zod.ts) supplies `metadataForms..label`/`.description` for EVERY + * registry entry, including form-less types, and `METADATA_FORM_REGISTRY` + * itself (packages/spec/src/system/metadata-form-registry.ts, the map, not + * the `*.form.ts` leaves it points at) decides which types get section/field + * labels at all. Editing either moves the same bundles PR #9113 paid for — + * but unlike the `.form.ts` leaves, neither file carries a filename the + * `.form.ts` convention (or any convention) distinguishes, so a KIND entry + * here would need to invent one for exactly two files. + * + * That is not the same shape as the two entries above: this is not a + * runtime-enumerated population at all, it is two SPECIFIC, KNOWN files — + * the shape `SURFACE_MODULE` and `check-type-check-coverage.mjs`'s + * `ROOT_PROGRAM_COUPLED_SCRIPT` already use. So it is closed there instead: + * `check-i18n-bundles.mjs` declares both paths as bare module-body coupling + * constants (`METADATA_TYPE_REGISTRY_MODULE` / `METADATA_FORM_REGISTRY_ + * MODULE`), which the ORDINARY path-literal derivation now reads directly off + * that gate's own source — no `CHANGE_KIND_GATES` entry, no `matches` + * function, nothing here to keep in sync. See that pair's doc comment in + * check-i18n-bundles.mjs for the full reasoning, and this file's own + * self-test for the live pins that keep the constants honest as the coupling + * they are: manual, per-file, and silently rottable if nothing watched it. + * + * ## Why the ROOT-program entry does not weaken the ledger's discipline (#9873) + * + * `check-type-check-coverage.mjs` states, in its own source, the very thesis + * the card for this entry was filed to argue: "The root program is everything + * outside packages/apps/examples, so no list here can ever be complete -- add a + * constant when a coupling has actually been measured, the way this one was." + * That is a deliberate policy, written by the gate's author before the card + * existed, and the entry below is a proposal against it. So it owes an answer + * rather than a shrug. + * + * The answer is that the two lists answer different questions, and only one of + * them is a measurement. + * + * The gate's ledger records MAGNITUDE. `ROOT_PROGRAM_COUPLED_SCRIPT` does not + * merely say "this file is in the program" — it carries a measured claim, that + * the file accounts for 29 of that entry's 80 errors, and the ledger note + * spends that number. A rule that manufactured such constants automatically + * really would register couplings nobody had measured, and the refusal is + * right. This entry adds no constant, moves no count and asserts no magnitude: + * the ledger keeps exactly the one measured coupling it has today, with every + * reference to it intact. + * + * This table records RELEVANCE — which gate a seat is told to run before it + * pushes. That claim needs no measurement to be true, because it is already + * settled by a file the repo maintains for another purpose entirely: a path is + * in the root program when the root tsconfig does not exclude it. Nothing here + * is kept in sync by hand, so there is no second contract to rot, and the + * measured-couplings rule the gate states about ITS list is untouched. + * + * What leaving the two questions merged cost, once, in the expensive direction: + * PR #9853 added a single file under a directory this entry now covers, derived + * its gate union with this script at final head, ran both gates the run named + * and reported them green — then CI failed the type-debt ratchet with 19 new + * errors from that one file. The gate worked exactly as designed. Nobody could + * know to run it, and the repair most available at that point is the one the + * gate's own text calls maintainer-only. + * + * ⚠ Its known limit, stated here rather than discovered later: `exclude` drops + * files only from tsc's INITIAL WALK, so sources under an excluded directory + * that a root script IMPORTS are pulled into the program anyway — the ledger + * note for this entry records 4 of its 80 errors arriving exactly that way, + * from the showcase example. A path-shaped trigger cannot see an import graph, + * so a card editing only such a file is still not sent here. That is + * deliberately NOT closed on this card: the direction is the safe one (this + * entry under-covers rather than over-covers), and closing it means resolving + * the program INCLUDING imports, which is a different tool than a path + * predicate and a different card's scope. + * + * ## How these entries stay honest + * + * - Every `name` here is resolved against the families actually discovered in + * the workflows at runtime. A gate that is renamed, retired or dropped from + * 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 (the two `./packages` globs, one flat and + * one nested) to THIS file's own hint set as hints. That spelling is now + * LOAD-BEARING rather than + * merely tidy: it used to be inert as well, because `hintCovers` refused a + * hint that COLLAPSED to a bare top-level directory, and since #9626 that + * refusal reads the hint as written — `packages/*` carries a separator, so + * quoting it here would make this file's own prose match every card under + * `packages/`. Leave the filter values unquoted. A gate list that fabricates + * hints out of its own explanations is the failure this whole script is + * written against, and this is the one place in the tree where the trade is + * live rather than hypothetical. + * - Every `name` here is checked against the LIVE workflows by the self-test, + * not only by the run that happens to print it. The STALE branch reports rot + * to whoever is looking at the output; the self-test case makes the same rot + * fail CI, because this table's names are the one enumerable list in the file + * and an enumerable list is one a guard can hold. + * - 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. For the + * three ratchets that means a literal naming their POPULATION — the baseline + * path each already carries is their own output, and a card editing a + * baseline matches through it today without making the gate derivable for + * anybody else. + * + * ⚠ "A literal naming their POPULATION" is the whole of that criterion, + * and one gate in this kind now fails it while READING as satisfied. + * Measured on this tree (#11199, the day PR #12300 landed): of the 2773 + * tracked test files, the hint route names `check:cross-package-test- + * inputs` for 2760 of them — 99.5%, against 0–3.3% for its five siblings + * in this same kind — because #12300 taught `hintCovers` to read a glob in + * a non-final segment and the deep `packages` glob for TypeScript files + * came back to life. (That glob is not spelled here: its own wildcard + * closes a block comment.) The hint + * is neither this gate's population nor even its own literal: it is + * INHERITED from the declaration table the gate imports, where it is ONE + * package's declared turbo `inputs` glob (`@objectstack/core`'s, wide + * because a single pin test there walks the whole repo with `git + * ls-files`). It is a row the gate JUDGES, not a population the gate + * DECLARES — so it narrows the day that package's declaration narrows, + * which is the direction the gate's own repair advice pushes. And even at + * 99.5% it reaches no test file outside `packages/**` (10 tracked today, + * all under `examples/**`), none with a `.tsx` suffix (3 today, all in + * client-react), and none under `apps/**` the day one arrives — while + * the KIND reaches every one of them, because the trigger really is "a + * test file's content changed, full stop". Both routes are kept (two + * routes to one gate is redundancy, not a defect); the KIND is the + * load-bearing one. The residue and the inheritance are pinned in the + * self-test, so the next reader re-points a red case instead of + * re-deriving this paragraph. + * - i18n entry: when `check-i18n-bundles.mjs` stops discovering its targets + * at runtime and names its POPULATION in its own source — a literal each + * owning package path starts with — the path half matches and this entry + * is redundant. Growing more prerequisite paths does not qualify; that is + * what it already has. + * - metadata-form entry: when every extract config passes + * `--no-metadata-forms`, no form module can move a committed bundle. That + * 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. + * - status entry: on the same criterion as the error-code entry. The + * conformance gate's source would have to declare the population it walks + * in a form this derivation can read, and that means its bare-root ledger + * row moving off REFUSE-WIDE, which takes a ruling. ⛔ Growing the gate's + * derivation rules does NOT qualify, and ⛔ neither does this predicate + * going quiet on a card, for the reason given above. + * - 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 + * this entry is redundant. Growing more measured coupling constants does + * NOT qualify: each names one file, and this entry exists for the files + * that have no constant yet, which is every new one. + * - gate-script entry: when BOTH gates it names declare the population they + * judge in a form this derivation can read, the ordinary path match names + * them and this entry is redundant. Neither can today, and the reasons + * differ, so the criterion is met only when both move: + * `bare-root-worklist` walks every family's own files and declares nothing + * (deliberately — recognising its species needs a heuristic over constant + * NAMES, and #10705 refused to put one on the path that derives every PR's + * gate list, which is why this entry names the gate rather than importing + * its verdicts); `check:pm-dispatch-gates` declares three tracked FILES, + * an artifact roster this tool itself flags as "the shape that reads as a + * clearance and is not", so it reaches a card by gate-script identity + * alone. ⛔ Growing more roster entries does NOT qualify — that is what it + * already has. ⛔ Nor does either gate happening to go quiet: three of the + * five measured instances involved a green that proved nothing, because a + * sweep that cannot see your file is not evidence about your file. + * + * + * Delete an entry the day its criterion is met, not before. + */ +export const CHANGE_KIND_ROWS = [ + { + kind: 'adds or edits a test file', + matches: 'test-file', + gates: [ + { + name: 'check:query-options-erasure', + why: 'its test-surface ceiling counts sites in *.test/*.spec files, so new test code moves it', + }, + { + name: 'check:type-check-coverage', + 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)", + }, + { + name: 'check:engine-double-contract', + why: 'it walks every *.test.* file for fake engine doubles and fails when one declares delete()/update() without routing through assertEngineDeleteDispatch/assertEngineUpdateDispatch, against a shrink-only per-file baseline. A new double, or a new test file carrying one, moves it — and so does a delegating pass-through seam wrapping a real engine, which is the reading that missed it twice. Repair by fixing the double, never by raising the baseline. Cheap and whole-tree: one run answers for the whole repo and names the file and line', + }, + { + name: 'check:cross-package-test-inputs', + why: "it walks packages/, apps/ and examples/ for tests that read or import OUTSIDE their own package, and fails when turbo.json's `inputs` for that package does not declare what the test really reads — so a new test, or a new cross-package read in an existing one, moves it. Listed as a KIND rather than by path (#10542): its walk covers 5263 tracked files to judge the 2611 test files among them, so a subtree declaration would name it at 49.6% precision, while the kind names it at the granularity it actually judges. Repair by declaring the input in turbo.json, never by moving the fixture", + }, + { + name: 'check:where-matcher', + why: 'it walks every *.test.ts file for hand-written WHERE matchers and fails on a NEW silently-wrong one (a combinator read as a field name), against a shrink-only baseline. It rides the same test code as the double gate — one new fake engine tripped both, one round apart, because these steps run sequentially inside the ESLint job and the first failure aborts the rest. Conforming by REFUSING the unsupported shape is the convention most of the discovered matchers already follow; the suite cannot notice this class, which is why the gate exists', + }, + ], + }, + { + kind: 'edits a file in a package that owns an i18n-extract.config.ts', + matches: 'i18n-bundle-package', + gates: [ + { + name: 'check:i18n', + why: "it re-extracts every owning package's translation bundles and fails on drift, so any edit that changes what the extractor emits (an object definition, a label, the config itself) moves it — regenerate with `node scripts/check-i18n-bundles.mjs --write`", + }, + { + name: 'check:i18n-stale-fill', + why: "REVISING an existing source string (a label, description or help text) is the move `check:i18n` cannot see: the extractor's merge fills gaps only, so the regeneration rewrites `en` and LEAVES the previous source text in every translated locale — in sync by key, green gate, superseded draft served forever (#11671). This ratchet fails when a NEW leaf goes stale that way. It needs no build. If your revision stranded a leaf, re-translate it and commit the bundle; regenerating does NOT fix it, because a present-but-stale string is not a gap", + }, + ], + }, + { + kind: 'edits a metadata form module (a *.form.ts the Studio form registry collects)', + matches: 'metadata-form-module', + gates: [ + { + name: 'check:i18n', + why: "the metadataForms half of the bundles is registry-driven, so a form's sections, field labels, helpText or placeholder are extracted into ONE package's committed bundles — platform-objects today — and a form edit drifts them from a package your diff never touches. This is the edge PR #9113 paid a CI round for. Same repair as the entry above: regenerate with `node scripts/check-i18n-bundles.mjs --write` and commit the moved bundles", + }, + ], + }, + { + kind: 'adds or edits a GATE SCRIPT (a file some discovered check family runs)', + matches: 'gate-script', + gates: [ + { + name: 'scripts/pm/bare-root-worklist.mjs --self-test', + why: 'a gate whose population is spelled as a BARE top-level word (a separator-less string such as the one naming the package root) builds no watch hint at all, so it lands unnameable by every dispatch brief — and this self-test refuses the tree until a verdict for it is RECORDED. That obligation is a ledger row, not a command, so no amount of running the families you were given surfaces it: four devs learned it from red CI instead, twice within one hour, each AFTER reporting. Three directions bite, which is why an EDIT counts and not only an add: FRESH (a new invisible population, unjudged), STALE (a recorded verdict whose row you renamed or removed), CONTRADICTED (you declared a hint on a gate whose recorded verdict says the population cannot be spelled). The remedy is the one the failure text names: REFUSE-WIDE, REFUSE-UNSPELLABLE, or the subtree-glob idiom beside the constant. ⛔ Declaring a root the gate does not really read is the costlier error, and ⛔ the map is shrink-only, so a new row is never a remedy for a stale one', + }, + { + name: 'check:pm-dispatch-gates', + why: 'the SECOND obligation of the same shape, in this tool, and the one it cannot name for you: a gate that declares a bare top-level word the tree HAS joins the escapable-literal species, and this gate refuses the tree until the literal is either respelled or recorded. It reaches your card by gate-script IDENTITY only — its own declared literals are an artifact roster rather than a population — so a card that merely INCURS the obligation is never named by the path derivation, which is measured, not suspected. Two remedies and which is right depends on what your gate actually READS: it really does walk that root, so declare the subtree spelling beside the literal; or it does not, so respell the literal to say what the predicate means. ⛔ Do not reach for the first by default, and ⛔ the ledger is shrink-only', + }, + ], + }, + { + kind: 'adds or edits TypeScript in the ROOT tsc program (outside the directories tsconfig.json excludes)', + matches: 'root-ts-program', + gates: [ + { + name: 'check:type-check-debt', + why: 'the ROOT ledger entry (@objectstack/spec-monorepo) IS this program, so a file here moves its raw tsc count even though your diff touches no package — measured, one added bench file put it 19 over and cost a CI round. It is a shrink-only ratchet: the repair is to make the file typecheck, and raising the entry is maintainer-only — ⛔ MAINTAINER-ONLY under the #8435 convention — never the co-equal option. Most of this class is one missing setting rather than real breakage — the root config carries lib ES2020 and no types, so process and console are absent unless the file declares them ambiently. 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)', + }, + ], + }, + { + 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: 'error-code-literal', + 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', + }, + ], + }, + { + kind: 'adds or edits a file that binds an HTTP STATUS to an error response (judged from CONTENT — no path derivation can name this gate)', + matches: 'http-status-emit', + gates: [ + { + name: 'check:error-status-conformance', + why: 'it derives every (code, HTTP status) pair the non-test TypeScript sources under the package root can emit (an error class declaring both, the four-argument sendError door, a code-and-status or status-and-body terminal, an assignment pair on one error) and reconciles that set in BOTH directions with the statuses the error catalog and the error-handling page publish. So a status added, changed or removed at an emit site moves it, and so does a status constant another file resolves. This is a gate no path derivation can name: it walks a bare top-level root that the bare-root ledger records as REFUSE-WIDE, so it scores the same quiet silence for every card, and PR #22311 paid a CI round trip for that silence (#22320). It needs NO build, being a source scan in one pass over the whole tree, and it names the code, the status and the emit site. Repair by DOCUMENTING the status on that code entry (an exception line when the code already publishes another status), or by correcting the emit site when the status is the defect. ⛔ Never by admitting the code to the unpinned baseline, which is ⛔ MAINTAINER-ONLY under the #8435 convention. ⚠ This lead fires on any file that binds a status value, not only one that changes it, for the trade the vocabulary entry above states: the wasted run is one cheap gate and the miss is a CI round trip', + }, + ], + }, +]; + +/** + * The globs that MANDATE a model tier for any card whose file surface touches + * them, as DATA. This is the one list in this file besides CHANGE_KIND_GATES, + * and it is here for the same reason: it is enumerable, so a guard can hold it. + * + * ## Why a tier is derived here at all (#8640) + * + * Gate families used to be hand-recalled per card; this script exists because + * recall expires. The model tier had the same shape and had not been fixed: the + * PM recalled the mandatory roots and wrote free prose into the claim comment's + * `Container & model` line. Measured incident: a card whose surface included + * `.claude/skills/pm-dispatch/references/review-checklist.md` was claimed as + * "not under the fable-mandatory roots" and dispatched at opus. One + * misclassification sentence flowed unchecked from claim to dispatch to model + * choice, and only a downstream seat's skepticism caught it — at PR time, after + * the work was done, when the compensation available was a re-review rather + * than a re-dispatch. Nothing mechanical had compared the recorded surface + * against the mandatory globs, because nothing mechanical could: the globs + * lived only in prose. + * + * So the invariant this section installs is narrow and total: a mandatory path + * anywhere in the surface ⇒ the output cannot say otherwise. `deriveTier` + * refuses to return a result whose parts contradict each other and `tierLines` + * refuses to render one, in the same shape as the residue partition guard — + * a derivation that cannot complete exits non-zero rather than printing a wrong + * answer. + * + * ## What this derivation CANNOT promise, stated where it cannot be missed + * + * The mandatory-tier policy has two clauses and only the first is a question + * about paths: + * + * - clause ①, encoded below: a card editing the PM lane's PROTOCOL-SEMANTIC + * surfaces is `CONTRACT_REVIEW_TIER` — the pm-dispatch SKILL.md main file, every + * file carrying an enforced copy of the decision frame (the COPIES table + * of check:skill-frame-sync), the dev-agent definition, and — since the + * maintainer's 2026-09-10 ruling (「必须 fable的还包括对外发布的skills」) — + * the whole published catalog `skills/**`, which ships verbatim to third + * parties (`npx skills add objectstack-ai/objectstack/skills`, + * `npm create objectstack`). Narrowed from "the whole skill tree, + * references included" by the maintainer's 2026-08-20 ruling + * (「接受你的建议」— fable 当审计师用,不当施工队用): references-only + * surfaces carry NO path mandate any more (default-tier execution, + * compensated by the skills seat's review at CONTRACT_REVIEW_TIER). Still + * a file-surface predicate, and exactly what this script takes as argv; + * - clause ②, NOT encoded and deliberately not: a card that changes contract + * accept/reject behaviour or widens the public surface is built at the + * default tier and REVIEWED at `CONTRACT_REVIEW_TIER`. WHO owes that + * review is keyed by LANE — the maintainer's lane rule, keyed by seat on + * 2026-09-10, re-keyed by served tier on 2026-09-16 and restated as the + * lane rule on 2026-09-17 (「曾经要求只有 spec 和 skills 需要 fable,其他 + * opus 就够了,理论上其他车道不需要契约复审」): the spec and skills lanes + * owe it on every round they deliver — in-seat when the seat's served + * tier is that tier, otherwise by the at-tier review subagent the seat + * spawns (the fastest route, per the maintainer) — and every other lane + * owes NO contract review: its whole bar is the three landing pre-checks + * and the gates, ⛔ no default-tier "self-review" record is demanded of + * it and ⛔ no at-tier subagent is spawned from it (neither the triage + * seat nor the maintainer-summoned director spawns one for anything). A + * clause-② hit outside those two lanes is lane ROUTING, never a review + * demand on the lane that found it: the work is the spec lane's, + * whichever seat found it, and moves there. Clause ② itself is judged + * from the card's CONTENT — what the change + * does to the contract — and a path cannot answer it. An ordinary-looking + * surface (one package's source file) is the NORMAL shape of a clause-② + * card. The closest a path can honestly get is SUSPICION: + * SUSPECT_TIER_GLOBS below marks the contract surface itself — its test + * files excepted, because tests do not ship — and `--tier` prints a hint + * for it — never a verdict. The enforcement lives one step later, in the + * PM skill's enqueue gate over the PR's ACTUAL diff. + * + * A path derivation that pretended to cover clause ② would produce the failure + * this whole file is written against, one level up: a "no mandate" line read as + * a clearance. So the no-mandate output says which clause it checked and which + * it cannot reach, every time, rather than leaving the reader to remember there + * were two. The output is a FLOOR, never a ceiling. + * + * The sanctioned exits from a mandate — the one-line-class mechanical-edit + * downgrade (a card CONTENT judgment, like clause ②), the measured quota + * exemption (the mandated tier EXHAUSTED ⇒ the default tier, never lower — a + * tier that is RETIRED is not exhausted and is a maintainer ruling instead, + * ⛔ never a seat's reading) and the proactive low-headroom downgrade — are + * claim-time judgments, not properties of the file surface. This tool states the mandate; the seat records any exit and + * its reason in the claim comment. ONE exit is path-shaped, and so it IS + * encoded: the one-line-class downgrade does not exist for a surface under + * `skills/**` — the 2026-09-10 ruling's 必须, because a closed enumeration + * beats a per-line "is this mechanical" call on a catalog third parties + * install — so that entry carries `oneLineExit: false` and `tierLines` + * refuses to offer the exit for any card whose surface hits it. + * + * ## Why the globs are matched with `hintCovers`, asymmetry included + * + * Same matcher as the gate half, so there is one path-comparison rule in this + * file rather than two — and so a glob gets the segment-boundary semantics for + * free: a declared surface of `.claude/skills/pm-disp` is not an ancestor of + * `.claude/skills/pm-dispatch/SKILL.md`, though it is a string prefix of it. + * + * `hintCovers` also matches in the other direction — an input that is an + * ANCESTOR of the glob (a surface declared as `.claude/skills`) counts as a + * hit. For gate matching that direction is a fabricated lead; here it is the + * correct one, because the error costs are not the same in the two halves. An + * over-matched gate pastes a wrong command into a prompt; an under-mandated + * tier crosses a maintainer guardrail and is only visible afterwards. A surface + * declared as a directory that CONTAINS a mandatory root may well touch it, so + * the derivation errs toward the mandate. Both directions are pinned in the + * self-test. + * + * ## Keeping this list from rotting + * + * Two guards, both live. Every declared glob must name a path that EXISTS in + * this tree — a renamed skill root would otherwise leave dead data that + * mandates nothing while reading as protection, which is the incident class + * itself. And two globs that cover one path with DIFFERENT tiers is a + * derivation this file cannot complete honestly (nothing here orders tiers), so + * it throws rather than picking one. + * + * ## One measured side effect of putting a path in a MODULE BODY + * + * Comment masking cannot reach a module-body string, so these globs — and the + * suspect glob below — are watch hints of this file's own source. Re-measured + * on c48d46d70a over 6840 tracked files: `extractWatchHints` yields 9 hints + * here, and the four globs of these two tables cover 1026 files between them + * (1023 of that is the suspect glob's contract surface). The `skills/**` + * entry (2026-09-10) adds one hint and the 47 tracked files under `skills/` + * (`git ls-files skills` on ebf9a489), one of which — the published PM + * skill — the table already covered. That skill was deleted on 2026-09-10 + * (maintainer, verbatim: 「发布版 skills/objectstack-pm-dispatch 删」) and its + * own entry left with it — a dead glob is refused by the self-test — so the + * catalog is covered by `skills/**` alone and a catalog file hits exactly ONE + * entry; the frame-copy half of clause ① now names the internal copy only. + * + * They stay inert against a gate that RESOLVES to this file, because no check + * family does — `check:pm-dispatch-gates` resolves to `check-dispatch-gates.mjs` + * and matches this file through that file's one constant. If the tool is ever + * wired as its own gate (a shape `check-dispatch-gates.mjs`'s header measures + * and refuses), this hint would start printing that gate as MATCHED for every + * card editing the PM skill — a fabricated lead the refusal recorded there is + * what prevents. + * + * They are inert against a gate that IMPORTS this module for a second reason + * now, and that one is structural rather than remembered: the module's own + * `inherited-population` declaration (top of the module body, #11556) names the + * single population a follower inherits, and these globs are not in it. That + * closes the class rather than these four literals — a tier glob added tomorrow + * inherits nothing without someone widening the declaration, and the declaration + * cannot be widened to a path this file does not spell. + * + * The authority for the policy is the maintainer ruling quoted in the PM + * dispatch skill (2026-08-10 three-tier ruling, clause ① of its 强制条款, as + * narrowed to protocol semantics by the 2026-08-20 ruling quoted there, and + * widened to the published `skills/**` catalog by the 2026-09-10 ruling). + * This table is a machine-readable copy of ONE predicate from it, not a second + * statement of the policy: when they disagree, the skill wins and this table is + * the thing to fix. The frame-copy half of the predicate is DEFINED by another + * gate's table — check:skill-frame-sync's COPIES — and the self-test pins this + * table as covering every file listed there, so a copy added to that gate + * cannot silently fall out of the mandate. + */ +export const MANDATORY_TIER_GLOB_ROWS = [ + { + glob: '.claude/skills/pm-dispatch/SKILL.md', + tier: 'CONTRACT_REVIEW_TIER', + why: 'clause ① of the model-tiering ruling (narrowed to protocol semantics, 2026-08-20): the PM dispatch skill MAIN file is the lane\'s own operating protocol and a wrong edit propagates to every later dispatch — references/** dropped out of the path mandate that day', + }, + { + glob: '.claude/agents/os-dev.md', + tier: 'CONTRACT_REVIEW_TIER', + why: 'clause ① (2026-08-20 narrowing): the dev-agent definition is protocol semantics — every dispatched dev runs under it, and receives the decision frame the PM pastes into its prompt at dispatch time rather than carrying a copy of its own', + }, + { + glob: 'skills/**', + tier: 'CONTRACT_REVIEW_TIER', + why: 'clause ① (2026-09-10 ruling, verbatim 「必须 fable的还包括对外发布的skills」): the published catalog ships verbatim to third parties by `npx skills add objectstack-ai/objectstack/skills` and `npm create objectstack`, so a wrong edit is installed elsewhere before anyone here reads it — and no one-line exemption applies under this root', + // The one-line-class mechanical-edit exit is a card-CONTENT judgment + // everywhere else; here the ruling closes it by PATH (a closed enumeration + // beats a per-line call on an installed catalog), so it is data, not prose. + oneLineExit: false, + }, +]; + +/** + * The globs that make a surface a clause-② SUSPECT — a HINT, never a verdict. + * + * Clause ② is judged from a card's CONTENT; no path predicate can decide it + * (the docblock above MANDATORY_TIER_GLOBS says why pretending otherwise would + * recreate the incident class this file exists against). What a path CAN say + * is where such cards normally land: `packages/spec/src/**` is the contract + * surface itself — the error-code ledger and the `*.zod.ts` contract schemas + * live there, and the measured incident shape (a below-tier dispatch flipping + * accept-to-reject behaviour in the ledger, the same hole passed three times + * in one day) sat exactly under it. So `--tier` prints a suspicion line for + * these paths: judge the tier from the card content as best you can, and + * whichever tier is dispatched, the PR's ACTUAL diff passes the clause-② + * enqueue gate before the card may enqueue — the diff is a fact; the card's + * semantics were a prediction. The gate itself lives in the PM skill + * (入队与落地); this output only points at it. + * + * ## Test files are EXCEPTED, by a predicate this file imports (#19936) + * + * The enqueue gate's path limb reads this surface, and the review rule it + * guards (the skill's contract-review reference) owes an at-tier review for + * `packages/spec/src/**` NON-TEST files only. Without an exception the two + * disagreed on a test-only diff: the limb demanded an at-tier record that the + * review rule forbade spawning an agent to write, so an off-tier seat's + * test-only spec PR could never enqueue. The maintainer's ruling (director + * batch #219 item 1, letter A, comment 5805897677) settled it toward the review + * rule: a published-contract change owes the record; a test-only change does + * not, because tests do not ship. + * + * So an entry may carry `except`, a predicate over a path its glob covers, and + * `deriveTier` drops a path it answers true for BEFORE recording a suspicion. + * The predicate is `isTestPath`, imported from `check-undeclared-dep-imports.mjs` + * and never respelled, as the ruling orders ("the repo's own test-file + * predicate, not a new spelling"). Chosen over the repo's other test predicates + * on measurement, not taste: that gate's own question is which files under a + * package's `src/` are published source and which are its tests — the ruling's + * question exactly; it covers the four shapes the ruling names (`*.test.ts`, + * `*.pin.test.ts`, anything under `__tests__/`, fixtures under a test + * directory); and it excepts no directory word a contract domain carries. A + * census predicate that treats `qa/` as a test directory would drop + * `packages/spec/src/qa/testing.zod.ts`, a real contract schema — pinned. + * + * A subtraction fails SILENT, so this one is held live: the self-test reds if + * the exception drops any tracked `*.zod.ts` (the package's `files[]` ships + * every `*.zod.ts` under `src/` verbatim), and if the predicate stops being + * the imported one. The call hands it the repo-relative path although it was + * written for package-relative ones; for this glob that is exact, because no + * segment of `packages/spec/src/` is a test-directory name. ⛔ The exception + * narrows the SUSPICION only: MANDATORY_TIER_GLOBS carries none, and an input + * that CONTAINS the contract surface (a directory surface such as + * `packages/spec`) is still a suspect, since the predicate answers no for it. + */ +export const SUSPECT_TIER_GLOB_ROWS = [ + { + glob: 'packages/spec/src/**', + why: 'the contract surface (error-code ledger, *.zod.ts contract schemas) — the normal landing zone of a clause-② card', + except: 'test-path', + exceptWhy: 'a test file ships nothing, so a test-only diff changes no published contract and owes no at-tier record (the review rule already reads non-test files only)', + }, +]; + +/** The tier floor for a card with no mandate — the ruling's 最低下限. */ +export const TIER_FLOOR = 'sonnet'; + +/** The default judgment tier for a card with no mandate — the ruling's 默认判断档. */ +export const TIER_DEFAULT = 'opus'; + +/** + * Tier family words a maintainer ruling has RETIRED — none today. + * + * ⛔ Not a tier table and ⛔ not an ordering — a RETIRED-SPELLING guard, the + * same shape as the retired clause-② keys pinned further down. The self-test + * asserts no rendering contains one, so the day a ceiling is written down by + * hand again it reds instead of quietly outliving the harness that served it. + * + * EMPTY is this list's correct steady state, ⛔ not a disabled guard. A word + * enters it only on the maintainer's explicit ruling that names a retirement + * and leaves it only on a ruling that brings the tier back — the conditions + * {@link CONTRACT_REVIEW_TIER}'s docblock states; the one entry this list held + * was written on a session's quota refusal, which is none of those. Because an + * empty list clears every rendering for free, the self-test proves the guard + * on a MUTATED copy — a list naming a word the ladder really prints has to red + * — so the green above it is measured rather than vacuous. + */ +export const RETIRED_TIER_WORDS = Object.freeze([]); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 3d7a1bc3f7..03c62e6ccc 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -16,7 +16,7 @@ * node scripts/pm/dispatch-gates.mjs --changed # the same, said out loud * node scripts/pm/dispatch-gates.mjs --repo / ... # refuse unless this checkout IS that repo * node scripts/pm/dispatch-gates.mjs --tier --repo ... # a GOVERNED sister repo's tier verdict, from the path globs ALONE - * node scripts/pm/dispatch-gates.mjs --self-test + * node scripts/pm/dispatch-gates.mjs --self-test [--fast] # the battery; --fast runs the fast tier alone * * A path NAMED on argv that is not in this tree has two readings — a surface of * THIS repo that is not written yet, and a path belonging to ANOTHER repo — and @@ -431,27 +431,13 @@ */ import { spawnSync } from 'node:child_process'; -import { - readFileSync, - readdirSync, - existsSync, - statSync, - mkdtempSync, - mkdirSync, - writeFileSync, - rmSync, - realpathSync, - symlinkSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import * as nodePath from 'node:path'; import process from 'node:process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; import { anyConfigExtractsMetadataForms, findExtractConfigs, findMetadataFormModules, - flagsExtractMetadataForms, isExtractConfigPath, isMetadataFormModulePath, } from '../i18n-bundle-surface.mjs'; @@ -470,13 +456,47 @@ 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 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. +import { + POPULATION_MARKER_KEYS, + MARKER_COMMENT_FORMS, + MARKER_KEY_FORMS, + REASON_TAIL_MARKER_KEYS, + PATH_LIST_MARKER_KEYS, + ROOT_WALK_RESIDUE_LEDGER, + COMPOUND_ANCHOR_LEDGER, + GOVERNED_READ_FLOOR, + TIER_FLOOR, + TIER_DEFAULT, + RETIRED_TIER_WORDS, + ESCAPABLE_LITERAL_LEDGER_ROWS, + CHANGE_KIND_ROWS, + MANDATORY_TIER_GLOB_ROWS, + SUSPECT_TIER_GLOB_ROWS, +} from './dispatch-gates.data.mjs'; + +export { + POPULATION_MARKER_KEYS, + MARKER_COMMENT_FORMS, + MARKER_KEY_FORMS, + REASON_TAIL_MARKER_KEYS, + PATH_LIST_MARKER_KEYS, + ROOT_WALK_RESIDUE_LEDGER, + COMPOUND_ANCHOR_LEDGER, + GOVERNED_READ_FLOOR, + TIER_FLOOR, + TIER_DEFAULT, + RETIRED_TIER_WORDS, +}; // Re-exported so this tool's self-test drives the SAME predicates the gate // runs, not copies of them. They used to be written twice — see the shared // module's header, and the i18n entry in CHANGE_KIND_GATES below. export { isExtractConfigPath, isMetadataFormModulePath }; -const ROOT = new URL('../..', import.meta.url).pathname; +export const ROOT = new URL('../..', import.meta.url).pathname; // ── The source maskers are memoised, because discovery masks each file ~12x ── // @@ -3016,169 +3036,13 @@ export function declaredNoCheckFamiliesReason(workflowText, file = null) { return read.reason; } -/** - * The THREE population markers' grammar, in ONE spelling, and the reading of - * whether the reason one of them captures is WHOLE (#18422). - * - * ## The defect this exists for - * - * Every population marker below captured its reason with `(\S.*)$` under the - * `m` flag, so the capture ends at the FIRST NEWLINE. A reason an author wraps - * across two or three comment lines — which is what a comment that long looks - * like in every file in this tree — was captured as line ONE, and nothing - * refused it: `wholeTreePopulationRefusal` checks that a reason EXISTS and that - * a root walk BACKS it, never that it is whole. Measured on card #17472: a - * three-line reason rendered to the seat as "…and the verdict is", a sentence - * that simply stops. The failure mode is the expensive kind — a truncated - * reason reads as a complete sentence that merely ends oddly, and the reason is - * the ONE thing a seat reads off that row when deciding whether a family - * belongs on its card. - * - * ## The contract: the reason is WHOLE, or the declaration is RED - * - * A declaration OWNS the line it is written on and nothing else. It is - * terminated by a blank line, a blank comment line, a non-comment line, EOF, or - * another `dispatch-gates:` declaration. A comment line immediately below it, - * in the SAME comment form, carrying text that is not a new `dispatch-gates:` - * key, is read as a CONTINUATION of the reason — and a continued reason is - * refused, naming the file and the line that continues it. - * - * ⛔ The remedy is NOT a marker that consumes a comment BLOCK. Nothing in the - * text can tell a wrapped reason from an unrelated comment written under the - * declaration, so a block-consuming marker would silently make the next - * paragraph part of a seat-facing reason — the same class of defect pointed the - * other way, and the coin toss this file refuses everywhere else. Refusing is - * decidable; swallowing is a guess. Measured over the tree at the time of - * writing: 27 declarations across 25 gate files, 26 of them already writing the - * whole reason on one line (up to 1215 characters of it), and exactly one - * wrapped — so the one-line spelling is what this convention already IS, and - * the refusal names the one declaration that was being cut. - * - * ## Why the grammar is built here rather than written out three times - * - * The continuation reading has to agree with the capture about what a marker - * line IS, down to the comment form. Two spellings of one grammar drift - * silently — the exact failure `declaredNoCheckFamiliesReason`'s docblock - * prices one level up — so the pattern each marker uses and the pattern the - * continuation reading uses come out of this one function. Group 1 is the - * comment form (`//` or `#`), group 2 is the reason; the form is captured - * rather than discarded because a `#` line under a `//` declaration is not a - * comment in the same language and cannot be continuing it. - */ -const POPULATION_MARKER_KEYS = Object.freeze(['no-path-population', 'whole-tree-population', 'wide-population']); - -/** - * The COMMENT FORMS a `dispatch-gates:` declaration may be written in, in ONE - * roster — and the HEAD every marker pattern below is built out of it: the - * indent, the form, and the key that follows it. - * - * ## The defect the roster was widened for (#18661) - * - * The alternation listed `//` and `#` and nothing else, so a declaration - * written in the file's own BLOCK-comment idiom parsed as NOTHING: not - * refused, not printed, not counted. Measured on `origin/main` 95b21b33be, - * `declaredNoPathPopulation` read `null` over both of this tree's block-form - * declarations — `scripts/symbol-anchors.mjs` (a slash-star opener) and - * `scripts/release-verify-npm.mjs` (a star-prefixed line inside a docblock) — - * and both families sat in the residue's `undetermined` bucket with `hints=0`, - * the exact bucket the marker exists to split them out of. A dropped - * declaration printed IDENTICALLY to one nobody ever wrote, which is the one - * shape neither side will go and check: its author believes they explained the - * emptiness, its reader believes nobody ever did. - * - * ## Two KINDS of form, because they answer the wholeness question differently - * - * `line` — `//` and `#`. Each line is its OWN comment. The line under a - * declaration is a SEPARATE comment, and nothing in the text says whether it - * belongs to the reason or is an unrelated remark. So the declaration owns - * its line, and a comment line under it is refused as a CUT (#18422) — - * unchanged here, byte for byte, and `populationReasonCutRefusal`'s text - * still states it. - * - * `block` — a slash-star opener (one star or two) and the star-prefixed - * CONTINUATION lines inside it. The whole comment is ONE comment and its - * internal newlines are FORMATTING, not comment boundaries, so a reason that - * wraps is decidable rather than a guess: inside a block, the next - * star-prefixed line is a continuation BY CONSTRUCTION. What the block form - * therefore cannot do is cross any of the three places a block-comment author - * signals a new thought — each one pinned in the self-test: - * - * the CLOSING delimiter the comment is over - * a blank star-only line the author ended the paragraph - * the next star-@tag line the docblock's tag section begins - * - * plus the two the line forms already carry: another `dispatch-gates:` key, - * and EOF. A line INSIDE the block carrying text with no star prefix is none - * of the five — it is reason text this walk cannot read — so it is recorded - * as a CUT and refused, the same direction and for the same reason the line - * forms are refused in. - * - * ⚠️ The residual asymmetry, named here rather than left to be found: a second - * sentence written on the very next star line, with no blank line between it - * and the declaration, IS swallowed into the reason. That is OVER-inclusion, - * and it reaches the seat as a reason that says too much — visible on the row. - * The truncation #18422 refused is UNDER-inclusion, and reaches the seat as a - * sentence that merely ends oddly — invisible. The block idiom's own paragraph - * break is the text that separates the two, and it is what a block-comment - * author already writes; the line forms have no such text, which is why they - * refuse instead. - * - * ## One alternation, one roster - * - * The form alternation is the half a reader has to be able to change in ONE - * place: a form this roster does not list parses as nothing at all, silently, - * and widening it in one builder while the other kept its own copy would fix - * half the markers and leave the other half reading exactly as they did. Both - * builders below and the continuation reading come out of this roster. Group 1 - * of every pattern is the form, because the reading has to know a `#` line is - * not continuing a `//` one — and now also which KIND of form it is reading. - * - * ⚠️ Order is load-bearing: the two-star opener is listed BEFORE the one-star - * opener, so a `/**` docblock opener captures whole instead of matching `/*` - * and stranding its second star in front of the key. - * - * ⛔ Every example in the docblocks below is written with the docblock's OWN - * star prefix AND the example's own form — two openers on one line. That is - * not decoration: exactly ONE opener is what this grammar and the - * unparsed-form probe both accept, so a two-opener line is documentation to - * both of them and can never be read as a live declaration about this file. - */ -const MARKER_COMMENT_FORMS = Object.freeze([ - Object.freeze({ label: '//', kind: 'line', open: '\\/\\/' }), - Object.freeze({ label: '#', kind: 'line', open: '#' }), - Object.freeze({ label: '/**', kind: 'block', open: '\\/\\*\\*' }), - Object.freeze({ label: '/*', kind: 'block', open: '\\/\\*' }), - Object.freeze({ label: '*', kind: 'block', open: '\\*' }), -]); +// `POPULATION_MARKER_KEYS` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. -/** - * The marker keys whose files are NOT JavaScript, and the comment forms those - * files really have (#18662). - * - * The roster above is the set of idioms a `dispatch-gates:` declaration may be - * written in across this tree; it is not a claim that every one of them is a - * comment in every LANGUAGE a declaration is read out of. `no-check-families` - * is read out of workflow YAML, where `#` is the only comment there is: a - * `//` or slash-star line in a workflow is document content, and reading a - * declaration off one would be reading it off text the workflow's own parser - * never treats as a remark. So the alternation this key's pattern is built - * from is the roster FILTERED to the forms its language has — never a second - * roster, and never a second pattern. - * - * A key absent from this table gets the whole roster, which is the answer for - * every marker read out of a JavaScript or shell source. - * - * ⛔ This table may only ever NARROW: it names a subset of the labels in - * `MARKER_COMMENT_FORMS`, and a label that is not one of them throws below - * rather than silently contributing nothing to the alternation — a form set - * that quietly emptied would make every declaration of that key parse as - * nothing at all, which is precisely the #18661 failure one level up. - */ -const MARKER_KEY_FORMS = Object.freeze({ - 'no-check-families': Object.freeze(['#']), -}); +// `MARKER_COMMENT_FORMS` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. + +// `MARKER_KEY_FORMS` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. -function markerFormsFor(key, table = MARKER_KEY_FORMS) { +export function markerFormsFor(key, table = MARKER_KEY_FORMS) { const labels = table[key]; if (!labels) return MARKER_COMMENT_FORMS; const forms = MARKER_COMMENT_FORMS.filter((f) => labels.includes(f.label)); @@ -3208,7 +3072,7 @@ function markerLineHead(key) { * matched it. Throws on anything else: the capture group can only ever hold a * label from that roster, so an unknown one means the two have drifted apart. */ -function markerFormKind(form) { +export function markerFormKind(form) { const known = MARKER_COMMENT_FORMS.find((f) => f.label === form); if (!known) { throw new Error( @@ -3219,23 +3083,9 @@ function markerFormKind(form) { return known.kind; } -/** - * The REASON-TAIL markers — the keys whose grammar is head + `-- `, - * with nothing between the key and the separator (#18662). - * - * The three population keys were the whole roster until this card, and the - * builder is still spelled `populationMarkerPattern` because `population*` is - * what this machinery is CALLED everywhere it is exported - * (`populationReasonContinuation`, `populationReasonCutRefusal`) and a rename - * would move the names a reader greps for without moving a single behaviour. - * ⚠️ The ROSTER, not the name, is the authority on which keys it serves: - * `no-check-families` has exactly this grammar and is built here rather than - * out of the fourth hand-written copy of the pattern it used to be — which is - * what left its reason outside the #18422 wholeness reading for two cards. - */ -const REASON_TAIL_MARKER_KEYS = Object.freeze([...POPULATION_MARKER_KEYS, 'no-check-families']); +// `REASON_TAIL_MARKER_KEYS` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. -function populationMarkerPattern(key) { +export function populationMarkerPattern(key) { if (!REASON_TAIL_MARKER_KEYS.includes(key)) { throw new Error( `dispatch-gates: unknown population marker key '${key}' — known keys: ${REASON_TAIL_MARKER_KEYS.join(', ')}. ` + @@ -3245,30 +3095,9 @@ function populationMarkerPattern(key) { return new RegExp(`${markerLineHead(key)}${key}[ \\t]*--[ \\t]*(\\S.*)$`, 'm'); } -/** - * The PATH-LIST markers' grammar, in one spelling, for the same reason the - * reason-only markers have one (#18673). - * - * A path-list declaration names files BEFORE its reason — - * `dispatch-gates: [ ...] -- ` — so it cannot come - * out of `populationMarkerPattern`, whose tail is a bare reason. What it CAN - * share is the head: the indent, the comment form and the key. Group 1 is the - * comment form, group 2 the path list, group 3 the reason. - * - * The `--` is SPACE-delimited on both sides here (never `[ \t]*`), because a - * path may legitimately contain one and a bare separator would split it. - * - * ⚠️ `local-env` shares this grammar with a list of ENVIRONMENT NAMES in the - * path position (#20278). The grammar is LIST-then-reason and never reads what - * the list holds; each key's own reader does (`declaredLocalEnv` refuses a - * token that is not an env name). The roster keeps its name for the reason - * `REASON_TAIL_MARKER_KEYS` states: a rename would move what a reader greps for - * without moving a behaviour, and a third builder would be the copy of this - * pattern the refusal below forbids. - */ -const PATH_LIST_MARKER_KEYS = Object.freeze(['inherited-population', 'self-test-reads', 'local-env']); +// `PATH_LIST_MARKER_KEYS` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. -function pathListMarkerPattern(key) { +export function pathListMarkerPattern(key) { if (!PATH_LIST_MARKER_KEYS.includes(key)) { throw new Error( `dispatch-gates: unknown path-list marker key '${key}' — known keys: ${PATH_LIST_MARKER_KEYS.join(', ')}. ` + @@ -3299,7 +3128,7 @@ function pathListMarkerPattern(key) { * Group 2 is the reason for a reason-tail key (group 1 is the form); group 3 * is the reason for a path-list key (group 2 is the path list). */ -const MARKER_REASON_GRAMMARS = Object.freeze(Object.fromEntries([ +export const MARKER_REASON_GRAMMARS = Object.freeze(Object.fromEntries([ ...REASON_TAIL_MARKER_KEYS.map((k) => [k, Object.freeze({ build: populationMarkerPattern, reasonGroup: 2 })]), ...PATH_LIST_MARKER_KEYS.map((k) => [k, Object.freeze({ build: pathListMarkerPattern, reasonGroup: 3 })]), ])); @@ -3335,7 +3164,7 @@ const MARKER_REASON_GRAMMARS = Object.freeze(Object.fromEntries([ * ENDS. `MARKER_COMMENT_FORMS`' docblock is the authority on why; the two * functions under this one are where that answer is executed. */ -function readPopulationMarker(scriptSource, markerKey) { +export function readPopulationMarker(scriptSource, markerKey) { const grammar = MARKER_REASON_GRAMMARS[markerKey]; if (!grammar) { throw new Error( @@ -3370,7 +3199,7 @@ function readPopulationMarker(scriptSource, markerKey) { * opener comes off the same roster the alternation was built from so the two * can never disagree about what that form's comment looks like. */ -function lineFormReason(lines, declarationLine, form, tail) { +export function lineFormReason(lines, declarationLine, form, tail) { const reason = tail.trim(); const next = lines[declarationLine]; if (next === undefined) return { reason, cut: null }; @@ -3540,7 +3369,7 @@ export function markerReasonCutRefusal(markerKey, cut) { * sentence. The message names the FILE, the LINE, the MARKER and the text that * continues it — the four a reader needs to navigate to it. */ -function refuseCutMarkerReason(read, markerKey, file) { +export function refuseCutMarkerReason(read, markerKey, file) { if (!read?.cut) return; const why = markerReasonCutRefusal(markerKey, { ...read.cut, file: file ?? null }); throw new Error(`dispatch-gates: ${file ?? 'the declaring file'} ${why}`); @@ -3851,7 +3680,7 @@ function markerLookalikeHead(key) { * whole roster: telling the author of a workflow to rewrite their line as `//` * would be prescribing a remedy their file cannot take. */ -const MARKER_LOOKALIKES = Object.freeze(Object.fromEntries( +export const MARKER_LOOKALIKES = Object.freeze(Object.fromEntries( Object.keys(MARKER_REASON_GRAMMARS).map((key) => [key, new RegExp(`${markerLookalikeHead(key)}${key}\\b(.*)$`)]), )); @@ -3982,7 +3811,7 @@ export function unparsedPopulationMarkerRefusal(unparsed) { + 'nobody ever looked, and neither has anything to check. Either the comment form is not one of the forms named ' + 'beside that key above, or the line carries no "-- " tail. Rewrite the declaration in one of THAT ' + 'key\'s forms with a reason, or — if the form is a real comment idiom the language that key is read out of ' - + 'uses — add it to MARKER_COMMENT_FORMS in scripts/pm/dispatch-gates.mjs (and to that key\'s MARKER_KEY_FORMS ' + + 'uses — add it to MARKER_COMMENT_FORMS in scripts/pm/dispatch-gates.data.mjs (and to that key\'s MARKER_KEY_FORMS ' + 'entry, where it has one) with a self-test case beside it, answering where a reason written in it ENDS. ' + '⛔ Never delete the line to clear this refusal: a declaration nobody can read and a declaration nobody wrote ' + 'are the two states this refusal exists to keep apart.'; @@ -3999,7 +3828,7 @@ export function unparsedPopulationMarkerRefusal(unparsed) { * is the one place that would have to be edited to add one. The self-test holds * the roster equal to `POPULATION_MARKER_KEYS`, so neither can grow alone. */ -const POPULATION_DECLARATION_FIELDS = Object.freeze({ +export const POPULATION_DECLARATION_FIELDS = Object.freeze({ 'no-path-population': Object.freeze({ reason: 'noPopulationReason', cut: 'noPopulationReasonCut', read: declaredNoPathPopulation, }), @@ -4022,7 +3851,7 @@ const POPULATION_DECLARATION_FIELDS = Object.freeze({ * A family with several files keeps the FIRST declaration it meets, exactly as * the `??=` this replaced did. */ -function readPopulationDeclaration(entry, scriptSource, file, markerKey) { +export function readPopulationDeclaration(entry, scriptSource, file, markerKey) { const fields = POPULATION_DECLARATION_FIELDS[markerKey]; if (!fields) { throw new Error( @@ -4319,106 +4148,7 @@ export function widePopulationRefusal(entry) { return null; } -/** - * The WHOLE-TREE RESIDUE ledger (#15312) — the families whose own source sweeps - * the repository root, that this derivation can place on NO card, and that are - * deliberately not in the whole-tree bucket above. - * - * ## The defect - * - * `check:driver-memory-census` counts a `vi.mock` of a frozen driver package as - * a module binding wherever in the tree it is written, and names no path - * literal anywhere in its source — so this derivation scored it `undetermined` - * for every card and no `--commands` harvest could contain it. A seat derived - * its family, ran 57 of them with 53 green, and CI's lint job then failed on - * the one gate the derivation could not offer. The gate was right; what was - * missing was a way for a seat to be TOLD about it before the push, which is - * exactly what the whole-tree channel above exists to be. It now declares. - * - * ⚠️ Fixing that one gate is not the fix. The question worth answering is - * "which gates does CI run that no derivation can name", mechanically, so the - * NEXT one reds here rather than in CI a cycle later. This table and the live - * case in `--self-test` are that answer for the class the card names. - * - * ## The population, and why the closure subtraction is load-bearing - * - * A family is IN it when three things hold at once: its own source carries a - * recognised repo-root walk (`repoRootWalkSpelling` — the same predicate that - * vouches for a whole-tree declaration), it declares NEITHER marker, and no - * path in the tree OUTSIDE the gate's own file closure can place it. That last - * subtraction is not a detail: every family matches the card that edits the - * gate itself, through the identity key, so counting that as "the derivation - * can name it" would score this whole population green on a card nobody files. - * - * ## Why this class and not "every gate CI runs" - * - * The wider question was MEASURED for this card rather than guessed at: on - * cd1f8ee96, of the 186 gate scripts CI runs, 44 are named by no derivation at - * all. The bulk of them are SUBTREE walkers — seeded at `packages`, `apps`, - * `examples` through a runtime constant no source scan reads — whose remedy is - * the ordinary `ROOT_DIR_WATCH_HINTS` declaration, one per-gate judgement each. - * A table that swallowed all 44 would be forty rows nobody revisits, which is - * the shape `artifactRosterLines` refuses for its own members. So this holds - * the class the card named, the rest is a filed follow-up, and the boundary is - * STATED here rather than left to be inferred from what the table happens to - * contain. - * - * ## Maintaining this table - * - * A member this table does not list reds `check:pm-dispatch-gates`, and there - * are exactly two honest repairs. If the gate really does read the whole tree, - * give it the `whole-tree-population` marker: it then leaves this population by - * DECLARING, which is the outcome this table exists to push toward. If it does - * not, add a row saying what it reads INSTEAD, so a reader can check the claim - * against the gate. ⛔ Never a row that only says "not whole-tree" — that is - * the reason-less opt-out both markers refuse, and it reads exactly like a - * placeholder nobody will revisit. A listed family that stops being a member - * reds too: a stale exclusion is an exclusion nobody is measuring any more. - */ -export const ROOT_WALK_RESIDUE_LEDGER = [ - [ - 'scripts/check-console-intercept-disarm.mjs', - 'its `scan(REPO_ROOT)` walks `workspacePackageDirs(root)` — every workspace PACKAGE ROOT\'s package.json and ' - + 'vitest.config.*, read off pnpm-workspace.yaml. That is workspace-wide but it is not every file: a new test ' - + 'file under an existing package does not move it, a new PACKAGE does. Declaring the whole tree would put it ' - + 'on every card on a population it does not read.', - ], - [ - 'scripts/check-console-intercept-disarm.mjs --self-test', - 'the same gate file, reached through its self-test invocation; the reading above is the whole of it.', - ], - [ - 'scripts/check-skill-frame-freshness.mjs --self-test', - 'lint.yml runs the SELF-TEST HALF and never the scan — its step is named that, and the step comment states why ' - + '(the pnpm script would drag the scan in with it). The repo-root default parameter belongs to the scan half ' - + 'CI does not schedule, so a whole-tree row for this family would advertise work no workflow performs.', - ], - // ⚖️ `check:pm-half-states` LEFT this population under #16904's D2 and its row - // is gone with it — a listed family that stops being a member reds here, and - // a stale exclusion is an exclusion nobody measures. It is now placeable BY - // PATH, and truthfully: `check-half-states.mjs`'s self-test reads two sibling - // sources as program text — `scripts/pm/sweep-stale-finding.mjs` and - // `scripts/pm/check-prior-rulings.mjs` — to pin that the sweep still ALIASES - // the one stale-`finding` screen rather than re-growing a copy, and that the - // prior-ruling line's writer still prints the key the patrol greps. So a card - // touching either sibling really does owe this gate, which is the opposite of - // the #15753 placement this row was written about: that one came from a - // noise-floor constant and said the opposite of what the constant declares, - // while these two literals are exactly what the self-test reads. - // ⚖️ `scripts/symbol-anchors.mjs --self-test` LEFT this population under - // #18661 and its row is gone with it — a listed family that stops being a - // member reds here, and a stale exclusion is an exclusion nobody measures. - // It left through the OUTCOME this table exists to push toward: it DECLARES. - // The declaration was there the whole time — a `no-path-population` marker - // written in that file's own block-comment idiom, in a form the marker - // grammar did not list, so it read back `null` and the family arrived here - // looking like a gate whose emptiness nobody had examined. This row was the - // price of that silence: a hand-written exclusion, carrying by hand the - // reading the gate's own source already carried, for a family that was never - // a member of this population at all. Widening the form set (see - // `MARKER_COMMENT_FORMS`) is what let the declaration be read; deleting the - // row is the other half of the same landing. -]; +// `ROOT_WALK_RESIDUE_LEDGER` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. /** The ledger as a Map, keyed by the family key this derivation places. */ export const ROOT_WALK_RESIDUE_REASONS = new Map(ROOT_WALK_RESIDUE_LEDGER); @@ -4763,7 +4493,7 @@ export function workflowEnvValues(entry) { * * Returns `{ population, reason }`, or null when the module declares nothing. */ -const INHERITED_POPULATION_MARKER = pathListMarkerPattern('inherited-population'); +export const INHERITED_POPULATION_MARKER = pathListMarkerPattern('inherited-population'); export function declaredInheritedPopulation(moduleSource, hints = null, file = null) { const source = String(moduleSource); @@ -5603,7 +5333,7 @@ export function maskSelfTests(source) { * JavaScript declaration shape, so the corpus is the tree's JavaScript and * TypeScript, and nothing else. */ -const ANCHOR_CENSUS_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/; +export const ANCHOR_CENSUS_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/; /** * The bare name this tree's self-test convention spells. Every other name the @@ -5612,139 +5342,7 @@ const ANCHOR_CENSUS_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/; */ const BARE_ENTRY_POINT_NAME = 'selfTest'; -/** - * Every COMPOUND-name declaration the self-test anchor matches, tree-wide, and - * whether that match is what the anchor MEANT. - * - * ## The defect this ledger answers - * - * `SELF_TEST_DECL` decides "this is a self-test" from the declaration's NAME. - * A name is not a role, so the anchor also fires on production code whose name - * merely spells self-test — and when it does, `maskSelfTests` blanks that - * production body, and `extractWatchHints` never sees the paths in it. The - * failure direction is SILENCE: a hint that is never extracted cannot be - * missed, so a gate family quietly stops being derived for a file it really - * opens. - * - * The specimen that opened this is `maskSelfTests` itself, six lines above: - * `mask` + `Self` + `Test` + `s` matches, so this module's masker blanks its own - * body whenever the module scans itself. - * - * ## The census, re-derived on this tree - * - * 275 code-position matches over the tracked JS/TS corpus. 244 are the bare - * `selfTest`; the remaining 31 carry compound names over 28 distinct spellings, - * and they are the rows below. Twenty are genuine self-test batteries — the - * anchor firing on them is the anchor working. ELEVEN are production code: - * - * scripts/check-self-test-wired.mjs carriesSelfTest - * scripts/check-self-test-workflow-commands.mjs runSelfTest - * scripts/check-step-collectors.mjs selfTestTargets - * scripts/check-step-collectors.mjs selfTestDiscoveries - * scripts/measure-durability-swallow-family.mjs selfTestMode - * scripts/measure-self-test-floor.mjs selfTestDefs - * scripts/pm/dispatch-gates.mjs selfTestOnlyCallables - * scripts/pm/dispatch-gates.mjs maskSelfTests - * scripts/pm/dispatch-gates.mjs selfTestCaseLines - * scripts/pm/dispatch-gates.mjs selfTestOnlyInvocation - * scripts/pm/dispatch-gates.mjs declaredSelfTestReads - * - * Every one of them is a gate that REASONS ABOUT self-tests, which is why they - * cluster: a tool that finds, spawns, counts or masks other scripts' self-tests - * names its functions after the thing it handles, and the anchor cannot tell - * "runs a self-test" from "is one". - * - * ## What it costs today: nothing, MEASURED, and that is the whole point - * - * Neutralising each of the ten one at a time and re-extracting moves no hint - * in any of the six files. The claim is therefore live rather than recalled — - * and it is exactly the kind of claim that stops being true without anything - * going red, which is what the pin in this module's self-test exists to catch. - * - * The same measurement, redone over the table's current twenty genuine rows, - * is still NOT zero, and that asymmetry is what makes the classification - * load-bearing rather than decorative: `fixtureSelfTest` drops - * `packages/spec/spec-changes.json` and `prePushIsArmedSelfTest` drops - * `.githooks/pre-push`, both fixture paths in `scripts/check-regen-pending.mjs`, - * both correctly refused. So "no - * compound-name match may contribute a hint" is FALSE as a blanket invariant; - * the invariant holds only over the accidental half, and only a classification - * can name that half. - * - * ## ⛔ Why the anchor is NOT narrowed, and why nothing is special-cased - * - * The obvious repairs were both refuted by the census rather than judged: - * - * - **Narrowing the name pattern is impossible.** `runSelfTest` is a GENUINE - * entry point in `scripts/check-turbo-task-graph.mjs`, reached only from - * that file's `--self-test` guard, and ACCIDENTAL in - * `scripts/check-self-test-workflow-commands.mjs`, where it is exported and - * spawns other scripts' self-tests from the gate body. One spelling, both - * classes. No predicate over the name can separate them, so any narrowing - * that excludes the accidental one also unmasks a real self-test battery and - * readmits its fixture paths as hints — the fabricated-lead family this - * whole masker exists to refuse, traded for a silence that costs nothing. - * - **Special-casing this module's own path fixes four rows of ten.** The - * other six live in five other files, so the objection that a rename - * "fixes one instance and leaves the class" applies to it too, one file - * wider — and it would make the tool's self-scan differ from every other - * scan, which is a hazard of its own. - * - * ⇒ What ships is neither. The anchor keeps firing on all 31, the mask keeps - * blanking all 31, and the cost of the eleven accidental ones is MEASURED on - * every run instead of asserted in prose. Silence was the defect; the remedy is - * noise on the day it starts costing something. - * - * ## Maintaining this table - * - * A compound-name declaration this table does not list reds - * `check:pm-dispatch-gates`. Classify it and add a row: `accidental: false` if - * it is a self-test battery (its fixtures SHOULD be masked away), `true` if it - * is production code the anchor caught by accident — in which case the pin then - * measures, and keeps measuring, that masking it costs no hint. ⛔ Do not - * "repair" a red by renaming the function to dodge the anchor: the row is the - * record, and the next accidental name is the one nobody will notice. - * - * The TOTAL / GENUINE / ACCIDENTAL counts stated above are pinned the same - * way (#15310): `--self-test` computes them fresh from this table and checks - * the docblock's own prose against that computation, never against a second - * hand-typed constant. Prose that drifts from the table reds there, instead - * of drifting further unnoticed the way it had — twice — by the time #15310 - * measured it. - */ -const COMPOUND_ANCHOR_LEDGER = [ - ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'specSelfTest', false], - ['packages/lint/scripts/check-doc-formula-expressions.mjs', 'fieldRuleSelfTest', false], - ['scripts/audits/14744-before-update-per-row-value-census.mjs', 'runSelfTest', false], - ['scripts/check-comment-mask-corpus.mjs', 'runSelfTestCases', false], - ['scripts/check-doc-authoring.mjs', 'selfTestRule3', false], - ['scripts/check-doc-authoring.mjs', 'selfTestPackagesProse', false], - ['scripts/check-durability-degradation-log-level.mjs', 'checkSelfTestFloor', false], - ['scripts/check-durability-degradation-log-level.mjs', 'selfTestReadSeams', false], - ['scripts/check-platform-checklist.mjs', 'selfTestTrapVocabulary', false], - ['scripts/check-platform-checklist.mjs', 'selfTestProvisioningUse', false], - ['scripts/check-platform-checklist.mjs', 'selfTestUnreferencedRecipes', false], - ['scripts/check-platform-checklist.mjs', 'selfTestMetaCallSpelling', false], - ['scripts/check-platform-checklist.mjs', 'selfTestLineCitationBinding', false], - ['scripts/check-platform-checklist.mjs', 'selfTestSymbolAnchors', false], - ['scripts/check-platform-checklist.mjs', 'selfTestPlannedStatus', false], - ['scripts/check-regen-pending.mjs', 'fixtureSelfTest', false], - ['scripts/check-regen-pending.mjs', 'prePushIsArmedSelfTest', false], - ['scripts/check-regen-pending.mjs', 'decisionTableSelfTest', false], - ['scripts/check-turbo-task-graph.mjs', 'runSelfTest', false], - ['scripts/check-workspace-manifest-cycles.mjs', 'runSelfTest', false], - ['scripts/check-self-test-wired.mjs', 'carriesSelfTest', true], - ['scripts/check-self-test-workflow-commands.mjs', 'runSelfTest', true], - ['scripts/check-step-collectors.mjs', 'selfTestTargets', true], - ['scripts/check-step-collectors.mjs', 'selfTestDiscoveries', true], - ['scripts/measure-durability-swallow-family.mjs', 'selfTestMode', true], - ['scripts/measure-self-test-floor.mjs', 'selfTestDefs', true], - ['scripts/pm/dispatch-gates.mjs', 'selfTestOnlyCallables', true], - ['scripts/pm/dispatch-gates.mjs', 'maskSelfTests', true], - ['scripts/pm/dispatch-gates.mjs', 'selfTestCaseLines', true], - ['scripts/pm/dispatch-gates.mjs', 'selfTestOnlyInvocation', true], - ['scripts/pm/dispatch-gates.mjs', 'declaredSelfTestReads', true], -]; +// `COMPOUND_ANCHOR_LEDGER` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. /** * The ledger as `"::"` keys. `runSelfTest` alone proves the key has @@ -9449,7 +9047,7 @@ export const PROGRAM_TEXT_TARGET = /\.(?:[cm]?[jt]sx?|sh)$/; * would be over-masked by that default, which is a missing lead rather than a * fabricated one — the direction the two scans next door both state. */ -function hashCommentProgram(scriptPath) { +export function hashCommentProgram(scriptPath) { return ( typeof scriptPath === 'string' && PROGRAM_TEXT_TARGET.test(scriptPath) @@ -9596,60 +9194,7 @@ export function governedReadCensus({ files = null, read = null } = {}) { return rows; } -/** - * The governed reads this tree carries, pinned — a FLOOR the next card lowers, - * never a list this derivation reads (#18673). - * - * Three rows at the landing of this card, measured by `governedReadCensus`: - * - * scripts/check-commit-card-trailers.mjs .claude/agents/os-dev.md undeclared - * scripts/pm/check-expected-skips.mjs .claude/skills/pm-dispatch/SKILL.md DECLARED - * scripts/pm/check-settings-deny-roster.mjs .claude/settings.json undeclared - * - * ## Why the two undeclared rows are not declared HERE - * - * Both are already MATCHED for the file they read, through a key this card did - * not add: each spells its governed path in its MODULE BODY as well as in its - * self-test, so `extractWatchHints` sees it and the derivation names the family - * for a card touching it — measured, both ways, at this landing. They cost - * nothing today, and declaring them would widen this PR onto two gates it was - * not dispatched to. They are listed so the next reader inherits the - * measurement rather than re-deriving it. - * - * ## What each column makes fail - * - * A row whose READ disappears reds (the census no longer finds it) — which is - * what makes the pin on this class unsatisfiable by deleting the read. A NEW - * governed read reds until it is classified here. A row whose `declared` flag - * changes reds, in both directions: a declaration added is a floor to lower, - * a declaration deleted is this card's defect coming back. - * - * ## The fourth row: a gate whose SUBJECT is a published skill - * - * `scripts/check-skill-top-level-keys.mjs` reconciles the top-level key - * enumeration in the published platform skill against the stack schema, so - * the governed file is its input, not a fixture; it spells that path in its - * module body and is MATCHED for it the same way the two undeclared rows - * above are, so `declared` is false for the same reason. - */ -export const GOVERNED_READ_FLOOR = Object.freeze([ - Object.freeze({ script: 'scripts/check-commit-card-trailers.mjs', file: '.claude/agents/os-dev.md', declared: false }), - Object.freeze({ - script: 'scripts/check-skill-top-level-keys.mjs', - file: 'skills/objectstack-platform/SKILL.md', - declared: false, - }), - Object.freeze({ - script: 'scripts/pm/check-expected-skips.mjs', - file: '.claude/skills/pm-dispatch/SKILL.md', - declared: true, - }), - Object.freeze({ - script: 'scripts/pm/check-settings-deny-roster.mjs', - file: '.claude/settings.json', - declared: false, - }), -]); +// `GOVERNED_READ_FLOOR` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. /** * ── The PROGRAM a gate RUNS: the third spelling of the same fact (#13511) ─── @@ -10740,62 +10285,10 @@ export function escapableLiteralKey({ check, hint }) { } /** - * ⛔ SHRINK-ONLY. The gates whose declared population is a bare top-level word - * the tree HAS, and which have not declared the subtree spelling for it. - * - * It is a DEBT list, not an exception list, and the same property makes it safe - * that makes `KNOWN_IMPORT_UNSAFE` safe (#10665): every entry has one remedy — - * declare the subtree spelling beside the literal, the `ROOT_DIR_WATCH_HINTS` - * idiom — and no entry records a judgement anyone has to re-make later. There - * is no supported route in the other direction: a family this rule newly - * reaches is a FAILURE with that one remedy, never a new line in here. An entry - * whose gate has since taken the escape fails as STALE and names itself, which - * is what stops the list from rotting into an allowlist nobody re-reads. - * - * Both halves are asserted in this file's self-test, against the live tree, - * and `check:pm-dispatch-gates` runs that self-test on every pull request. So a - * gate written tomorrow that spells a bare root word fails at AUTHORING time - * rather than landing invisible — which is the half of this class the six - * historical instances could not fix, because each of them was archaeology. - * - * ⚠️ Spelling rule for a new row: it must not become a watch hint of THIS file. - * `extractWatchHints` reads any quoted span carrying a separator, so a family - * keyed by a direct script path (`node scripts/check-x.mjs`) would enter this - * file's own declared population as a path it does not read — the same trap - * `DEFAULT_BASE_REF` is assembled in two halves to avoid. Spell such a row so - * it carries no separator, or join it at runtime. A self-test case below holds - * this, so the rule fails rather than needing to be remembered. - */ -const ESCAPABLE_LITERAL_LEDGER = new Set([ - // EMPTY — a verdict, not an absence. Every gate this derivation can SEE - // spelling a bare top-level root has been discharged. Both instances are - // recorded here because they took the two DIFFERENT remedies the idiom - // allows, and a future FRESH row has to CHOOSE between them rather than - // reach for the first one it is offered: - // - // DISCHARGED (#10784): `check:parse-guard scripts`. That gate really does - // walk the repo's scripts/ tree, so declaring the subtree spelling beside - // the literal was TRUE. The row then failed as STALE by name and came out — - // the shrink the docblock above describes, walked once end to end. - // - // DISCHARGED (#10875): `check:published-files scripts`. That gate reached - // `scripts` through a package-relative predicate over would-be tarball - // contents, NOT through the repo root it appeared to name, so the escape - // hatch was the wrong remedy for it: a `scripts/**` declaration would have - // been false, and would have named the gate for every repo-root scripts/ - // edit it does not read — a fabricated lead, which `hintCovers`' docblock - // prices above a missing one. It took the OTHER remedy the idiom allows, - // stop spelling a bare root, and the row discharged by CONSTRUCTION rather - // than by declaration: nothing was added to this list to make it happen, - // which is the shape a shrink-only list wants. - // - // ⚠️ An empty ledger is NOT "the species is gone", and must not be read as - // one. This list only ever saw the half the derivation can see — a literal - // that reaches the HINT SET, which needs a separator somewhere in the source - // spelling. #10840 measures the other half at ~33 gates whose population - // literal carries no separator at all; those are invisible to the derivation - // AND to this ledger, and emptying this list moves none of them. -]); + * The ledger's rows are data (`ESCAPABLE_LITERAL_LEDGER_ROWS` in `dispatch-gates.data.mjs`, with the + * docblock that governs them); this binding is the Set the derivation reads. + */ +export const ESCAPABLE_LITERAL_LEDGER = new Set(ESCAPABLE_LITERAL_LEDGER_ROWS); // --------------------------------------------------------------------------- // Change-kind derivation — the gates a path match can never reach @@ -11277,455 +10770,31 @@ export function reachesMetadataFormModule(path, modulePaths) { } /** - * Gates that fire on what a change IS, keyed by a mechanically-detectable - * convention. Everything else in this script is derived at runtime and lists - * nothing; this table is the one exception, and it is bounded on purpose. - * - * ## Why these cannot be derived like the rest - * - * The path derivation matches a gate when the gate's own source names a - * directory that covers your file. Every gate here computes its population - * instead of naming it, so no source carries a literal to match: - * - * - the two type-check gates — one lints a glob set that lives in the shared - * ESLint config, the other walks the workspace members — sit permanently - * in the "undetermined" bucket; - * - the three test-file RATCHETS (`check:query-options-erasure`, - * `check:engine-double-contract`, `check:where-matcher`) each walk the tree - * for `*.test.*` files and reconcile the count against a baseline JSON. For - * two of the three, what the source names is that baseline — an artifact - * roster, never the population — so those two score `silent` for every card - * in the tree, and they HAVE hints, so the "undetermined" bucket never sees - * them either. Before this entry named them they were printed in NEITHER - * half of the output for every card in the tree. - * - * ⛔ Their hint sets are NOT transcribed here, and a freshly re-measured - * copy must not be put back. The copy that used to sit here listed all - * three sets and read as measured; by the time anyone re-derived it, - * exactly one of its five claims — the `check:engine-double-contract` row — - * was still true. It named a git ref as a hint for two of the rows, a class - * `isNonPathNamespace` refuses (this file's own self-test pins that - * refusal); it gave `check:query-options-erasure` a one-hint set its source - * has since outgrown; and it drew the conclusion below from both. Not one - * of those drifts touched THIS file, so nothing here could have reported - * them. `--residue` prints every family's live `names:` set and re-derives - * it on every run: that is the authority for this question, and a reader - * who wants the sets should run it rather than trust a paragraph. - * - * ⚠ `check:where-matcher` is the exception to the paragraph above, and it - * stays in this entry anyway. Since #13231 its source declares its - * `*.test.ts` population as a literal, so the ORDINARY path derivation - * MATCHES it for a test file under `packages/` and it is no longer silent. - * That is the shape the `check:cross-package-test-inputs` measurement in - * the deletion criterion below describes, and it is answered the same way: - * the declaration is set-equal to that gate's own walk, which is rooted at - * `packages/`, so it reaches no test file outside that root, while the KIND - * reaches every one — and the KIND reaches a card dispatched BEFORE its - * code exists, which no path derivation can. Two routes to one gate is - * redundancy, not a defect; the KIND is the load-bearing one. That gate's - * own source says the same thing in the docblock above its literal, so - * neither side of the pair asserts it alone. - * - * Every membership claim in the two paragraphs above is re-derived in - * `--self-test` against the live tree rather than restated here, so a tree - * that moves one turns a case RED instead of leaving this prose quietly - * false — which is the failure this entry has now paid for twice; - * - `check:i18n` walks `packages/` at runtime for files NAMED - * `i18n-extract.config.ts` and re-extracts each owning package's bundles. - * Its source names only three hints (measured, post-#9144): the shared - * walk module (SURFACE_MODULE) and the two metadata-registry coupling - * constants below — none of them the OWNING-PACKAGE population this entry - * answers for. So it still matches nothing on an ordinary object/field - * edit AND, having hints, never reaches the "undetermined" bucket either: - * before this entry existed, an edit to - * `packages/services/service-messaging/src/objects/` — which regenerates - * that package's four bundles — printed the gate in NEITHER half of the - * output. A gate the derivation cannot mention at all is the one shape - * this script must not produce; it cost a PR a CI round. - * - * No per-card gate list derived from paths can ever name these, however the - * derivation improves. - * - * ## Why a named table and not a wider heuristic - * - * The tempting generalisation — scan every discovered check script for - * `*.test.ts`-shaped literals and call those the test-sensitive gates — was - * measured against this tree and names 22 families, because a script's source - * mentions test paths in its fixtures, its self-test and its comments. - * "Mentions a test file" is not "counts test files", and 22 leads is the same - * as none. So the pair is written down, and the cost of writing it down is paid - * back by the two properties below. - * - * ## Why the two ratchets below joined this entry (#8632) - * - * `check:engine-double-contract` and `check:where-matcher` were handed to the - * PM's judgment in this file's closing prose instead of being derived. Three - * measured instances, all of them CI rounds, say that boundary was in the wrong - * place — and the deciding evidence is not the incidents but a structural - * identity with an entry that was already here. - * - * All three ratchets discover their population the same way: a walk collecting - * `*.test.*` files (`scripts/check-engine-double-contract.mjs`, `walk` at ~line - * 342, `/\.(test|spec)\.(ts|tsx|mts)$/`; `scripts/check-where-matcher- - * conformance.mjs`, corpus walk at ~line 562, `/\.test\.ts$/`), reconciled - * against a shrink-only baseline. `check:query-options-erasure` has sat in this - * entry for exactly that reason. Naming one of three and calling the other two a - * judgment call was an inconsistency in this table, not a considered line. - * - * The noise objection recorded on the card — a path-level trigger fires on test - * files that contain no fake engine at all — is real and is answered by what - * these gates cost to run rather than by narrowing the trigger. Each is a - * whole-tree shrink-only ratchet: one invocation answers for the entire tree, - * needs no build, and prints the offending file and line when it fails. A seat - * that runs one needlessly loses seconds; a seat that is never prompted loses a - * CI round, which is what all three instances did. The trigger deliberately does - * NOT read the file's contents to confirm a double is present: a card is - * dispatched BEFORE its code exists, so the double the gate will object to is - * usually not on disk at derivation time — the second instance added one to a - * file that already had one, the first added the file itself. - * - * This is the shape the "22 leads" note rejects a heuristic for, and it survives - * that objection because the trigger is the gates' own population test, mirrored - * (`isTestFilePath`), not a guess at which scripts look test-flavoured. - * - * ## What the two i18n entries still refuse to list - * - * Neither `matches` enumerates anything. The first walks for the packages that - * own a bundle, the second for the tree's metadata form modules, and both walks - * are the GATE'S OWN, imported from `scripts/i18n-bundle-surface.mjs` rather - * than mirrored here. That import is the fix for a defect this file used to - * carry in its own comment: `findI18nBundlePackages` was a hand-written copy - * described as mirroring `findConfigs` "exactly", which is a second contract - * with no way to report the day it stopped agreeing. What is written down here - * is the KIND, not its population, so a tenth package growing a bundle — or an - * eighteenth form module — is matched by the next run with nothing to update. - * - * ## Why the SECOND i18n entry exists (#9116) - * - * The owning-package entry answers for one of a bundle's two producers. The - * `objects` half is enumerated by the config's own package, so the owning - * package is the trigger surface. The `metadataForms` half is registry-driven - * and identical for every stack, so exactly ONE package commits that baseline - * (`platform-objects`; every other config passes `--no-metadata-forms`) while - * its source sits in `packages/spec`, which owns no extract config and which - * the gate's walk never reaches. - * - * Measured, and paid for once: PR #9113 added two form entries in - * `packages/spec/src/data/`, four `platform-objects` metadata-form bundles - * moved, `check:i18n` reddened on CI, and the dev's diff-derived gate union - * could not have named the family — the path derivation misses it for the - * reason stated above, and the owning-package entry does not cover - * `packages/spec`. Cost: one CI round trip plus a patch commit. The invariant - * the card states is the one this entry restores — a gate a diff can move must - * be derivable FROM that diff; `undetermined` is an honest unknown, not a - * standing blind spot on a known edge. - * - * Its applicability is read, never assumed: `metadataFormsSurfaceIsExtracted` - * asks the configs' own documented flags whether any package still commits that - * baseline. The day the last one opts out, no form module can move a committed - * bundle and this entry stops firing on its own. - * - * ## Why there is no THIRD i18n entry, for the type-registry edge (#9144) - * - * `walkMetadataForms` has a second edge the SECOND entry above does not reach: - * `DEFAULT_METADATA_TYPE_REGISTRY` (packages/spec/src/kernel/metadata-plugin. - * zod.ts) supplies `metadataForms..label`/`.description` for EVERY - * registry entry, including form-less types, and `METADATA_FORM_REGISTRY` - * itself (packages/spec/src/system/metadata-form-registry.ts, the map, not - * the `*.form.ts` leaves it points at) decides which types get section/field - * labels at all. Editing either moves the same bundles PR #9113 paid for — - * but unlike the `.form.ts` leaves, neither file carries a filename the - * `.form.ts` convention (or any convention) distinguishes, so a KIND entry - * here would need to invent one for exactly two files. - * - * That is not the same shape as the two entries above: this is not a - * runtime-enumerated population at all, it is two SPECIFIC, KNOWN files — - * the shape `SURFACE_MODULE` and `check-type-check-coverage.mjs`'s - * `ROOT_PROGRAM_COUPLED_SCRIPT` already use. So it is closed there instead: - * `check-i18n-bundles.mjs` declares both paths as bare module-body coupling - * constants (`METADATA_TYPE_REGISTRY_MODULE` / `METADATA_FORM_REGISTRY_ - * MODULE`), which the ORDINARY path-literal derivation now reads directly off - * that gate's own source — no `CHANGE_KIND_GATES` entry, no `matches` - * function, nothing here to keep in sync. See that pair's doc comment in - * check-i18n-bundles.mjs for the full reasoning, and this file's own - * self-test for the live pins that keep the constants honest as the coupling - * they are: manual, per-file, and silently rottable if nothing watched it. - * - * ## Why the ROOT-program entry does not weaken the ledger's discipline (#9873) - * - * `check-type-check-coverage.mjs` states, in its own source, the very thesis - * the card for this entry was filed to argue: "The root program is everything - * outside packages/apps/examples, so no list here can ever be complete -- add a - * constant when a coupling has actually been measured, the way this one was." - * That is a deliberate policy, written by the gate's author before the card - * existed, and the entry below is a proposal against it. So it owes an answer - * rather than a shrug. - * - * The answer is that the two lists answer different questions, and only one of - * them is a measurement. - * - * The gate's ledger records MAGNITUDE. `ROOT_PROGRAM_COUPLED_SCRIPT` does not - * merely say "this file is in the program" — it carries a measured claim, that - * the file accounts for 29 of that entry's 80 errors, and the ledger note - * spends that number. A rule that manufactured such constants automatically - * really would register couplings nobody had measured, and the refusal is - * right. This entry adds no constant, moves no count and asserts no magnitude: - * the ledger keeps exactly the one measured coupling it has today, with every - * reference to it intact. - * - * This table records RELEVANCE — which gate a seat is told to run before it - * pushes. That claim needs no measurement to be true, because it is already - * settled by a file the repo maintains for another purpose entirely: a path is - * in the root program when the root tsconfig does not exclude it. Nothing here - * is kept in sync by hand, so there is no second contract to rot, and the - * measured-couplings rule the gate states about ITS list is untouched. - * - * What leaving the two questions merged cost, once, in the expensive direction: - * PR #9853 added a single file under a directory this entry now covers, derived - * its gate union with this script at final head, ran both gates the run named - * and reported them green — then CI failed the type-debt ratchet with 19 new - * errors from that one file. The gate worked exactly as designed. Nobody could - * know to run it, and the repair most available at that point is the one the - * gate's own text calls maintainer-only. - * - * ⚠ Its known limit, stated here rather than discovered later: `exclude` drops - * files only from tsc's INITIAL WALK, so sources under an excluded directory - * that a root script IMPORTS are pulled into the program anyway — the ledger - * note for this entry records 4 of its 80 errors arriving exactly that way, - * from the showcase example. A path-shaped trigger cannot see an import graph, - * so a card editing only such a file is still not sent here. That is - * deliberately NOT closed on this card: the direction is the safe one (this - * entry under-covers rather than over-covers), and closing it means resolving - * the program INCLUDING imports, which is a different tool than a path - * predicate and a different card's scope. - * - * ## How these entries stay honest - * - * - Every `name` here is resolved against the families actually discovered in - * the workflows at runtime. A gate that is renamed, retired or dropped from - * 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 (the two `./packages` globs, one flat and - * one nested) to THIS file's own hint set as hints. That spelling is now - * LOAD-BEARING rather than - * merely tidy: it used to be inert as well, because `hintCovers` refused a - * hint that COLLAPSED to a bare top-level directory, and since #9626 that - * refusal reads the hint as written — `packages/*` carries a separator, so - * quoting it here would make this file's own prose match every card under - * `packages/`. Leave the filter values unquoted. A gate list that fabricates - * hints out of its own explanations is the failure this whole script is - * written against, and this is the one place in the tree where the trade is - * live rather than hypothetical. - * - Every `name` here is checked against the LIVE workflows by the self-test, - * not only by the run that happens to print it. The STALE branch reports rot - * to whoever is looking at the output; the self-test case makes the same rot - * fail CI, because this table's names are the one enumerable list in the file - * and an enumerable list is one a guard can hold. - * - 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. For the - * three ratchets that means a literal naming their POPULATION — the baseline - * path each already carries is their own output, and a card editing a - * baseline matches through it today without making the gate derivable for - * anybody else. - * - * ⚠ "A literal naming their POPULATION" is the whole of that criterion, - * and one gate in this kind now fails it while READING as satisfied. - * Measured on this tree (#11199, the day PR #12300 landed): of the 2773 - * tracked test files, the hint route names `check:cross-package-test- - * inputs` for 2760 of them — 99.5%, against 0–3.3% for its five siblings - * in this same kind — because #12300 taught `hintCovers` to read a glob in - * a non-final segment and the deep `packages` glob for TypeScript files - * came back to life. (That glob is not spelled here: its own wildcard - * closes a block comment.) The hint - * is neither this gate's population nor even its own literal: it is - * INHERITED from the declaration table the gate imports, where it is ONE - * package's declared turbo `inputs` glob (`@objectstack/core`'s, wide - * because a single pin test there walks the whole repo with `git - * ls-files`). It is a row the gate JUDGES, not a population the gate - * DECLARES — so it narrows the day that package's declaration narrows, - * which is the direction the gate's own repair advice pushes. And even at - * 99.5% it reaches no test file outside `packages/**` (10 tracked today, - * all under `examples/**`), none with a `.tsx` suffix (3 today, all in - * client-react), and none under `apps/**` the day one arrives — while - * the KIND reaches every one of them, because the trigger really is "a - * test file's content changed, full stop". Both routes are kept (two - * routes to one gate is redundancy, not a defect); the KIND is the - * load-bearing one. The residue and the inheritance are pinned in the - * self-test, so the next reader re-points a red case instead of - * re-deriving this paragraph. - * - i18n entry: when `check-i18n-bundles.mjs` stops discovering its targets - * at runtime and names its POPULATION in its own source — a literal each - * owning package path starts with — the path half matches and this entry - * is redundant. Growing more prerequisite paths does not qualify; that is - * what it already has. - * - metadata-form entry: when every extract config passes - * `--no-metadata-forms`, no form module can move a committed bundle. That - * 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. - * - status entry: on the same criterion as the error-code entry. The - * conformance gate's source would have to declare the population it walks - * in a form this derivation can read, and that means its bare-root ledger - * row moving off REFUSE-WIDE, which takes a ruling. ⛔ Growing the gate's - * derivation rules does NOT qualify, and ⛔ neither does this predicate - * going quiet on a card, for the reason given above. - * - 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 - * this entry is redundant. Growing more measured coupling constants does - * NOT qualify: each names one file, and this entry exists for the files - * that have no constant yet, which is every new one. - * - gate-script entry: when BOTH gates it names declare the population they - * judge in a form this derivation can read, the ordinary path match names - * them and this entry is redundant. Neither can today, and the reasons - * differ, so the criterion is met only when both move: - * `bare-root-worklist` walks every family's own files and declares nothing - * (deliberately — recognising its species needs a heuristic over constant - * NAMES, and #10705 refused to put one on the path that derives every PR's - * gate list, which is why this entry names the gate rather than importing - * its verdicts); `check:pm-dispatch-gates` declares three tracked FILES, - * an artifact roster this tool itself flags as "the shape that reads as a - * clearance and is not", so it reaches a card by gate-script identity - * alone. ⛔ Growing more roster entries does NOT qualify — that is what it - * already has. ⛔ Nor does either gate happening to go quiet: three of the - * five measured instances involved a green that proved nothing, because a - * sweep that cannot see your file is not evidence about your file. - * - * - * Delete an entry the day its criterion is met, not before. - */ -export const CHANGE_KIND_GATES = [ - { - kind: 'adds or edits a test file', - matches: isTestFilePath, - gates: [ - { - name: 'check:query-options-erasure', - why: 'its test-surface ceiling counts sites in *.test/*.spec files, so new test code moves it', - }, - { - name: 'check:type-check-coverage', - 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)", - }, - { - name: 'check:engine-double-contract', - why: 'it walks every *.test.* file for fake engine doubles and fails when one declares delete()/update() without routing through assertEngineDeleteDispatch/assertEngineUpdateDispatch, against a shrink-only per-file baseline. A new double, or a new test file carrying one, moves it — and so does a delegating pass-through seam wrapping a real engine, which is the reading that missed it twice. Repair by fixing the double, never by raising the baseline. Cheap and whole-tree: one run answers for the whole repo and names the file and line', - }, - { - name: 'check:cross-package-test-inputs', - why: "it walks packages/, apps/ and examples/ for tests that read or import OUTSIDE their own package, and fails when turbo.json's `inputs` for that package does not declare what the test really reads — so a new test, or a new cross-package read in an existing one, moves it. Listed as a KIND rather than by path (#10542): its walk covers 5263 tracked files to judge the 2611 test files among them, so a subtree declaration would name it at 49.6% precision, while the kind names it at the granularity it actually judges. Repair by declaring the input in turbo.json, never by moving the fixture", - }, - { - name: 'check:where-matcher', - why: 'it walks every *.test.ts file for hand-written WHERE matchers and fails on a NEW silently-wrong one (a combinator read as a field name), against a shrink-only baseline. It rides the same test code as the double gate — one new fake engine tripped both, one round apart, because these steps run sequentially inside the ESLint job and the first failure aborts the rest. Conforming by REFUSING the unsupported shape is the convention most of the discovered matchers already follow; the suite cannot notice this class, which is why the gate exists', - }, - ], - }, - { - kind: 'edits a file in a package that owns an i18n-extract.config.ts', - matches: (path) => isInI18nBundlePackage(path, i18nBundlePackageDirs()), - gates: [ - { - name: 'check:i18n', - why: "it re-extracts every owning package's translation bundles and fails on drift, so any edit that changes what the extractor emits (an object definition, a label, the config itself) moves it — regenerate with `node scripts/check-i18n-bundles.mjs --write`", - }, - { - name: 'check:i18n-stale-fill', - why: "REVISING an existing source string (a label, description or help text) is the move `check:i18n` cannot see: the extractor's merge fills gaps only, so the regeneration rewrites `en` and LEAVES the previous source text in every translated locale — in sync by key, green gate, superseded draft served forever (#11671). This ratchet fails when a NEW leaf goes stale that way. It needs no build. If your revision stranded a leaf, re-translate it and commit the bundle; regenerating does NOT fix it, because a present-but-stale string is not a gap", - }, - ], - }, - { - kind: 'edits a metadata form module (a *.form.ts the Studio form registry collects)', - matches: (path) => metadataFormsSurfaceIsExtracted() && reachesMetadataFormModule(path, metadataFormModulePaths()), - gates: [ - { - name: 'check:i18n', - why: "the metadataForms half of the bundles is registry-driven, so a form's sections, field labels, helpText or placeholder are extracted into ONE package's committed bundles — platform-objects today — and a form edit drifts them from a package your diff never touches. This is the edge PR #9113 paid a CI round for. Same repair as the entry above: regenerate with `node scripts/check-i18n-bundles.mjs --write` and commit the moved bundles", - }, - ], - }, - { - kind: 'adds or edits a GATE SCRIPT (a file some discovered check family runs)', - matches: (path) => isGateScriptPath(path, gateFamilyFiles()), - gates: [ - { - name: 'scripts/pm/bare-root-worklist.mjs --self-test', - why: 'a gate whose population is spelled as a BARE top-level word (a separator-less string such as the one naming the package root) builds no watch hint at all, so it lands unnameable by every dispatch brief — and this self-test refuses the tree until a verdict for it is RECORDED. That obligation is a ledger row, not a command, so no amount of running the families you were given surfaces it: four devs learned it from red CI instead, twice within one hour, each AFTER reporting. Three directions bite, which is why an EDIT counts and not only an add: FRESH (a new invisible population, unjudged), STALE (a recorded verdict whose row you renamed or removed), CONTRADICTED (you declared a hint on a gate whose recorded verdict says the population cannot be spelled). The remedy is the one the failure text names: REFUSE-WIDE, REFUSE-UNSPELLABLE, or the subtree-glob idiom beside the constant. ⛔ Declaring a root the gate does not really read is the costlier error, and ⛔ the map is shrink-only, so a new row is never a remedy for a stale one', - }, - { - name: 'check:pm-dispatch-gates', - why: 'the SECOND obligation of the same shape, in this tool, and the one it cannot name for you: a gate that declares a bare top-level word the tree HAS joins the escapable-literal species, and this gate refuses the tree until the literal is either respelled or recorded. It reaches your card by gate-script IDENTITY only — its own declared literals are an artifact roster rather than a population — so a card that merely INCURS the obligation is never named by the path derivation, which is measured, not suspected. Two remedies and which is right depends on what your gate actually READS: it really does walk that root, so declare the subtree spelling beside the literal; or it does not, so respell the literal to say what the predicate means. ⛔ Do not reach for the first by default, and ⛔ the ledger is shrink-only', - }, - ], - }, - { - kind: 'adds or edits TypeScript in the ROOT tsc program (outside the directories tsconfig.json excludes)', - matches: (path) => isInRootTsProgram(path, rootTsProgramExcludedDirs()), - gates: [ - { - name: 'check:type-check-debt', - why: 'the ROOT ledger entry (@objectstack/spec-monorepo) IS this program, so a file here moves its raw tsc count even though your diff touches no package — measured, one added bench file put it 19 over and cost a CI round. It is a shrink-only ratchet: the repair is to make the file typecheck, and raising the entry is maintainer-only — ⛔ MAINTAINER-ONLY under the #8435 convention — never the co-equal option. Most of this class is one missing setting rather than real breakage — the root config carries lib ES2020 and no types, so process and console are absent unless the file declares them ambiently. 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)', - }, - ], - }, - { - 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', - }, - ], - }, - { - kind: 'adds or edits a file that binds an HTTP STATUS to an error response (judged from CONTENT — no path derivation can name this gate)', - matches: emitsAnHttpStatus, - gates: [ - { - name: 'check:error-status-conformance', - why: 'it derives every (code, HTTP status) pair the non-test TypeScript sources under the package root can emit (an error class declaring both, the four-argument sendError door, a code-and-status or status-and-body terminal, an assignment pair on one error) and reconciles that set in BOTH directions with the statuses the error catalog and the error-handling page publish. So a status added, changed or removed at an emit site moves it, and so does a status constant another file resolves. This is a gate no path derivation can name: it walks a bare top-level root that the bare-root ledger records as REFUSE-WIDE, so it scores the same quiet silence for every card, and PR #22311 paid a CI round trip for that silence (#22320). It needs NO build, being a source scan in one pass over the whole tree, and it names the code, the status and the emit site. Repair by DOCUMENTING the status on that code entry (an exception line when the code already publishes another status), or by correcting the emit site when the status is the defect. ⛔ Never by admitting the code to the unpinned baseline, which is ⛔ MAINTAINER-ONLY under the #8435 convention. ⚠ This lead fires on any file that binds a status value, not only one that changes it, for the trade the vocabulary entry above states: the wasted run is one cheap gate and the miss is a CI round trip', - }, - ], - }, -]; + * The change-kind roster is data: `CHANGE_KIND_ROWS` in `dispatch-gates.data.mjs` carries each kind, the + * NAME of the predicate that recognises it, and the gates it names — with the docblock that governs the + * table. This binding resolves each predicate name to the function below, and refuses a name it does not + * know at load time, so a row that spells a predicate wrongly can never match nothing in silence. + */ +const CHANGE_KIND_PREDICATES = Object.freeze({ + 'test-file': isTestFilePath, + 'i18n-bundle-package': (path) => isInI18nBundlePackage(path, i18nBundlePackageDirs()), + 'metadata-form-module': (path) => metadataFormsSurfaceIsExtracted() && reachesMetadataFormModule(path, metadataFormModulePaths()), + 'gate-script': (path) => isGateScriptPath(path, gateFamilyFiles()), + 'root-ts-program': (path) => isInRootTsProgram(path, rootTsProgramExcludedDirs()), + 'error-code-literal': stampsAnErrorCodeLiteral, + 'http-status-emit': emitsAnHttpStatus, +}); + +export const CHANGE_KIND_GATES = CHANGE_KIND_ROWS.map((row) => { + const matches = CHANGE_KIND_PREDICATES[row.matches]; + if (typeof matches !== 'function') { + throw new Error( + `dispatch-gates: change-kind row '${row.kind}' names the predicate '${row.matches}', which this engine does not define — ` + + `known: ${Object.keys(CHANGE_KIND_PREDICATES).join(', ')}`, + ); + } + return { ...row, matches }; +}); /** * Render the convention-triggered section. Pure over its inputs so the @@ -12550,251 +11619,47 @@ export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1'; export const CONTRACT_REVIEW_TIER_NAME = 'CONTRACT_REVIEW_TIER'; /** - * The globs that MANDATE a model tier for any card whose file surface touches - * them, as DATA. This is the one list in this file besides CHANGE_KIND_GATES, - * and it is here for the same reason: it is enumerable, so a guard can hold it. - * - * ## Why a tier is derived here at all (#8640) - * - * Gate families used to be hand-recalled per card; this script exists because - * recall expires. The model tier had the same shape and had not been fixed: the - * PM recalled the mandatory roots and wrote free prose into the claim comment's - * `Container & model` line. Measured incident: a card whose surface included - * `.claude/skills/pm-dispatch/references/review-checklist.md` was claimed as - * "not under the fable-mandatory roots" and dispatched at opus. One - * misclassification sentence flowed unchecked from claim to dispatch to model - * choice, and only a downstream seat's skepticism caught it — at PR time, after - * the work was done, when the compensation available was a re-review rather - * than a re-dispatch. Nothing mechanical had compared the recorded surface - * against the mandatory globs, because nothing mechanical could: the globs - * lived only in prose. - * - * So the invariant this section installs is narrow and total: a mandatory path - * anywhere in the surface ⇒ the output cannot say otherwise. `deriveTier` - * refuses to return a result whose parts contradict each other and `tierLines` - * refuses to render one, in the same shape as the residue partition guard — - * a derivation that cannot complete exits non-zero rather than printing a wrong - * answer. - * - * ## What this derivation CANNOT promise, stated where it cannot be missed - * - * The mandatory-tier policy has two clauses and only the first is a question - * about paths: - * - * - clause ①, encoded below: a card editing the PM lane's PROTOCOL-SEMANTIC - * surfaces is `CONTRACT_REVIEW_TIER` — the pm-dispatch SKILL.md main file, every - * file carrying an enforced copy of the decision frame (the COPIES table - * of check:skill-frame-sync), the dev-agent definition, and — since the - * maintainer's 2026-09-10 ruling (「必须 fable的还包括对外发布的skills」) — - * the whole published catalog `skills/**`, which ships verbatim to third - * parties (`npx skills add objectstack-ai/objectstack/skills`, - * `npm create objectstack`). Narrowed from "the whole skill tree, - * references included" by the maintainer's 2026-08-20 ruling - * (「接受你的建议」— fable 当审计师用,不当施工队用): references-only - * surfaces carry NO path mandate any more (default-tier execution, - * compensated by the skills seat's review at CONTRACT_REVIEW_TIER). Still - * a file-surface predicate, and exactly what this script takes as argv; - * - clause ②, NOT encoded and deliberately not: a card that changes contract - * accept/reject behaviour or widens the public surface is built at the - * default tier and REVIEWED at `CONTRACT_REVIEW_TIER`. WHO owes that - * review is keyed by LANE — the maintainer's lane rule, keyed by seat on - * 2026-09-10, re-keyed by served tier on 2026-09-16 and restated as the - * lane rule on 2026-09-17 (「曾经要求只有 spec 和 skills 需要 fable,其他 - * opus 就够了,理论上其他车道不需要契约复审」): the spec and skills lanes - * owe it on every round they deliver — in-seat when the seat's served - * tier is that tier, otherwise by the at-tier review subagent the seat - * spawns (the fastest route, per the maintainer) — and every other lane - * owes NO contract review: its whole bar is the three landing pre-checks - * and the gates, ⛔ no default-tier "self-review" record is demanded of - * it and ⛔ no at-tier subagent is spawned from it (neither the triage - * seat nor the maintainer-summoned director spawns one for anything). A - * clause-② hit outside those two lanes is lane ROUTING, never a review - * demand on the lane that found it: the work is the spec lane's, - * whichever seat found it, and moves there. Clause ② itself is judged - * from the card's CONTENT — what the change - * does to the contract — and a path cannot answer it. An ordinary-looking - * surface (one package's source file) is the NORMAL shape of a clause-② - * card. The closest a path can honestly get is SUSPICION: - * SUSPECT_TIER_GLOBS below marks the contract surface itself — its test - * files excepted, because tests do not ship — and `--tier` prints a hint - * for it — never a verdict. The enforcement lives one step later, in the - * PM skill's enqueue gate over the PR's ACTUAL diff. - * - * A path derivation that pretended to cover clause ② would produce the failure - * this whole file is written against, one level up: a "no mandate" line read as - * a clearance. So the no-mandate output says which clause it checked and which - * it cannot reach, every time, rather than leaving the reader to remember there - * were two. The output is a FLOOR, never a ceiling. - * - * The sanctioned exits from a mandate — the one-line-class mechanical-edit - * downgrade (a card CONTENT judgment, like clause ②), the measured quota - * exemption (the mandated tier EXHAUSTED ⇒ the default tier, never lower — a - * tier that is RETIRED is not exhausted and is a maintainer ruling instead, - * ⛔ never a seat's reading) and the proactive low-headroom downgrade — are - * claim-time judgments, not properties of the file surface. This tool states the mandate; the seat records any exit and - * its reason in the claim comment. ONE exit is path-shaped, and so it IS - * encoded: the one-line-class downgrade does not exist for a surface under - * `skills/**` — the 2026-09-10 ruling's 必须, because a closed enumeration - * beats a per-line "is this mechanical" call on a catalog third parties - * install — so that entry carries `oneLineExit: false` and `tierLines` - * refuses to offer the exit for any card whose surface hits it. - * - * ## Why the globs are matched with `hintCovers`, asymmetry included - * - * Same matcher as the gate half, so there is one path-comparison rule in this - * file rather than two — and so a glob gets the segment-boundary semantics for - * free: a declared surface of `.claude/skills/pm-disp` is not an ancestor of - * `.claude/skills/pm-dispatch/SKILL.md`, though it is a string prefix of it. - * - * `hintCovers` also matches in the other direction — an input that is an - * ANCESTOR of the glob (a surface declared as `.claude/skills`) counts as a - * hit. For gate matching that direction is a fabricated lead; here it is the - * correct one, because the error costs are not the same in the two halves. An - * over-matched gate pastes a wrong command into a prompt; an under-mandated - * tier crosses a maintainer guardrail and is only visible afterwards. A surface - * declared as a directory that CONTAINS a mandatory root may well touch it, so - * the derivation errs toward the mandate. Both directions are pinned in the - * self-test. - * - * ## Keeping this list from rotting - * - * Two guards, both live. Every declared glob must name a path that EXISTS in - * this tree — a renamed skill root would otherwise leave dead data that - * mandates nothing while reading as protection, which is the incident class - * itself. And two globs that cover one path with DIFFERENT tiers is a - * derivation this file cannot complete honestly (nothing here orders tiers), so - * it throws rather than picking one. - * - * ## One measured side effect of putting a path in a MODULE BODY - * - * Comment masking cannot reach a module-body string, so these globs — and the - * suspect glob below — are watch hints of this file's own source. Re-measured - * on c48d46d70a over 6840 tracked files: `extractWatchHints` yields 9 hints - * here, and the four globs of these two tables cover 1026 files between them - * (1023 of that is the suspect glob's contract surface). The `skills/**` - * entry (2026-09-10) adds one hint and the 47 tracked files under `skills/` - * (`git ls-files skills` on ebf9a489), one of which — the published PM - * skill — the table already covered. That skill was deleted on 2026-09-10 - * (maintainer, verbatim: 「发布版 skills/objectstack-pm-dispatch 删」) and its - * own entry left with it — a dead glob is refused by the self-test — so the - * catalog is covered by `skills/**` alone and a catalog file hits exactly ONE - * entry; the frame-copy half of clause ① now names the internal copy only. - * - * They stay inert against a gate that RESOLVES to this file, because no check - * family does — `check:pm-dispatch-gates` resolves to `check-dispatch-gates.mjs` - * and matches this file through that file's one constant. If the tool is ever - * wired as its own gate (a shape `check-dispatch-gates.mjs`'s header measures - * and refuses), this hint would start printing that gate as MATCHED for every - * card editing the PM skill — a fabricated lead the refusal recorded there is - * what prevents. - * - * They are inert against a gate that IMPORTS this module for a second reason - * now, and that one is structural rather than remembered: the module's own - * `inherited-population` declaration (top of the module body, #11556) names the - * single population a follower inherits, and these globs are not in it. That - * closes the class rather than these four literals — a tier glob added tomorrow - * inherits nothing without someone widening the declaration, and the declaration - * cannot be widened to a path this file does not spell. - * - * The authority for the policy is the maintainer ruling quoted in the PM - * dispatch skill (2026-08-10 three-tier ruling, clause ① of its 强制条款, as - * narrowed to protocol semantics by the 2026-08-20 ruling quoted there, and - * widened to the published `skills/**` catalog by the 2026-09-10 ruling). - * This table is a machine-readable copy of ONE predicate from it, not a second - * statement of the policy: when they disagree, the skill wins and this table is - * the thing to fix. The frame-copy half of the predicate is DEFINED by another - * gate's table — check:skill-frame-sync's COPIES — and the self-test pins this - * table as covering every file listed there, so a copy added to that gate - * cannot silently fall out of the mandate. - */ -export const MANDATORY_TIER_GLOBS = [ - { - glob: '.claude/skills/pm-dispatch/SKILL.md', - tier: CONTRACT_REVIEW_TIER, - why: 'clause ① of the model-tiering ruling (narrowed to protocol semantics, 2026-08-20): the PM dispatch skill MAIN file is the lane\'s own operating protocol and a wrong edit propagates to every later dispatch — references/** dropped out of the path mandate that day', - }, - { - glob: '.claude/agents/os-dev.md', - tier: CONTRACT_REVIEW_TIER, - why: 'clause ① (2026-08-20 narrowing): the dev-agent definition is protocol semantics — every dispatched dev runs under it, and receives the decision frame the PM pastes into its prompt at dispatch time rather than carrying a copy of its own', - }, - { - glob: 'skills/**', - tier: CONTRACT_REVIEW_TIER, - why: 'clause ① (2026-09-10 ruling, verbatim 「必须 fable的还包括对外发布的skills」): the published catalog ships verbatim to third parties by `npx skills add objectstack-ai/objectstack/skills` and `npm create objectstack`, so a wrong edit is installed elsewhere before anyone here reads it — and no one-line exemption applies under this root', - // The one-line-class mechanical-edit exit is a card-CONTENT judgment - // everywhere else; here the ruling closes it by PATH (a closed enumeration - // beats a per-line call on an installed catalog), so it is data, not prose. - oneLineExit: false, - }, -]; + * The clause-① mandate rows are data: `MANDATORY_TIER_GLOB_ROWS` in `dispatch-gates.data.mjs` carries each + * glob, the NAME of the tier it mandates, its reason and its one-line-exit switch — with the docblock that + * governs the table. This binding resolves the tier name to the one value site above, and refuses a name + * it does not know at load time, so a row cannot mandate a tier this engine does not serve. + */ +const TIER_BY_NAME = Object.freeze({ [CONTRACT_REVIEW_TIER_NAME]: CONTRACT_REVIEW_TIER }); + +export const MANDATORY_TIER_GLOBS = MANDATORY_TIER_GLOB_ROWS.map((row) => { + const tier = TIER_BY_NAME[row.tier]; + if (typeof tier !== 'string') { + throw new Error( + `dispatch-gates: mandate row '${row.glob}' names the tier '${row.tier}', which this engine does not define — ` + + `known: ${Object.keys(TIER_BY_NAME).join(', ')}`, + ); + } + return { ...row, tier }; +}); /** - * The globs that make a surface a clause-② SUSPECT — a HINT, never a verdict. - * - * Clause ② is judged from a card's CONTENT; no path predicate can decide it - * (the docblock above MANDATORY_TIER_GLOBS says why pretending otherwise would - * recreate the incident class this file exists against). What a path CAN say - * is where such cards normally land: `packages/spec/src/**` is the contract - * surface itself — the error-code ledger and the `*.zod.ts` contract schemas - * live there, and the measured incident shape (a below-tier dispatch flipping - * accept-to-reject behaviour in the ledger, the same hole passed three times - * in one day) sat exactly under it. So `--tier` prints a suspicion line for - * these paths: judge the tier from the card content as best you can, and - * whichever tier is dispatched, the PR's ACTUAL diff passes the clause-② - * enqueue gate before the card may enqueue — the diff is a fact; the card's - * semantics were a prediction. The gate itself lives in the PM skill - * (入队与落地); this output only points at it. - * - * ## Test files are EXCEPTED, by a predicate this file imports (#19936) - * - * The enqueue gate's path limb reads this surface, and the review rule it - * guards (the skill's contract-review reference) owes an at-tier review for - * `packages/spec/src/**` NON-TEST files only. Without an exception the two - * disagreed on a test-only diff: the limb demanded an at-tier record that the - * review rule forbade spawning an agent to write, so an off-tier seat's - * test-only spec PR could never enqueue. The maintainer's ruling (director - * batch #219 item 1, letter A, comment 5805897677) settled it toward the review - * rule: a published-contract change owes the record; a test-only change does - * not, because tests do not ship. - * - * So an entry may carry `except`, a predicate over a path its glob covers, and - * `deriveTier` drops a path it answers true for BEFORE recording a suspicion. - * The predicate is `isTestPath`, imported from `check-undeclared-dep-imports.mjs` - * and never respelled, as the ruling orders ("the repo's own test-file - * predicate, not a new spelling"). Chosen over the repo's other test predicates - * on measurement, not taste: that gate's own question is which files under a - * package's `src/` are published source and which are its tests — the ruling's - * question exactly; it covers the four shapes the ruling names (`*.test.ts`, - * `*.pin.test.ts`, anything under `__tests__/`, fixtures under a test - * directory); and it excepts no directory word a contract domain carries. A - * census predicate that treats `qa/` as a test directory would drop - * `packages/spec/src/qa/testing.zod.ts`, a real contract schema — pinned. - * - * A subtraction fails SILENT, so this one is held live: the self-test reds if - * the exception drops any tracked `*.zod.ts` (the package's `files[]` ships - * every `*.zod.ts` under `src/` verbatim), and if the predicate stops being - * the imported one. The call hands it the repo-relative path although it was - * written for package-relative ones; for this glob that is exact, because no - * segment of `packages/spec/src/` is a test-directory name. ⛔ The exception - * narrows the SUSPICION only: MANDATORY_TIER_GLOBS carries none, and an input - * that CONTAINS the contract surface (a directory surface such as - * `packages/spec`) is still a suspect, since the predicate answers no for it. - */ -export const SUSPECT_TIER_GLOBS = [ - { - glob: 'packages/spec/src/**', - why: 'the contract surface (error-code ledger, *.zod.ts contract schemas) — the normal landing zone of a clause-② card', - except: isTestPath, - exceptWhy: 'a test file ships nothing, so a test-only diff changes no published contract and owes no at-tier record (the review rule already reads non-test files only)', - }, -]; + * The clause-② suspect rows are data: `SUSPECT_TIER_GLOB_ROWS` in `dispatch-gates.data.mjs` carries each + * glob, its reason and the NAME of the exception predicate — with the docblock that governs the table. + * This binding resolves the exception name to the repo's own predicate, imported above, never a + * respelling here, and refuses a name it does not know at load time. + */ +const SUSPECT_EXCEPTIONS = Object.freeze({ 'test-path': isTestPath }); + +export const SUSPECT_TIER_GLOBS = SUSPECT_TIER_GLOB_ROWS.map((row) => { + if (row.except === undefined) return { ...row }; + const except = SUSPECT_EXCEPTIONS[row.except]; + if (typeof except !== 'function') { + throw new Error( + `dispatch-gates: suspect row '${row.glob}' names the exception '${row.except}', which this engine does not define — ` + + `known: ${Object.keys(SUSPECT_EXCEPTIONS).join(', ')}`, + ); + } + return { ...row, except }; +}); -/** The tier floor for a card with no mandate — the ruling's 最低下限. */ -export const TIER_FLOOR = 'sonnet'; +// `TIER_FLOOR` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. -/** The default judgment tier for a card with no mandate — the ruling's 默认判断档. */ -export const TIER_DEFAULT = 'opus'; +// `TIER_DEFAULT` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. /** * The FAMILY word inside a model id — `claude--` ⇒ `family`. @@ -12828,24 +11693,7 @@ export function tierWordOf(modelId) { */ export const TIER_CEILING = tierWordOf(CONTRACT_REVIEW_TIER); -/** - * Tier family words a maintainer ruling has RETIRED — none today. - * - * ⛔ Not a tier table and ⛔ not an ordering — a RETIRED-SPELLING guard, the - * same shape as the retired clause-② keys pinned further down. The self-test - * asserts no rendering contains one, so the day a ceiling is written down by - * hand again it reds instead of quietly outliving the harness that served it. - * - * EMPTY is this list's correct steady state, ⛔ not a disabled guard. A word - * enters it only on the maintainer's explicit ruling that names a retirement - * and leaves it only on a ruling that brings the tier back — the conditions - * {@link CONTRACT_REVIEW_TIER}'s docblock states; the one entry this list held - * was written on a session's quota refusal, which is none of those. Because an - * empty list clears every rendering for free, the self-test proves the guard - * on a MUTATED copy — a list naming a word the ladder really prints has to red - * — so the green above it is measured rather than vacuous. - */ -export const RETIRED_TIER_WORDS = Object.freeze([]); +// `RETIRED_TIER_WORDS` is data: it lives in `dispatch-gates.data.mjs` beside this file, imported and re-exported above. /** * Place a card's file surface against the mandatory globs. Pure over its @@ -15132,7 +13980,7 @@ export function derivationJson({ paths, size = null, matchedRows, kindGroups, pe * and the declared WIDE population was not mentioned in it at all. It reads * `outsideBlockNames` now, with the counts this function already holds (#16795). */ -function machineReadableOutput(mode, { paths, size = null, matchedRows, kindGroups, pending, counts, alwaysRunsRows = [], widePopulationRows = [], rosters = [], jobFiltered = { rows: [], counts: {} }, typeCheckLanes = { rows: [], counts: {} } }) { +export function machineReadableOutput(mode, { paths, size = null, matchedRows, kindGroups, pending, counts, alwaysRunsRows = [], widePopulationRows = [], rosters = [], jobFiltered = { rows: [], counts: {} }, typeCheckLanes = { rows: [], counts: {} } }) { const identity = repoIdentity(); const commands = commandsFor({ matchedRows, kindGroups, alwaysRunsRows }); const split = spellingSplit(commands); @@ -15853,9 +14701,9 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si * carries a slash, so the joined value exists only at runtime. Same reasoning * as the unquoting convention in the gate file next door; see its header. */ -const DEFAULT_BASE_REMOTE = 'origin'; -const DEFAULT_BASE_BRANCH = 'main'; -const DEFAULT_BASE_REF = `${DEFAULT_BASE_REMOTE}/${DEFAULT_BASE_BRANCH}`; +export const DEFAULT_BASE_REMOTE = 'origin'; +export const DEFAULT_BASE_BRANCH = 'main'; +export const DEFAULT_BASE_REF = `${DEFAULT_BASE_REMOTE}/${DEFAULT_BASE_BRANCH}`; function runGit(args, cwd) { const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); @@ -16016,7 +14864,7 @@ export function lineCountOf(bytes) { * produced the list — a derived run that looked identical to an explicit-path * run would just move the unverifiable claim one level up. */ -function derivationProvenance({ paths, base, mergeBase, counts, size = null }) { +export function derivationProvenance({ paths, base, mergeBase, counts, size = null }) { const sized = sizeVerdict(size); const sizeLine = sized.measured ? [ @@ -16110,14 +14958,14 @@ function derivationProvenance({ paths, base, mergeBase, counts, size = null }) { */ /** The assertion flag. Leading dashes keep it out of this file's own hint set. */ -const REPO_FLAG = '--repo'; +export const REPO_FLAG = '--repo'; /** * The run-record flag (#13774). Same spelling rule as the assertion above: the * leading dashes keep it out of this file's own hint set, and its VALUE is a * path this tool reads at runtime rather than a literal it declares. */ -const RAN_FLAG = '--ran'; +export const RAN_FLAG = '--ran'; /** * `/` out of a git remote URL, or null when it is not recoverable. @@ -16710,13214 +15558,76 @@ export function selfTestCaseLines(name, cond, detail = null) { return lines; } -const SELF_TEST_VERDICT = 'dispatch-gates self-test reached its verdict'; - -function selfTest() { - const cases = []; - // How many cases handed `t` a diagnostic reading (#15539). Read by one case - // near the tail, after every call site that passes one has run. - let detailedCases = 0; - // Stream the verdict the moment it is decided (#14281) rather than only at - // the tail: every `t()` call evaluates `cond` eagerly at the call site, so - // the line below is not a preview of the tail loop's output — it prints the - // SAME verdict, just however many calls earlier than a buffered run did. A - // run that dies mid-battery (the container's foreground cap SIGTERMs a run - // past ~10 minutes; see check-dispatch-gates.mjs's header for the detached - // workaround) used to leave zero case lines; now the log already carries - // every case decided before the kill. `cases` still collects every entry — - // the tail's `failed`/`length` summary reads it unchanged. - const t = (name, cond, detail = null) => { - cases.push([name, cond]); - // Counted so the repair cannot go vacuous: the six call sites #15539 names - // were passing this argument into a function that had no parameter for it, - // and a case that only pins the RENDERER would stay green the day someone - // "cleaned up" the arguments those call sites pass. See the case near the - // tail that reads this. - if (detail !== null && detail !== undefined && detail !== '') detailedCases += 1; - // The third parameter is #15539's whole repair: six call sites below were - // already passing a diagnostic reading into an arity-two function. It is - // rendered by `selfTestCaseLines` rather than here so both directions are - // pinned by cases of their own. - for (const line of selfTestCaseLines(name, cond, detail)) console.log(line); - }; +export const SELF_TEST_VERDICT = 'dispatch-gates self-test reached its verdict'; - // ── A subject this TREE cannot decide is not a passing case (#15255) ────── - // - // A handful of cases below read the REAL corpus rather than a fixture, - // because a fixture cannot show that a live specimen still reaches the tree - // it names. That is the right shape, and it carries one hazard a fixture does - // not: the specimen's population is a property of the tree, and a tree may - // legitimately hold none of it. `.changeset/*.md` is the measured instance — - // a changesets version pass consumes the whole population by design, so the - // Version Packages PR carries a tree the corpus assertions cannot be - // evaluated over, and `main` carries one for as long as it takes the next - // changesets to land. - // - // The control that guarded them asserted the population itself - // (`length >= 100`), which is a claim about the release cycle rather than - // about this tool, and it made a REQUIRED context red on the one PR whose - // merge IS the release. ⛔ The repair is not a quiet `if` around them either: - // a silent skip is exactly how a specimen rots unnoticed, which is the - // failure the population control was reaching for in the first place. - // - // So an undecidable subject is neither: it prints its own line, states WHY - // this tree cannot decide it in words a reader can check, and is counted - // apart from `cases` in the verdict. It is never pushed into `cases` — there - // it would be one more `✓`, indistinguishable from a case that ran. - const notMeasured = []; - const unmeasurable = (subject, why) => { - notMeasured.push([subject, why]); - console.log(` ⊘ NOT MEASURED — ${subject}`); - console.log(` ${why}`); - }; +/** + * The self-test lives in `dispatch-gates.self-test.mjs` beside this file — imported by the + * `--self-test` branch below and nowhere else, so importing this module runs no case. It drives + * the exported derivation functions and the data rows; `SELF_TEST_VERDICT` above is the handshake + * it must return, and `--fast` narrows it to the fast tier (see that file's header for the tiers). + */ - /** - * The verdict's NOT-MEASURED suffix — a pure renderer so the two properties - * that matter can be pinned on fixtures instead of on a run of this tree, - * which by construction skips nothing: it is EMPTY when nothing was skipped, - * so a fully-measured run's verdict line is byte-identical to the one this - * file printed before the tally existed; and it NAMES every skipped subject - * when there is one, so no skip can reach a reader as a bare pass count. - */ - const notMeasuredSuffix = (entries) => - entries.length ? ` ⊘ ${entries.length} subject(s) NOT MEASURED on this tree — ${entries.map(([s]) => s).join(' · ')}.` : ''; - - /** - * The `.changeset/*.md` live specimen's population in a corpus, and the whole - * decision behind the block far below: with one member the corpus assertions - * are real, with none they are vacuous. Named and pure so both of its call - * sites ask the same question and so the boundary can be pinned at the sizes - * the release cycle really produces, rather than only at the one this tree - * happens to be at today. - */ - const changesetSpecimenPop = (corpus) => corpus.filter((f) => /^\.changeset\/[^/]+\.md$/.test(f)); - - const wf = [ - 'jobs:', - ' lint:', - ' steps:', - ' - name: A', - ' run: pnpm check:engine-double-contract', - ' - name: B', - ' run: pnpm --filter @objectstack/spec check:authorable-surface', - ' - name: C', - ' run: node scripts/check-nul-bytes.mjs', - ' - name: not-a-check', - ' run: pnpm build', - ].join('\n'); - const invs = extractCheckInvocations(wf, 'lint.yml'); - t('extracts plain pnpm check', invs.some((i) => i.check === 'check:engine-double-contract' && i.filter === null)); - t('extracts filtered check with its package', invs.some((i) => i.check === 'check:authorable-surface' && i.filter === '@objectstack/spec')); - t('extracts direct node scripts/check-*.mjs', invs.some((i) => i.check === 'scripts/check-nul-bytes.mjs' && i.direct)); - t('ignores non-check runs', !invs.some((i) => String(i.check).includes('build'))); - - // ── The package-local gate lane: a path is keyed WHOLE (#15342) ─────────── - // - // `lint.yml` invoked `packages/lint/scripts/check-reference-carrier-shape.mjs` - // by path, twice (that gate is retired; the lane has no live member today, so - // these cases are synthetic on purpose and are what holds the grammar). - // Before the directory prefix documented beside the patterns, - // `node ` had to be followed IMMEDIATELY by `scripts/`, so neither matcher saw - // that step at all: no family, no hints, and nothing for `--residue` to place. - // - // Cases 1-3 and 6 FAIL against the base spelling — measured by applying this - // battery to `/node[ \t]+(scripts\/…)/` in a scratch ablation — which is what - // makes them an instrument rather than a restatement of the operators. Cases - // 4, 5, 7 and 8 hold on BOTH spellings: they are the controls that prove the - // widening did not buy its new answers by dropping the old ones. - { - const PKG = 'packages/lint/scripts/check-reference-carrier-shape.mjs'; - const pkgInvs = extractCheckInvocations( - ['jobs:', ' lint:', ' steps:', ' - name: package-local gate', ' run: |', - ` node ${PKG} --self-test`, ` node ${PKG}`].join('\n'), - 'lint.yml', - ); - t( - 'a gate CI invokes by a PACKAGE-LOCAL path is discovered at all — unfound, it is in no family, so no ' - + 'card can be told to run it and --residue has no bucket to place it in either (#15342)', - pkgInvs.some((i) => i.check === `${PKG} --self-test` && i.direct), - ); - t( - '…and its bare production invocation arrives as the SECOND family under its own key, the #14880 split', - pkgInvs.some((i) => i.check === PKG && i.direct), - ); - t( - '…both keyed by the REAL path, which is what `entry.files` carries and `existsSync` then opens', - pkgInvs.length === 2 && pkgInvs.every((i) => i.script === PKG), - ); - t( - 'control — no PHANTOM root key is minted beside them: a `scripts/…` TAIL keyed as a path in its own ' - + 'right names a file this ROOT does not hold, and every audit downstream then passes for the wrong reason', - !pkgInvs.some((i) => String(i.script ?? i.check).startsWith('scripts/')), - ); - t( - 'control — a CLIMBING spelling is refused rather than resolved: `..` is not a prefix segment, because ' - + 'a path this ROOT cannot resolve is exactly how a phantom identity key gets minted', - extractCheckInvocations(' - run: node ../scripts/check-x.mjs\n', 'x.yml').length === 0, - ); - t( - 'the SELF-TEST matcher carries the same prefix grammar — a package-local gate not named `check-*` ' - + 'would otherwise be discovered by neither matcher, which is this silence one filename over', - extractCheckInvocations(' - run: node packages/lint/scripts/carrier-census.mjs --self-test\n', 'x.yml') - .some((i) => i.check === 'packages/lint/scripts/carrier-census.mjs --self-test' && i.direct), - ); - t( - 'control — the prefix widens the DIRECTORY a gate may sit under and never the SPECIES of file: a path ' - + 'with no `scripts/` segment is still admitted by neither matcher (`packages/cli/bin/run.js` is live)', - extractCheckInvocations(' - run: node packages/cli/bin/run.js check\n', 'x.yml').length === 0, - ); - t( - 'control — a root-spelled invocation is keyed BYTE-IDENTICALLY to before, so the prefix re-attributes ' - + 'nothing: it matches empty there and the remainder of each pattern is unchanged', - extractCheckInvocations(' - run: node scripts/check-nul-bytes.mjs\n', 'x.yml') - .every((i) => i.check === 'scripts/check-nul-bytes.mjs' && i.script === 'scripts/check-nul-bytes.mjs'), - ); - } +// ── CLI ───────────────────────────────────────────────────────────────────── - // Block-scalar bodies (#8410). A step written `run: |` keeps its commands on - // the following lines; reading only the `run:` line collected "|" and missed - // every gate invoked this way. Both scalar styles and both invocation shapes - // are pinned, plus the two directions in which the body must END. - const blockWf = [ - 'jobs:', - ' changeset-check:', - ' steps:', - ' - name: Literal block, two commands', - ' env:', - ' MERGE_BASE: abc', - ' run: |', - ' node scripts/check-adr-0087-registration.mjs --self-test', - ' node scripts/check-adr-0087-registration.mjs --base "$MERGE_BASE"', - '', - ' # A YAML comment BETWEEN steps naming `pnpm check:invented-by-prose`.', - ' - name: Folded block with a pnpm check', - ' run: >-', - ' pnpm --filter @objectstack/spec check:folded-surface', - ' - name: Body carrying a shell comment', - ' run: |', - ' # first run node scripts/check-mentioned-only.mjs, they said', - ' pnpm check:really-invoked', - ' - name: Back to a one-liner', - ' run: node scripts/check-nul-bytes.mjs', - ].join('\n'); - const blockInvs = extractCheckInvocations(blockWf, 'pr-automation.yml'); - const blockNames = blockInvs.map((i) => i.check); - // The key carries the argv the block body really spells (#15083), so the - // subject of this case — a direct script pulled out of a literal block — - // is asserted on the invocation the body contains rather than on a bare path - // the body does not. - t('extracts a direct script from a literal block body', blockNames.includes('scripts/check-adr-0087-registration.mjs --base "$MERGE_BASE"')); - t('extracts a pnpm check from a folded block body, with its filter', blockInvs.some((i) => i.check === 'check:folded-surface' && i.filter === '@objectstack/spec')); - t('a dedented step ends the block body (the one-liner after it still parses)', blockNames.includes('scripts/check-nul-bytes.mjs')); - t('a blank line does NOT end the block body', blockNames.includes('check:folded-surface')); - // The over-match guard: discovery must report what a step RUNS, never what a - // comment mentions. Measured on this tree — four families (check:adr-links, - // check:empty-changeset, check:platform-checklist, check:skill-frame-freshness) - // appear in workflow prose only, and a naive any-token scan invents all four. - t('a gate named only in a YAML comment between steps is not discovered', !blockNames.includes('check:invented-by-prose')); - t('a gate named only in a shell comment inside a body is not discovered', !blockNames.includes('scripts/check-mentioned-only.mjs')); - t('a real command in the same body as a comment is still discovered', blockNames.includes('check:really-invoked')); - - // ── The self-test invocation matcher (#11404) ───────────────────────────── - // - // A gate whose script follows neither naming convention was not a family at - // all — absent from the matched list, the convention list, the unreachable - // list and all three residue buckets, because it never entered the universe - // those partition. The specimen is the one that shipped the red on PR #11397 - // over a diff whose seven derived families were all green. - const selfTestWf = [ - 'jobs:', - ' gates:', - ' steps:', - ' - name: Bare-root worklist self-test', - ' run: node scripts/pm/bare-root-worklist.mjs --self-test', - ' - name: Release tooling, not a gate', - ' run: node scripts/release-github-releases.mjs', - ' - name: A wrapper whose WRAPPED command carries the flag', - ' run: node scripts/run-with-stall-guard.mjs --log "/tmp/x.log" --stall-minutes 10 -- pnpm test', - ' - name: Two commands, one body, only the second is a self-test', - ' run: |', - ' node scripts/docs-audit/affected-docs.mjs --json base > affected.json', - ' node scripts/pm/git-history.mjs --self-test', - ].join('\n'); - const stInvs = extractCheckInvocations(selfTestWf, 'lint.yml'); - const stNames = stInvs.map((i) => i.check); - // Absent rather than thrown: with the matcher ablated these lookups return - // nothing, and a self-test that CRASHES instead of naming its failing case - // reports "something is broken" where the whole value is "this exact case - // went red". Measured — the first ablation run of this change died on a - // destructure here and printed no case name at all. - const stFind = (name) => stInvs.find((i) => i.check === name) ?? { check: '(not discovered)', direct: true }; - t( - 'the gate that shipped the red on PR #11397 is discovered, flag included', - stNames.includes('scripts/pm/bare-root-worklist.mjs --self-test'), - ); - t( - '…as a DIRECT family resolving to the script file, not to the flagged key', - stFind('scripts/pm/bare-root-worklist.mjs --self-test').script - === 'scripts/pm/bare-root-worklist.mjs', - ); - t( - '…and it prints as a command a dev can paste, flag included — the bare path exits 0 without testing anything', - runnableInvocation(stFind('scripts/pm/bare-root-worklist.mjs --self-test')) - === 'node scripts/pm/bare-root-worklist.mjs --self-test', - ); - // The refusal, which is the half that keeps this from being the widening - // `hintCovers` prices at +139084 pairs: a `scripts/**` script in a `run:` - // step is NOT a gate unless it says so. - t( - 'a scripts/ script invoked WITHOUT the flag is not a family', - !stNames.some((n) => String(n).includes('release-github-releases')), - ); - // The over-match guard, both live shapes. A command text is one whole `run:` - // body, so the flag must not travel backwards across a separator or a value. - t( - 'a wrapper does not absorb the flag of the command it wraps — the `--` separator and the quoted --log value both stop the match', - !stNames.some((n) => String(n).includes('run-with-stall-guard')), - ); - t( - 'the FIRST command in a two-command body does not take the SECOND command\'s flag', - !stNames.some((n) => String(n).includes('affected-docs')), - ); - t( - '…while the second command, which really carries it, is discovered', - stNames.includes('scripts/pm/git-history.mjs --self-test'), - ); - // The no-double-count property: ONE workflow invocation yields ONE family, - // whichever matcher admits it. - // - // ⚠️ This case was rewritten by #14880 and the rewrite is deliberate, so the - // two halves it used to assert together are separated here. Its COUNT half — - // one invocation, one family — is the invariant #11404 built the `check-` - // skip to protect, and it is untouched: the direct matcher admits this - // invocation and the self-test matcher skips it. Its KEY half asserted that - // the family lands under the BARE path key, and that half WAS the defect - // #14880 fixed: it is what collapsed CI's two invocations of one script into - // the plain one and left the failing `--self-test` invocation with no entry. - // The key now carries the argv (`renderedArgv`'s docblock has the - // measurement), so the expectation moves with it. - const dualWf = [ - 'jobs:', - ' j:', - ' steps:', - ' - run: node scripts/check-adr-0087-registration.mjs --self-test', - ].join('\n'); - const dualInvs = extractCheckInvocations(dualWf, 'x.yml'); - const dualNames = dualInvs.map((i) => i.check); - t( - 'a check- script invoked with the flag is ONE family, not one per matcher', - dualNames.length === 1, - ); - t( - '⭐ …and its key carries the flag, so the invocation CI runs is the one derived (#14880)', - dualNames[0] === 'scripts/check-adr-0087-registration.mjs --self-test', - ); - t( - '…resolving to the script FILE, never to the flagged key — the flag is not a path', - dualInvs[0]?.script === 'scripts/check-adr-0087-registration.mjs', - ); - t( - '…and it is marked as a self-test invocation, which is what the follow narrowings read', - dualInvs[0]?.selfTest === true, - ); +/** + * The entry guard, and the predicate under it, both live in + * `scripts/invoked-as.mjs` — one implementation for all of `scripts/`. + * + * `invokedAs` is re-exported because this module's self-test drives it + * directly, and because that export was this tree's first landing of the + * two-comparison shape. The implementation moved; the export did not. + * + * Its failure direction is SILENT: an entry guard that wrongly answered + * `false` would turn every mode of this tool into a no-op that prints nothing + * and exits 0, and `check:pm-dispatch-gates` holds the child's exit STATUS + * only (see that gate's header) — so the no-op would report as a pass. + */ +export { invokedAs }; - // ── The derivation KEY is (script, args) (#14880), and the argv is RENDERED - // rather than refused (#15083) ─────────────────────────────────────────── - // - // #14880's third mechanism, and the one no better output mode reaches: the - // derived list named the PLAIN invocation of a script CI also runs with a - // flag, and the flagged invocation was the red one. That split is pinned - // below, unchanged. - // - // ⚠️ #14880's other half — the REFUSAL — is what #15083 retired, so the two - // cases that pinned it are rewritten here rather than dropped. They asserted - // that a value-bearing tail and a continued tail keep the BARE path key, and - // that key is an invocation CI never runs: measured on this tree, - // `node scripts/check-test-completeness.mjs` exits 3 with `PREREQUISITE NOT - // MET`, and four more of the nine answer a different question than CI asks. - // The subject of each case is unchanged — same tail, same fixture — and what - // moved is the expectation, from "keeps the bare key" to "renders in full and - // says whether it is runnable". Every OTHER case in this block keeps its - // verdict untouched, the two census cases and the redirection case included. - // - // The fixtures below are QUOTED FROM the live workflow text, invocation for - // invocation, so no case can pin a shape the tree does not have. Their - // sources: `pr-automation.yml` (the `--base` gates), `ci.yml` (the shard - // attestation), `engine-split-metric.yml` (`--days 90`), - // `required-set-patrol.yml` (a complete argv behind a continuation and a - // redirection), `prerelease-pin-watch.yml` (`--verbose 2>&1`). The live half - // at the end of the block re-reads them from the workflows themselves. - const keyWf = [ - 'jobs:', - ' gates:', - ' steps:', - ' - name: Both invocations, the shape lint.yml really uses', - ' run: |', - ' node scripts/check-tenant-audit-census.mjs --self-test', - ' node scripts/check-tenant-audit-census.mjs', - ' - name: VARIABLE — pr-automation.yml pins the base to a step output', - ' run: node scripts/check-empty-changeset.mjs --base "$MERGE_BASE"', - ' - name: VARIABLE — ci.yml continues the attestation across two more lines', - ' run: |', - ' node scripts/check-shard-attestation.mjs --emit \\', - ' --job test --shard ${{ matrix.shard }} --total 6 \\', - ' --out "$RUNNER_TEMP/att"', - ' - name: LITERAL — engine-split-metric.yml writes the window down', - ' run: node scripts/check-engine-split-ratio.mjs --days 90', - " - name: LITERAL behind a CONTINUATION and a REDIRECTION, both the shell's", - ' run: |', - ' node scripts/check-required-contexts.mjs --verify-required-set \\', - ' > "$RUNNER_TEMP/required-set.md" 2> "$RUNNER_TEMP/required-set.err"', - ' - name: LITERAL whose line ends in a REDIRECTION carrying its own fd', - ' run: node scripts/check-prerelease-pin-watch.mjs --verbose 2>&1', - ' - name: A complete flag run whose line ends in a REDIRECTION, not an argument', - ' run: node scripts/check-release-section-coverage.mjs --strict > "$RUNNER_TEMP/x.txt"', - ].join('\n'); - const keyInvs = extractCheckInvocations(keyWf, 'lint.yml'); - const keyNames = keyInvs.map((i) => i.check); - const censusKeys = keyNames.filter((n) => n.startsWith('scripts/check-tenant-audit-census.mjs')); - t( - '⭐ the two census invocations derive TWO entries, not one collapsed onto the plain run', - censusKeys.length === 2 - && censusKeys.includes('scripts/check-tenant-audit-census.mjs --self-test') - && censusKeys.includes('scripts/check-tenant-audit-census.mjs'), - ); - t( - 'CONTROL: the plain census entry is still there — the fix ADDS the flagged invocation, it does not move the plain one', - keyNames.includes('scripts/check-tenant-audit-census.mjs'), - ); - t( - '…and both print as commands a dev can paste, each reproducing the invocation CI runs', - keyInvs - .filter((i) => i.script === 'scripts/check-tenant-audit-census.mjs') - .map((i) => runnableInvocation(i)) - .sort() - .join('|') - === 'node scripts/check-tenant-audit-census.mjs|node scripts/check-tenant-audit-census.mjs --self-test', - ); - // ⭐ THE VARIABLE KIND (#15083). Rewritten from "keeps the bare path key": the - // bare key is an invocation `pr-automation.yml` never makes, and it answered - // against the DEFAULT base while CI pins it to the PR's merge base. The - // invocation now renders in full, with the variable's own name in the value - // position, and carries the variable so the row can be labelled. - const emptyKey = keyInvs.find((i) => i.script === 'scripts/check-empty-changeset.mjs'); - t( - '⭐ an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gone', - keyNames.includes('scripts/check-empty-changeset.mjs --base "$MERGE_BASE"') - && !keyNames.includes('scripts/check-empty-changeset.mjs'), - ); - t( - '…and it carries the variable it takes from the workflow, which is what marks the row NOT RUNNABLE LOCALLY', - (emptyKey?.argvVariables ?? []).join(',') === '$MERGE_BASE', - ); - // ⭐ The sharpest of them, and the one a terminator at the backslash would - // have got wrong in the direction that LOOKS right: ` --emit ` reads as a - // complete flag run and the invocation's real values are on the next two - // lines. Joining is how the whole argv arrives; refusing it was how a - // truncation was avoided before there was a join. - const shardKey = keyInvs.find((i) => i.script === 'scripts/check-shard-attestation.mjs'); - t( - '⭐ an invocation CONTINUED across two lines renders as ONE command, values and all', - shardKey?.check - === 'scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/att"', - ); - t( - '…with BOTH of its workflow variables named, the expression and the shell expansion', - (shardKey?.argvVariables ?? []).join(',') === '${{ matrix.shard }},$RUNNER_TEMP', - ); - t( - '…and ⛔ no bare key survives beside it — a bare run of this script is an invocation ci.yml never makes', - !keyNames.includes('scripts/check-shard-attestation.mjs'), - ); - // ⭐ THE LITERAL KIND (#15083) — three shapes, all of them renderable, and the - // last two were refused before this card only because a REDIRECTION and a - // CONTINUATION stood behind an argv that was already complete. - const literalKeys = [ - 'scripts/check-engine-split-ratio.mjs --days 90', - 'scripts/check-required-contexts.mjs --verify-required-set', - 'scripts/check-prerelease-pin-watch.mjs --verbose', - ]; - t( - '⭐ an invocation whose every token is a LITERAL renders as the command CI runs, value included', - literalKeys.every((k) => keyNames.includes(k)), - ); - t( - '…and every one of them is runnable — no variable, so nothing to label', - keyInvs.filter((i) => literalKeys.includes(i.check)).every((i) => i.argvVariables.length === 0), - ); - t( - '…and none of the three keeps a bare key CI never runs', - !['scripts/check-engine-split-ratio.mjs', 'scripts/check-required-contexts.mjs', 'scripts/check-prerelease-pin-watch.mjs'] - .some((k) => keyNames.includes(k)), - ); - // ⛔ The truncation this card must not reintroduce, asserted as a shape over - // every key rather than as one expectation: a key ending in a dangling flag, - // a bare backslash or a stray redirection fd is the outcome #14880 refused - // the whole class to avoid, and rendering is only an improvement while none - // of them can appear. - // - // ⭐ The QUOTE half (#15116). `DIRECT_CHECK_INVOCATION` cuts at `(`, so a - // value written as a command substitution is cut INSIDE its own construct and - // leaves a tail whose quote never closes — `--base "$` from - // `--base "$(git merge-base origin/main HEAD)"`. Reading that shape here is - // deliberately INDEPENDENT of `argvTokens`: this scans the finished KEY with - // the shell's own outermost-quote rule (a `'` inside a `"…"` is text, not a - // delimiter), so a bug in the tokeniser cannot make the pin agree with it. A - // parity count would not do — measured on this tree zero live keys nest a - // quote, and the day one does a parity scan calls a balanced key truncated. - const carriesTruncation = (text) => { - const s = String(text); - let quote = null; - let i = 0; - while (i < s.length) { - if (quote !== null) { - if (s[i] === quote) quote = null; - i += 1; - continue; - } - if (s[i] === "'" || s[i] === '"') { - quote = s[i]; - i += 1; - continue; - } - if (s.startsWith('${{', i)) { - const close = s.indexOf('}}', i + 3); - if (close === -1) return true; - i = close + 2; - continue; - } - i += 1; - } - return quote !== null || /\$(?=\s|$)/.test(s); - }; - // The statement is over EVERY derived key and it is the contrapositive rather - // than a flat "every key is balanced": under this file's own rule a truncated - // tail is NAMED — its unresolved remainder is a value the workflow supplies, - // so the row is marked NOT RUNNABLE LOCALLY and the key stays out of - // `--commands`. What must never happen is a truncation that RENDERS as a - // command a dev can paste, and that is exactly what this says. - t( - '⛔ no derived key is a TRUNCATED argv — no continuation backslash, no redirection fd surviving as an argument, and ⭐ no key carrying an unclosed quote, an unclosed ${{ … }} or a bare `$` is RUNNABLE (#15116)', - keyNames.every((n) => !/\\/.test(n)) - && keyNames.includes('scripts/check-prerelease-pin-watch.mjs --verbose') - && !keyNames.some((n) => /^scripts\/check-prerelease-pin-watch\.mjs --verbose\s+\d+$/.test(n)) - && keyInvs.every((i) => !carriesTruncation(i.check) || (i.argvVariables ?? []).length > 0), - ); +const invokedDirectly = isEntrypoint(import.meta.url); - // ⭐ THE UNRENDERABLE KIND (#15116), and this fixture is the ONLY place the - // tree has it. Measured at this commit: 114 direct invocations across - // `.github/workflows` and ZERO carry a paren or a terminator inside a quoted - // value — the three paren hits in the workflow text are all comment lines. So - // unlike `keyWf` above, whose every step is quoted from live workflow text, - // this fixture is WRITTEN rather than quoted, and it says so: the shape is - // latent, and a fixture is what stands in for a tree that does not have it - // yet. The live half at the end of the block asserts the same property over - // the workflows and is vacuous today by that measurement, which is the whole - // reason these three steps exist. - const cutWf = [ - 'jobs:', - ' gates:', - ' steps:', - ' - name: a command substitution in the value position — the tail is cut at the paren', - ' run: node scripts/check-x.mjs --base "$(git merge-base origin/main HEAD)"', - ' - name: a terminator INSIDE a quoted value — the tail is cut at the semicolon', - " run: node scripts/check-y.mjs --gate 'a;b'", - ' - name: CONTROL — the two live spellings, which must classify exactly as before', - " run: node scripts/check-z.mjs --gate 'Test Core' --shard ${{ matrix.shard }}", - ].join('\n'); - const cutInvs = extractCheckInvocations(cutWf, 'x.yml').filter((i) => i.direct); - const cutOf = (script) => cutInvs.find((i) => i.script === script); - t( - 'the `$(…)` value really IS cut to a truncation — the fixture reproduces the key this card measured, so the cases below cannot go vacuous', - cutOf('scripts/check-x.mjs')?.check === 'scripts/check-x.mjs --base "$', - ); - t( - '⭐ …and that truncation is NOT runnable: the unresolved remainder is named as the value the workflow supplies', - (cutOf('scripts/check-x.mjs')?.argvVariables ?? []).join(',') === '"$', - ); - t( - '⭐ a terminator inside a quoted value lands the same way — named, never rendered as a command a dev could paste', - cutOf('scripts/check-y.mjs')?.check === "scripts/check-y.mjs --gate 'a" - && (cutOf('scripts/check-y.mjs')?.argvVariables ?? []).join(',') === "'a", - ); - t( - "CONTROL: `--gate 'Test Core'` and `--shard ${{ matrix.shard }}` tokenise and classify EXACTLY as today — the expression is the one variable, the quoted literal contributes none", - cutOf('scripts/check-z.mjs')?.check === "scripts/check-z.mjs --gate 'Test Core' --shard ${{ matrix.shard }}" - && (cutOf('scripts/check-z.mjs')?.argvVariables ?? []).join(',') === '${{ matrix.shard }}', - ); - t( - '⛔ …and the shape-level statement over every key this fixture derives: carrying a truncation and rendering as runnable are mutually exclusive — non-vacuously, 2 of the 3 carry one', - cutInvs.every((i) => !carriesTruncation(i.check) || i.argvVariables.length > 0) - && cutInvs.filter((i) => carriesTruncation(i.check)).length === 2, - ); - // ...while a REDIRECTION really does end the argv, so the flag run before it - // is complete and is keyed. Unchanged by #15083, verdict and all: what the - // shell hands to this command and what it keeps for itself is the same - // boundary it always was. - t( - 'a complete flag run followed by a redirection IS keyed — the redirection is the shell\'s, never this argv', - keyNames.includes('scripts/check-release-section-coverage.mjs --strict') - && !keyNames.includes('scripts/check-release-section-coverage.mjs'), - ); - t( - 'renderedArgv renders every tail and reports which values come from the workflow', - renderedArgv(' --self-test').args === '--self-test' - && renderedArgv(' --self-test').variables.length === 0 - && renderedArgv(' --emit --verify').args === '--emit --verify' - && renderedArgv('') === null - && renderedArgv(' --days 90').args === '--days 90' - && renderedArgv(' --days 90').variables.length === 0 - && renderedArgv(' --base "$MERGE_BASE"').variables.join(',') === '$MERGE_BASE' - && renderedArgv(' "$RUNNER_TEMP/test-core.log"').variables.join(',') === '$RUNNER_TEMP' - && renderedArgv(' --shard ${{ matrix.shard }}').args === '--shard ${{ matrix.shard }}' - // ⭐ #15116: the three spellings the CUT leaves unresolved. Each names its - // own remainder, which is what keeps the key out of `--commands`. - && renderedArgv(' --base "$').variables.join(',') === '"$' - && renderedArgv(' --base $').variables.join(',') === '$' - && renderedArgv(' --job ${{ inputs.a').variables.join(',') === '${{ inputs.a' - // …and the CONTROL, on the same call: a CLOSED quote resolves, so a - // quoted literal is still a literal and still renders as runnable. - && renderedArgv(" --gate 'Test Core'").args === "--gate 'Test Core'" - && renderedArgv(" --gate 'Test Core'").variables.length === 0, - ); - t( - 'argvTokens holds a quoted value and a ${{ … }} expression together, spaces and all — and ⭐ swallows an UNCLOSED one into a single token, which is the seam #15116 reads', - argvTokens(' --gate \'Test Core\' --shard ${{ matrix.shard }}').join('|') - === "--gate|'Test Core'|--shard|${{ matrix.shard }}" - && argvTokens(' --base "$').join('|') === '--base|"$' - && argvTokens(' --job ${{ inputs.a').join('|') === '--job|${{ inputs.a', - ); - t( - 'joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENT', - joinLineContinuations('a \\\n b').trim() === 'a b' - && joinLineContinuations(' # a comment \\\n node scripts/check-x.mjs').split('\n').length === 2, - ); - // The LIVE half, and the count is READ from the workflow rather than typed — - // a number typed here would rot the first time lint.yml moved. The point of - // the reading is that the fixture above judges a real convention: if this - // ever fell to zero, every case in this block would be about a shape the tree - // no longer has. Measured when this landed: 28 in lint.yml, 41 across all - // workflow files, every one of them carrying a `check-` basename and so - // collapsed by the old key. - { - const lintText = readFileSync(nodePath.join(ROOT, '.github/workflows/lint.yml'), 'utf8'); - const argvOfScript = new Map(); - for (const inv of extractCheckInvocations(lintText, 'lint.yml')) { - if (!inv.direct) continue; - if (!argvOfScript.has(inv.script)) argvOfScript.set(inv.script, new Set()); - argvOfScript.get(inv.script).add(inv.check); - } - const multi = [...argvOfScript.entries()].filter(([, keys]) => keys.size > 1); - t( - `lint.yml really invokes ${multi.length} script(s) more than once under different argv, so the cases above judge a live convention`, - multi.length > 0, - ); - t( - '⭐ and the census pair CI runs on two lines derives as two families on the real workflow, not one', - (argvOfScript.get('scripts/check-tenant-audit-census.mjs')?.size ?? 0) === 2, - ); - t( - 'every derived key is either the bare script path or that path plus the WHOLE argv, re-tokenising to itself', - [...argvOfScript.entries()].every(([script, keys]) => - [...keys].every((k) => k === script || renderedArgv(k.slice(script.length))?.args === k.slice(script.length).trim())), - ); - } +/** + * The one usage line, printed on the derivation-failure path — and held to the + * refusals the argv chain below really enforces (#15036). + * + * `--residue` used to sit OUTSIDE the alternation, which is the notation's way + * of saying it combines with every member of it. Three of the four it really + * does modify; the fourth it does not: `--tier --residue` exits 2 since + * #14753, because `--tier` derives no gate family and so leaves `--residue` + * nothing to list. A usage line that advertises a refused pair as legal costs + * a reader a second's confusion at exactly the moment the tool has already + * failed once — so the modifier moves INSIDE, attached to the three modes it + * still modifies, and `--tier` stands alone as the alternative it is. + * + * ⛔ Deleting `[--residue]` instead would understate it — the flag really is + * legal with the other three, and with the plain human rendering. + * + * `--changed` had the mirror-image problem: it sat OUTSIDE the alternation as + * a whole-invocation alternative, which reads as excluding every member next + * to it — `--commands`/`--json` included, though `--changed --commands` is + * legal and answers (it derives the path list ` ...` would otherwise + * supply, and nothing more). Moved to the position ` ...` occupies, the + * other path source it stands in for, so the line no longer implies a refusal + * the argv chain does not make (#14870). + * + * A CONSTANT rather than a literal at the print site, because the pin belongs + * beside the refusals it mirrors: reaching the print site needs a checkout + * where `changedPathsFromGit()` refuses, and a pin that cannot be run in the + * self-test is not a pin. + */ +/** + * The invariant half of the refusal `--changed` prints on a tree with NO diff. + * + * A CONSTANT for USAGE_LINE's reason and for one more that is this file's own + * subject. The self-test's `--changed --commands` CONTROL has to tell that + * refusal — which only the derivation can print — apart from an argv-parse + * refusal, and BOTH exit 2. Matching a retyped copy of the sentence would pin + * the case to a memory of the message rather than to the message, so the case + * and the print site read the same constant (#15278). + */ +export const NO_DIFF_REFUSAL = 'changes nothing against'; - // ── The value-bearing class, read from the LIVE workflows (#15083) ───────── - // - // The card counted nine scripts whose only CI invocations carry a value or a - // continuation. The count is READ here rather than typed, for the reason the - // block above reads its own: a list typed into a self-test rots the first - // time a workflow moves, and this one already has — the same sweep over the - // tree at this commit finds `scripts/pm/check-half-states.mjs` too, a TENTH - // member the card's table does not name (`half-state-patrol.yml` runs it - // `--format=markdown --provenance="$PROVENANCE"` and nowhere else). - // - // What is asserted is the PROPERTY, not the roster: every direct invocation - // in the tree renders, and every rendered key is either all-literal (and - // therefore in `--commands`) or names the variables that keep it out. A - // script with both kinds gets both entries, which is the card's third clause. - { - const liveInvs = []; - for (const wf of readdirSync(nodePath.join(ROOT, '.github/workflows')).filter((f) => /\.ya?ml$/.test(f))) { - liveInvs.push(...extractCheckInvocations(readFileSync(nodePath.join(ROOT, '.github/workflows', wf), 'utf8'), wf)); - } - const direct = liveInvs.filter((i) => i.direct); - const valueBearing = direct.filter((i) => (i.argvVariables ?? []).length > 0); - // The live half of the package-local lane (#15342). The fixtures above prove - // the pattern; these read the tree CI actually runs, so the phantom class is - // held over EVERY direct invocation, not only over a specimen. - // - // The specimen the first of these used to name — - // `packages/lint/scripts/check-reference-carrier-shape.mjs` — was the tree's - // ONLY package-local by-path invocation, and it was retired by maintainer - // ruling. So the lane did not move, it emptied, and a live pin on it could - // only ever be a pin on zero from here. The reading is recorded as a zero WITH - // ITS CONTROL: no `packages/…` direct invocation, while the same extraction - // over the same corpus yields 143 root ones — so the zero is an empty lane and - // not a reader that stopped matching. The grammar itself stays under the - // synthetic fixtures above. ⛔ Do not widen the matchers to manufacture a - // subject; the day CI invokes a package-local gate by path, the fixtures - // already key it and a specimen can be named here again. - t( - `⭐ the package-local lane reads as EMPTY against a corpus that yields ${direct.length} direct root ` - + 'invocation(s) — the control that makes the zero a reading. If the control collapses to 0 the ' - + 'extraction broke; if a `packages/…` direct invocation appears, name it as the specimen again', - direct.length > 0 && !direct.some((i) => i.script.startsWith('packages/')), - ); - t( - `every one of the ${direct.length} direct invocation(s) in the live tree resolves to a file that EXISTS ` - + 'on disk — a key with no file behind it reads as a confident gate identity in both directions (#15342)', - direct.length > 0 && direct.every((i) => existsSync(nodePath.join(ROOT, i.script))), - ); - t( - `the live tree really carries ${valueBearing.length} value-bearing invocation(s) across ${new Set(valueBearing.map((i) => i.script)).size} script(s), so the cases above judge a live class`, - valueBearing.length > 0, - ); - // Five of the card's six named specimens. The sixth, - // `scripts/check-cross-package-test-inputs.mjs`, classified as VARIABLE - // through ci.yml's inline package-selection step (`--union-into - // "$RUNNER_TEMP/turbo-ls.json" --changed …`) until #16453 moved that step's - // shell into scripts/ci/select-shard-packages.sh, which no workflow-text - // scan reaches; its only workflow invocation is now the all-literal - // `pnpm check:cross-package-test-inputs`, so it belongs to the LITERAL - // class below and no longer to this roster — the rot the block comment - // above predicts for a typed list, and the reason the count is read. - t( - '⭐ the card\'s named specimens all classify as VARIABLE from the workflow text — no per-script table was needed', - ['scripts/check-empty-changeset.mjs', 'scripts/check-test-completeness.mjs', 'scripts/check-shard-attestation.mjs', - 'scripts/check-adr-0087-registration.mjs', 'scripts/check-changeset-no-major.mjs'] - .every((script) => valueBearing.some((i) => i.script === script)), - ); - t( - '⭐ …and the three the card called value-bearing that are really LITERAL render as runnable commands instead', - ['scripts/check-engine-split-ratio.mjs --days 90', 'scripts/check-required-contexts.mjs --verify-required-set', - 'scripts/check-prerelease-pin-watch.mjs --verbose'] - .every((key) => direct.some((i) => i.check === key && i.argvVariables.length === 0)), - ); - t( - '⛔ no live key survives as a bare path for a script CI only ever invokes WITH argv', - !direct.some((i) => i.check === i.script) - || direct.filter((i) => i.check === i.script).every((bare) => liveInvs.some((i) => i.script === bare.script && i.check === i.script)), - ); - // The same property the fixture block asserts, read from the workflows. It - // is VACUOUS today and that is the reading, not an oversight: at this commit - // no live invocation carries an unclosed quote, an unclosed `${{ … }}` or a - // bare `$`, so the non-vacuous subjects live in the fixture above. What this - // half adds is the day the tree grows one — the property holds then too, - // because the classification names the remainder rather than rendering it. - t( - '⛔ and no live key is truncated: no continuation backslash reaches one, and ⭐ any key carrying an unclosed quote, an unclosed ${{ … }} or a bare `$` is NOT runnable (#15116)', - direct.every((i) => !i.check.includes('\\')) - && direct.every((i) => !carriesTruncation(i.check) || (i.argvVariables ?? []).length > 0), - ); - // A script invoked BOTH ways gets BOTH entries — the card's third clause, - // read off the live tree. `check-release-section-coverage.mjs` is the - // specimen: `lint.yml` runs it bare, `release-coverage-patrol.yml` runs it - // bare AND `--strict`, and before the continuation join the `--strict` run - // had no entry of its own at all. - const coverageKeys = new Set(direct.filter((i) => i.script === 'scripts/check-release-section-coverage.mjs').map((i) => i.check)); - t( - '⭐ a script CI invokes bare AND with argv keeps BOTH entries — the bare key is kept where CI really runs it bare', - coverageKeys.has('scripts/check-release-section-coverage.mjs') - && coverageKeys.has('scripts/check-release-section-coverage.mjs --strict'), - ); - } - - - // ── The DECLARED-DEFAULT repair, and the class it belongs to (#15441) ────── - // - // The classification above asks whether the WORKFLOW supplies a value. It - // never asked whether the SCRIPT needs it supplied, and for the `--base` - // gates the two answers differ: `pr-automation.yml` pins the base to the PR's - // merge base, and the script's own first lines say `base defaults to - // origin/main` while the scan starts at `merge-base(base, head)` either way. - // The family scored value-bearing, so the only member `--commands` offered - // for it was the SELF-TEST invocation of the same script — a zero from a - // command that cannot answer the question, sitting in the union where nothing - // distinguishes it from a green. It bit: a dev reported "50 run · 50 exit 0 · - // 0 red" on a PR whose Check Changeset job was red in CI the whole time. - // - // The class was ENUMERATED by predicate before any of it was repaired, and - // the enumeration is asserted below as a property rather than typed as a - // roster, for the reason the value-bearing block above states: a list typed - // into a self-test rots the first time a workflow moves. - { - const usageFixture = [ - '#!/usr/bin/env node', - '// check-fixture -- a gate.', - '//', - '// node scripts/check-fixture.mjs --base [--head ]', - '// node scripts/check-fixture.mjs # base defaults to origin/main', - '//', - "const REPO_ROOT = '.';", - '// the endpoint defaults to the repository default branch', - 'export function work() { return REPO_ROOT; }', - ].join('\n'); - t( - '⭐ a script that documents a default for the flag CI pins declares it, and the declaration is read from the script', - [...declaredArgvDefaults(usageFixture)].map(([f, v]) => `${f}=${v}`).join(',') === '--base=origin/main', - ); - t( - '⛔ and prose PAST the usage block mints nothing — a sentence about behaviour is not a declaration about an argument', - !declaredArgvDefaults(usageFixture).has('--endpoint'), - ); - t( - '…which is the whole point of reading the LEADING comment block: it stops at the first line of code', - leadingCommentBlock(usageFixture).includes('base defaults to origin/main') - && !leadingCommentBlock(usageFixture).includes('the endpoint defaults to'), - ); - const fixtureDefaults = declaredArgvDefaults(usageFixture); - t( - '⭐ DIRECTION ONE — a DEFAULTED variable renders the REAL invocation, with the default spelled out where CI writes the variable', - defaultedArgv('--base "$MERGE_BASE"', fixtureDefaults).args === '--base origin/main', - ); - t( - '…and it says which workflow value it filled, so the row can state what CI pins instead', - JSON.stringify(defaultedArgv('--base "$MERGE_BASE"', fixtureDefaults).defaulted) - === JSON.stringify([{ flag: '--base', variable: '$MERGE_BASE', value: 'origin/main' }]), - ); - t( - '⭐ DIRECTION TWO — an UNDEFAULTED variable is untouched, so its family stays value-bearing and unrunnable', - defaultedArgv('--format=markdown --provenance="$PROVENANCE"', fixtureDefaults).args - === '--format=markdown --provenance="$PROVENANCE"' - && defaultedArgv('--format=markdown --provenance="$PROVENANCE"', fixtureDefaults).defaulted.length === 0, - ); - t( - '⛔ a token the workflow only PART wrote is not a variable this can replace — half a path is not a value to substitute', - defaultedArgv('--base "$RUNNER_TEMP/base.txt"', fixtureDefaults).args === '--base "$RUNNER_TEMP/base.txt"' - && defaultedArgv('--base "$RUNNER_TEMP/base.txt"', fixtureDefaults).defaulted.length === 0, - ); - t( - 'the `--flag=value` spelling is covered too, since that is how the tree writes the one flag with no default', - defaultedArgv('--base="$MERGE_BASE"', fixtureDefaults).args === '--base=origin/main', - ); - // ⭐ THE DRIFT DIRECTION the ruling names, driven on the REAL specimen - // source rather than on a fixture: a script that stops documenting the - // default returns its family to NOT RUNNABLE LOCALLY with no edit here. - // That is the whole reason the declaration is read from the script instead - // of from a table in this file. - const specimenPath = 'scripts/check-adr-0087-registration.mjs'; - const specimenSource = readFileSync(nodePath.join(ROOT, specimenPath), 'utf8'); - t( - 'CONTROL: the live specimen really documents its default, so the drift case below is not vacuous', - declaredArgvDefaults(specimenSource).get('--base') === 'origin/main', - ); - const undocumented = specimenSource.replace(/^.*base defaults to origin\/main.*$/m, '//'); - t( - '⭐ a script whose usage block STOPS documenting the default returns its family to unrunnable — no table here to drift', - undocumented !== specimenSource - && !declaredArgvDefaults(undocumented).has('--base') - && defaultedArgv('--base "$MERGE_BASE"', declaredArgvDefaults(undocumented)).defaulted.length === 0, - ); - - // ── The live half: the class, enumerated by predicate on this tree ─────── - const defaultsFamilies = discoverFamilies().byCheck; - const withArgv = [...defaultsFamilies.values()].filter((e) => e.direct && e.script && (e.check !== e.script)); - const repaired = withArgv.filter((e) => (e.argvDefaulted ?? []).length > 0); - const stillValueBearing = withArgv.filter((e) => e.notRunnable); - t( - `the live tree carries ${repaired.length} repaired and ${stillValueBearing.length} still-value-bearing famil(ies), so both directions below judge a live class`, - repaired.length > 0 && stillValueBearing.length > 0, - ); - t( - '⭐ ALL OR NOTHING — no family is repaired while a workflow value it cannot fill is still in its argv', - repaired.every((e) => !e.notRunnable), - ); - t( - '⭐ every repaired family read its default off ITS OWN script, flag by flag — nothing here invented a value', - repaired.every((e) => { - const declared = declaredArgvDefaults(readFileSync(nodePath.join(ROOT, e.script), 'utf8')); - return (e.argvDefaulted ?? []).every((d) => declared.get(d.flag) === d.value); - }), - ); - t( - '⭐ and every family still filed value-bearing is one whose script declares NO default for what the workflow pins', - stillValueBearing.every((e) => { - const declared = declaredArgvDefaults(readFileSync(nodePath.join(ROOT, e.script), 'utf8')); - return (e.notRunnable?.variables ?? []).length > 0 && ![...declared.keys()].some((flag) => e.check.includes(`${flag} `)); - }), - ); - t( - '⛔ the KEY never moved — a repaired family is still keyed on the invocation CI runs, so nothing was re-attributed', - repaired.every((e) => e.check.includes('$') || e.check.includes('${{')), - ); - // ⭐ The card's own specimen, on the live tree. - const adrKey = 'scripts/check-adr-0087-registration.mjs --base "$MERGE_BASE"'; - const adrEntry = defaultsFamilies.get(adrKey); - t( - '⭐ THE SPECIMEN — the ADR-0087 registration gate is keyed as pr-automation.yml runs it and is no longer filed unrunnable', - Boolean(adrEntry) && !adrEntry.notRunnable, - ); - t( - '…and what a dev pastes for it is the REAL check with the script\'s own default spelled out, never the self-test', - runnableInvocation(adrEntry ?? {}) === 'node scripts/check-adr-0087-registration.mjs --base origin/main', - ); - t( - 'CONTROL: its --self-test invocation is STILL its own family — the repair ADDS the real check, it moves nothing', - defaultsFamilies.has('scripts/check-adr-0087-registration.mjs --self-test'), - ); - t( - 'CONTROL: the sweeper the workflow hands a provenance string is untouched — no default, so still value-bearing', - Boolean(defaultsFamilies.get('scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE"')?.notRunnable), - ); - } - - // ── The self-test harness prints the reading its call sites pass (#15539) ── - // - // `t` was declared with arity TWO while six call sites passed a THIRD - // argument carrying the case's diagnostic reading, and JavaScript dropped - // every one. Each sits on a case whose verdict is about the LIVE tree, so the - // day one goes red the person triaging it gets the sentence and nothing else - // — while the author had already written the reading they would need. - t( - '⭐ a FAILING case with a reading prints the reading, on its own line under the case', - JSON.stringify(selfTestCaseLines('subject', false, JSON.stringify({ before: 3, after: 3 }))) - === JSON.stringify([' ✗ subject', ' ↳ reading: {"before":3,"after":3}']), - ); - t( - '…and a non-string reading is rendered rather than printed as [object Object]', - selfTestCaseLines('subject', false, { verdict: 'silent' })[1] === ' ↳ reading: {"verdict":"silent"}', - ); - t( - '⛔ a PASSING case is byte-identical to what this harness has always printed — the reading is owed to a red, not to 1400 greens', - JSON.stringify(selfTestCaseLines('subject', true, JSON.stringify({ before: 3 }))) === JSON.stringify([' ✓ subject']), - ); - t( - '⛔ and a failing case with NO reading grows no line either — an empty diagnostic is not a diagnostic', - JSON.stringify(selfTestCaseLines('subject', false, null)) === JSON.stringify([' ✗ subject']) - && JSON.stringify(selfTestCaseLines('subject', false, '')) === JSON.stringify([' ✗ subject']), - ); - - // The live halves. Fixtures cannot prove the tree changed; these read it. - const liveSelfTestFamilies = [...discoverFamilies().byCheck].filter(([, e]) => e.selfTest); - t( - `the live tree really has self-test families (${liveSelfTestFamilies.length}), so the cases above judge something`, - liveSelfTestFamilies.length > 0, - ); - t( - 'the PR #11397 gate is one of them, on the real workflows', - liveSelfTestFamilies.some(([c]) => c === 'scripts/pm/bare-root-worklist.mjs --self-test'), - ); - t( - '…and it resolves to a file that EXISTS, which is what a flagged key would have broken', - liveSelfTestFamilies - .filter(([c]) => c === 'scripts/pm/bare-root-worklist.mjs --self-test') - .every(([, e]) => (e.files ?? []).length === 1 && existsSync(nodePath.join(ROOT, e.files[0]))), - ); - // The import narrowing, proven NON-VACUOUS: the module this gate imports - // really does declare literals, and they really would have reached the tree. - // Without that half the case below is satisfied by a module with nothing in - // it, which is the shape a green-over-nothing pin takes. - const bareRootEntry = liveSelfTestFamilies.find(([c]) => c === 'scripts/pm/bare-root-worklist.mjs --self-test')?.[1]; - const importedByBareRoot = firstPartyImportTargets( - 'scripts/pm/bare-root-worklist.mjs', - readFileSync(nodePath.join(ROOT, 'scripts/pm/bare-root-worklist.mjs'), 'utf8'), - ); - const wouldHaveInherited = importedByBareRoot.flatMap((m) => - extractWatchHints(readFileSync(nodePath.join(ROOT, m), 'utf8'), m), - ); - t( - `the refused inheritance is real — the modules this gate imports declare ${wouldHaveInherited.length} literal(s)`, - wouldHaveInherited.length > 0, - ); - t( - 'a self-test family inherits NONE of them — those literals are join bases and tier globs, the fabrication #8162 already refused by spawning', - (bareRootEntry?.hints ?? []).length === 0 && (bareRootEntry?.hintOrigin?.size ?? 0) === 0, - ); - - // The SECOND guard, on the same live specimen (#11556). The narrowing above - // is invocation-shaped: it holds for a `--self-test` family and nothing else, - // so a `check-` gate importing the same modules was untouched by it. What - // covers that caller is the module's OWN inherited-population declaration, - // and this measures it through the follow's rule rather than through the raw - // extractor the case above uses. - const inheritableFromImports = importedByBareRoot.flatMap((m) => { - const src = readFileSync(nodePath.join(ROOT, m), 'utf8'); - const spelled = extractWatchHints(src, m); - return declaredInheritedPopulation(src, spelled, m)?.population ?? spelled; - }); - t( - `a gate that IMPORTS the same modules inherits ${inheritableFromImports.length} of those ${wouldHaveInherited.length} literal(s)`, - inheritableFromImports.length > 0 && inheritableFromImports.length < wouldHaveInherited.length, - ); - const inhSweep = trackedFiles(); - const inhCovered = (hs) => inhSweep.filter((f) => hs.some((h) => hintCovers(h, f))).length; - t( - `and the price of that import drops from ${inhCovered(wouldHaveInherited)} tracked files to ${inhCovered(inheritableFromImports)}`, - inhCovered(inheritableFromImports) < inhCovered(wouldHaveInherited), - ); - // The direction that could SUBTRACT, asserted rather than argued: a - // declaration is a narrowing, and a narrowing that took the real population - // with it would read exactly like this one — fewer pairs, every gate green. - // The tool DOES readdir the workflow tree, so every file in it must stay - // reachable through what a follower inherits. - t( - 'and it is not a coverage cut — every workflow file the tool really readdirs is still reachable through the declaration', - inhSweep.filter((f) => f.startsWith('.github/workflows/')).length > 0 - && inhSweep - .filter((f) => f.startsWith('.github/workflows/')) - .every((f) => inheritableFromImports.some((h) => hintCovers(h, f))), - ); - - // The direction that could SUBTRACT, and the reason it is asserted rather - // than argued: admitting these nine makes six previously-followable modules - // GATE FILES, and `discoverFamilies` refuses to follow a gate file. Any - // family that used to inherit a hint from one of them would silently stop — - // and a lead that stops appearing is indistinguishable from a lead that was - // never earned, so nothing in the output would say so. Measured here: the - // only newly-promoted module that declares a literal at all is pr-labels.mjs - // (`.github/labeler.yml`), and no family imports it. - // ⚠️ Narrowed by #14880, and the narrowing is what keeps this case measuring - // its own claim. A `check-`named script invoked with `--self-test` is now a - // self-test family too, but its file was ALREADY a gate file — CI also runs - // it plainly, or the direct matcher admits it under a `check-` basename - // either way — so counting it as "promoted BY the self-test admission" reads - // a refusal that predates that admission as a loss it caused. Measured: with - // the raw list, four families reported hints "lost" to modules - // (`check-adr-links.mjs`, `check-self-test-wired.mjs`) that were gate files - // on the base tree as well, and the follow had already been refusing them. - // What this case is about is the module a self-test family is the ONLY - // reason to treat as a gate file, so that is what it takes. - const namedByWorkFamilies = new Set( - [...discoverFamilies().byCheck.values()].filter((e) => !e.selfTest).flatMap((e) => e.files ?? []), - ); - const promoted = liveSelfTestFamilies - .flatMap(([, e]) => e.files ?? []) - .filter((f) => !namedByWorkFamilies.has(f)); - const subtracted = []; - for (const [check, entry] of discoverFamilies().byCheck) { - if (entry.selfTest) continue; - for (const f of entry.files ?? []) { - if (!existsSync(nodePath.join(ROOT, f))) continue; - const src = readFileSync(nodePath.join(ROOT, f), 'utf8'); - for (const mod of firstPartyImportTargets(f, src)) { - if (!promoted.includes(mod)) continue; - const lost = extractWatchHints(readFileSync(nodePath.join(ROOT, mod), 'utf8'), mod); - if (lost.length > 0) subtracted.push(`${check} <- ${mod} (${lost.join(', ')})`); - } - } - } - t( - `promoting ${promoted.length} module(s) to gate files subtracts no inherited hint from any other family` - + `${subtracted.length ? ` — LOST: ${subtracted.join(' · ')}` : ''}`, - subtracted.length === 0, - ); - - // ── The refused fifth key, kept honest (#13126) ──────────────────────────── - // `coveringKey`'s docblock refuses an IDENTITY key over these same import - // edges, and that refusal is a MEASUREMENT rather than a preference: it holds - // only while the class stays concentrated in the shared utilities nearly - // every gate links. Prose cannot notice the tree flattening under it, so the - // price is re-derived here on every run and asserted. A red in this block is - // not a broken derivation — it says the refusal is due a re-pricing. - const importClassFamilies = [...discoverFamilies().byCheck]; - const importClassEdges = new Map(); - for (const [check, e] of importClassFamilies) { - const edges = new Set(); - for (const f of e.files ?? []) { - if (!existsSync(nodePath.join(ROOT, f))) continue; - for (const mod of firstPartyImportTargets(f, readFileSync(nodePath.join(ROOT, f), 'utf8'))) { - if ((e.files ?? []).includes(mod)) continue; - edges.add(mod); - } - } - importClassEdges.set(check, edges); - } - const importNovel = []; - let importCoveredElsewhere = 0; - for (const [check, e] of importClassFamilies) { - for (const mod of importClassEdges.get(check) ?? []) { - if (coveringKey(e, mod)) importCoveredElsewhere++; - else importNovel.push([check, mod]); - } - } - t( - `the refused import-edge class is real and NOVEL — ${importNovel.length} (family, imported module)` - + ` pair(s) no key reaches, of ${importNovel.length + importCoveredElsewhere}`, - importNovel.length > 0, - ); - t( - `…and the split the refusal quotes is not invented: ${importCoveredElsewhere} pair(s) another key` - + ' already answers, so the novel half is a measurement and not the raw count', - importCoveredElsewhere > 0, - ); - // The card's own witness, and the single lead this refusal is KNOWN to cost. - // Asserted in both halves: the import edge exists, and no key names it. - const bareRootKey = 'scripts/pm/bare-root-worklist.mjs --self-test'; - const bareRootImportFamily = importClassFamilies.find(([c]) => c === bareRootKey)?.[1]; - t( - 'the witness holds — bare-root-worklist --self-test imports THIS file, and no key names that' - + ' family for a card editing it', - (importClassEdges.get(bareRootKey)?.has('scripts/pm/dispatch-gates.mjs') ?? false) - && !!bareRootImportFamily - && coveringKey(bareRootImportFamily, 'scripts/pm/dispatch-gates.mjs') === null, - ); - // Why it is refused, re-derived rather than recalled: the worst module would - // print a list nobody reads. The bound is the header's own "22 leads is the - // same as none", doubled — green through ordinary drift, red only if the - // concentration genuinely collapses and the class is worth re-pricing. - const importAddPerModule = new Map(); - for (const [, mod] of importNovel) importAddPerModule.set(mod, (importAddPerModule.get(mod) ?? 0) + 1); - const importWorst = [...importAddPerModule] - .map(([mod, add]) => ({ - mod, - add, - after: add + importClassFamilies.filter(([, e]) => coveringKey(e, mod)).length, - })) - .sort((a, b) => b.after - a.after); - t( - `the refusal is still earned — a card editing ${importWorst[0]?.mod} would name` - + ` ${importWorst[0]?.after} families under the refused key`, - (importWorst[0]?.after ?? 0) > 44, - ); - const importTop5 = importWorst.slice(0, 5).reduce((s, r) => s + r.add, 0); - t( - `…and the class is still concentrated: ${importTop5} of ${importNovel.length} novel pair(s) land` - + ` on ${Math.min(5, importWorst.length)} module(s)`, - importTop5 * 2 > importNovel.length, - ); - // The PRICE the refusal states is an AVERAGE (#13467). "The miss costs one CI - // round" holds for a novel pair whose family some UNFILTERED workflow runs — - // CI opens the module on that PR regardless — and does not hold for one whose - // family no every-PR workflow runs at all: nothing on the PR repays that - // miss. Prose cannot notice the split moving and this one has moved three - // times, so the docblock states the SHAPE and these three assertions print - // the sizes. A red here re-prices the paragraph; it does not fault the - // derivation. - const everyPRWorkflow = new Map(); - for (const wf of readdirSync(nodePath.join(ROOT, '.github/workflows')).filter((f) => /\.ya?ml$/.test(f))) { - const wfText = readFileSync(nodePath.join(ROOT, '.github/workflows', wf), 'utf8'); - // No `paths:` on a workflow that declares `pull_request` is the same - // reading `discoverFamilies` makes when it drops such a workflow from - // `triggers` — an unfiltered workflow discriminates nothing, which is - // exactly why it runs on every PR. - everyPRWorkflow.set(wf, declaresPullRequestTrigger(wfText) && extractTriggerPaths(wfText).length === 0); - } - const runsOnEveryPR = (e) => [...(e?.workflows ?? [])].some((wf) => everyPRWorkflow.get(wf)); - const importFamilyByCheck = new Map(importClassFamilies); - const importDeferred = importNovel.filter(([check]) => !runsOnEveryPR(importFamilyByCheck.get(check))); - const importDeferredWorkflows = [...new Set( - importDeferred.flatMap(([check]) => [...(importFamilyByCheck.get(check)?.workflows ?? [])]), - )].sort(); - t( - `the "one CI round" price is an average, not a uniform one — ${importDeferred.length} of` - + ` ${importNovel.length} novel pair(s) sit in families no every-PR workflow runs` - + ` (${importDeferredWorkflows.join(', ') || 'none'}), so no CI round on the PR repays that miss`, - importDeferred.length > 0 && importDeferred.length * 4 < importNovel.length, - ); - // The half that keeps the exception from being over-read: a deferred pair is - // a deferred LEAD, never a silent load break. Every module carrying one is - // imported by every-PR families too, so a module that fails to LOAD reddens - // the PR through a sibling; what defers is the narrower break. - const importLoadBreakSilent = importDeferred.filter(([, mod]) => - !importClassFamilies.some(([c2, e2]) => runsOnEveryPR(e2) && (importClassEdges.get(c2)?.has(mod) ?? false))); - t( - 'a deferred pair defers the LEAD, not the load break — every module carrying one is imported by' - + ' an every-PR family as well, so failing to load still reddens the PR' - + `${importLoadBreakSilent.length ? ` — SILENT: ${importLoadBreakSilent.map(([c, m]) => `${c} <- ${m}`).join(' · ')}` : ''}`, - importLoadBreakSilent.length === 0, - ); - // And they are not spread thin: the deferred pairs land on the shared heads - // this key is refused FOR — the modules most likely to be edited into a - // break. `fan-in <= 3` is the tail boundary the aggregate paragraph uses. - const importFanIn = (mod) => importClassFamilies.filter(([c2]) => importClassEdges.get(c2)?.has(mod)).length; - const importDeferredOnHeads = importDeferred.filter(([, mod]) => importFanIn(mod) > 3); - t( - `…and the deferred pair(s) concentrate on the heads rather than the tail:` - + ` ${importDeferredOnHeads.length} of ${importDeferred.length} land on a module more than 3` - + ` families import`, - importDeferredOnHeads.length * 2 > importDeferred.length, - ); - - // #12107, the live half — three claims about THIS tree, each one a thing the - // fix buys that a fixture cannot show. - const tsLiveFamilies = [...discoverFamilies().byCheck]; - - // 1. No gate file the derivation NAMES is a path that does not exist. This is - // the invariant the package-relative normalisation buys, and it is the one - // that catches the near-miss: widening the extensions WITHOUT normalising - // produced `packages/client/scripts/check-exported-any-returns.mts` — a - // phantom identity key that `coveringKey` would print as a `gate script` - // match while `existsSync` kept the file closed and the family kept - // reading zero hints. Measured on this tree at the fix: 0 phantoms. - const phantomGateFiles = tsLiveFamilies.flatMap(([check, e]) => - (e.files ?? []).filter((f) => !existsSync(nodePath.join(ROOT, f))).map((f) => `${check} -> ${f}`), - ); - t( - `every gate file the derivation names exists on disk${phantomGateFiles.length ? ` — PHANTOM: ${phantomGateFiles.join(' · ')}` : ''}`, - phantomGateFiles.length === 0, - ); - - // 2. The TypeScript families really do resolve now, on the live tree rather - // than through a fixture — and the climbing one resolves to the ROOT path. - const anyReturns = tsLiveFamilies.find(([c]) => c === 'check:exported-any-returns')?.[1]; - t( - 'the live TypeScript gate that climbs out of its package resolves to the tracked root path', - (anyReturns?.files ?? []).join() === 'scripts/check-exported-any-returns.mts', - ); - const tsFamilies = tsLiveFamilies.filter(([, e]) => - (e.files ?? []).some((f) => /\.(?:ts|mts|cts)$/.test(f)), - ); - t(`the live tree resolves TypeScript-authored gates at all (${tsFamilies.length} families)`, tsFamilies.length >= 20); - - // 3. What is LEFT zero-file, and why it must stay that way. `resolveCheckToFiles` - // reads PATHS out of a command string; a family whose script names a package - // and a script NAME instead (`pnpm --filter @objectstack/cli run check:…`) - // carries no path for any extension list to match. It is a different - // mechanism and a different card, so the assertion is that every remaining - // zero-file family is one of those composites — never that the count is - // zero, which would mean this fix had absorbed a family it cannot honestly - // resolve. - const rootScriptsMap = JSON.parse(readFileSync(nodePath.join(ROOT, 'package.json'), 'utf8')).scripts ?? {}; - const zeroFile = tsLiveFamilies.filter(([, e]) => (e.files ?? []).length === 0); - const unexplained = zeroFile - .map(([check]) => [check, rootScriptsMap[check] ?? '']) - .filter(([, cmd]) => !/\bpnpm\b[^&|]*?(?:--filter|--recursive|-r)\b[^&|]*?\brun\b/.test(cmd)) - .map(([check, cmd]) => `${check} (${cmd || 'no root script'})`); - t( - `every zero-file family left is a pnpm workspace composite, not an unmatched extension${unexplained.length ? ` — UNEXPLAINED: ${unexplained.join(' · ')}` : ''}`, - unexplained.length === 0, - ); - t('and there is still at least one, so the assertion above is not vacuous', zeroFile.length > 0); - - // 4. The SUBTRACTION direction, asserted the way the self-test families' - // promotion above asserts its own: admitting these sources makes them GATE - // FILES, and `discoverFamilies` refuses to follow a gate file. A family - // that used to inherit a hint from one of them would silently stop, and a - // lead that stops appearing is indistinguishable from one never earned. - // Measured on this tree: none of the newly admitted sources is imported by - // any gate at all — all 12 modules this tree follows live in the root - // `scripts/` dir, and 22 of the 23 admitted sources live under - // `packages/spec/scripts/`. The 23rd (`scripts/check-exported-any-returns.mts`) - // is imported by nothing. - const admittedTs = tsLiveFamilies.flatMap(([, e]) => (e.files ?? []).filter((f) => /\.(?:ts|mts|cts)$/.test(f))); - const tsSubtracted = []; - for (const [check, entry] of tsLiveFamilies) { - if (entry.selfTest) continue; - for (const f of entry.files ?? []) { - if (/\.(?:ts|mts|cts)$/.test(f)) continue; - if (!existsSync(nodePath.join(ROOT, f))) continue; - for (const mod of firstPartyImportTargets(f, readFileSync(nodePath.join(ROOT, f), 'utf8'))) { - if (!admittedTs.includes(mod)) continue; - const lost = extractWatchHints(readFileSync(nodePath.join(ROOT, mod), 'utf8'), mod); - if (lost.length > 0) tsSubtracted.push(`${check} <- ${mod} (${lost.join(', ')})`); - } - } - } - t( - `admitting ${admittedTs.length} TypeScript gate source(s) subtracts no inherited hint from any other family` - + `${tsSubtracted.length ? ` — LOST: ${tsSubtracted.join(' · ')}` : ''}`, - tsSubtracted.length === 0, - ); - - // `runCommandTexts` on its own: one entry per step, in file order. - const texts = runCommandTexts(blockWf); - t('one command text per run step', texts.length === 4); - t('a block body keeps its lines joined', texts[0].split('\n').filter((l) => l.trim()).length === 2); - t('a one-line run yields its command verbatim', texts[3] === 'node scripts/check-nul-bytes.mjs'); - - // The compact step form (#9203): `- run: …`, a block-sequence entry with the - // key on the dash line. Before it was read, every command in a step written - // this way was invisible to the derivation. - // - // The `env:` values are the load-bearing part of the fixture, not padding. - // They name gates the steps do NOT run, positioned exactly where a body walk - // that mis-reads `indent` swallows them — the compact one sits in the columns - // the `- ` marker occupies, which is the only place the two candidate - // readings disagree. Pinning the classic form's `env:` too keeps the two step - // shapes asserted against the same trap, so a future edit cannot fix one - // reading by breaking the other. - const compactWf = [ - 'jobs:', - ' smoke:', - ' steps:', - ' - name: Classic block, sibling key after the body', - ' run: |', - ' pnpm check:classic-body', - ' env:', - ' NOTE: "we do not run pnpm check:phantom-classic here"', - ' - run: pnpm --filter @objectstack/spec check:compact-one-liner', - ' - run: |', - ' pnpm check:compact-body', - ' node scripts/check-compact-direct.mjs', - ' env:', - ' NOTE: "we do not run pnpm check:phantom-compact here"', - ' - run: node scripts/check-compact-tail.mjs', - ' - name: Back to the named form', - ' run: pnpm check:after-compact', - ].join('\n'); - const compactInvs = extractCheckInvocations(compactWf, 'showcase-smoke.yml'); - const compactNames = compactInvs.map((i) => i.check); - // Leg 1 — the form is now REACHED. Each of these was zero before #9203. - t('a compact `- run:` one-liner is discovered, with its filter', compactInvs.some((i) => i.check === 'check:compact-one-liner' && i.filter === '@objectstack/spec')); - t('a compact `- run: |` block body is discovered', compactNames.includes('check:compact-body')); - t('a direct script in a compact block body is discovered', compactNames.includes('scripts/check-compact-direct.mjs')); - t('a compact step after a compact block body still parses', compactNames.includes('scripts/check-compact-tail.mjs')); - // Leg 2 — the widening bought no over-consumption. `indent` counts the `- `, - // so a compact block body ends at its own sibling keys exactly as the named - // form's does; both directions of the mis-read fabricate a gate here. - t('a compact block body ends at the `env:` key of its own step', !compactNames.includes('check:phantom-compact')); - t('the named form still ends its block body at the `env:` key', !compactNames.includes('check:phantom-classic')); - t('the step after a compact block body is not swallowed by it', compactNames.includes('check:after-compact')); - const compactTexts = runCommandTexts(compactWf); - // Indexed reads are defaulted rather than asserted-then-dereferenced: under a - // parser that drops the compact form entirely there is no element 2, and a - // bare `compactTexts[2].split(…)` THROWS out of the whole self-test — the - // reverse-verification run for this card hit exactly that and got one stack - // trace where it needed a list of named failures. A gate that cannot say - // which case broke is a worse gate, even when it is correctly red. - t('one command text per compact step too', compactTexts.length === 5); - t('a compact block body keeps both of its lines', (compactTexts[2] ?? '').split('\n').filter((l) => l.trim()).length === 2); - t('a compact one-liner yields its command verbatim', compactTexts[1] === 'pnpm --filter @objectstack/spec check:compact-one-liner'); - // A `-` that is not a list marker must not be read as one: `-run:` is a key - // named `-run`, and `- name:` is a step whose `run:` comes later on its own - // line (already covered above, but the negative half needs its own pin). - t('a bare `-run:` is not read as a compact step', runCommandTexts(' -run: pnpm check:not-a-step').length === 0); - - // ── The step's `env:` is the OTHER carrier of a workflow value (#15761) ──── - // - // `partof-closing-keyword-guard.yml` passes the whole input through `env:` - // and leaves the argv bare, so the argv-shaped classifier scored - // `node scripts/check-partof-closing-keyword.mjs` as a command a dev can - // paste — one whose only possible outcome here is the gate's own exit 2 - // ("NOT WIRED — neither PR_BODY nor PR_NUMBER is set"). The fixture carries - // the specimen's shape plus the three shapes that must NOT move. - const envWf = [ - 'jobs:', - ' guard:', - ' steps:', - ' - name: The specimen — bare argv, the input through env:', - ' env:', - ' PR_BODY: ${{ github.event.pull_request.body }}', - ' PR_NUMBER: ${{ github.event.pull_request.number }}', - ' run: node scripts/check-env-carried.mjs', - ' - name: A LITERAL env value is not a workflow value', - ' run: node scripts/check-literal-env.mjs', - ' env:', - ' PR_BODY: a body written down right here', - ' HOME_ISH: $HOME/not-an-expression', - ' - name: argv-carried, the regression control', - ' run: node scripts/check-argv-carried.mjs --base "$MERGE_BASE"', - ' - name: one value, one carrier — the command spells this env name itself', - ' env:', - ' MERGE_BASE: ${{ github.event.pull_request.base.sha }}', - ' run: node scripts/check-spelled-env.mjs --base "$MERGE_BASE"', - ' - run: node scripts/check-compact-env.mjs', - ' env:', - ' COMPACT: ${{ github.sha }}', - ' - name: an env: that belongs to a nested mapping is not this step\'s', - ' uses: some/action@v1', - ' with:', - ' env:', - ' NESTED: ${{ github.sha }}', - ' run: node scripts/check-nested-env.mjs', - ].join('\n'); - const envInvs = extractCheckInvocations(envWf, 'partof-closing-keyword-guard.yml'); - const envOf = (name) => (envInvs.find((i) => i.script === name)?.envVariables ?? null); - // (a) The specimen: the step's `env:` names ride the invocation, in order. - t( - '\u2b50 a bare argv beside an `env:` expression carries the env NAMES the step passes it', - (envOf('scripts/check-env-carried.mjs') ?? []).join(',') === 'PR_BODY,PR_NUMBER', - ); - t( - '\u2026and it reads the `env:` block whichever side of `run:` the step writes it on', - (envOf('scripts/check-compact-env.mjs') ?? []).join(',') === 'COMPACT', - ); - // (b) A literal `env:` value keeps the command runnable — including the shell - // spellings, which Actions does NOT substitute in an env value. - t( - '\u2b50 a LITERAL `env:` value is not a workflow value, so the command stays runnable', - (envOf('scripts/check-literal-env.mjs') ?? null)?.length === 0, - ); - // (c) The regression control: argv-carried detection is untouched, and a name - // the command spells for itself is ONE value with ONE carrier. - t( - 'CONTROL: an argv-carried variable is still detected, and carries no env of its own', - envInvs.some((i) => i.check === 'scripts/check-argv-carried.mjs --base "$MERGE_BASE"' - && (i.argvVariables ?? []).join(',') === '$MERGE_BASE' - && (i.envVariables ?? []).length === 0), - ); - t( - '\u2b50 CONTROL: an env name the command SPELLS is argv-carried, not counted twice \u2014 this is what keeps #15441\'s repaired `--base` families runnable', - (envOf('scripts/check-spelled-env.mjs') ?? null)?.length === 0, - ); - t( - 'CONTROL: an `env:` nested under `with:` is not read as the step\'s own', - (envOf('scripts/check-nested-env.mjs') ?? null)?.length === 0, - ); - t( - 'the same walk, read directly: one step per `run:` key, each with its own env', - runCommandSteps(envWf).length === 6 - && runCommandSteps(envWf)[0].envVariables.join(',') === 'PR_BODY,PR_NUMBER' - && runCommandSteps(envWf)[1].envVariables.length === 0, - ); - t( - 'CONTROL: the classic and compact fixtures above carry no `env:` expression, so this widening moved neither', - runCommandSteps(blockWf).every((step) => step.envVariables.length === 0) - && runCommandSteps(compactWf).every((step) => step.envVariables.length === 0), - ); - // The limbs, each one of which has a live case on this tree (see - // `workflowEnvValues`' docblock for which family each keeps out). - const envEntry = (over = {}) => ({ envVariables: ['PR_BODY'], direct: true, selfTest: false, ciOnly: null, ...over }); - t('\u2b50 a direct, non-self-test family with no payload dependence is classified by its env carrier', workflowEnvValues(envEntry()).join(',') === 'PR_BODY'); - t('a `--self-test` invocation consumes no workflow value, so its step\'s env cannot subtract it', workflowEnvValues(envEntry({ selfTest: true })).length === 0); - t('a `check:*` family is invocable by name and its KEY drops the argv, so one step\'s env is not a property of it', workflowEnvValues(envEntry({ direct: false })).length === 0); - t('a family already named CI-MEASURED ONLY is not named a second time as value-bearing', workflowEnvValues(envEntry({ ciOnly: { env: 'GITHUB_EVENT_PATH' } })).length === 0); - t('CONTROL: no env carrier at all classifies nothing', workflowEnvValues(envEntry({ envVariables: [] })).length === 0); - - // ── The script's own `local-env` declaration (#20278) ───────────────────── - // - // Judged on the specimen's own step text: `lint.yml`'s diff-scoped citation - // step passes two workflow values through `env:`, and the script declares its - // BARE run needs neither. Pinned by DIRECTION — which command reaches the - // runnable union and which stays NOT MEASURED — never by a count. - const localEnvWf = [ - 'jobs:', - ' lint:', - ' steps:', - ' - name: Issue citations this change adds resolve on the board', - ' env:', - ' GITHUB_TOKEN: ${{ github.token }}', - ' OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}', - ' run: pnpm check:issue-citations && node scripts/check-issue-citations.mjs', - ' - name: the census, report-only', - ' env:', - ' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}', - ' run: node scripts/check-issue-citations.mjs --census', - ].join('\n'); - const localEnvSource = [ - "export const SURFACES = ['packages/**'];", - '', - '// dispatch-gates: local-env GITHUB_TOKEN OS_GATE_MERGE_GROUP_BASE_SHA -- the bare diff run reads a public board and falls back to the merge base', - '', - 'export function run() {}', - ].join('\n'); - const localEnvDecl = declaredLocalEnv(localEnvSource, 'scripts/check-issue-citations.mjs'); - t( - '⭐ a `local-env` declaration reads back its NAMES, its whole reason and its line', - localEnvDecl?.names.join(',') === 'GITHUB_TOKEN,OS_GATE_MERGE_GROUP_BASE_SHA' - && localEnvDecl?.reason === 'the bare diff run reads a public board and falls back to the merge base' - && localEnvDecl?.line === 3, - ); - t('CONTROL: a script that declares nothing reads back null', declaredLocalEnv("export const X = 'local-env';\n") === null); - t( - 'a `local-env` reason cut by the comment line under it is REFUSED, by file and line — the shared wholeness reading reaches this key by construction', - (() => { - try { - declaredLocalEnv('// dispatch-gates: local-env GITHUB_TOKEN -- the diff run\n// needs no token\n', 'scripts/x.mjs'); - return false; - } catch (error) { - return String(error.message).includes('scripts/x.mjs:2 continues it with'); - } - })(), - ); - t( - 'a token in the name list that is not an environment variable name is REFUSED, never read as one', - (() => { - try { - declaredLocalEnv('// dispatch-gates: local-env GITHUB_TOKEN --census -- x\n', 'scripts/x.mjs'); - return false; - } catch (error) { - return String(error.message).includes('scripts/x.mjs:1') && String(error.message).includes('--census'); - } - })(), - ); - const localEnvInvs = extractCheckInvocations(localEnvWf, 'lint.yml'); - const localEnvBareInv = localEnvInvs.find((i) => i.check === 'scripts/check-issue-citations.mjs'); - const localEnvCensusInv = localEnvInvs.find((i) => i.check === 'scripts/check-issue-citations.mjs --census'); - t( - 'CONTROL: the step text yields the bare run and the census as two direct keys, each carrying its OWN step env', - (localEnvBareInv?.envVariables ?? []).join(',') === 'GITHUB_TOKEN,OS_GATE_MERGE_GROUP_BASE_SHA' - && (localEnvCensusInv?.envVariables ?? []).join(',') === 'GITHUB_TOKEN' - && localEnvBareInv?.direct === true && localEnvCensusInv?.direct === true, - ); - const localEnvRow = (inv, declaration) => { - const entry = { ...inv, ciOnly: null, localEnv: declaration }; - const values = workflowEnvValues(entry); - return { - check: entry.check, - command: runnableInvocation(entry), - ciOnly: null, - notRunnable: values.length > 0 ? { variables: values.map((n) => `env ${n}`), envVariables: values } : null, - }; - }; - const localEnvRows = [localEnvRow(localEnvBareInv ?? {}, localEnvDecl), localEnvRow(localEnvCensusInv ?? {}, localEnvDecl)]; - const localEnvCommands = commandsFor({ matchedRows: localEnvRows }); - const localEnvUnrunnable = notRunnableCommandSet(localEnvRows); - t( - '⭐ the lint.yml step text, with the script\'s declaration, puts the BARE command in --commands and NOT in the not-runnable set', - localEnvCommands.includes('node scripts/check-issue-citations.mjs') - && !localEnvUnrunnable.has('node scripts/check-issue-citations.mjs'), - ); - t( - '⭐ …while the census invocation of the SAME script stays not-runnable, on the token its own step passes', - !localEnvCommands.includes('node scripts/check-issue-citations.mjs --census') - && localEnvUnrunnable.has('node scripts/check-issue-citations.mjs --census') - && (localEnvRows[1].notRunnable?.variables ?? []).join(',') === 'env GITHUB_TOKEN', - ); - const localEnvUndeclared = [localEnvRow(localEnvBareInv ?? {}, null), localEnvRow(localEnvCensusInv ?? {}, null)]; - t( - 'CONTROL: with no declaration the bare run is not-runnable on both names — the reading this card repairs, unchanged for every script that declares nothing', - notRunnableCommandSet(localEnvUndeclared).has('node scripts/check-issue-citations.mjs') - && (localEnvUndeclared[0].notRunnable?.variables ?? []).join(',') === 'env GITHUB_TOKEN,env OS_GATE_MERGE_GROUP_BASE_SHA', - ); - t( - 'the limb is per NAME: a declaration naming only the token leaves the undeclared base keeping the family out', - (() => { - const tokenOnly = { names: ['GITHUB_TOKEN'], reason: 'x', line: 1 }; - return (localEnvRow(localEnvBareInv ?? {}, tokenOnly).notRunnable?.variables ?? []).join(',') === 'env OS_GATE_MERGE_GROUP_BASE_SHA'; - })(), - ); - t( - 'the scope is the BARE key alone: a `--census` or `--self-test` key of the declaring script, and any `check:*` key, admit nothing', - localEnvAdmitted({ ...localEnvCensusInv, localEnv: localEnvDecl }).length === 0 - && localEnvAdmitted({ check: 'scripts/check-issue-citations.mjs --self-test', script: 'scripts/check-issue-citations.mjs', direct: true, selfTest: true, localEnv: localEnvDecl }).length === 0 - && localEnvAdmitted({ check: 'check:issue-citations', direct: false, localEnv: localEnvDecl }).length === 0 - && localEnvAdmitted({ ...localEnvBareInv, localEnv: localEnvDecl }).join(',') === 'GITHUB_TOKEN,OS_GATE_MERGE_GROUP_BASE_SHA', - ); - const localEnvBareEntry = { ...(localEnvBareInv ?? {}) }; - t( - 'CONTROL: a declaration naming exactly what the bare run\'s step passes is NOT refused', - localEnvRefusal(localEnvDecl, localEnvBareEntry, 'scripts/check-issue-citations.mjs') === null, - ); - t( - '⭐ a STALE name — one the step does not pass — is REFUSED, naming the file, the line and the name', - (localEnvRefusal({ names: ['GITHUB_TOKEN', 'PR_BODY'], reason: 'x', line: 3 }, localEnvBareEntry, 'scripts/check-issue-citations.mjs') ?? '') - .includes('scripts/check-issue-citations.mjs:3 declares local-env PR_BODY,'), - ); - t( - 'a name only SOME steps pass is refused too — the bare key carries the intersection, and a value one step omits is not a property of the invocation', - (localEnvRefusal(localEnvDecl, { ...localEnvBareEntry, envVariables: ['GITHUB_TOKEN'] }, 'scripts/check-issue-citations.mjs') ?? '') - .includes('declares local-env OS_GATE_MERGE_GROUP_BASE_SHA,'), - ); - t( - '⭐ a declaration whose BARE run no workflow makes is REFUSED — an argv no workflow runs admits nothing and must not read as a promise', - (localEnvRefusal(localEnvDecl, null, 'scripts/check-issue-citations.mjs') ?? '').includes('no workflow runs `node scripts/check-issue-citations.mjs` with no argv') - && localEnvRefusal(localEnvDecl, { ...localEnvCensusInv }, 'scripts/check-issue-citations.mjs') !== null, - ); - - // #7440: the printed line must be runnable as-is. The three shapes come from - // the same three fixtures above, so the sample workflow and the print site - // cannot drift apart. - const inv = (name) => invs.find((i) => i.check === name); - t('prints a package-scoped check as its full --filter invocation', runnableInvocation(inv('check:authorable-surface')) === 'pnpm --filter @objectstack/spec run check:authorable-surface'); - t('prints a root-scoped check unchanged', runnableInvocation(inv('check:engine-double-contract')) === 'pnpm check:engine-double-contract'); - t('prints a direct script as a node invocation', runnableInvocation(inv('scripts/check-nul-bytes.mjs')) === 'node scripts/check-nul-bytes.mjs'); - - const scripts = { 'check:foo': 'node scripts/check-foo.mjs --self-test && node scripts/check-foo.mjs' }; - t('resolves script file from package.json', resolveCheckToFiles('check:foo', scripts).join() === 'scripts/check-foo.mjs'); - t('unknown check resolves to nothing', resolveCheckToFiles('check:bar', scripts).length === 0); - - // #12107 — the extension list. Each of the three TypeScript spellings is a - // case the OLD alternation (`mjs|cjs|js|sh`) resolved to nothing, which is - // why they are pinned separately rather than as one representative: the - // defect was an alternation, and an alternation regresses one branch at a - // time. - const tsScripts = { - 'check:ts': 'tsx scripts/check-generated.ts', - 'check:mts': 'tsx scripts/check-variant-docs.mts', - 'check:cts': 'tsx scripts/check-legacy.cts', - 'check:mixed': 'node scripts/pre.mjs && tsx scripts/check-generated.ts', - }; - t('resolves a .ts gate script — zero-file under the old alternation', resolveCheckToFiles('check:ts', tsScripts).join() === 'scripts/check-generated.ts'); - t('resolves a .mts gate script', resolveCheckToFiles('check:mts', tsScripts).join() === 'scripts/check-variant-docs.mts'); - t('resolves a .cts gate script', resolveCheckToFiles('check:cts', tsScripts).join() === 'scripts/check-legacy.cts'); - t('a command naming both an .mjs and a .ts file yields both', resolveCheckToFiles('check:mixed', tsScripts).join() === 'scripts/pre.mjs,scripts/check-generated.ts'); - // The negative half of the alternation: widening it must not admit every - // extension. A `.json` argument is data the gate READS, not a file it runs, - // and the hint scan is what places those. - t('a data file argument is still not a gate script', resolveCheckToFiles('check:x', { 'check:x': 'tsx scripts/check-x.mts scripts/fixtures/pins.json' }, { dir: '' }).join() === 'scripts/check-x.mts'); - t('nor a .tsx file, the one this widening would newly have mis-matched as .ts', resolveCheckToFiles('check:x', { 'check:x': 'tsx scripts/render.tsx' }).length === 0); - t('while the extension it is a prefix of still resolves', resolveCheckToFiles('check:x', { 'check:x': 'tsx scripts/render.ts' }).join() === 'scripts/render.ts'); - - // #12107 point 1 — a package manifest spells its script relative to ITSELF, - // so the manifest's directory is an input to the resolution, not a prefix - // the caller staples on afterwards. - const pkgScripts = { - 'check:in-package': 'tsx scripts/build-docs.ts --check', - 'check:climbing': 'tsx ../../scripts/check-exported-any-returns.mts --self-test && tsx ../../scripts/check-exported-any-returns.mts --package packages/client', - 'check:escaping': 'tsx ../../../../scripts/check-elsewhere.mjs', - }; - t('a package-local spelling lands under the package that declares it', resolveCheckToFiles('check:in-package', pkgScripts, { dir: 'packages/spec' }).join() === 'packages/spec/scripts/build-docs.ts'); - t('a spelling that climbs out of its package normalises to the tracked repo path', resolveCheckToFiles('check:climbing', pkgScripts, { dir: 'packages/client' }).join() === 'scripts/check-exported-any-returns.mts'); - // The regression this normalisation exists for, stated as the wrong answer - // rather than only as the right one: with the extensions widened and the - // climb prefix dropped, this resolved to a path that does not exist, and the - // family left the honest `undetermined` bucket while still reading no hints. - t('and never to the package-prefixed path that does not exist', !resolveCheckToFiles('check:climbing', pkgScripts, { dir: 'packages/client' }).includes('packages/client/scripts/check-exported-any-returns.mts')); - t('the twice-named climbing script is ONE file, deduped on the normalised path', resolveCheckToFiles('check:climbing', pkgScripts, { dir: 'packages/client' }).length === 1); - t('a spelling that climbs clear of the repo root is dropped, not returned unnameable', resolveCheckToFiles('check:escaping', pkgScripts, { dir: 'packages/client' }).length === 0); - t('an absent dir leaves a root-manifest spelling exactly as it was', resolveCheckToFiles('check:foo', scripts).join() === 'scripts/check-foo.mjs'); - - const src = [ - "const DIR = '.claude/agents';", - "const GLOB = 'packages/spec/src/**/*.zod.ts';", - "const URL2 = 'https://example.com/x';", - "const FLAG = '--self-test';", - "const WORD = 'hello';", - ].join('\n'); - const hints = extractWatchHints(src); - t('finds dotted-dir hint', hints.includes('.claude/agents')); - t('finds glob hint', hints.some((h) => h.startsWith('packages/spec/src'))); - t('skips urls', !hints.some((h) => h.includes('example.com'))); - t('skips flags and bare words', !hints.includes('--self-test') && !hints.includes('hello')); - - // ── An EXCLUSION constant is not a watch surface (#15753) ───────────────── - // - // The declaration is the whole reading: the same literal, under a name that - // says "never look here", must not become a lead — and under any other name - // must still be one. Both halves are asserted on ONE literal so the pair - // cannot drift, and the positive control is what makes the negative a - // measurement rather than a scan that stopped working. - // - // Every fixture NAME that would MATCH the predicate is assembled at run time - // rather than written after a `const`. `maskSelfTests` already blanks this - // body before the module scans itself, so no fixture here reaches the live - // hint set today; the assembly is what keeps that true if the block is ever - // moved out of the masked region, where a verbatim declaration would be a - // real declaration site silencing THIS module's own hints. It is the fixture - // hazard `check-watch-hint-literal.mjs` records about its own, one class over - // — there with no mask standing in the way. - const NOISE_SUFFIX = ['NO', 'ISE'].join(''); - const excluded = `export const SHARED_PREFIX_${NOISE_SUFFIX} = Object.freeze(['.changeset/']);`; - const included = "export const SHARED_PREFIX_ROOTS = Object.freeze(['.changeset/']);"; - t('a path literal declared inside an exclusion constant is NOT a hint', !extractWatchHints(excluded).includes('.changeset')); - t('CONTROL: the same literal under a plain declaration still is — the scan did not simply stop', extractWatchHints(included).includes('.changeset')); - // The span, not the line: a noise floor is usually written multi-line, and a - // per-line check would admit every entry but the first. - const multiline = [ - `const SCAN_${NOISE_SUFFIX} = new Set([`, - " 'packages/generated/src',", - " 'apps/fixtures/src',", - ']);', - "const REAL = 'packages/core/src';", - ].join('\n'); - const multilineHints = extractWatchHints(multiline); - t('every entry of a MULTI-LINE exclusion list is skipped, not just the one on the declaration line', !multilineHints.some((h) => h.startsWith('packages/generated') || h.startsWith('apps/fixtures'))); - t('and a declaration AFTER it is unaffected — the span closes where the statement does', multilineHints.includes('packages/core/src')); - // The named spellings, one case each, so a narrowing of the predicate is - // visible here rather than only in the live census. - for (const word of ['SKIP', 'EXCLUDE', 'EXCLUDED', 'EXCLUSIONS', 'IGNORE', 'DEFERRED']) { - const named = `const ${word}_PATHS = ['packages/skipped/src'];`; - t(`\`${word}\` names an exclusion too — the predicate is the convention, not one constant`, !extractWatchHints(named).includes('packages/skipped/src')); - } - t('a name that merely CONTAINS the word without a segment boundary is not one', extractWatchHints("const SKIPPY_ROOTS = ['packages/skippy/src'];").includes('packages/skippy/src')); - t('nor is a CALLABLE whose name matches — a function body is not a population declaration', extractWatchHints("function skipDirs() { return ['packages/callable/src']; }").includes('packages/callable/src')); - // LIVE, against the gate the card was filed on: the noise floor is gone from - // its hints and the gate is still reachable by its own script path, which is - // the pair a narrowing has to hold. Read from disk rather than restated. - { - const half = 'scripts/pm/check-half-states.mjs'; - const liveHints = extractWatchHints(readFileSync(nodePath.join(ROOT, half), 'utf8'), half); - t('LIVE: the gate that declares the noise floor no longer offers `.changeset` as a surface it watches', !liveHints.some((h) => h.startsWith('.changeset'))); - t('LIVE: and it is still reachable — the narrowing took the exclusion, not the gate', liveHints.includes(half)); - } - - // ── What a gate READS vs what its source MENTIONS (#8478) ───────────────── - // - // Both halves are pinned in both directions, because both directions are the - // product: a path in prose or in a fixture must NOT be a hint, and a path the - // module body really opens must still be one. The fabricated half is the - // expensive one — a false lead is pasted into every dispatch prompt whose - // surface brushes it — but a narrowing that also drops the real inputs would - // just move the dishonesty. - // Every path a comment case names is QUOTED inside that comment, because the - // scan only ever reads quoted spans: an unquoted path in prose is invisible - // to it with or without masking, so a fixture spelling one bare would assert - // nothing and pass forever. Measured — the first draft of the two cases below - // did exactly that, and only the reverse-verification run that removed the - // comment mask showed them staying green through it. - const commented = [ - '/**', - ' * Reads `packages/spec/src` and `.changeset/x.md` — prose, not inputs.', - ' */', - "const REAL = 'packages/rest/src'; // twin of 'packages/core/src', says the comment", - "const U = 'https://example.com/a/b'; const AFTER_URL = 'packages/metadata/src';", - "const RE = /['\"`]/;", - "const AFTER_REGEX = 'packages/client/src';", - ].join('\n'); - const commentHints = extractWatchHints(commented); - t('a backticked path in a block comment is not a hint', !commentHints.includes('packages/spec/src')); - t('a dotted path in a block comment is not a hint', !commentHints.some((h) => h.startsWith('.changeset'))); - t('a path in a trailing line comment is not a hint', !commentHints.includes('packages/core/src')); - t('the module-body literal on that same line still is', commentHints.includes('packages/rest/src')); - // The two traps that make this a scan and not a regex: `//` inside a URL is - // not a comment, and a quote character inside a regex literal does not open a - // string. Either mistake blanks real code — silently, and only downstream. - t('a `//` inside a string does not start a comment', commentHints.includes('packages/metadata/src')); - t('a quote inside a regex literal does not open a string', commentHints.includes('packages/client/src')); - t('masking preserves every offset', maskComments(commented).length === commented.length); - - // ...and the re-export of that masker is CODE, not comment text (#9640). The - // statement sits at the end of the longest docblock in this file, and a - // missing `*/` swallows it into prose that still parses: the module then has - // no `maskComments` export while its header says it has one, and nothing goes - // red — every gate stayed green over it until someone parsed for it. Asked of - // this file's own source with this file's own masker, which is what the - // docblock claims. Column 0 only, and a match the scan flags as literal is - // rejected, so no fixture spelling in this self-test can stand in for the - // statement. - const ownSource = readFileSync(new URL(import.meta.url), 'utf8'); - const ownScan = scanSource(ownSource); - t( - 'the maskComments re-export is code, not comment text', - [...ownSource.matchAll(/^export \{ maskComments \};$/gm)].some( - (m) => !ownScan.comment[m.index] && !ownScan.literal[m.index], - ), - ); - - // The self-test boundary. The fixture puts a column-0 `}` inside a template - // literal on purpose: that is the shape this tree really has (a check script - // whose self-test embeds TS sources as fixtures), and a boundary that stopped - // at the first column-0 `}` would end the mask there and leak every fixture - // after it — measured on `scripts/check-engine-double-contract.mjs`, whose - // self-test carries 24 such braces and whose last fixture path sits 400 lines - // past the first one. - const withSelfTest = [ - "const REAL = 'packages/runtime/src';", - 'function selfTest() {', - " const FIXTURE = 'packages/spec/src/data/filter.zod.ts';", - ' const embedded = `', - '}', - " const AFTER_BRACE = 'packages/objectql/src';", - ' `;', - '}', - "const TAIL = 'docs/adr';", - ].join('\n'); - const bodyHints = extractWatchHints(withSelfTest); - t('a fixture path inside the self-test is not a hint', !bodyHints.includes('packages/spec/src/data/filter.zod.ts')); - t('a column-0 brace inside a fixture does not end the self-test', !bodyHints.includes('packages/objectql/src')); - t('the module body before the self-test still hints', bodyHints.includes('packages/runtime/src')); - t('the module body after the self-test still hints', bodyHints.includes('docs/adr')); - const otherSpellings = [ - 'async function selfTest() {', - " const A = 'packages/aaa/src';", - '}', - 'function fixtureSelfTest() {', - " const B = 'packages/bbb/src';", - '}', - 'const box = {', - ' run() {', - " const NESTED = 'packages/ccc/src';", - ' },', - '};', - ].join('\n'); - const spellingHints = extractWatchHints(otherSpellings); - t('an async self-test is masked too', !spellingHints.includes('packages/aaa/src')); - t('a compound self-test name is masked too', !spellingHints.includes('packages/bbb/src')); - t('an ordinary nested function is NOT masked', spellingHints.includes('packages/ccc/src')); - // Composition order: comments are masked first, so a self-test declaration - // QUOTED in a docblock (this file is full of them) cannot anchor a mask over - // real code below it. - const declInComment = [ - '/*', - 'function selfTest() {', - '*/', - "const REAL = 'packages/ddd/src';", - ].join('\n'); - t('a self-test declaration inside a comment anchors nothing', extractWatchHints(declInComment).includes('packages/ddd/src')); - - // ── The helpers the self-test CALLS ────────────────────────────────────── - // - // `SELF_TEST_DECL` finds the ENTRY POINT by name, and a fixture builder is - // named for what it builds, so its body used to survive the mask whole. - // Measured specimen, live on this tree: - // `scripts/pm/release-rehearsal-clone.mjs` commits a fixture `.changeset` - // tree inside `makeSource`, and its two entries were read as paths that gate - // OPENS — the residue printed "the tree stops at .changeset; the layout moved - // under it" for them, a directory rename that never happened. - // - // The safety half is pinned beside it: a helper the module body can also - // reach is a path the gate really reads, and must survive. - const helperFixtures = [ - "const REAL = 'packages/runtime/src';", - 'function makeFixture(root) {', - " write(root, 'packages/fixture-only/one.ts');", - '}', - 'function shared() {', - " return 'packages/shared/src';", - '}', - 'function unreferenced() {', - " return 'packages/dead/src';", - '}', - 'export function alsoExported() {', - " return 'packages/exported/src';", - '}', - 'function selfTest() {', - ' makeFixture(tmp);', - ' shared();', - ' alsoExported();', - '}', - 'function run() {', - ' return shared();', - '}', - 'run();', - ].join('\n'); - const helperHints = extractWatchHints(helperFixtures); - t( - 'a fixture literal in a helper only the self-test calls is not a hint', - !helperHints.includes('packages/fixture-only/one.ts'), - ); - t('…but a helper the module body also reaches keeps its literal', helperHints.includes('packages/shared/src')); - t('…and a declaration nothing references at all is left alone', helperHints.includes('packages/dead/src')); - t('…and an exported helper is reachable from outside this file, so it stays', helperHints.includes('packages/exported/src')); - t('the module body around them still hints', helperHints.includes('packages/runtime/src')); - - // Transitive, and through a signature that carries braces. Counting the - // SIGNATURE's braces closes the body before it opens — the mask then covers - // 29 characters and reports success, which is how - // `function makeSource(root, name, { branch = 'main', … } = {})` reads. - const transitiveHelpers = [ - 'function writeOne(root, rel) {', - " return rel === 'packages/leaf/fixture.ts';", - '}', - 'function buildTree(root, { depth = 0 } = {}) {', - " writeOne(root, 'packages/branch/fixture.ts');", - '}', - 'function selfTest() {', - ' buildTree(root);', - '}', - ].join('\n'); - const transitiveHints = extractWatchHints(transitiveHelpers); - t('a helper reached only THROUGH another helper is masked too', !transitiveHints.includes('packages/leaf/fixture.ts')); - t( - 'a destructured default in the signature does not end the body early', - !transitiveHints.includes('packages/branch/fixture.ts'), - ); - - // ── The anchor fires on NAMES, so it also fires on production code ─────── - // - // Live, over the tracked tree, against `COMPOUND_ANCHOR_LEDGER`. The census - // and the argument for measuring rather than narrowing are at that table; - // what runs here is the half that can go red. - // - // Read the three assertions as one instrument. The first says the population - // has not moved under the table. The second says masking the accidental half - // still costs no hint — the claim the ledger makes in prose, re-measured on - // every run, so the day someone writes a path literal into `maskSelfTests`, - // `carriesSelfTest` or any other accidental row, THIS goes red and names the - // hint instead of dropping it in silence. The third is the control that makes - // the second mean anything: a counterfactual that silently failed to rename - // would report "no hint moves" for every row, which is indistinguishable from - // a pass, so at least one GENUINE row must be seen to move a hint. - { - const census = new Map(); - for (const rel of trackedFiles()) { - if (!ANCHOR_CENSUS_EXTENSIONS.test(rel)) continue; - let text; - try { - text = readFileSync(nodePath.join(ROOT, rel), 'utf8'); - } catch { - continue; - } - if (!/[Ss]elf[_]?[Tt]est/.test(text)) continue; - for (const decl of compoundAnchorDecls(text)) census.set(`${rel}::${decl.name}`, rel); - } - const unlisted = [...census.keys()].filter((k) => !COMPOUND_ANCHOR_KEYS.has(k)).sort(); - const stale = [...COMPOUND_ANCHOR_KEYS.keys()].filter((k) => !census.has(k)).sort(); - t( - `every compound self-test NAME the anchor matches is classified in COMPOUND_ANCHOR_LEDGER` + - (unlisted.length ? ` — unlisted: ${unlisted.join(', ')}` : '') + - (stale.length ? ` — listed but gone: ${stale.join(', ')}` : ''), - unlisted.length === 0 && stale.length === 0, - ); - - const costly = []; - const movers = []; - for (const [key, accidental] of COMPOUND_ANCHOR_KEYS) { - const rel = census.get(key); - if (!rel) continue; - const name = key.slice(rel.length + 2); - const src = readFileSync(nodePath.join(ROOT, rel), 'utf8'); - const alt = withoutAnchor(src, name); - if (alt === null || compoundAnchorDecls(alt).length !== compoundAnchorDecls(src).length - 1) { - costly.push(`${key} (the counterfactual rename did not land — this row was NOT measured)`); - continue; - } - const before = extractWatchHints(src, rel); - const after = extractWatchHints(alt, rel); - const dropped = after.filter((h) => !before.includes(h)); - if (dropped.length === 0) continue; - if (accidental) costly.push(`${key} now hides ${JSON.stringify(dropped)}`); - else movers.push(key); - } - t( - 'masking an ACCIDENTAL name match still costs this tree no watch hint' + - (costly.length ? ` — ${costly.join('; ')}` : ''), - costly.length === 0, - ); - t( - 'control: at least one GENUINE self-test battery is seen to lose a fixture hint, so the ' + - 'measurement above is an instrument and not a broken rename reporting zero everywhere', - movers.length > 0, - ); - } - - // The card's own specimen, pinned by identity: this module's masker and the - // helper predicate beside it are both named into the anchor's population, so a - // rename that "fixes" either one has to move the ledger row rather than the - // problem. - { - const selfCensus = compoundAnchorDecls(readFileSync(nodePath.join(ROOT, 'scripts/pm/dispatch-gates.mjs'), 'utf8')); - const names = selfCensus.map((d) => d.name); - t("this module's own masker is in the anchor's population", names.includes('maskSelfTests')); - t('…and so is the reachability helper beside it', names.includes('selfTestOnlyCallables')); - } - - // #15310 — the docblock above states this table's TOTAL / GENUINE / - // ACCIDENTAL composition in prose, and prose does not move when a row is - // added: three integers that agree with EACH OTHER while jointly - // disagreeing with the table is exactly the shape that let this drift twice - // without ever looking wrong. The declared numbers are read out of this - // module's own docblock text, never re-typed as a second constant here, and - // checked against a count taken fresh from COMPOUND_ANCHOR_LEDGER itself — - // so a row added without touching the docblock reds here, and an edit to - // any unrelated line changes neither side and stays green. - { - const ownSource = readFileSync(nodePath.join(ROOT, 'scripts/pm/dispatch-gates.mjs'), 'utf8'); - const ledgerAt = ownSource.indexOf('const COMPOUND_ANCHOR_LEDGER = ['); - const before = ledgerAt < 0 ? '' : ownSource.slice(0, ledgerAt); - const blockStart = before.lastIndexOf('/**'); - const blockEnd = before.lastIndexOf('*/'); - // Line-wrapped JSDoc prose carries a `\n * ` between words that happen to - // fall on a line break — flattened to single spaces so a future rewrap of - // this paragraph cannot itself make a true reading look false. - const prose = - blockStart < 0 || blockEnd < 0 - ? '' - : ownSource - .slice(blockStart, blockEnd) - .replace(/\n[ \t]*\*[ \t]?/g, ' ') - .replace(/[ \t]+/g, ' '); - - const total = COMPOUND_ANCHOR_LEDGER.length; - const genuine = COMPOUND_ANCHOR_LEDGER.filter(([, , accidental]) => !accidental).length; - const accidental = COMPOUND_ANCHOR_LEDGER.length - genuine; - const distinctSpellings = new Set(COMPOUND_ANCHOR_LEDGER.map(([, name]) => name)).size; - - // Only as wide as the words this docblock actually spells; extending it is - // a deliberate edit, not silent tolerance for a new spelling. - const NUMBER_WORDS = { - zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, - ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, - seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, - }; - const asCount = (word) => (/^\d+$/.test(word) ? Number(word) : NUMBER_WORDS[String(word).toLowerCase()]); - - const remaining = prose.match(/the remaining (\d+) carry compound names/); - const allFiring = prose.match(/keeps firing on all (\d+), the mask keeps blanking all (\d+)/); - const genuineLine = prose.match(/([A-Za-z]+) are genuine self-test batteries/); - const accidentalLine = prose.match(/([A-Za-z]+) are production code:/); - const spellingsLine = prose.match(/over (\d+) distinct spellings/); - - t( - 'the docblock\'s TOTAL row count — "the remaining N carry compound names" and both "all N" claims — ' + - `agrees with the table (table: ${total}; declared: ` + - `${remaining ? remaining[1] : ''}/${allFiring ? allFiring[1] : ''}/` + - `${allFiring ? allFiring[2] : ''})`, - remaining !== null - && allFiring !== null - && Number(remaining[1]) === total - && Number(allFiring[1]) === total - && Number(allFiring[2]) === total, - ); - t( - "the docblock's GENUINE count (\"N are genuine self-test batteries\") agrees with the table " + - `(table: ${genuine}; declared: ${genuineLine ? genuineLine[1] : ''})`, - genuineLine !== null && asCount(genuineLine[1]) === genuine, - ); - t( - "the docblock's ACCIDENTAL count (\"N are production code:\") agrees with the table " + - `(table: ${accidental}; declared: ${accidentalLine ? accidentalLine[1] : ''})`, - accidentalLine !== null && asCount(accidentalLine[1]) === accidental, - ); - // Distinct NAMES, not rows: `runSelfTest` is a genuine entry point in one - // file and an accidental one in another, so it is one spelling occupying - // two rows — the same reason COMPOUND_ANCHOR_KEYS has to carry the file in - // its key. This is derivable from the table exactly like the three above, - // so it is pinned the same way rather than left as the one clause in this - // paragraph a future row could still drift without going red. - t( - "the docblock's distinct-spellings count (\"over N distinct spellings\") agrees with the table " + - `(table: ${distinctSpellings}; declared: ${spellingsLine ? spellingsLine[1] : ''})`, - spellingsLine !== null && Number(spellingsLine[1]) === distinctSpellings, - ); - } - - // A population DECLARED for this very scanner is referenced by no executing - // code — being unreferenced is what such a declaration IS. Extending the mask - // to value declarations was implemented and REFUSED on this evidence: over - // the 204 scripts this derivation scans it took 175 hints from 36 files - // instead of 104 from 9, and the 71 extra were the declared populations of - // eight gates (`ROOT_DIR_WATCH_HINTS` and its spellings). - const declaredPopulation = [ - "const ROOT_DIR_WATCH_HINTS = ['packages/drivers/**'];", - 'function selfTest() {', - ' return ROOT_DIR_WATCH_HINTS;', - '}', - ].join('\n'); - t( - 'a declaration constant only the self-test names is still a declaration', - extractWatchHints(declaredPopulation).includes('packages/drivers/**'), - ); - - // `${…}` is CODE, and reading it as string text is not academic: the live - // specimen names its own path only from inside template literals, so a scan - // blind to interpolations finds that constant unreferenced and masks the one - // hint the file really declares. - const interpolatedReference = [ - 'function banner() {', - " return 'scripts/pm/thing.mjs';", - '}', - 'function usage() {', - ' return `node ${banner()} --help`;', - '}', - 'function selfTest() {', - ' banner();', - '}', - 'usage();', - ].join('\n'); - t( - 'a reference from inside a template interpolation keeps a helper alive', - extractWatchHints(interpolatedReference).includes('scripts/pm/thing.mjs'), - ); - - // The specimen itself, on the live tree rather than in a fixture — both - // directions, so a future edit that deletes the file or empties its - // declaration cannot leave this green by vacuity. - const rehearsalPath = 'scripts/pm/release-rehearsal-clone.mjs'; - const rehearsalAbs = nodePath.join(ROOT, rehearsalPath); - t('the fixture-in-helper specimen is still on the tree', existsSync(rehearsalAbs)); - if (existsSync(rehearsalAbs)) { - const rehearsalHints = extractWatchHints(readFileSync(rehearsalAbs, 'utf8'), rehearsalPath); - t( - 'the fixture changesets it commits are not hints', - !rehearsalHints.some((h) => /^\.changeset\/(one|two)\.md$/.test(h)), - ); - t('…while the population it really declares survives', rehearsalHints.includes('.changeset/*.md')); - t('…and so does its own path', rehearsalHints.includes(rehearsalPath)); - } - // Module-relative spellings: `new URL('../../x', import.meta.url)` is how - // these scripts name a repo path, and the leading segments are the script's - // own depth, not part of what it watches. - const relative = ["const P = new URL('../../.claude/agents/os-dev.md', import.meta.url);", "const R = '../..';"].join('\n'); - const relHints = extractWatchHints(relative); - t('a module-relative path is normalised to repo-relative', relHints.includes('.claude/agents/os-dev.md')); - t('a literal that is nothing but dots names no file', !relHints.some((h) => h.startsWith('..'))); - - // ── The literal is RESOLVED against its writer, never stripped (#12371) ─── - // - // The strip assumed the writer sits at the depth its own `../` run climbs to. - // That holds for `scripts/*.mjs` and FAILS for a gate inside a package, whose - // `'./lib/x'` came out as the top-level `lib/x` — a string this tree has no - // `lib/` for, while the file it names is on disk. Both directions are pinned: - // what MUST convert, and what must NOT. - const insideAPackage = "import { f } from './lib/dist-freshness';\nconst S = '../src/kernel/protocol-version';"; - const pkgHints = extractWatchHints(insideAPackage, 'packages/spec/scripts/check-x.ts'); - t( - 'a gate inside a package resolves its own-directory literal against ITSELF', - pkgHints.includes('packages/spec/scripts/lib/dist-freshness'), - ); - t( - '…and a `../` literal against its parent, not against the repo root', - pkgHints.includes('packages/spec/src/kernel/protocol-version'), - ); - t( - 'the top-level spelling the strip used to produce is GONE, not merely joined', - !pkgHints.includes('lib/dist-freshness') && !pkgHints.includes('src/kernel/protocol-version'), - ); - // The no-op half, and the reason the widening is cheap: a writer that really - // does sit at the depth it climbs gets the same hint it always got. - t( - 'a literal already spelled from the root by a writer at that depth is unchanged', - extractWatchHints("const P = '../../packages/spec/src';", 'scripts/pm/x.mjs').includes('packages/spec/src'), - ); - t( - 'a literal carrying no relative prefix is untouched by the resolve', - extractWatchHints("const P = 'packages/spec/src';", 'packages/spec/scripts/check-x.ts').includes('packages/spec/src'), - ); - // MUST NOT convert. Admission still reads the literal as the author wrote it, - // so a single-segment sibling specifier is no hint at all — the same refusal - // `hintCovers`' docblock states for a bare filename. Admitting it would hand - // every gate its own import specifiers as a watched population, a second and - // unpriced answer to the question `firstPartyImportTargets` owns. - // - // Both are asserted WITH the live tree, never without one. The refusal is - // cheap to pass for the wrong reason — a call with no tree refuses every - // single-segment literal — so a pin that omitted it would be green whatever - // `moduleRelativeDirectoryHint` did. - const hintTree = watchHintTree(); - const isTrackedDir = (p) => hintTree.prefixes.has(p) && !hintTree.files.has(p); - t( - 'a single-segment sibling specifier does NOT convert into a hint', - extractWatchHints("import { invokedAs } from './invoked-as.mjs';", 'scripts/check-x.mjs', { tree: hintTree }) - .length === 0, - ); - t( - '…and it is refused because the resolve lands on a tracked FILE, not for want of a tree', - hintTree.files.has('scripts/invoked-as.mjs') && !isTrackedDir('scripts/invoked-as.mjs'), - ); - t( - '…nor does a bare sibling manifest name', - extractWatchHints("const P = './package.json';", 'scripts/check-x.mjs', { tree: hintTree }).length === 0, - ); - - // ── ONE class IS admitted back: a single-segment literal whose resolve lands - // ── on a tracked DIRECTORY (#12470) - // - // `moduleRelativeDirectoryHint` carries the split of the 53 hints the naive - // widening adds and the judgement that puts this class apart from the two - // `hintCovers` refuses. Pinned here as the four things a reader needs to be - // able to break: that it FIRES, WHERE the admitted hint lands, and the two - // structural refusals that keep it from becoming either of its neighbours. - const specDirLiteral = "const SRC_DIR = path.resolve(__dirname, '../src');"; - t( - 'a single-segment literal resolving to a tracked directory DOES convert', - extractWatchHints(specDirLiteral, 'packages/spec/scripts/build-docs.ts', { tree: hintTree }).join() === - 'packages/spec/src', - extractWatchHints(specDirLiteral, 'packages/spec/scripts/build-docs.ts', { tree: hintTree }).join(), - ); - t( - '…and without a tree the same call keeps the standing refusal — a missing lead, never a fabricated one', - extractWatchHints(specDirLiteral, 'packages/spec/scripts/build-docs.ts').length === 0, - ); - // ARRIVAL, not departure. That the literal left the refused set says nothing - // about which family it reaches or which files: both live gates are named, - // and each is shown to reach a real file under the directory that NO other - // hint of that family reaches — so the pair is this rule's, not a coincidence - // of some other hint already covering the tree there. - const dirLanding = discoverFamilies({ tree: hintTree }).byCheck; - const specSrcFile = trackedFiles().find((f) => f.startsWith('packages/spec/src/')); - for (const check of ['check:docs', 'check:skill-refs']) { - const entry = dirLanding.get(check); - t( - `${check} carries the admitted directory hint`, - (entry?.hints ?? []).includes('packages/spec/src'), - ); - t( - `…and it is what reaches ${specSrcFile} for ${check} — no other hint of that family does`, - Boolean(specSrcFile) && - (entry?.hints ?? []).filter((h) => hintCovers(h, specSrcFile)).join() === 'packages/spec/src', - ); - } - // The BLAST RADIUS, both directions, over the live fleet: which families this - // rule changes at all. Read as the difference between the discovery WITH the - // tree and the same discovery WITHOUT one, so it measures the rule and not - // the tree — `packages/spec/src` is already a hint several other gates spell - // from the root, and asking "who carries it" would count those too. - const withoutTree = discoverFamilies({ tree: null }).byCheck; - const dirGained = []; - const dirLost = []; - for (const [check, entry] of dirLanding) { - const before = new Set(withoutTree.get(check)?.hints ?? []); - for (const h of entry.hints ?? []) if (!before.has(h)) dirGained.push(`${check} +${h}`); - for (const h of before) if (!(entry.hints ?? []).includes(h)) dirLost.push(`${check} -${h}`); - } - // ⚠️ WITH-tree against WITHOUT-tree measures every TREE-COUPLED rule at once, - // and there are TWO of them now: this one, and the package-root anchor - // (#14208), which refuses without a tree for the same reason. The rows are - // PARTITIONED rather than filtered — a filter would let a third rule's rows - // disappear from the one pin whose job is to notice them. - const dirRuleGained = dirGained.filter((r) => r.endsWith('+packages/spec/src')); - const parseRow = (row, sign) => { - const at = row.indexOf(` ${sign}`); - return { check: row.slice(0, at), hint: row.slice(at + 2) }; - }; - const anchorGained = dirGained.filter((r) => !dirRuleGained.includes(r)).map((r) => parseRow(r, '+')); - const anchorLost = dirLost.map((r) => parseRow(r, '-')); - t( - 'the rule changes exactly the two gates that walk packages/spec/src, and adds exactly the directory they walk', - dirRuleGained.join(' · ') === 'check:docs +packages/spec/src · check:skill-refs +packages/spec/src', - dirGained.join(' · '), - ); - // It takes nothing away, and every remaining tree-coupled row belongs to the - // anchor — asserted as a PAIRING rather than as a list of names, so the pin - // survives packages/spec's artifact ledger moving and still reds the day the - // anchor starts ADDING a hint beside a dead one instead of replacing it. - t( - 'and it takes NOTHING away — a widening that also subtracted would read exactly like this one', - anchorLost.length === anchorGained.length && - anchorGained.every((g) => - anchorLost.some( - (l) => - l.check === g.check && - g.hint.endsWith(`/${l.hint}`) && - hintTree.files.has(`${g.hint.slice(0, g.hint.length - l.hint.length - 1)}/package.json`), - ), - ), - `${anchorGained.map((g) => `${g.check} +${g.hint}`).join(' · ')} || ${dirLost.join(' · ')}`, - ); - - // ── The THIRD anchor: a literal bound to the writer's PACKAGE ROOT (#14208) - // - // `packageRootAnchoredHint` carries the population sweep, the pricing against - // the two refusals it must not become, and the provenance split. Pinned here - // as the four things a reader has to be able to break: that it FIRES, that it - // is REFUSED without a tree, that it cannot invent a path, and that it leaves - // the layout-moved class alone. - const pkgAnchorSrc = - "const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');\n" + - "const GATED = [{ check: 'check:api-surface', artifact: 'api-surface/' }];\n"; - t( - 'a literal dead at the repo root is re-read against the package root its writer binds', - extractWatchHints(pkgAnchorSrc, 'packages/spec/scripts/check-x.ts', { tree: hintTree }).join() === - 'packages/spec/api-surface', - extractWatchHints(pkgAnchorSrc, 'packages/spec/scripts/check-x.ts', { tree: hintTree }).join(), - ); - t( - '…and without a tree the same call keeps the root spelling — a missing lead, never a fabricated one', - extractWatchHints(pkgAnchorSrc, 'packages/spec/scripts/check-x.ts').join() === 'api-surface', - ); - t( - 'a re-anchoring that reaches nothing is REFUSED, so the rule cannot invent a path', - extractWatchHints( - `${pkgAnchorSrc}const P = 'no-such-dir/no-such-file.ts';`, - 'packages/spec/scripts/check-x.ts', - { tree: hintTree }, - ).includes('no-such-dir/no-such-file.ts'), - ); - // The layout-moved class is NOT this rule's: its first segment is a real repo - // directory, so the root is the base its author most plausibly meant, and - // re-anchoring it would replace a triage lead with a fabricated one. - t( - 'a hint the tree stops short of keeps the ROOT spelling — the layout-moved class is left to triage', - extractWatchHints( - `${pkgAnchorSrc}const P = 'scripts/gone-from-here.mjs';`, - 'packages/spec/scripts/check-x.ts', - { tree: hintTree }, - ).includes('scripts/gone-from-here.mjs'), - ); - t( - 'a module-anchored binding that carries no package.json is no anchor', - packageRootBinding( - 'scripts/pm/x.mjs', - "const D = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');", - hintTree, - ) === null, - ); - t( - '…and the repo root is never the anchor — that base is the one already applied', - packageRootBinding('scripts/pm/x.mjs', "const R = new URL('../..', import.meta.url).pathname;", hintTree) === null, - ); - // LIVE, on this tree: the specimen the card was filed for. ARRIVAL, not - // departure — the family is named, and the re-anchored hint is shown to reach - // a real file that NO other hint of that family reaches, so the pair is this - // rule's rather than a coincidence of some other hint already covering it. - t( - 'the live spec gate binds the package root the card names', - packageRootBinding( - 'packages/spec/scripts/check-generated.ts', - readFileSync(nodePath.join(ROOT, 'packages/spec/scripts/check-generated.ts'), 'utf8'), - hintTree, - ) === 'packages/spec', - ); - const generatedEntry = dirLanding.get('check:generated'); - const apiSurfaceFile = trackedFiles().find((f) => f.startsWith('packages/spec/api-surface/')); - t( - 'check:generated carries its artifact ledger re-anchored, and that hint is what reaches it', - Boolean(apiSurfaceFile) && - (generatedEntry?.hints ?? []).filter((h) => hintCovers(h, apiSurfaceFile)).join() === - 'packages/spec/api-surface', - (generatedEntry?.hints ?? []).filter((h) => hintCovers(h, apiSurfaceFile)).join(), - ); - - // REFUSAL 1 — module-relative ONLY. `resolve` treats a bare word exactly like - // a `./` one, so without this the rule reads any bare word against its - // writer's directory and becomes the bare-word class one gate at a time. The - // specimen is the sharpest one on the tree: a member of - // check-error-status-conformance's SKIP_DIRS, a directory the gate DECLARES - // it does not read, which the tree happens to have under `scripts/`. - t( - 'a BARE word is not resolved against its writer, even when that lands on a tracked directory', - extractWatchHints("const SKIP = new Set(['fixtures']);", 'scripts/check-x.mjs', { tree: hintTree }).length === 0, - ); - t( - '…and that refusal is non-vacuous: scripts/fixtures IS a tracked directory the resolve would have found', - isTrackedDir('scripts/fixtures'), - ); - // REFUSAL 2 — the resolved form must carry a SEPARATOR. A bare root would - // build a hint `hintCovers` refuses on its own bare-word rule, reaching - // nothing and landing as a fresh row in the SHRINK-ONLY escapable-literal - // ledger. Refusing it here makes that structural rather than lucky. - t( - 'a single-segment literal that resolves to a bare ROOT is refused', - extractWatchHints("const P = '../../skills';", 'scripts/pm/x.mjs', { tree: hintTree }).length === 0, - ); - t( - '…non-vacuously: skills IS a tracked directory, and a hint spelling it would reach nothing anyway', - isTrackedDir('skills') && !trackedFiles().some((f) => hintCovers('skills', f)), - ); - // The #12794 boundary, asserted as the structural exclusion it is rather than - // as a count. `extensionlessModuleTarget` refuses any hint the tree has as a - // prefix, and this rule admits ONLY hints the tree has as a prefix — so no - // literal can be both a tracked-directory hint and an extensionless module - // target, on this tree or any other. - t( - 'a single-segment literal whose target the tree has only under a dropped extension is NOT admitted', - extractWatchHints("import { invokedAs } from './invoked-as';", 'scripts/check-x.mjs', { tree: hintTree }) - .length === 0, - ); - t( - '…non-vacuously: had it been admitted, hintCovers would have matched the file through the extension rule', - hintCovers('scripts/invoked-as', 'scripts/invoked-as.mjs'), - ); - t( - 'the two rules are mutually exclusive by construction — an admitted directory is a tracked prefix, which extensionlessModuleTarget refuses', - extensionlessModuleTarget('packages/spec/src', hintTree.files, hintTree.prefixes) === null && - isTrackedDir('packages/spec/src'), - ); - t( - 'a literal that climbs out of the repo names nothing', - extractWatchHints("const P = '../../../elsewhere/x/y';", 'scripts/check-x.mjs').length === 0, - ); - t( - 'and one that resolves to the repo root itself names nothing — it would cover the tree', - resolveModuleRelativeHint('../..', 'scripts/pm/x.mjs') === null, - ); - // A caller with no path keeps the strip: not every caller has a writer to - // resolve against, and a missing lead is the direction this file errs in. - t( - 'a caller that passes no script path still gets the stripped spelling', - extractWatchHints("import { f } from './lib/dist-freshness';").includes('lib/dist-freshness'), - ); - - // ── `#` COMMENTS on a shell-kind source (#16132) ────────────────────────── - // - // The card's fixture table, which is the whole of the acceptance criterion: - // three sources that were indistinguishable and must not be, and a fourth row - // that is the CONTROL — the JS masking discipline already reached this file - // kind, and this change may not cost it. ⛔ A pin written against only the - // negative rows would pass on an instrument that returned nothing at all, and - // a pin written against only the positive one would pass on the broken - // instrument, which returned `1` for all three. - const shTarget = 'scripts/bump-objectui.selftest.sh'; - const shProseTick = '# see `' + shTarget + '` for the self-test\n'; - const shProseQuote = '# see "' + shTarget + '" for the self-test\n'; - const shInvocation = "bash '" + shTarget + "'\n"; - const shJsComment = '// see `' + shTarget + '`\n'; - const shHints = (src, path = 'scripts/fixture.sh') => extractWatchHints(src, path, { tree: hintTree }); - t( - 'a path a shell `#` comment quotes in BACKTICKS is not a hint', - shHints(shProseTick).length === 0, - shHints(shProseTick), - ); - t( - '…nor one it quotes in DOUBLE QUOTES — the two spellings reach the scan as a template and as a string, and one fix must cover both', - shHints(shProseQuote).length === 0, - shHints(shProseQuote), - ); - t( - '…while a REAL invocation on the same file still yields its hint, so the mask removed prose rather than the population', - shHints(shInvocation).join() === shTarget, - shHints(shInvocation), - ); - t( - 'and the fourth row still reads 0 — the `//` mask this change composes onto is untouched on a shell source', - shHints(shJsComment).length === 0, - shHints(shJsComment), - ); - // ── The PHANTOM BLOCK COMMENT the order used to open (#16744) ───────────── - // - // The other direction of the same "THIRD kind" defect, and the card's whole - // acceptance criterion: two sources that differ in FOUR CHARACTERS OF PROSE, - // both of which must read the one hint their code spells. The first carries a - // block-comment opener inside a `#` comment; while `#` was masked LAST that - // opener reached the JS scanner and blanked the real `DEST=` line under it. - // - // ⛔ The control is not decoration. A change that only makes the first row - // fire — by disabling the mask, or by dropping the JS scanner on this kind — - // takes the second row down with it or leaves it as the ONLY row that fires. - // Both rows read 1, or this is not the fix. - const shPhantom = '# published @objectstack/* packages\nDEST="node_modules/@objectstack/spec/dist"\n'; - const shPhantomControl = '# published packages\nDEST="node_modules/@objectstack/spec/dist"\n'; - t( - '⭐ a `/*` inside a `#` comment no longer opens a block comment over the shell code below it', - shHints(shPhantom, 'scripts/x.sh').join() === 'node_modules/@objectstack/spec/dist', - shHints(shPhantom, 'scripts/x.sh'), - ); - t( - '⭐ CONTROL: the same code under a comment with NO opener still reads its one hint — the mask was fixed, not switched off', - shHints(shPhantomControl, 'scripts/x.sh').join() === 'node_modules/@objectstack/spec/dist', - shHints(shPhantomControl, 'scripts/x.sh'), - ); - // The span, not just the line: an unterminated opener ran to the END OF FILE, - // so the cost was every hint below it rather than the one line beside it. - const shPhantomSpan = - '# everything below this line is /* invisible\n' - + 'bash "scripts/one.sh"\n' - + 'bash "scripts/two.sh"\n'; - t( - '…and it ran to END OF FILE, so the recovered span is every hint below the comment, not one line', - shHints(shPhantomSpan, 'scripts/x.sh').join() === 'scripts/one.sh,scripts/two.sh', - shHints(shPhantomSpan, 'scripts/x.sh'), - ); - t( - '…non-vacuously: the JS-kind control on the same bytes still loses both, which is what this order costs a `.sh` file', - shHints(shPhantomSpan, 'scripts/x.sh.mjs').length === 0, - shHints(shPhantomSpan, 'scripts/x.sh.mjs'), - ); - // KIND-SCOPED, in both directions. The same bytes on a `.mjs` path must keep - // spelling their hint: `#` is not a comment in JavaScript, and a mask that - // fired there would be a widening rather than this card's narrowing. - t( - 'the `#` mask does NOT reach a JS source — the same prose on a .mjs path still spells its hint', - shHints(shProseTick, 'scripts/check-x.mjs').join() === shTarget, - shHints(shProseTick, 'scripts/check-x.mjs'), - ); - t( - 'the kind predicate is the DIFFERENCE of the two that already exist, so a widened follow arrives already masked', - hashCommentProgram('scripts/x.sh') - && !hashCommentProgram('scripts/x.mjs') - && !hashCommentProgram('packages/spec/src/x.ts') - && !hashCommentProgram('apps/docs/x.tsx') - && !hashCommentProgram('docs/x.md') - && !hashCommentProgram(null), - ); - // A `#` opens a comment only at the start of a WORD, and only outside quotes. - // Each of these is a line a blank-from-`#`-to-end-of-line pass would destroy, - // and each carries a real hint AFTER the `#` so the case cannot pass by - // returning nothing. - t( - 'a `#` that is not at a word start opens no comment — $#, ${#…} and a bare a#b all keep the hint beside them', - shHints('[ "$#" -gt 0 ] && cat "docs/a/b.md"\n').join() === 'docs/a/b.md' - && shHints('n=${#argv[@]} ; cat "docs/a/b.md"\n').join() === 'docs/a/b.md' - && shHints('git log --grep=fix#1 -- "docs/a/b.md"\n').join() === 'docs/a/b.md', - ); - t( - 'a `#` inside single or double quotes opens no comment either', - shHints("grep '#' \"docs/a/b.md\"\n").join() === 'docs/a/b.md' - && shHints('grep "#" "docs/a/b.md"\n').join() === 'docs/a/b.md', - ); - t( - 'a TRAILING `#` comment is masked without taking the code before it', - shHints('cat "docs/a/b.md" # and see `docs/gone.md`\n').join() === 'docs/a/b.md', - shHints('cat "docs/a/b.md" # and see `docs/gone.md`\n'), - ); - // The two shapes that make a CROSS-LINE quote scanner desync on real shell, - // and the reason quote state is line-scoped instead. Both were measured on - // this tree against a here-doc-aware implementation before this one: a - // here-string read as a here-doc introducer, and a command substitution whose - // inner `"…"` closes the outer one. Either desync silently disables the mask - // for the rest of the file, which is the FABRICATING direction. - t( - 'a `<<<` here-string does not disable the mask for what follows it', - shHints('awk \'{ print $2 }\' <<< "$rest"\n# see `docs/gone.md`\n').length === 0, - shHints('awk \'{ print $2 }\' <<< "$rest"\n# see `docs/gone.md`\n'), - ); - t( - '…nor does a command substitution carrying its own quotes', - shHints('v="$(printf \'%s\' "$input" | head -1)"\n# see `docs/gone.md`\n').length === 0, - shHints('v="$(printf \'%s\' "$input" | head -1)"\n# see `docs/gone.md`\n'), - ); - // The residue that line-scoping BUYS those two with, pinned so nobody - // "repairs" it back into carried state: a `#` beginning a line inside a - // multi-line quoted string or a here-doc BODY is masked as if it were a - // comment. That text is data rather than a path the script opens, so the cost - // is a missing lead — the direction this file errs in everywhere. - t( - '⭐ the deliberate over-mask: a `#` line inside a here-doc body is blanked, and that is the cheap direction, not a defect', - shHints('cat > /tmp/n <<\'EOF\'\n# see `docs/gone.md`\nEOF\n').length === 0, - ); - // The projection, asserted as `blank`'s contract next door states it: spans - // become spaces, so every byte offset and every line number survives and a - // caller can index this output against the unmasked source. - const shProjectionSrc = 'cat "docs/a/b.md" # x\n# y\nbash \'scripts/z.sh\'\n'; - const shProjected = maskShellComments(shProjectionSrc); - t( - 'maskShellComments only ever BLANKS — same length, same newlines, and every surviving character is the source\'s own', - shProjected.length === shProjectionSrc.length - && shProjected.split('\n').length === shProjectionSrc.split('\n').length - && [...shProjected].every((c, k) => c === ' ' || c === shProjectionSrc[k]) - && shProjected !== shProjectionSrc, - ); - // ── LIVE, on this tree: the census the card was filed on ────────────────── - // - // Appending `.mjs` to a shell path turns the kind predicate off while leaving - // the writer's DIRECTORY — the only other thing `scriptPath` decides here — - // byte for byte the same, so it is the control for what this mask removed. - // ⛔ Written as a DIRECTION and a floor rather than as a count: a reading - // belongs to a named commit, and this one moves whenever a shell script gains - // or loses a comment. - const liveShellFiles = trackedFiles().filter((f) => f.endsWith('.sh')); - let shellGrew = 0; - let shellShrank = 0; - let shellBefore = 0; - let shellAfter = 0; - const shellAddedProse = []; - for (const f of liveShellFiles) { - const src = readFileSync(nodePath.join(ROOT, f), 'utf8'); - const masked = extractWatchHints(src, f, { tree: hintTree }); - const unmasked = extractWatchHints(src, `${f}.mjs`, { tree: hintTree }); - shellBefore += unmasked.length; - shellAfter += masked.length; - const added = masked.filter((h) => !unmasked.includes(h)); - // An ADDED hint is admissible only if it is spelled in CODE: its literal has - // to survive the `#` mask. One spelled only inside a `#` comment would be - // the FABRICATING direction arriving through the very reorder that fixed - // the under-mask, so it is collected by name rather than counted. - const code = maskShellComments(src); - for (const h of added) if (!code.includes(h)) shellAddedProse.push([f, h]); - if (added.length) shellGrew++; - else if (masked.length < unmasked.length) shellShrank++; - } - t( - `⭐ LIVE: over ${liveShellFiles.length} tracked .sh file(s) every hint the \`#\` mask ADDS is spelled in CODE — ${shellBefore} hints without the mask, ${shellAfter} with`, - liveShellFiles.length > 0 && shellAddedProse.length === 0 && shellAfter < shellBefore, - JSON.stringify({ files: liveShellFiles.length, shellBefore, shellAfter, shellGrew, shellShrank, shellAddedProse }), - ); - t( - '…non-vacuously: at least one live file really loses a hint, so the sweep is not passing over an instrument that changed nothing', - shellShrank >= 1, - JSON.stringify({ shellShrank }), - ); - // ⛔ This case replaced a `shellGrew === 0` pin (#16132), and the replacement - // is the point of #16744 rather than a relaxation of it. That spelling was - // true only because `#` was masked LAST, which left shell prose to reach the - // JS scanner first: a `#` comment containing `/*` opened a phantom block - // comment over the real code below it, so the code could not spell a hint in - // EITHER column and the difference read 0. Masking `#` FIRST uncovers that - // code, so additions are now expected — and the honest invariant is what they - // ARE, not that there are none. - t( - '…and the additions really happen, so the case above is not a subset test wearing a code test\'s name', - shellGrew >= 1, - JSON.stringify({ shellGrew }), - ); - // The card's sharpest specimen, both ends. DEPARTURE alone would stay green - // on a mask that emptied the file, so the ARRIVAL half names a live shell - // script whose hint is spelled in CODE and must survive. - const liveBumpSrc = readFileSync(nodePath.join(ROOT, 'scripts/bump-objectui.sh'), 'utf8'); - t( - 'LIVE: the file the card measured no longer offers the path its `#` comments merely NAME', - !extractWatchHints(liveBumpSrc, 'scripts/bump-objectui.sh', { tree: hintTree }).includes( - 'docs/releases-maintenance.md', - ), - ); - t( - '…non-vacuously: that path IS spelled in the file, inside a `#` comment, and the unmasked control still reads it', - extractWatchHints(liveBumpSrc, 'scripts/bump-objectui.sh.mjs', { tree: hintTree }).includes( - 'docs/releases-maintenance.md', - ), - ); - // ⭐ LIVE RECOVERY (#16744): the site the card named, both ends. The `.mjs` - // control is what makes it a recovery rather than an ordinary arrival — the - // JS-only path STILL cannot read this line, because the phantom comment the - // header's `@objectstack/*` opens is what hid it, and that is precisely what - // masking `#` first removes. - const liveDownstream = 'scripts/downstream-smoke.sh'; - const liveDownstreamSrc = readFileSync(nodePath.join(ROOT, liveDownstream), 'utf8'); - t( - '⭐ LIVE RECOVERY: a path spelled in real shell CODE under a `#` comment carrying a block-comment opener is read again', - extractWatchHints(liveDownstreamSrc, liveDownstream, { tree: hintTree }).includes( - 'node_modules/@objectstack/spec/dist', - ), - extractWatchHints(liveDownstreamSrc, liveDownstream, { tree: hintTree }), - ); - t( - '…non-vacuously: the JS-kind control on the same bytes still cannot see it, so a phantom comment is what was hiding it', - !extractWatchHints(liveDownstreamSrc, `${liveDownstream}.mjs`, { tree: hintTree }).includes( - 'node_modules/@objectstack/spec/dist', - ), - ); - const liveShardSelfTest = 'scripts/ci/select-shard-packages.selftest.sh'; - t( - '⭐ LIVE ARRIVAL: a shell script whose hint is spelled in CODE still spells it, so the mask removed prose and not the population', - extractWatchHints(readFileSync(nodePath.join(ROOT, liveShardSelfTest), 'utf8'), liveShardSelfTest, { - tree: hintTree, - }).includes('scripts/ci/select-shard-packages.sh'), - ); - // One resolver, not two. `firstPartyImportTargets` answers the same question - // for the import follow; if they could disagree, one of them is the copy - // nobody re-measured. - t( - 'the hint resolve and the import follow agree on a shared specimen', - resolveModuleRelativeHint('./invoked-as.mjs', 'scripts/check-doc-anchors.mjs') === - firstPartyImportTargets('scripts/check-doc-anchors.mjs', "import { invokedAs } from './invoked-as.mjs';")[0], - ); - // LIVE, on this tree: the specimen the card was filed for, driven through - // `hintCovers` — never through `collapseHint` and never re-implemented. - const liveSchemaHints = extractWatchHints( - readFileSync(nodePath.join(ROOT, 'packages/spec/scripts/build-schemas.ts'), 'utf8'), - 'packages/spec/scripts/build-schemas.ts', - ); - t( - 'the live spec builder names its own src subtree, resolved', - liveSchemaHints.includes('packages/spec/src/data'), - ); - t( - '…and that hint really reaches a tracked file, which the stripped spelling never did', - hintCovers('packages/spec/src/data', 'packages/spec/src/data/field.zod.ts') && - !hintCovers('src/data', 'packages/spec/src/data/field.zod.ts'), - ); - // The residue's claim stops being false: a resolved literal HAS a tracked - // prefix, so `unreachableReason` no longer files it under the by-construction - // sentence about a file that exists. - // - // ⚠️ The assertion is written against the sentence that branch prints TODAY, - // not against a phrase it used to print. Pinned to a retired phrase this pin - // would pass on any tree at all — the branch cannot emit a string nothing - // renders — which is a green over the exact regression it exists to catch. - // - // ⚠️ This case asserted ONLY `Boolean(deepest)` — that the hint had LEFT the - // "never" branch — and leaving that branch is precisely what puts a hint into - // the "layout moved" one, so it stayed green through a false reason it never - // looked at (#12299). A departure pin cannot see an arrival: both ends are - // asserted here now, on the same live specimen. - const distFreshnessFiles = trackedFiles(); - const distFreshnessDead = [ - { - hint: 'packages/spec/scripts/lib/dist-freshness', - deepest: deepestTrackedPrefix('packages/spec/scripts/lib/dist-freshness', trackedPrefixes(distFreshnessFiles)), - target: extensionlessModuleTarget( - 'packages/spec/scripts/lib/dist-freshness', - new Set(distFreshnessFiles), - trackedPrefixes(distFreshnessFiles), - ), - }, - ]; - t( - 'a resolved dead hint has a tracked prefix, so the residue stops filing it by construction', - Boolean(distFreshnessDead[0].deepest) && - !/no tracked path under its first segment/.test(unreachableReason(distFreshnessDead)), - ); - t( - '...and it does NOT arrive at "the layout moved" instead — the tree HAS the file, named', - !/layout moved/.test(unreachableReason(distFreshnessDead)) && - unreachableReason(distFreshnessDead).includes('packages/spec/scripts/lib/dist-freshness.ts'), - ); - t( - '...so the family carrying it is a standing fact, not a miss worth triaging', - unreachableClass(distFreshnessDead) === 'by construction', - ); - // The extension list is a NARROWING, and the price of a narrowing is what it - // refuses. Pinned as an AGREEMENT rather than as a count, so it survives the - // tree moving: over every inert hint in the live fleet, resolving through - // MODULE_SPECIFIER_EXTENSIONS and resolving through "any suffix in the same - // directory" must pick the same file. Today both pick 38 hints and all 38 - // land on `.ts`. The day they disagree, some gate imports a specifier whose - // only sibling is not a module — and whether to name that file is a decision - // for whoever is standing there, not a drift for nobody to notice. - const agreeFiles = trackedFiles(); - const agreeFileSet = new Set(agreeFiles); - const agreePrefixes = trackedPrefixes(agreeFiles); - const agreeHints = new Set(); - for (const [, entry] of discoverFamilies().byCheck) for (const h of entry.hints ?? []) agreeHints.add(h); - const looseTarget = (hint) => { - const plain = collapseHint(hint); - if (!plain || agreePrefixes.has(plain)) return null; - return agreeFiles.find((f) => f.startsWith(`${plain}.`) && !f.slice(plain.length + 1).includes('/')) ?? null; - }; - // POPULATION: every distinct hint in the fleet, not just the inert ones. - // #12514 made the matcher follow a dropped extension, so the 38 hints this - // trade was measured over are LIVE now and an `inert`-scoped population would - // have emptied — taking three green cases with it while asserting nothing. - // Both `looseTarget` and `extensionlessModuleTarget` already refuse a hint - // whose collapsed form the tree HAS as a path, so widening the population - // adds no candidate; measured, it still selects the same 38. - const agreeCandidates = [...agreeHints]; - const strictOf = (h) => extensionlessModuleTarget(h, agreeFileSet, agreePrefixes); - const refusedByNarrowing = agreeCandidates.filter((h) => !strictOf(h) && looseTarget(h)); - t( - `the extension narrowing costs no lead — every hint the loose rule resolves, this one resolves too (refused: ${refusedByNarrowing.join(', ') || 'none'})`, - refusedByNarrowing.length === 0, - ); - t( - 'and it invents nothing: every file it names is a tracked file', - agreeCandidates.every((h) => !strictOf(h) || agreeFileSet.has(strictOf(h))), - ); - // The reason the list is explicit rather than "any suffix", held to the tree - // so the justification cannot quietly evaporate: somewhere in the fleet the - // loose rule picks a `.test.ts` sibling over the module the import means. If - // those test files are ever removed, re-point this case at whatever pair the - // tree then has rather than deleting it — the trade is decided, not stale. - t( - 'the loose alternative really would name the wrong file, which is why it is refused', - agreeCandidates.some((h) => strictOf(h) && looseTarget(h) && strictOf(h) !== looseTarget(h)), - ); - // ⚠️ And the trade is now load-bearing in a second place. It used to decide - // one SENTENCE in a listing; since #12514 the same list decides which files - // the MATCHED column names, so the loose rule would put a gate's `.test.ts` - // sibling into dispatch prompts. Re-measured here: 4 of the 38 (the four - // named in MODULE_SPECIFIER_EXTENSIONS' docblock), and the matcher reaches - // the module rather than the test for every one of them. - const trapped = agreeCandidates.filter((h) => strictOf(h) && looseTarget(h) && strictOf(h) !== looseTarget(h)); - t( - `the matcher reaches the module, never the test sibling the loose rule would have named (${trapped.length} such hints)`, - trapped.length > 0 && - trapped.every((h) => hintCovers(h, strictOf(h)) && !hintCovers(h, looseTarget(h))), - ); - - // ── A dropped extension is followed, at COMPARISON time (#12514) ────────── - // - // Nine `packages/spec` families were unreachable ENTIRELY because a gate - // spells an imported module without its extension, so no path derivation - // could name them and a dev editing the file was never told they owed them. - // Silent under-derivation, exit 0. The pins below are ARRIVAL pins: where a - // hint lands, never merely that it left the branch it used to be in. - t( - 'the matcher follows the extension an ESM specifier drops', - hintCovers('packages/spec/src/migrations/registry', 'packages/spec/src/migrations/registry.ts'), - ); - // The live specimen the card was filed for, driven end to end through the - // real fleet: the family, the hint and the file are all read from the tree. - const extlessLive = discoverFamilies().byCheck.get('check:spec-changes'); - t( - 'check:spec-changes really declares the extensionless specifier', - (extlessLive?.hints ?? []).includes('packages/spec/src/migrations/registry'), - ); - t( - '...and a card touching that file now MATCHES it — the silent under-derivation this card names', - (extlessLive?.hints ?? []).some((h) => hintCovers(h, 'packages/spec/src/migrations/registry.ts')), - ); - // EQUALITY, not a prefix. The rule may name the one file the specifier - // resolves to and nothing else — not a subtree under it, not a sibling that - // merely starts the same way, and not a second extension stacked on the - // first. Each of these would be a fabricated lead, which this file prices - // above a missing one. - t('the extension follow does not become a subtree claim', !hintCovers('packages/spec/src/migrations/registry', 'packages/spec/src/migrations/registry.ts/inner.ts')); - t('nor reach a sibling that merely shares the stem', !hintCovers('packages/spec/src/migrations/registry', 'packages/spec/src/migrations/registry-v2.ts')); - t('nor a doubled extension', !hintCovers('packages/spec/src/migrations/registry', 'packages/spec/src/migrations/registry.ts.ts')); - t('and it stays inside the segment rule — a bare word is still refused, extension or not', !hintCovers('registry', 'registry.ts')); - // The dotted-suffix sibling trade (#8534) is what a LOOSE suffix rule would - // have taken back. Pinned here as well as above, because this card is the one - // that would have broken it: `.base.json` is not a module extension. - t('the sibling-file refusal survives the extension follow', !hintCovers('packages/spec/authorable-surface', 'packages/spec/authorable-surface.base.json')); - - // COHERENCE: the matcher and the residue cannot disagree about a file the - // tree HAS. `extensionlessModuleTarget` exists to say "the tree has this - // file" about a hint the sweep calls dead; after this change no hint that - // carries a separator can be in both states at once, so the residue can never - // print that sentence about a hint the derivation is silently missing. - const stillDead = agreeCandidates.filter((h) => h.includes('/') && !hintReachesTree(h, agreeFiles)); - t( - 'no separator-carrying hint is both dead to the matcher and resolvable to a tracked file', - stillDead.every((h) => !strictOf(h)), - ); - - // ── The fabrication direction, re-homed from #12299 ─────────────────────── - // - // This widening is root-agnostic: it follows an extension wherever the hint - // points, INCLUDING at a top-level root the tree does not have. The tree - // grows a `src/` or a `data/` and inert hints become MATCHED pairs for gates - // that never read those files — the fabricated-lead direction this file - // prices above a missing one. The requirement adopted on #12299 was that this - // cannot happen SILENTLY, so the roots are held out by name here and their - // arrival reds THIS case instead of quietly minting pairs into prompts. - const fabTopLevel = new Set(agreeFiles.map((f) => f.split('/')[0])); - const fabRoots = new Set(); - for (const h of agreeCandidates) { - if (hintReachesTree(h, agreeFiles)) continue; - const root = collapseHint(h).split('/')[0]; - if (root && !fabTopLevel.has(root)) fabRoots.add(root); - } - // The mechanism, shown rather than argued — a synthetic pair, so the reader - // can see exactly what the guard below is holding out. - t('a hint under an absent root WOULD convert the day that root appears', hintCovers('src/kernel/protocol-version', 'src/kernel/protocol-version.ts')); - t( - `and today none of the ${fabRoots.size} roots one directory away is in the tree — ` + - 'if this reds, a new top-level directory just converted inert hints into MATCHED pairs; ' + - 'check each against the gate that declares it before accepting the leads', - [...fabRoots].every((r) => !fabTopLevel.has(r)), - ); - // The two the #12299 requirement names, held explicitly so the guard cannot - // evaporate if the derived set above ever empties for an unrelated reason. - t('`src` is not a tracked top-level entry — 20 inert hints are rooted there', !fabTopLevel.has('src') && fabRoots.has('src')); - t('`data` is not a tracked top-level entry — 9 inert hints are rooted there', !fabTopLevel.has('data') && fabRoots.has('data')); - - t('hint covers deeper path', hintCovers('.claude/agents', '.claude/agents/os-dev.md')); - t('collapsed glob prefix covers', hintCovers('packages/spec/src/**', 'packages/spec/src/data/filter.zod.ts')); - t('input dir covers hint below it', hintCovers('packages/spec/scripts/check-x.mjs', 'packages/spec')); - t('unrelated path does not match', !hintCovers('.claude/agents', 'packages/rest/src/server.ts')); - - // ── Segment boundaries, not string prefixes (#8534) ─────────────────────── - // - // Every case above is one the raw-prefix rule also passed; they stay to prove - // the narrowing kept them. The cases below are the ones it failed. - t('a hint equal to the input path covers it', hintCovers('docs/adr', 'docs/adr')); - t('a sibling sharing a name prefix is NOT covered', !hintCovers('packages/client', 'packages/client-react/src/index.ts')); - t('nor in the other direction', !hintCovers('packages/spec-extra/x.ts', 'packages/spec')); - // The live specimen this is measured on: a real FILE sitting beside a real - // directory of the same name stem. The filed census called the rule dormant - // after probing package DIRECTORIES; it was live all along, on a file. The - // original specimen was `content/docs` + `content/docs.site.json`; that file - // was deleted as dead config (#12489) and this case was re-pointed, per the - // standing instruction kept here: if `packages/spec/authorable-surface.base.json` - // is ever removed, re-point this case at whatever sibling pair the tree then - // has rather than deleting it. - t('the live sibling FILE is no longer claimed by the directory hint', !hintCovers('packages/spec/authorable-surface', 'packages/spec/authorable-surface.base.json')); - t('while the directory it names is still covered', hintCovers('packages/spec/authorable-surface', 'packages/spec/authorable-surface/ai.json')); - // The collapsed-glob reach trade, pinned in BOTH directions so the decision - // reads as an assertion. Refusing the sibling reach is the DECIDED loss (see - // hintCovers' docblock): measured, no repo-path hint of this shape exists — - // the only two live partial-segment globs are npm specifiers. - t('a collapsed partial-segment glob does NOT reach the sibling it would match as a glob', !hintCovers('packages/client*', 'packages/client-react/src/index.ts')); - t('the same glob still covers the package it names', hintCovers('packages/client*', 'packages/client/src/index.ts')); - t('a segment-boundary glob is untouched by the trade', hintCovers('packages/client/**', 'packages/client/src/index.ts')); - - // ── A glob in a NON-FINAL segment is matched, not collapsed (#12246) ────── - // - // Collapse-by-deletion mangles this one shape into a double separator no tree - // can hold, so the hint matched nothing BY CONSTRUCTION and its family was - // then misfiled as "THE LAYOUT MOVED". Both halves are pinned: what the rule - // now reaches, and — the larger half — everything it deliberately does NOT - // disturb, because the refused alternative (matching ALL whole-segment globs) - // breaks the ROOT_DIR_WATCH_HINTS idiom by −7404 pairs on each of three gates. - t('the predicate reads the LAST segment, so a trailing glob is not this case', !globInNonFinalSegment('packages/**')); - t('nor is a partial-segment glob in the last segment', !globInNonFinalSegment('packages/client*')); - t('a glob in a middle segment IS', globInNonFinalSegment('skills/*/references/_index.md')); - t('and a `**` in a middle segment IS', globInNonFinalSegment('packages/**/*.ts')); - // The live specimen: `packages/spec/scripts/build-skill-references.ts` emits - // these nine files and the derivation reached zero of them. If the skills - // layout ever changes, re-point this case at whatever mid-segment glob the - // fleet then declares rather than deleting it. - t('a mid-segment glob reaches the file it names', hintCovers('skills/*/references/_index.md', 'skills/objectstack-formula/references/_index.md')); - t('and does NOT claim the rest of the subtree it passes through', !hintCovers('skills/*/references/_index.md', 'skills/objectstack-formula/SKILL.md')); - t('a `**` crosses separators, a single `*` does not', hintCovers('packages/**/*.ts', 'packages/spec/src/data/filter.zod.ts')); - t('so a single `*` segment matches exactly one segment', !hintCovers('skills/*/references/_index.md', 'skills/a/b/references/_index.md')); - t('the extension the glob names is honoured', !hintCovers('packages/**/*.object.ts', 'packages/spec/src/index.ts')); - // Reverse containment, the direction a coarse card surface needs: the literal - // prefix before the first wildcard still reaches back to a directory surface, - // exactly as the collapsed form used to. - t('a directory surface above the glob still derives the gate', hintCovers('skills/*/references/_index.md', 'skills')); - t('but an unrelated root does not', !hintCovers('skills/*/references/_index.md', 'packages')); - // ⛔ The refused alternative, pinned as the loss it would be. Each of these - // is a trailing glob and MUST keep going through the collapse. - t('a trailing `**` still collapses to the root it names', hintCovers('packages/**', 'packages/spec/src/index.ts')); - t('the ROOT_DIR_WATCH_HINTS idiom is untouched', hintCovers('skills/**', 'skills/objectstack-formula/SKILL.md')); - t('and so is a trailing single `*`', hintCovers('examples/*', 'examples/app-showcase/src/x.ts')); - t('the DECIDED partial-segment trade still refuses the sibling', !hintCovers('packages/client*', 'packages/client-react/src/index.ts')); - - // ── `**` covers ZERO segments too (#12329) ─────────────────────────────── - // - // The branch above judges these hints with `triggerCovers`, i.e. with - // GitHub's filter-pattern language, where `**` is a CHARACTER wildcard and - // the `/` written after it is a literal that must still appear. That makes - // `**` mean ONE OR MORE segments, so the natural spelling for a top-level - // population reaches none of it. Read from the real corpus, not a fixture: a - // fixture cannot show that the tree still has the shape the trap needs. - const topLevelMirrors = trackedFiles().filter((f) => /^scripts\/[^/]+\.d\.mts$/.test(f)); - t('the tree really does hold top-level `.d.mts` files under a root', topLevelMirrors.length >= 3); - t('a `**` root reaches the top-level files under it', topLevelMirrors.every((f) => hintCovers('scripts/**/*.d.mts', f))); - t('and claims nothing else in the whole tree', trackedFiles().filter((f) => hintCovers('scripts/**/*.d.mts', f)).length === topLevelMirrors.length); - t('the ONE-OR-MORE reading it used to have is still there', hintCovers('scripts/**/*.d.mts', 'scripts/pm/x.d.mts')); - t('at any depth', hintCovers('scripts/**/*.d.mts', 'scripts/a/b/x.d.mts')); - t('the extension the glob names is still honoured at the top level', !hintCovers('scripts/**/*.d.mts', 'scripts/invoked-as.mjs')); - t('and a directory surface above it still derives the gate', hintCovers('scripts/**/*.d.mts', 'scripts')); - // The forms are itself first, then the reductions — the original spelling is - // never lost, which is what keeps the one-or-more cases above passing. - t('the forms of a `**` hint are the hint and its zero-segment reduction', zeroSegmentForms('scripts/**/*.d.mts').join(' ') === 'scripts/**/*.d.mts scripts/*.d.mts'); - t('each `**` drops independently, so two of them give the power set', zeroSegmentForms('a/**/b/**/c').join(' ') === 'a/**/b/**/c a/b/**/c a/**/b/c a/b/c'); - t('a hint with no `**` segment has exactly one form', zeroSegmentForms('skills/*/references/_index.md').join(' ') === 'skills/*/references/_index.md'); - t('and so does a hint with no glob at all', zeroSegmentForms('packages/spec/src/index.ts').join(' ') === 'packages/spec/src/index.ts'); - t('a hint above the cap keeps its written and fully-reduced forms only', zeroSegmentForms('a/**/**/**/**/**/**/**/**/**/z').length === 2); - // ⛔ Deliberately NOT droppable — three refusals that keep this narrow. - t('a single `*` segment is not a zero-segment wildcard', !hintCovers('skills/*/references/_index.md', 'skills/references/_index.md')); - t('nor is a `**` that is only PART of a segment', zeroSegmentForms('packages/a**/b.ts').join(' ') === 'packages/a**/b.ts'); - t('and a trailing `**` never reaches this rule at all', hintCovers('packages/**', 'packages/spec/src/index.ts') && !globInNonFinalSegment('packages/**')); - // The CI mirror is untouched, which is the whole reason the repair lives in - // `hintCovers` and not in `triggerPatternRegex`: a hint is a glob a gate - // author wrote, a trigger is a filter GitHub will evaluate, and this file - // must keep saying what GitHub does. `validate-deps.yml` declares - // `'**/package.json'` and is the live specimen. - t('the trigger language still reads `**` as the character wildcard GitHub documents', !triggerCovers('**/package.json', 'package.json')); - t('while the same spelling as a HINT covers the root file', hintCovers('**/package.json', 'package.json')); - // The sibling spelling used to be dead by the OLDER route and is repaired by - // `globCarriesLiteralSuffix` (#13448). The collapse still mangles it — that - // is the whole reason it must not be judged by the collapse — so BOTH halves - // are pinned: the mangle is still what deletion produces, and the hint no - // longer goes through it. - t('the collapse of a final-segment glob with a literal suffix is still a splice', collapseHint('scripts/*.d.mts') === 'scripts/.d.mts'); - t('...so the hint is judged as a pattern instead', judgedAsPattern('scripts/*.d.mts') && globCarriesLiteralSuffix('scripts/*.d.mts')); - t('...and now reaches every one of the files it names', topLevelMirrors.every((f) => hintCovers('scripts/*.d.mts', f))); - t('...and claims nothing else in the whole tree', trackedFiles().filter((f) => hintCovers('scripts/*.d.mts', f)).length === topLevelMirrors.length); - t('a single `*` still never crosses a separator', !hintCovers('scripts/*.d.mts', 'scripts/pm/x.d.mts')); - - // ── A final-segment glob with a literal SUFFIX is a splice too (#13448) ─── - // - // The species `zeroSegmentForms` recorded and left. Deletion-collapse mangles - // `.changeset/*.md` into `.changeset/.md`, so the hint reached ZERO of 548 - // tracked changesets while reading as an ordinary literal, and the residue - // then named a directory rename as the cause. Read from the REAL corpus: a - // fixture cannot show that the hint still reaches the population the trap was - // sprung on. What that corpus is NOT is something this file may require: it - // grows with every merged PR and a version pass takes all of it back (#15255, - // and the docblock under `changesetPop` below). - const suffixCorpus = trackedFiles(); - const changesetPop = changesetSpecimenPop(suffixCorpus); - // ⛔ NOT `changesetPop.length >= 100` (#15255). That control was reaching for - // something real — the two assertions under it pass VACUOUSLY over an empty - // population, `every` on nothing and `0 === 0` — but it bought the guard with - // a claim this tool has no standing to make. The size of that population is - // owned by the release cycle: a changesets version pass consumes all of it, - // so it is 1 on the Version Packages PR (measured on #11336's head - // b8573e843: `.changeset/` holds README.md and config.json and nothing else), - // it is whatever has landed since on `main` for the days after, and it is 865 - // here. A required context that reds at 1 and at 3 blocks the release itself, - // and then blocks `main` behind it. - // - // What a gate CAN require is the specimen's HOME. `.changeset/config.json` is - // the changesets tool's own configuration: while it is tracked, an empty - // population is this repo mid-cycle and the specimen is merely resting; when - // it goes, the specimen has no population to come back to and somebody must - // pick a new one for this species. That distinction is the whole difference - // between "not measurable today" and "rotted", and it is the one the count - // could not draw. - // - // The vacuity the count was guarding is closed by construction instead: the - // three corpus assertions run only where there is a corpus, and where there - // is not, `unmeasurable` says so out loud rather than letting them green. - // Note the population is 1, not 0, on the real release PR — `README.md` - // survives a version pass — so a guard written at `=== 0` would have left the - // gate red on the very tree it was written for. It is written at "empty" and - // measured at 0, 1, 3 and 865. - t('the `.changeset/*.md` specimen still has a home in this tree', suffixCorpus.includes('.changeset/config.json')); - if (changesetPop.length === 0) { - unmeasurable( - 'the `.changeset/*.md` live-specimen corpus assertions', - 'this tree tracks no `.changeset/*.md` at all — that is what a changesets version pass produces, and the ' + - 'population comes back as changesets land. The specimen still has its home (see the case above); the three ' + - 'assertions that need a population are the only thing skipped, and every literal case in this block ran. ' + - "Check it yourself: git ls-files '.changeset/'", - ); - } else { - t('the live specimen reaches every changeset it names', changesetPop.every((f) => hintCovers('.changeset/*.md', f))); - t('and claims nothing else in the whole tree', suffixCorpus.filter((f) => hintCovers('.changeset/*.md', f)).length === changesetPop.length); - t('so it is nobody\'s dead literal any more', hintReachesTree('.changeset/*.md', suffixCorpus)); - } - // The branch above is a decision this tree can only exercise one way — it - // holds a population today and will hold one on almost every run — so the - // sizes the release cycle really produces are pinned on fixtures, at the four - // states named in the docblock. A boundary written at the wrong one is the - // defect that shipped: `>= 100` is green at 865 and red at every size a - // version pass leaves behind. - const csTree = (...names) => ['AGENTS.md', '.changeset/config.json', ...names]; - t('at 865 the corpus assertions run, which is this tree and every ordinary day', - changesetSpecimenPop(csTree(...Array.from({ length: 865 }, (_, i) => `.changeset/c${i}.md`))).length === 865); - t('at 3 they still run — the state `main` is in for days after a release lands, and where `>= 100` was red', - changesetSpecimenPop(csTree('.changeset/a.md', '.changeset/b.md', '.changeset/c.md')).length === 3); - t('at 1 they still run, and 1 is what the Version Packages PR really carries — README.md survives a version pass', - changesetSpecimenPop(csTree('.changeset/README.md')).length === 1); - t('only an EMPTY population is undecidable, and that is the only state that skips', - changesetSpecimenPop(csTree()).length === 0); - // The home discriminator, both directions: it is what separates "resting" from - // "rotted", so it must not answer the same way for a tree that has retired - // changesets altogether. - t('a tree mid-cycle still has the specimen home, so an empty population reads as resting', - csTree().includes('.changeset/config.json')); - t('...while a tree that retired changesets has no home, and the case above reds instead of skipping', - !['AGENTS.md', 'package.json'].includes('.changeset/config.json')); - // The tally the skip is reported through. Pinned here rather than at the - // verdict because a green run of this file never reaches the non-empty branch - // of it, so nothing else in this program can show that a skip is visible. - t('a run that skipped nothing prints the verdict it always printed', notMeasuredSuffix([]) === ''); - t('...and a run that skipped something names it, so no skip reaches a reader as a bare pass count', - notMeasuredSuffix([['a subject', 'a reason']]).includes('NOT MEASURED') && - notMeasuredSuffix([['a subject', 'a reason']]).includes('a subject')); - t('...naming every one of them, never just a count', - notMeasuredSuffix([['first', 'x'], ['second', 'y']]).includes('first') && - notMeasuredSuffix([['first', 'x'], ['second', 'y']]).includes('second')); - t('and a skipped subject is never a case, so the pass count cannot absorb one', - !cases.some(([name]) => name.includes('NOT MEASURED'))); - t('the extension the glob names is honoured', !hintCovers('.changeset/*.md', '.changeset/config.json')); - t('a single `*` matches exactly one segment here too', !hintCovers('.changeset/*.md', '.changeset/pre/x.md')); - t('a directory surface above it still derives the gate', hintCovers('.changeset/*.md', '.changeset')); - t('but an unrelated root does not', !hintCovers('.changeset/*.md', 'packages')); - // The predicate, both directions. What decides it is a LITERAL behind the - // glob, never a literal in front of one. - t('a glob with a literal behind it in the last segment is the splice case', globCarriesLiteralSuffix('.changeset/*.md')); - t('a literal PREFIX before the glob is not what decides it', globCarriesLiteralSuffix('scripts/check-*.mjs') && !globCarriesLiteralSuffix('scripts/check-*')); - t('a TRAILING glob is not this case — deletion truncates it to a real prefix', !globCarriesLiteralSuffix('packages/**') && !globCarriesLiteralSuffix('packages/*') && !globCarriesLiteralSuffix('packages/client*')); - t('nor is a hint with no glob at all', !globCarriesLiteralSuffix('packages/spec/src/index.ts')); - // ⛔ The refusals, pinned as the losses they would be. Each of these MUST - // keep going through the collapse: the alternative was measured at −7404 - // pairs on each of three gates (see zeroSegmentForms' docblock). - t('the ROOT_DIR_WATCH_HINTS idiom is bit-for-bit untouched', - hintCovers('skills/**', 'skills/objectstack-formula/SKILL.md') && - hintCovers('examples/*', 'examples/app-showcase/src/x.ts') && - hintCovers('packages/**', 'packages/spec/src/index.ts')); - t('the DECIDED partial-segment trade still refuses the sibling', !hintCovers('packages/client*', 'packages/client-react/src/index.ts')); - t('...and still covers the package it names', hintCovers('packages/client*', 'packages/client/src/index.ts')); - // ⛔ `?`, `+` and `[…]` are NOT admitted. `collapseHint` never deleted them, - // so they are not a mangle — they are an ordinary literal that fails to - // match, which is the MISSING-lead direction this file errs in. Measured at - // zero live instances; pinned so their arrival is a decision somebody makes - // rather than a fabricated-pair widening nobody sees. - const finalSegmentShapes = new Set(); - for (const [, entry] of discoverFamilies().byCheck) for (const h of entry.hints ?? []) finalSegmentShapes.add(h.split('/').pop()); - t('no hint in the fleet carries a `?`, `+` or character class', ![...finalSegmentShapes].some((s) => /[?+[]/.test(s))); - t('and such a hint is still judged by the collapse, exactly as before', !judgedAsPattern('scripts/check-?.mjs')); - - // The trailing-separator strip is ONE call, not two: `/\/+$/` is greedy and - // anchored, so nothing survives for a second `/\/$/` to remove. Measured at - // zero of 754 hints; pinned on the probes that could tell them apart, so a - // future reader does not restore the redundant call as defence-in-depth. - t('the greedy trailing strip removes every trailing separator', collapseHint('a///') === 'a'); - t('including the one a trailing `**` leaves behind', collapseHint('a/**/') === 'a'); - t('and a hint that is nothing but separators collapses to empty', collapseHint('**/') === ''); - t('what it cannot touch is a separator left in the MIDDLE', collapseHint('skills/*/references/_index.md') === 'skills//references/_index.md'); - - // ── A declared SUBTREE is not a bare word (#9626) ───────────────────────── - // - // The genericity refusal reads the hint as the author wrote it. Both - // directions, because the whole value of the rule is the pair: the word is - // still refused, the declaration is now honoured. Collapsing `content/**` - // yields the same `content` the bare word yields, which is precisely why the - // refusal cannot be decided on the collapsed copy. - t('a single-segment root declared as a subtree covers the tree it names', hintCovers('content/**', 'content/docs/any-page.mdx')); - t('the same declaration covers the OTHER subtree under that root', hintCovers('content/**', 'content/blog/a-post.mdx')); - t('a bare top-level directory WORD is still refused as too generic', !hintCovers('packages', 'packages/spec/src/index.ts')); - t('a bare root that lost its separator to the trailing trim is still refused', !hintCovers(extractWatchHints("const D = 'examples/';")[0] ?? 'examples', 'examples/app-showcase/src/x.ts')); - t('a declared subtree does not reach a sibling root', !hintCovers('content/**', 'contentious/x.md')); - // A top-level FILE stays out of reach on purpose: accepting a bare filename - // would admit every `package.json` basename a gate joins with a package dir. - // Pinned so the loss reads as a decision, not an oversight. What is refused - // is the LITERAL, never the file — a gate whose population really is a root - // file reaches it by declaring the subtree spelling, which is what the - // rootFileDeclarations cases below pin. - t('a bare top-level FILE name is refused, the decided loss', !hintCovers('README.md', 'README.md')); - - // The three live declarations the refusal used to swallow, read from the real - // gates rather than fixtures — a fixture cannot show that the tree still has - // the shape. If one of these gates stops declaring its root, re-point the - // case at whatever gate then does; deleting one deletes the evidence. - // Re-pointed at the module that DECLARES the table (#11511): it moved out of - // check-cross-package-test-inputs.mjs into a plain module precisely so the - // follow below could reach it, and this read is of the declaration, not of - // the gate. Exactly what the paragraph above asks for -- "if one of these - // gates stops declaring its root, re-point the case at whatever gate then - // does". Left pointing at the gate it would have gone green over an empty - // hint list, which is the vacuous-pass shape these cases exist to refuse. - const crossPkgHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/cross-package-test-inputs.mjs'), 'utf8'), 'scripts/cross-package-test-inputs.mjs'); - // NOT `scripts/check-nul-bytes.mjs`: that gate names that file explicitly - // too, so the case would pass with the declaration still refused — measured, - // it survived the ablation. Pick a scripts path reachable ONLY through the - // declared subtree, or the case pins nothing. - t('the cross-package declaration table reaches the root scripts dir it declares', crossPkgHints.some((h) => hintCovers(h, 'scripts/pm/dispatch-gates.mjs'))); - t('and the content tree it declares', crossPkgHints.some((h) => hintCovers(h, 'content/docs/getting-started/index.mdx'))); - const governedHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/pm/check-governed-merges.mjs'), 'utf8'), 'scripts/pm/check-governed-merges.mjs'); - t('the governed-merge gate reaches the published skills catalog it declares', governedHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md'))); - - // The card this landed for: the ONLY fragment coverage in the repo, which - // scored `silent` for every content card while being REQUIRED in lint.yml. - const anchorHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/check-doc-anchors.mjs'), 'utf8'), 'scripts/check-doc-anchors.mjs'); - t('the doc-anchors gate reaches the content page population it declares', anchorHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); - t('and does not thereby claim a path outside that population', !anchorHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts'))); - - // The sixth instance of the class (#10648), and the worst-shaped one: three - // of check-doc-authoring's four roots were bare words (`.claude` survived on - // the dotted-dir arm alone), while its SKIP_PATHS carried separators and were - // taken. Five of the six paths it declared were therefore EXCLUSIONS, and 383 - // of its 389 walked files were declared by nothing. The failure printed as a - // populated `names:` column, which reads as "declared, just not relevant to - // you" rather than as a blind spot — the reason it survived five same-class - // fixes without being noticed. - const docAuthoringHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/check-doc-authoring.mjs'), 'utf8'), 'scripts/check-doc-authoring.mjs'); - // One case per declared root, because a single one passes for a declaration - // that dropped the other three — which is the exact shape being fixed. Each - // path is reachable ONLY through its root's subtree spelling, never through a - // SKIP_PATHS literal. - t('the doc-authoring gate reaches the live docs corpus it declares', docAuthoringHints.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md'))); - t('and the top-level docs guides, which are files rather than a subtree', docAuthoringHints.some((h) => hintCovers(h, 'docs/protocol-upgrade-guide.md'))); - // ⚠️ This one case does NOT pin the declaration, and says so rather than - // reading as though it does: `.claude` is a top-level DOTTED dir, which - // `looksPathy` admits and `hintCovers` does not refuse, so the bare ROOTS - // entry reaches this path on its own. Measured — deleting `.claude/**` from - // the gate leaves this case green, exactly the way check-nul-bytes survives - // the ablation above. What it pins is that `.claude` stays reachable AT ALL; - // the declaration itself is pinned in the gate's own self-test, which - // requires a subtree spelling for every separator-less ROOT. - t('and the agent operating manual it took in for the same reason', docAuthoringHints.some((h) => hintCovers(h, '.claude/agents/os-dev.md'))); - t('and the published skills catalog', docAuthoringHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md'))); - t('and the content tree', docAuthoringHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); - // Rule 3's root, added when the gate took in the spec's customer-facing zod - // refusal messages. It is NOT one of ROOTS — the Markdown rules never walk it - // — so the gate's own self-test (which derives its declaration from ROOTS) - // cannot pin it and this case is the only place that does. - t('and the spec refusal-message population Rule 3 walks', docAuthoringHints.some((h) => hintCovers(h, 'packages/spec/src/ui/action.zod.ts'))); - // #13297 widened Rule 3's root: the cross-package prose-id leg walks every - // sibling package's non-test sources against a pinned baseline, so the gate - // now genuinely reads any package edit and declares `packages/**`. The - // narrow claim these cases used to pin ("spec/src and nothing else under - // packages/") is the boundary the #13179 deferral drew, and the deferral's - // own codified revival condition retired it — a sibling package source is - // now POSITIVE coverage, not an over-claim. - t('and the sibling-package prose population the ledgered leg walks', docAuthoringHints.some((h) => hintCovers(h, 'packages/runtime/src/index.ts'))); - // The residual over-claim is bounded and known: `packages/**` subsumes - // spec's non-src files and every test file, which the leg's own walk skips - // (spec belongs to the position-based rule; test bodies are out). That is - // the tolerated carve-out-inside-a-walked-root case — the same shape as - // check:slot-lookup-ratchet declaring the whole of `packages/**` — pinned - // here so it stays a recorded residual rather than an accident. - t('spec outside its source tree rides the bounded packages/** over-claim', docAuthoringHints.some((h) => hintCovers(h, 'packages/spec/package.json'))); - // The negative half that SURVIVES the widening, still load-bearing: a gate - // named on EVERY card is the louder version of naming none, and the leg - // walks packages/ only — never apps/ or examples/. - t('and claims nothing under apps/', !docAuthoringHints.some((h) => hintCovers(h, 'apps/console/src/main.tsx'))); - t('nor under examples/', !docAuthoringHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts'))); - - // The second gate of that class (#9700): a whole-tree ESLint ratchet whose - // only literals were its own baseline artifact and the ref it diffs against, - // so it scored `silent` for every card in the tree while being REQUIRED in - // lint.yml — twice at the cost of a p0's CI round (#9391, PR #9695). It now - // declares the subtree it lints. Read from the real gate, not a fixture: what - // is being pinned is that the tree still HAS the declaration. - const slotHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/check-slot-lookup-ratchet.mjs'), 'utf8'), 'scripts/check-slot-lookup-ratchet.mjs'); - t('the slot-lookup ratchet reaches the package source population it declares', slotHints.some((h) => hintCovers(h, 'packages/services/service-datasource/src/admin-routes.ts'))); - // The negative half is the load-bearing one for a declaration this broad: a - // gate named on EVERY card is the louder version of naming none. `packages/**` - // must reach nothing outside `packages/`, and these three roots are where a - // widened extractor would have leaked it (measured in hintCovers' docblock: - // the rejected alternative takes one card from 7 matched families to 34). - t('and claims nothing under apps/', !slotHints.some((h) => hintCovers(h, 'apps/console/src/main.tsx'))); - t('nor under examples/', !slotHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts'))); - t('nor a content page', !slotHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); - - // The third gate of that class (#9964), and the one nothing above could - // reach: the pm line ratchet's population includes the repo-ROOT AGENTS.md, - // and a root file carries no separator for `looksPathy` to find — so its - // eighteen ceilings produced seventeen hints and an AGENTS.md card derived - // zero gates, on the largest ceiling in that map at headroom 0. It declares - // the subtree spelling instead. Read from the real gate, not a fixture: what - // is pinned is that the tree still HAS the declaration. - const lineRatchetHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/pm/check-skill-line-ratchet.mjs'), 'utf8'), 'scripts/pm/check-skill-line-ratchet.mjs'); - t('the pm line ratchet reaches the repo-root instruction file it declares', lineRatchetHints.some((h) => hintCovers(h, 'AGENTS.md'))); - // The negative half, and the reason this is a DECLARATION rather than an - // extractor change. Widening the extractor to admit bare top-level `*.md` - // literals was measured on the same corpus as the refusal above — 114 - // families x 6326 tracked files — and costs only 17 pairs, but 8 of them are - // fabricated: gates spell `README.md` and `CHANGELOG.md` as basenames they - // join with a package directory, so a README.md card gains six leads of which - // five name a gate that never reads it. The class stays refused; these pin - // that this declaration bought no part of it. - t('and claims no other repo-root file', !lineRatchetHints.some((h) => hintCovers(h, 'README.md'))); - t('nor a same-named file inside a directory', !lineRatchetHints.some((h) => hintCovers(h, 'examples/AGENTS.md'))); - t('a bare top-level file literal is still no hint at all', extractWatchHints("const F = 'README.md';").length === 0); - - // The rest of that class (#9979). The ratchet above was one of SIX families - // whose population genuinely includes a repo-root instruction file; the other - // five were measured still invisible, so an AGENTS.md card derived ONE gate - // out of six and a README.md / ARCHITECTURE.md card derived NONE at all — - // while `check:doc-anchors` is REQUIRED in lint.yml and is this repo's only - // fragment coverage. Each declares the subtree spelling in its own source. - // - // Read from the real gates, not fixtures: what is pinned is that the tree - // still HAS the declarations. If one of these gates stops reading its root - // file, delete its case with the declaration — never keep a case green by - // re-pointing it at a gate that never read the file. - const rootFileDeclarations = [ - ['the pm skill-id lint', 'scripts/pm/check-skill-id-lint.mjs', 'AGENTS.md'], - ['the governed-merge register', 'scripts/pm/check-governed-merges.mjs', 'AGENTS.md'], - ['the governed-merge register (CLAUDE.md half)', 'scripts/pm/check-governed-merges.mjs', 'CLAUDE.md'], - ['the governed-prose gate', 'scripts/pm/check-governed-prose.mjs', 'AGENTS.md'], - ['the docs-audit scope gate', 'scripts/docs-audit/check-audit-scope.mjs', 'AGENTS.md'], - ['the required-context pin', 'scripts/check-required-contexts.mjs', 'AGENTS.md'], - ['the doc-anchors gate', 'scripts/check-doc-anchors.mjs', 'README.md'], - ['the doc-anchors gate (ARCHITECTURE.md half)', 'scripts/check-doc-anchors.mjs', 'ARCHITECTURE.md'], - ]; - for (const [what, gate, rootFile] of rootFileDeclarations) { - const gateHints = extractWatchHints(readFileSync(nodePath.join(ROOT, gate), 'utf8'), gate); - t(`${what} reaches the repo-root file it declares (${rootFile})`, gateHints.some((h) => hintCovers(h, rootFile))); - // The negative half, and the reason each of these is a DECLARATION rather - // than an extractor change: a declaration must buy its own file and NOT the - // bare-`*.md` class the extractor still refuses. `examples/AGENTS.md` is - // the live specimen — a real tracked file, same basename, not read by any - // of these gates (check-governed-merges' own near-miss case names it). - t(`${what} claims no same-named file inside a directory`, !gateHints.some((h) => hintCovers(h, `examples/${rootFile}`))); - } - // …and the root files stay separated from each other: the governed-merge - // register is the only one of the six that declares two, and nothing here may - // reach a root file its gate does not read. - const proseHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/pm/check-governed-prose.mjs'), 'utf8'), 'scripts/pm/check-governed-prose.mjs'); - t('a one-root declaration does not reach the other root file', !proseHints.some((h) => hintCovers(h, 'CLAUDE.md'))); - const anchorRootHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/check-doc-anchors.mjs'), 'utf8'), 'scripts/check-doc-anchors.mjs'); - t('and the doc-anchors pair claims neither instruction file', !anchorRootHints.some((h) => hintCovers(h, 'AGENTS.md') || hintCovers(h, 'CLAUDE.md'))); - - // A THIRD shape of the same class, and the one with the worst failure - // direction (#13207): a gate whose declared population was its OWN GUARDED - // ARTIFACT. `check:llms-txt` re-derives the claims of `packages/spec/llms.txt` - // against trees elsewhere — the `*.zod.ts` counts under `packages/spec/src`, - // the `api-surface/` shards, the manifest `exports` keys, and the non-private - // `@objectstack/*` workspace set — but reached every one of them through - // `join(PKG, ...)`, so the only literal it spelled was `llms.txt` itself. - // - // The derivation could therefore name the gate only AFTER the artifact had - // been edited, while the edits that FALSIFY it land in those other trees. - // Measured on PR #13186 across two rounds of one branch: deleting a `src/` - // schema module moved `src/kernel/` 32 -> 31 and the summed total 208 -> 207, - // the derived family did not contain the gate, and the red reached CI. That - // is UNDER-matching — silent, and invisible in the tool's own output, where an - // omitted gate looks exactly like a gate that does not apply. - // - // Read from the real gate, not a fixture: what is pinned is that the tree - // still HAS the declaration. If this gate stops reading one of these trees, - // delete the case together with the literal — never keep it green by - // re-pointing it at a tree the gate never reads. - const llmsHints = extractWatchHints( - readFileSync(nodePath.join(ROOT, 'packages/spec/scripts/check-llms-txt.ts'), 'utf8'), - 'packages/spec/scripts/check-llms-txt.ts', - ); - const llmsReaches = (f) => llmsHints.some((h) => hintCovers(h, f)); - // The reproduction, as a case: the falsifying edit alone names the gate. - t('check:llms-txt reaches the schema tree its counts are derived from', llmsReaches('packages/spec/src/kernel/cluster.zod.ts')); - // …and by a route that is NOT the artifact hint. This is the reproduction - // itself: before this declaration the only hint covering anything was - // `packages/spec/llms.txt`, so a src-only diff derived nothing. - t('and by a route that is not the guarded artifact — the #13207 reproduction', llmsHints.some((h) => h !== 'packages/spec/llms.txt' && hintCovers(h, 'packages/spec/src/kernel/cluster.zod.ts'))); - t('it still reaches the artifact it guards', llmsReaches('packages/spec/llms.txt')); - t('it reaches the api-surface shards every NAMED claim resolves against', llmsReaches('packages/spec/api-surface/data.json')); - t('it reaches the manifest whose exports keys the SUBPATH claims resolve against', llmsReaches('packages/spec/package.json')); - t('it reaches the repo-root workspace file it opens', llmsReaches('pnpm-workspace.yaml')); - t('and the workspace manifests whose set is the package-ecosystem denominator', llmsReaches('packages/drivers/driver-mongodb/package.json')); - // The negative half, and the load-bearing one. A population this broad is - // one respelling away from the "22 leads is the same as none" failure the - // header prices: `packages/spec` or `packages/**` would have bought the flip - // too, and named this gate on nearly every card in the repo. These pin that - // it bought the four trees it reads and NOTHING else — including the sibling - // directories inside its own package. - t('but claims no other file in its own package', !llmsReaches('packages/spec/docs/anything.md')); - t('nor a sibling package source', !llmsReaches('packages/rest/src/analytics-dataset-dimension-gate.test.ts')); - t('nor a content page', !llmsReaches('content/docs/deployment/cli.mdx')); - t('nor an app source', !llmsReaches('apps/docs/components/ui/card.tsx')); - t('nor an example', !llmsReaches('examples/app-crm/src/objects/lead.object.ts')); - // A workspace manifest is reached; a workspace SOURCE file is not. This is - // the pair that separates `packages/**\/package.json` from `packages/**`. - t('and a package manifest is reached where its source is not', llmsReaches('packages/qa/dogfood/package.json') && !llmsReaches('packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts')); - - // The DIRECTORY half of the same class (#10107). A gate whose population is a - // top-level DIRECTORY spelled as a bare word is invisible for the same reason - // a root file is — `looksPathy` finds no separator, so the extractor builds no - // hint at all — and it is the more expensive half, because the word names a - // whole subtree rather than one file. `check:role-word` walks - // `['content/docs', 'skills']`: the first is a hint, the second was nothing, - // so a skills-only card derived the content half and scored this gate - // `silent`. PR #10038 paid for it — a green local union, then - // `role-word count grew 2 → 3` in CI. It declares the subtree spelling now. - // - // Read from the real gate, not a fixture: what is pinned is that the tree - // still HAS the declaration. If this gate stops walking that root, delete the - // declaration and these cases together — never keep them green by re-pointing - // at a gate that never read it. - const roleWordHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/check-role-word.mjs'), 'utf8'), 'scripts/check-role-word.mjs'); - t('the role-word ratchet reaches the published skills catalog it declares', roleWordHints.some((h) => hintCovers(h, 'skills/objectstack-platform/SKILL.md'))); - t('and still reaches the content half it always named', roleWordHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); - // The negative halves, and the reason this is a DECLARATION and not an - // extractor change. `.claude/skills/` is the live specimen: a real tracked - // tree whose last segment IS the declared root, which this gate does not walk - // — a widened extractor accepting the bare word `skills` would not tell them - // apart, and the collapsed subtree does. - t('and claims nothing under the internal .claude skills tree it never walks', !roleWordHints.some((h) => hintCovers(h, '.claude/skills/pm-dispatch/SKILL.md'))); - t('nor a package source file', !roleWordHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts'))); - // CONSTRUCTED path, deliberately: `content/docs.site.json` was a live file - // until it was deleted as dead config (#12489), and `content/` now holds only - // the two collection directories, so the tree offers no sibling beside the - // content root to name. The probe stays spelled against the `content/docs` - // hint anyway — that hint is the one with bite here, and re-pointing at a - // path some far-away gate names would keep this green while testing nothing. - t('nor a sibling FILE beside the content root', !roleWordHints.some((h) => hintCovers(h, 'content/docs.site.json'))); - // The pair that makes the declaration worth having: the bare word this gate - // actually spells in its ROOTS array stays refused, so the coverage above is - // bought by the declaration and by nothing else. - t('the bare root word the gate spells in ROOTS is still refused as too generic', !hintCovers('skills', 'skills/objectstack-platform/SKILL.md')); - t('while the declared subtree covers that same path', hintCovers('skills/**', 'skills/objectstack-platform/SKILL.md')); - - // The seventh instance of the same directory class (#10664), in a - // PACKAGE-scoped gate — `pnpm --filter @objectstack/lint run - // check:doc-formula-expressions`, REQUIRED in lint.yml — so the source this - // reads is resolved through that package's manifest rather than the root one. - // - // Its ROOTS were `['.claude', 'docs', 'skills', 'content']`, three bare words - // and one dotted dir, while its SKIP_PATHS spelled five exclusions WITH - // separators. Measured on this tree: of the 1388 files it walks, 396 (28.5%) - // were declared by nothing — every file under `docs` (156), `skills` (48) and - // `content` (192). Inside the `docs` root the shape was inverted rather than - // merely absent: `docs/plans/` derived the gate (an exclusion, via its own - // SKIP_PATHS literal) while `docs/qa/` derived nothing. - // - // Read from the real gate, not a fixture: what is pinned is that the tree - // still HAS the declaration. - const docFormulaHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'packages/lint/scripts/check-doc-formula-expressions.mjs'), 'utf8'), 'packages/lint/scripts/check-doc-formula-expressions.mjs'); - // One case per declared root, because a single one passes for a declaration - // that dropped the other two. Each path is reachable ONLY through its root's - // subtree spelling, never through a SKIP_PATHS literal. - t('the doc-formula gate reaches the live docs corpus it declares', docFormulaHints.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md'))); - t('and the published skills catalog', docFormulaHints.some((h) => hintCovers(h, 'skills/objectstack-upgrade/SKILL.md'))); - t('and the content tree', docFormulaHints.some((h) => hintCovers(h, 'content/docs/deployment/cli.mdx'))); - // ⚠️ These two do NOT pin the declaration, and say so rather than reading as - // though they do. `.claude` is a top-level DOTTED dir, which `looksPathy` - // admits and `hintCovers` does not refuse; `packages/spec/src` (the gate's - // SPEC_ROOT, its second surface — 972 files) already carries a separator. - // Both reach their paths on the bare literal alone — measured: deleting the - // declaration outright leaves both green. What they pin is that those two - // surfaces stay reachable AT ALL; the declaration itself is pinned in the - // gate's own self-test, which requires a subtree spelling for every - // separator-less ROOT and a separator in SPEC_ROOT. - t('and the agent operating manual it walks for the same reason', docFormulaHints.some((h) => hintCovers(h, '.claude/agents/os-dev.md'))); - t('and its second surface, the spec TSDoc population', docFormulaHints.some((h) => hintCovers(h, 'packages/spec/src/index.ts'))); - // The negative half, load-bearing for a declaration spanning four roots: a - // gate named on EVERY card is the louder version of naming none. `packages/` - // must be probed OUTSIDE `packages/spec/src`, which the gate really does read - // — a case using a spec path would pass on SPEC_ROOT and pin nothing. - t('and claims nothing elsewhere under packages/', !docFormulaHints.some((h) => hintCovers(h, 'packages/core/src/index.ts'))); - t('nor under apps/', !docFormulaHints.some((h) => hintCovers(h, 'apps/console/src/main.tsx'))); - t('nor under examples/', !docFormulaHints.some((h) => hintCovers(h, 'examples/crm/objects/account.object.ts'))); - // The bounded residual, whose PROVENANCE moved under #15753. `hintCovers` - // cannot subtract, so `docs/**` necessarily claims the exempt `docs/plans`. - // That subtree used to derive the gate a SECOND way as well, through the - // `SKIP_PATHS` literal that names it — and a skip list is not a watch - // surface, so the extractor no longer reads it. The over-claim is the - // declaration's alone now, which is the honest reading rather than a - // narrower one: a root claiming a subtree carved out of it, stated here. - // Asserted with the declaration removed from the hint set, which is what makes - // it a measurement instead of a restatement. - const withoutDeclaration = docFormulaHints.filter((h) => !['docs/**', 'skills/**', 'content/**', '.claude/**'].includes(h)); - t('the exempt subtree no longer derives the gate through the skip list that names it — an exclusion is not a watch surface (#15753)', !withoutDeclaration.some((h) => hintCovers(h, 'docs/plans/x.md'))); - t('while the declaration still reaches it, so the over-claim is bounded and STATED rather than quietly doubled', docFormulaHints.some((h) => hintCovers(h, 'docs/plans/x.md'))); - t('while the live corpus derived nothing without it — the gap this closes', !withoutDeclaration.some((h) => hintCovers(h, 'docs/qa/platform-checklist/RUNNER.md'))); - // The pair that makes the declaration worth having: the bare words this gate - // spells in its ROOTS array stay refused, so the coverage above is bought by - // the declaration and by nothing else. - t('the bare root words the gate spells in ROOTS are still refused as too generic', !hintCovers('docs', 'docs/qa/platform-checklist/RUNNER.md') && !hintCovers('content', 'content/docs/deployment/cli.mdx')); - t('while the declared subtrees cover those same paths', hintCovers('docs/**', 'docs/qa/platform-checklist/RUNNER.md') && hintCovers('content/**', 'content/docs/deployment/cli.mdx')); - - // ── The escapable-literal ledger (#10705) ──────────────────────────────── - // - // Every case above pins ONE gate that took the escape. What none of them can - // say is who still has not — and that is the whole finding: six instances - // were found one at a time, on six unrelated cards, the sixth a re-discovery - // of the fourth by an agent who did not know the enumeration existed. These - // cases turn that into a bounded list with a verdict. - // - // The predicate is pinned on FIXTURES first, because a tree-only assertion - // cannot show which of its conditions is doing the work; the live halves - // follow. - const fx = (hints) => [['check:fixture', { hints }]]; - const fxPrefixes = new Set(['scripts', 'scripts/pm', 'examples', 'skills']); - t( - 'a bare root literal the tree HAS, undeclared, is a ledger row', - escapableLiteralRows(fx(['scripts']), fxPrefixes).length === 1, - ); - t( - 'the same literal beside its subtree spelling is NOT — that gate escaped', - escapableLiteralRows(fx(['scripts', 'scripts/**']), fxPrefixes).length === 0, - ); - // The live distinction `check:published-files` forced: a hint that reaches - // INTO the root covers the bare directory through hintCovers' reverse - // containment, while covering no other file under it. If this case ever goes - // green the ledger has started retiring rows for gates that are still - // unnameable. - t( - 'a hint that merely reaches into the root does not escape it', - escapableLiteralRows(fx(['scripts', 'scripts/check-x.mjs']), fxPrefixes).length === 1, - ); - t( - 'a literal the covering rule never refused is no part of the species', - escapableLiteralRows(fx(['scripts/pm']), fxPrefixes).length === 0, - ); - t( - 'nor is a dotted root, which hintCovers admits as written', - escapableLiteralRows(fx(['.claude']), new Set(['.claude'])).length === 0, - ); - // The other species the residue block names, kept out by exactly the test - // `unreachableReason` uses to tell them apart: no declaration can fix a - // literal the tree does not have, so it is not a debt anyone can pay. - t( - 'a bare word the tree does NOT have is the genuinely-dead species, not this one', - escapableLiteralRows(fx(['node_modules']), fxPrefixes).length === 0, - ); - - // The live halves, over the real tree and through the SAME discovery pass - // `derive` runs — a second pass built here could enumerate a population no - // dispatch prompt is derived from. - const ledgerSwept = trackedFiles(); - const ledgerPrefixes = trackedPrefixes(ledgerSwept); - const ledgerFamilies = [...discoverFamilies().byCheck]; - const ledgerRows = escapableLiteralRows(ledgerFamilies, ledgerPrefixes); - const ledgerKeys = ledgerRows.map(escapableLiteralKey); - // #4690 at ZERO ROWS: a quiet sweep must still prove it can SPEAK. - // - // This guard used to be `ledgerRows.length > 0` — a live-tree count, which - // read the right rule off the wrong quantity. It conflated two separable - // claims: "the recognizer still works" and "the tree still owes a row". - // While a debt existed the two moved together, so the conflation was - // invisible; paying the LAST row (#10875) is what pulled them apart, and the - // guard then failed on a clean tree — a shrink-only ledger that could not be - // allowed to reach zero, which is the one end state it exists to reach. - // - // So the recognizer is asked directly instead: splice ONE synthetic family - // spelling a bare root into the LIVE corpus and require the sweep to find - // exactly it, and exactly one more row than the tree really owes. That holds - // at zero live rows and at any other count, it fails loudly if the recognizer - // or the prefix set goes dead, and unlike the fixture cases above it runs on - // the live prefixes the real sweep runs on. - const probeKey = 'check:escapable-literal-probe'; - const probedRows = escapableLiteralRows([...ledgerFamilies, [probeKey, { hints: ['scripts'] }]], ledgerPrefixes); - const probedKeys = probedRows.map(escapableLiteralKey); - t( - 'the live sweep still RECOGNISES this species — a probe family spelling a bare root is found,' + - ' so a quiet sweep means a clean tree rather than a broken recognizer (#4690)', - probedKeys.filter((k) => k === `${probeKey} scripts`).length === 1 && - probedRows.length === ledgerRows.length + 1, - ); - const freshRows = ledgerKeys.filter((k) => !ESCAPABLE_LITERAL_LEDGER.has(k)); - const staleRows = [...ESCAPABLE_LITERAL_LEDGER].filter((k) => !ledgerKeys.includes(k)).sort(); - t( - 'no gate has NEWLY joined the escapable-literal species' + - (freshRows.length - ? ` — FRESH: ${freshRows.join(' · ')}. Unnameable by any dispatch derivation as spelled,` + - ' so it lands already invisible. TWO remedies, and which one is right depends on what the' + - ' gate actually READS — pick, do not reach for the first one:' + - ' (a) it really does walk that repo root ⇒ declare the subtree spelling beside the literal,' + - ' the ROOT_DIR_WATCH_HINTS idiom (see check-role-word.mjs and check-examples-live-imports.mjs);' + - ' (b) it does NOT ⇒ stop spelling a bare root, respelling the literal to say what the predicate' + - ' means (check-published-files.mjs is the worked instance: its predicate is package-relative,' + - ' over tarball contents, so it took (b) and the row discharged by construction).' + - ' ⛔ Declaring a root the gate does not read is a FABRICATED lead — worse than the row,' + - ' at the price hintCovers puts on one. ⛔ The ledger is SHRINK-ONLY: a new line is not a remedy.' - : ''), - freshRows.length === 0, - ); - t( - 'and no ledger row is stale' + - (staleRows.length - ? ` — STALE: ${staleRows.join(' · ')}. Good news, and the list must say so:` + - ' delete each one from ESCAPABLE_LITERAL_LEDGER. A stale line is how this would start' + - ' drifting into an allowlist nobody re-reads.' - : ''), - staleRows.length === 0, - ); - // The spelling rule the ledger's docblock states, held mechanically rather - // than remembered: a row keyed by a direct script path would enter THIS - // file's own hint set as a path it does not read. - // - // Carried on a WITNESS PAIR rather than on the ledger alone. The ledger is - // empty now, and `[].every(...)` is a pass that proves nothing — precisely - // the vacuous shape #10784's ablation caught one case over, where a fixture - // with no subtree hint to strip compared silent to silent and read green. The - // sample row keeps the positive half exercised at zero rows; the negative - // witness is what gives the case teeth at all, since the live key format - // (\`${check} ${hint}\`) always carries a space and the extractor refuses a - // span with one — so a key in that format cannot build a hint whatever it - // names, and only the bare-path spelling the docblock warns against can. - const asHints = (row) => extractWatchHints(`const L = ${JSON.stringify(row)};`); - t( - 'no ledger row enters this file\'s own declared population as a path', - [...ESCAPABLE_LITERAL_LEDGER, 'check:sample-gate someroot'].every((row) => asHints(row).length === 0), - ); - t( - '…and that rule can FAIL: the direct-script spelling it forbids does build a hint', - asHints('scripts/check-x.mjs').length === 1, - ); - // The ledger describes the derivation, so it must agree with what the - // derivation actually reports: every row must name a root the tree HAS and - // the covering rule refuses — the pair that puts a row in this species rather - // than in the dead one beside it in the residue block. Asserted over whatever - // the ledger holds, never over a remembered count of it. - // - // Run over the PROBED rows, which are the live rows plus the one synthetic - // row spliced in above. Two things fall out of that and both are wanted: the - // property is still checked on every real row, and the case can never go - // vacuous now that the live half is legitimately empty — the probe row is a - // row of exactly this species, so `.every` always has something to judge. - t( - 'every ledger row names a root the tree HAS and the covering rule refuses', - probedRows.length > 0 && - probedRows.every( - ({ hint, plain }) => - // refused: the gate cannot be named for anything under the root - !hintCovers(hint, `${plain}/any-file-under-it.mjs`) && - // …and the root is really there, which is what separates this species - // from the dead literals beside it in the residue block - ledgerPrefixes.has(plain) && - deepestTrackedPrefix(hint, ledgerPrefixes) === plain, - ), - ); - - // ── The scripts/** blind spot, closed at the source (#10784) ────────────── - // - // BOTH gates that walk `scripts/` were invisible to this derivation, by two - // opposite routes: `check:parse-guard` declared a bare root the covering rule - // refuses as too generic, and `check:entry-guard` declared its baseline - // ROSTER — the files that already violate its import-safety half — so a newly - // added script could never be in the declared population, BY CONSTRUCTION. - // Anyone adding a script got neither gate named, and one CI round was paid. - // - // Pinned against the LIVE tree through the same discovery pass `derive` runs: - // a hand-built fixture here could pass while the real gates stayed - // unnameable, which is the exact failure this case exists to prevent from - // recurring. The probe path deliberately does not exist — "a file nobody has - // written yet" is the one input a roster of current members can never contain. - const scriptsFamilies = discoverFamilies().byCheck; - const unwrittenScript = 'scripts/the-one-nobody-has-written-yet.mjs'; - for (const gate of ['check:entry-guard', 'check:parse-guard']) { - const entry = scriptsFamilies.get(gate); - t(`${gate} is discovered at all — the pin below means nothing without this`, Boolean(entry)); - const verdict = entry ? classifyEntry(entry, [unwrittenScript]) : null; - t( - `${gate} is MATCHED for a brand-new scripts/ file, not silent and not unreachable`, - verdict?.verdict === 'matched', - JSON.stringify({ verdict: verdict?.verdict, hints: entry?.hints }), - ); - // The ablation, run in-place: strip the declared SUBTREE from the live hint - // set and the verdict must fall back to NOT MATCHED — that is the whole - // claim, since a brand-new file is nameable only through the subtree half. - // Without it the case above could pass through any hint that happened to - // cover the probe, and the reader could not tell which half was load-bearing. - // - // WHICH not-matched verdict it lands on is not fixed, and pinning one - // spelling was a latent trap: for `check:entry-guard` the residual depends - // on whether its KNOWN_IMPORT_UNSAFE roster still contributes path literals - // as hints — `silent` while it held entries, `undetermined` once it emptied - // and the stripped hint set is bare. That ledger is ⛔ SHRINK-ONLY and - // reaching zero is its GOAL, so the day it emptied this case went red over - // a gate that had not changed at all. Either verdict proves the subtree - // hint is the load-bearing half, so both are accepted — spelled as an - // explicit pair rather than `!== 'matched'`, so a NEW verdict value added - // later cannot slip through here as a pass. - const undeclared = entry ? { ...entry, hints: entry.hints.filter((h) => !h.includes('/*')) } : null; - const residual = undeclared ? classifyEntry(undeclared, [unwrittenScript]).verdict : null; - t( - `…and it is the subtree declaration doing it: strip it and ${gate} goes back to NOT MATCHED`, - // The length check is what stops this passing VACUOUSLY. With no subtree - // hint to remove, `undeclared` is the entry itself and a not-matched - // verdict compared against itself reads as a pass — measured, on the - // ablation run that removed both declarations: this case stayed green - // while the two above went red. - Boolean(undeclared) && - undeclared.hints.length < entry.hints.length && - ['silent', 'undetermined'].includes(residual), - JSON.stringify({ before: entry?.hints?.length, after: undeclared?.hints?.length, residual }), - ); - } - - // ── The MANIFEST population of check:merge-driver (#15501) ──────────────── - // - // That family's `git-merge-regen --self-test` refuses a generator with no - // recorded merge disposition, and the population that refusal sweeps is the - // MANIFESTS — the root one plus every workspace member's, read for their - // `gen:` / `check:` rows. What the family declared HERE was the artifact - // paths `scripts/regen-artifacts.mjs` carries, imported one level down: the - // generators ALREADY routed. So the one class of card the refusal exists to - // catch — a card that ADDS a generator, touching a manifest and a new - // `scripts/*.mjs` — was the one class this derivation could not name, and the - // gate fired a cycle late, in CI, on every card of that shape. Measured - // before the repair, on a `package.json` change set: zero lines naming it. - // - // Pinned against the LIVE tree through the same discovery pass `derive` runs, - // for the reason the scripts/** case above states: a hand-built fixture could - // pass here while the real family stayed unnameable, which is the exact - // failure these cases exist to stop recurring. - const mdFamilies = discoverFamilies().byCheck; - const MERGE_DRIVER = 'check:merge-driver'; - const mdEntry = mdFamilies.get(MERGE_DRIVER); - t(`${MERGE_DRIVER} is discovered at all — the pins below mean nothing without this`, Boolean(mdEntry)); - // The declared halves, stripped for the ablation below. Spelled as a - // PREDICATE over the live hint set rather than as a copy of the declaration, - // so a hint respelled in `git-merge-regen.mjs` cannot leave a stale twin here - // that keeps the ablation passing. - const isManifestHint = (h) => h === 'package.json/**' || h.endsWith('/package.json'); - const mdStripped = mdEntry ? { ...mdEntry, hints: (mdEntry.hints ?? []).filter((h) => !isManifestHint(h)) } : null; - // The POSITIVE direction, one probe per declared half. The table is pinned to - // its own length first (#13799's floor recipe): a loop over an emptied table - // runs zero cases and prints nothing, which reads exactly like a pass. - const MD_MANIFEST_PROBES = [ - ['the ROOT manifest, where the measured CI red added its `gen:` row', 'package.json'], - ['a workspace MEMBER manifest', 'packages/plugins/plugin-auth/package.json'], - ]; - t('the manifest probe table still has both declared halves in it', MD_MANIFEST_PROBES.length === 2); - for (const [why, probe] of MD_MANIFEST_PROBES) { - const verdict = mdEntry ? classifyEntry(mdEntry, [probe]).verdict : null; - // The reading rides in the case NAME, never in a third argument: `t` takes - // two, so a detail passed beyond them is dropped on the floor — and a - // failing case whose reading went with it is a case nobody can act on. - t( - `a change set touching ${why} derives ${MERGE_DRIVER} — ${probe} => ${verdict}`, - verdict === 'matched', - ); - // …and it is the manifest declaration doing it. Without this half the case - // above could pass through any hint that happens to cover the probe — - // `packages/spec/package.json`, for one, was already reachable through the - // `packages/spec` literal, so a probe chosen there would have proved - // nothing at all. Both not-matched verdicts are accepted, spelled as an - // explicit pair so a NEW verdict value added later cannot slip through as - // a pass — the same reasoning as the scripts/** ablation above. - const residual = mdStripped ? classifyEntry(mdStripped, [probe]).verdict : null; - t( - `…and it is the manifest declaration doing it: strip it and ${probe} goes back to NOT MATCHED` - + ` (hints ${mdEntry?.hints?.length} -> ${mdStripped?.hints?.length}, residual ${residual})`, - // The length comparison is what stops this passing VACUOUSLY: with no - // manifest hint to remove, `mdStripped` is the entry itself and a - // not-matched verdict compared against itself reads as a pass. - Boolean(mdStripped) && - mdStripped.hints.length < (mdEntry.hints ?? []).length && - ['silent', 'undetermined'].includes(residual), - ); - } - // The NEGATIVE direction, and it is what keeps the declaration from being a - // whole-tree widening in disguise: an unrelated script is not newly derived. - // Stated as "the declaration moved NOTHING for it" rather than as a bare - // not-matched verdict — the manifest hints are the only thing that changed, - // so the two verdicts agreeing is the claim, and it stays true whatever the - // rest of the family's population does later. - const mdUnrelated = 'scripts/the-one-nobody-has-written-yet.mjs'; - const mdUnrelatedVerdict = mdEntry ? classifyEntry(mdEntry, [mdUnrelated]).verdict : null; - t( - `an unrelated brand-new scripts/*.mjs is NOT derived to ${MERGE_DRIVER} (${mdUnrelatedVerdict})`, - ['silent', 'undetermined'].includes(mdUnrelatedVerdict), - ); - t( - 'and the manifest declaration is what moved nothing for it — the same verdict with and without', - mdUnrelatedVerdict === (mdStripped ? classifyEntry(mdStripped, [mdUnrelated]).verdict : null), - ); - - // ── The bare root this gate must NOT spell (#10875) ─────────────────────── - // - // `check:published-files` asks whether a published package ships a scripts/ - // directory OF ITS OWN — a package-relative predicate over would-be tarball - // contents. Written as a quoted literal it read to this derivation as a - // declaration of the repo's own scripts/ tree, which the gate never opens: - // the last row of ESCAPABLE_LITERAL_LEDGER, discharged by respelling the - // predicate rather than by declaring a subtree that would have been FALSE. - // - // ⛔ The remedy the FRESH message offers first — declare the subtree — is the - // WRONG one here, and it is the one a reader reaches for. The ledger cannot - // say so, because by design it says nothing at all once a row is out. So the - // correct verdict is pinned here, in both directions, against the live tree. - const publishedFiles = scriptsFamilies.get('check:published-files'); - t( - 'check:published-files is discovered at all — the pins below mean nothing without it', - Boolean(publishedFiles), - ); - t( - 'check:published-files declares no bare repo root it does not open', - Boolean(publishedFiles) && !publishedFiles.hints.includes('scripts'), - JSON.stringify({ hints: publishedFiles?.hints }), - ); - t( - '…so a brand-new repo-root scripts/ file does NOT name it — a fabricated lead is the costlier error', - Boolean(publishedFiles) && classifyEntry(publishedFiles, [unwrittenScript]).verdict !== 'matched', - JSON.stringify({ verdict: publishedFiles && classifyEntry(publishedFiles, [unwrittenScript]).verdict }), - ); - // Non-vacuity for the case above, and the whole difference between a verdict - // that is CORRECT and one that is merely quiet: it must be passing because - // the gate reads a different population, never because the gate went dark. - // Its own source is a population it really does have, and still names it. - t( - '…and it is not silent everywhere: the population it really has still names it', - Boolean(publishedFiles) && - classifyEntry(publishedFiles, ['scripts/check-published-files.mjs']).verdict === 'matched', - JSON.stringify({ - verdict: publishedFiles && classifyEntry(publishedFiles, ['scripts/check-published-files.mjs']).verdict, - }), - ); - - // ── Telling a WEAK silence from an INVERTED one (#10784) ────────────────── - // - // The residue said the same words for a gate that genuinely does not read - // your file and for one whose declared literals are a census of the files it - // already has. These pin the split as a property of the HINT SET, so a gate - // of that shape reports itself rather than waiting to be noticed. - t('a common directory is found on segment boundaries', commonDirectory(['scripts/a.mjs', 'scripts/pm/b.mjs']) === 'scripts'); - t('a sibling whose name merely shares a prefix does not invent one', commonDirectory(['packages/spec/a.ts', 'packages/species/b.ts']) === 'packages'); - t('files with nothing above the repo root share no directory', commonDirectory(['README.md', 'AGENTS.md']) === ''); - t('one file is its own directory', commonDirectory(['scripts/pm/a.mjs']) === 'scripts/pm'); - - const rosterTree = watchHintTree(['scripts/a.mjs', 'scripts/b.mjs', 'scripts/pm/c.mjs']); - const rosterFam = (hints) => ({ hints, files: [], workflows: new Set(['lint.yml']) }); - const roster = artifactOnlySilence(rosterFam(['scripts/a.mjs', 'scripts/b.mjs']), [unwrittenScript], rosterTree); - t('a population of nothing but tracked FILES is an artifact roster', roster?.artifacts.length === 2 && roster.dir === 'scripts'); - t('…and it is flagged when the card edits the directory the roster sits in', roster?.coversYourPath === true); - t( - 'the same roster is NOT flagged for a card somewhere else — it is a standing fact there, not a lead', - artifactOnlySilence(rosterFam(['scripts/a.mjs', 'scripts/b.mjs']), ['packages/spec/src/index.ts'], rosterTree)?.coversYourPath === false, - ); - t( - 'one declared DIRECTORY is a population, so the family is not a roster however many files sit beside it', - artifactOnlySilence(rosterFam(['scripts/a.mjs', 'scripts/pm']), [unwrittenScript], rosterTree) === null, - ); - t( - 'a literal the tree does not track is not an artifact either — that is the unreachable species', - artifactOnlySilence(rosterFam(['scripts/gone.mjs']), [unwrittenScript], rosterTree) === null, - ); - t('a family that declares nothing at all is undetermined, never a roster', artifactOnlySilence(rosterFam([]), [unwrittenScript], rosterTree) === null); - const rosterNote = artifactOnlyNote(roster).join('\n'); - t('the note states the shape', rosterNote.includes('artifact roster') && rosterNote.includes('2 declared')); - t( - 'and STOPS SHORT of claiming the gate reads your file — the half the tree cannot answer, and a fabricated lead if asserted', - !/reads your file|very likely reads/.test(rosterNote), - rosterNote, - ); - t('names the remedy as the subtree spelling of the roster\'s OWN root', rosterNote.includes('scripts/**')); - t('and hands over the discriminator instead of deciding intent', rosterNote.includes('If it really reads only those files')); - t( - 'a roster that does not touch the card prints the standing fact, not the warning', - artifactOnlyNote(artifactOnlySilence(rosterFam(['scripts/a.mjs', 'scripts/b.mjs']), ['packages/spec/src/index.ts'], rosterTree)) - .join('\n') - .includes('ordinary one'), - ); - - // ── The roster block, printed where a dev without --residue will see it (#14880) - // - // The note above is per family and prints only inside the silent listing, - // which is behind a flag no dispatch brief tells anyone to pass. Two measured - // CI reds on this card were carried by families of exactly this shape - // (`check:optional-error-sink`, `check:error-code-provenance`), invisible to - // a `--commands` harvest for EVERY card. The block states the standing fact - // and names the families — and its whole contract is that it is a block - // BESIDE the derived list, never a part of it. - const blockRows = [ - { check: 'check:b', command: 'pnpm check:b', workflows: ['lint.yml'], artifacts: ['scripts/a.mjs'], dir: 'scripts', coversYourPath: true, checkerHealth: false }, - { check: 'check:a', command: 'pnpm check:a', workflows: ['lint.yml'], artifacts: ['docs/x.md'], dir: 'docs', coversYourPath: false, checkerHealth: false }, - ]; - const blockOut = artifactRosterLines(blockRows); - t('no rosters, no block — an empty section is never printed', artifactRosterLines([]).length === 0); - t('the block sizes itself and names every family, sorted by the command a dev would run', blockOut[0].includes('2 famil(ies)') - && blockOut.filter((l) => l.startsWith(' - ')).join('|') === ' - pnpm check:a| - pnpm check:b ⛔ roster under scripts, which one of your paths is in'); - t( - '⭐ it says out loud that these are OUTSIDE the derived total, which is the whole reason it is a separate block', - blockOut.some((l) => l.includes('NOT counted among the derived')) && blockOut.some((l) => l.includes('NOT in the runnable total')), - ); - t( - 'and it marks the correlated subset — the rosters sitting in a directory one of the card\'s paths is in', - blockOut.some((l) => l.includes('1 of them keep that roster in a directory one of YOUR paths is in')), - ); - t( - 'a card no roster touches gets the standing fact instead of a warning about none of them', - artifactRosterLines([{ ...blockRows[1] }]).some((l) => l.includes('None of their rosters sits in a directory your paths are in')), - ); - // ⛔ The refusal, and it is the one that keeps this block from being the - // fabricated lead `artifactOnlyNote`'s docblock prices: whether a roster is a - // baseline in a directory or a census OF it is intent, and intent is not in - // the tree. The block must not call them scanners, and must not tell anyone - // the gate reads their file. - t( - '⛔ and it never calls them scanners or claims they read your file — the half the tree cannot answer', - !/scanner|reads your file|very likely reads/.test(blockOut.join('\n')), - blockOut.join('\n'), - ); - t( - 'it names the producer-side remedy the residue already carries, so the block points at a fix and not only at work', - blockOut.some((l) => l.includes('declare the scan surface beside the roster')), - ); - // ⛔ STRUCTURAL, not a filter someone has to remember: `commandsFor` reads the - // matched, convention and always-runs rows only, and a roster family is - // `silent`. Asserted against the real union so a future edit that started - // feeding rosters into it reddens here rather than in a dev's harvest. - t( - '⛔ a roster command is not in the runnable union, whatever the block prints', - !commandsFor({ matchedRows: [{ check: 'check:m', command: 'pnpm check:m', ciOnly: null }], kindGroups: [], alwaysRunsRows: [] }) - .some((c) => c === 'pnpm check:a' || c === 'pnpm check:b'), - ); - - // ── A roster row whose green cannot grade the diff (#16030) ────────────── - // - // ⚠️ Every case here asserts the AXIS and where the row LANDED, never that - // the block still renders. The defect was a green in the right shape: a row - // that cannot fail for anything in the diff, sitting in the same list and the - // same syntax as rows that can. A case checking only that the command appears - // would have been green against the bug. - // - // The predicate first, on the two carriers and on the shape that must NOT - // move — the conventional `--self-test && ` pair, where the second - // segment is what judges the diff. - t('a lone --self-test invocation grades the checker, not the diff', selfTestOnlyInvocation('node scripts/x.mjs --self-test') === true); - t( - '⛔ ...and the conventional two-segment shape does NOT: its second segment does the work', - selfTestOnlyInvocation('node scripts/x.mjs --self-test && node scripts/x.mjs') === false, - ); - t('...whichever separator joins the segments', selfTestOnlyInvocation('node scripts/x.mjs --self-test; node scripts/x.mjs') === false); - t('every segment counts, so a pair of self-tests is still checker-health', selfTestOnlyInvocation('node a.mjs --self-test && node b.mjs --self-test') === true); - t('a bare work invocation judges the diff', selfTestOnlyInvocation('node scripts/x.mjs') === false); - t('⛔ and a flag that merely CONTAINS the token is not the flag', selfTestOnlyInvocation('node scripts/x.mjs --self-testing') === false); - t('an empty or absent body is never read as checker-health', selfTestOnlyInvocation('') === false && selfTestOnlyInvocation(null) === false); - // ⭐ The carrier that the card's own discriminator misses. A direct row wears - // the flag in the bytes the block prints; a pnpm-spelled row is a NAME, and - // reading the printed bytes for it answers `false` — which is how both rows - // this card was filed for would have stayed unmarked by a fix that trusted - // the rendered command alone. - t( - 'a direct row is classified from the invocation the block prints', - rosterCheckerHealth({ direct: true }, 'node scripts/x.mjs --self-test') === true - && rosterCheckerHealth({ direct: true }, 'node scripts/x.mjs') === false, - ); - t( - '⭐ a pnpm-spelled row is classified from the manifest body, NOT from the name the block prints', - rosterCheckerHealth({ direct: false, manifestCommand: 'node scripts/x.mjs --self-test' }, 'pnpm check:x') === true - && rosterCheckerHealth({ direct: false, manifestCommand: 'node scripts/x.mjs --self-test && node scripts/x.mjs' }, 'pnpm check:x') === false, - ); - t( - '⛔ an unresolvable invocation is null — NEITHER judging nor checker-health', - rosterCheckerHealth({ direct: false, manifestCommand: null }, 'pnpm check:x') === null, - ); - // Now the rendering, which is where the harvest reads it. - const healthRow = { check: 'check:h', command: 'pnpm check:h', workflows: ['lint.yml'], artifacts: ['scripts/h.json'], dir: 'scripts', coversYourPath: false, checkerHealth: true }; - const splitOut = artifactRosterLines([...blockRows, healthRow]); - t( - '⭐ a --self-test-only roster entry lands in the checker-health sub-list, under its own heading', - splitOut.some((l) => l.includes("1 of these 3 famil(ies) run ONLY the checker's own")), - splitOut.join('\n'), - ); - // ⛔ ON THE ROW, not only in the heading: `spellingDistribution`'s docblock - // records that consumers grep ROWS out of this block, so a caption a row-wise - // harvest never reads would leave the two greens indistinguishable in the one - // stream that matters. - t( - '⛔ ...and the row itself carries the mark, because a harvest greps rows and not headings', - splitOut.some((l) => l.startsWith(' - pnpm check:h') && l.includes('NOT a PR verdict')), - splitOut.filter((l) => l.startsWith(' - ')).join('|'), - ); - t( - '...while the rows that DO judge the diff keep their plain shape, so nothing is marked that can fail', - splitOut.filter((l) => l.startsWith(' - ') && !l.includes('NOT a PR verdict')).length === 2 - && !splitOut.some((l) => l.startsWith(' - pnpm check:a') && l.includes('checker-health')), - splitOut.filter((l) => l.startsWith(' - ')).join('|'), - ); - t( - 'the instruction to run them now says what a green from such a row is worth', - splitOut.some((l) => l.includes('never read a green from a row')), - ); - // ⛔ The third answer, pinned so it can never quietly become one of the other - // two. Zero members on this tree today; a row this tool cannot resolve must - // be NAMED, because filing it among the judging rows mints the same false - // clearance through a different door. - const unresolvedOut = artifactRosterLines([{ ...healthRow, check: 'check:u', command: 'pnpm check:u', checkerHealth: null }]); - t( - '⛔ an unclassified row is named as unclassified, never defaulted into either side', - unresolvedOut.some((l) => l.includes('could not resolve to a command body')) - && unresolvedOut.some((l) => l.startsWith(' - pnpm check:u') && l.includes('UNCLASSIFIED')), - unresolvedOut.join('\n'), - ); - t( - '...and the block never claims of an unclassified row that it judges the diff', - !unresolvedOut.some((l) => l.startsWith(' - pnpm check:u') && l.includes('NOT a PR verdict')), - ); - - // ── The classifier returned a plausible WRONG CATEGORY (#13520) ─────────── - // - // ⚠️ Every case below asserts the CATEGORY, never "it did not crash" and - // never "it still classifies". This defect threw nothing and printed no - // error — it answered `null` where the answer is a roster, so a case that - // only checked for an answer would have been GREEN against the bug. Each - // assertion therefore names the bucket AND its contents: which tracked files, - // under which directory, and for the negatives, `null` exactly. - const extlessTree = watchHintTree([ - 'packages/spec/scripts/lib/dist-freshness.ts', - 'packages/spec/scripts/lib/sharded-artifacts.ts', - 'packages/spec/scripts/lib/notes.md', - 'packages/spec/src/index.ts', - ]); - const extlessRoster = artifactOnlySilence( - rosterFam(['packages/spec/scripts/lib/dist-freshness', 'packages/spec/scripts/lib/sharded-artifacts']), - ['packages/spec/scripts/lib/dist-freshness.ts'], - extlessTree, - ); - t( - 'a roster spelled as extensionless module specifiers is an artifact ROSTER, not an ordinary silence', - extlessRoster !== null, - JSON.stringify(extlessRoster), - ); - t( - '…and the CATEGORY is pinned by its contents: the tracked files those specifiers name', - JSON.stringify(extlessRoster?.artifacts) === - JSON.stringify(['packages/spec/scripts/lib/dist-freshness.ts', 'packages/spec/scripts/lib/sharded-artifacts.ts']), - JSON.stringify(extlessRoster?.artifacts), - ); - t( - '…under the directory those files really sit in, not the one the specifier stops short at', - extlessRoster?.dir === 'packages/spec/scripts/lib', - String(extlessRoster?.dir), - ); - t( - '…and for a card in that directory it is the INVERTED silence, which is the whole point of the split', - extlessRoster?.coversYourPath === true, - ); - t( - 'a roster mixing both spellings of the same claim resolves to the same category', - JSON.stringify( - artifactOnlySilence( - rosterFam(['packages/spec/scripts/lib/dist-freshness', 'packages/spec/scripts/lib/notes.md']), - [], - extlessTree, - )?.artifacts, - ) === JSON.stringify(['packages/spec/scripts/lib/dist-freshness.ts', 'packages/spec/scripts/lib/notes.md']), - ); - // The negatives, which are what stop the widening from becoming a second - // defect pointing the other way: a POPULATION must still refuse the roster - // category however resolvable its siblings are. - t( - 'a declared DIRECTORY beside resolvable specifiers is still a population, not a roster', - artifactOnlySilence( - rosterFam(['packages/spec/scripts/lib/dist-freshness', 'packages/spec/scripts/lib']), - [], - extlessTree, - ) === null, - ); - t( - 'a specifier that resolves to nothing is not an artifact — that is still the unreachable species', - artifactOnlySilence(rosterFam(['packages/spec/scripts/lib/gone']), [], extlessTree) === null, - ); - t( - 'a PATTERN whose collapse would land on a tracked file is refused before the file branch', - declaredFileTarget('packages/spec/scripts/lib/dist-freshness*.ts', extlessTree) === null && - extlessTree.files.has(collapseHint('packages/spec/scripts/lib/dist-freshness*.ts')), - ); - t( - '…non-vacuously: that hint really is judged as a pattern, and the same literal without the glob resolves', - judgedAsPattern('packages/spec/scripts/lib/dist-freshness*.ts') && - declaredFileTarget('packages/spec/scripts/lib/dist-freshness', extlessTree) === - 'packages/spec/scripts/lib/dist-freshness.ts', - ); - // The bare file set is the OLD parameter, and it is the one input that - // reproduces the defect exactly. It must not be readable as an empty answer. - let rosterRefusedBareSet = false; - try { - artifactOnlySilence(rosterFam(['scripts/a.mjs']), [], new Set(['scripts/a.mjs'])); - } catch { - rosterRefusedBareSet = true; - } - t('a bare file set is REFUSED, never answered — it is the shape that mis-categorises silently', rosterRefusedBareSet); - - // ⭐ THE CLASS GUARD, and the reason this card is not "nine gate names added - // to a table". The defect was ONE PREDICATE holding a private, weaker copy of - // the covering rule; the copy is gone, and this holds the two instruments - // EQUAL over the LIVE fleet, at FAMILY grain: - // - // for every discovered family — `artifactOnlySilence` returns a roster - // exactly when every declared literal of that family names exactly one - // tracked file under `hintCovers`, and the roster IS those files. - // - // ⚠️ Family grain, not literal grain, and the difference was measured rather - // than reasoned. Written against `declaredFileTarget` this case was GREEN - // against the very bug it exists to catch: the ablation that put the old rule - // back inside `artifactOnlySilence` left the resolver untouched, so a guard - // comparing resolver to covering rule saw nothing wrong. A guard on the OWNER - // does not hold the CALLER to it. Stated over the classifier's own output it - // reds, because the classifier is what the reader is shown. - // - // Non-tautological in both directions: the right side is computed by sweeping - // the whole tracked corpus through `hintCovers`, which shares no code with the - // membership test and resolver the classifier composes. Measured while - // writing this — before the repair the two disagreed about 40 of 754 declared - // literals and 9 of 192 families; after it, 0 and 0. The day someone teaches - // `hintCovers` a further spelling (the way #12514 taught it the extension - // list) and forgets this reader, or reintroduces a private test in the - // classifier, this reds for whatever family happens to carry it. No gate name - // appears in it, which is the whole point. - const classFiles = trackedFiles(); - const classTree = watchHintTree(classFiles); - const classFams = [...discoverFamilies({ tree: classTree }).byCheck]; - const isTrackedDirHint = (h) => { - const plain = collapseHint(h); - return classTree.prefixes.has(plain) && !classTree.files.has(plain); - }; - // The covering rule's own answer to "this literal names exactly one tracked - // FILE": swept, not resolved. A pattern and a directory are declared - // POPULATIONS and are excluded before the sweep — `apps/*/package.json` - // reaches exactly one file on this tree only because the repo has one app. - const coveringRuleFile = (h) => { - if (judgedAsPattern(h) || isTrackedDirHint(h)) return null; - const reached = classFiles.filter((f) => hintCovers(h, f)); - return reached.length === 1 ? reached[0] : null; - }; - const ruleRoster = (entry) => { - const declaredHints = [...new Set(entry.hints ?? [])]; - if (declaredHints.length === 0) return null; - const named = declaredHints.map(coveringRuleFile); - return named.every(Boolean) ? named : null; - }; - const classSplit = classFams.map(([check, entry]) => [ - check, - JSON.stringify(artifactOnlySilence(entry, [], classTree)?.artifacts ?? null), - JSON.stringify(ruleRoster(entry)), - ]); - const classDisagreements = classSplit.filter(([, mine, rule]) => mine !== rule); - t( - `the roster classifier and the covering rule agree about every family in the fleet (${classFams.length} families)`, - classDisagreements.length === 0, - classDisagreements.slice(0, 5).map(([c, mine, rule]) => `${c}: classifier=${mine} rule=${rule}`).join(' · '), - ); - // Non-vacuity for the case above — an agreement over an empty or all-null - // population asserts nothing, and the fleet really does carry both the shape - // this card was filed for and rosters that predate it. - t( - '…non-vacuously: the fleet carries families whose roster is named only through a dropped extension', - classFams.some(([, entry]) => { - const r = artifactOnlySilence(entry, [], classTree); - return r && r.artifacts.some((a, i) => a !== collapseHint([...new Set(entry.hints ?? [])][i])); - }), - ); - - // ── A trailing sentence period is not part of the path (#8534, half two) ── - // - // Coupled to the rule above: the raw-prefix comparison reached the real file - // THROUGH the stray period, so the boundary rule alone would have taken this - // hint from covering its own file to covering nothing. Both directions pinned. - const dotted = extractWatchHints("const CITED = ['scripts/check-x.mjs.'];"); - t('a hint ending in a sentence period is trimmed to the path it names', dotted.includes('scripts/check-x.mjs')); - t('no extracted hint ends in a dot', !dotted.some((h) => h.endsWith('.'))); - t('and the trimmed hint still reaches its file under the segment rule', dotted.some((h) => hintCovers(h, 'scripts/check-x.mjs'))); - t('trimming does not eat a leading dotted directory', extractWatchHints("const D = '.claude/agents';").includes('.claude/agents')); - t('trimming does not eat a trailing glob', extractWatchHints("const G = 'packages/spec/src/**';").some((h) => h.includes('**'))); - - // Change-kind derivation. The predicate is pinned in BOTH directions against - // what the two gates themselves count: filename infix, never directory — a - // helper inside `__tests__/` is in their non-test population. - t('test file by .test infix', isTestFilePath('packages/objectql/src/engine.test.ts')); - t('test file by .spec infix', isTestFilePath('packages/rest/src/server.spec.tsx')); - t('test file with an mts extension', isTestFilePath('packages/spec/src/x.test.mts')); - t('a __tests__ helper is NOT a test file to these gates', !isTestFilePath('packages/core/src/__tests__/fixtures.ts')); - t('a plain source file is not a test file', !isTestFilePath('packages/objectql/src/engine.ts')); - t('a non-TS file named test is not a test file', !isTestFilePath('docs/how.test.md')); - - const resolved = (name) => `pnpm ${name}`; - const kindHit = changeKindLines(['packages/objectql/src/engine.test.ts'], resolved); - // Seven: the kind's own heading plus its six gates (#10542 added - // check:cross-package-test-inputs, whose judged population is exactly this - // kind rather than a subtree any path hint can name). - t('a test path emits the convention section', kindHit.length === 7 && 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 - // branch exists to report. Measured on this entry rather than inherited from - // that one: renaming these gates to `check:query-options-erasure-v2` and - // `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. - // - // 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 five 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 —')) && kindHit.some((l) => l.includes('- pnpm check:engine-double-contract —')) && kindHit.some((l) => l.includes('- pnpm check:where-matcher —'))); - // The two ratchets this entry gained (#8632), pinned apart from the pair - // above because they arrived for a different reason: they were handed to the - // PM's judgment in this file's closing prose while a structurally identical - // ratchet sat in this table. Their `why` must carry the repair direction — - // fix the double / refuse the unsupported shape — because both baselines are - // shrink-only and a seat that raises one turns a caught defect into a pinned - // one. - const doubleLine = kindHit.find((l) => l.includes('- pnpm check:engine-double-contract —')) ?? ''; - const whereLine = kindHit.find((l) => l.includes('- pnpm check:where-matcher —')) ?? ''; - t('the engine-double line states the guard it wants and refuses the baseline raise', /assertEngineDeleteDispatch/.test(doubleLine) && /shrink-only/.test(doubleLine)); - t('the where-matcher line states that refusing is the conforming repair', /refus/i.test(whereLine) && /shrink-only/.test(whereLine)); - // 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 in no other kind emits nothing — a .mjs is outside the root tsc program too', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0); - - // ── The ROOT tsc program entry (#9873) ──────────────────────────────────── - // - // The one gate population no path literal can describe: the root program is - // declared by EXCLUSION, so this entry derives the complement from the root - // tsconfig's own list. These pin the predicate, the config read under it, the - // rendered line, and the property the entry was added for — the path from the - // PR that paid for this now derives the ratchet. - const rootExcl = rootTsProgramExcludedDirs(); - t('the root exclude list is read from the config and is not empty', rootExcl.length > 0); - t('and it really names the three source trees tsc skips', ['packages', 'apps', 'examples'].every((d) => rootExcl.includes(d))); - t('a new script in the root tree is in the root program — the PR #9853 case', isInRootTsProgram('scripts/bench/runtime-publish-gate.bench.mts', rootExcl)); - t('so is a top-level config file', isInRootTsProgram('tsup.config.ts', rootExcl)); - t('so is a declaration file in the same tree', isInRootTsProgram('scripts/check-regen-pending.d.mts', rootExcl)); - t('a leading ./ does not hide one', isInRootTsProgram('./scripts/check-test-typecheck.mts', rootExcl)); - t('a package source is NOT in the root program', !isInRootTsProgram('packages/rest/src/rest-server.ts', rootExcl)); - t('nor an app source', !isInRootTsProgram('apps/console/src/main.ts', rootExcl)); - t('nor an example source — imports can still pull one in, which the entry note states as its limit', !isInRootTsProgram('examples/app-showcase/src/data/objects/index.ts', rootExcl)); - // The extension half, which is what keeps this entry from firing on nearly - // every card that touches tooling. The root config sets no `allowJs`, so the - // tree's checker scripts are outside the program: 117 tracked JS files sit in - // these same directories against 11 TypeScript files that are really in it, - // so a bare "outside those directories" test would fire on 128 paths to reach - // 11 — and send each of them to a ratchet that needs a built closure. - t('a checker script is NOT in the root program — the root config sets no allowJs', !isInRootTsProgram('scripts/check-type-check-coverage.mjs', rootExcl)); - t('nor is this deriver itself', !isInRootTsProgram('scripts/pm/dispatch-gates.mjs', rootExcl)); - t('nor a non-TS file that merely lives there', !isInRootTsProgram('scripts/pm/README.md', rootExcl)); - - // The exclude-SHAPE guard. This complement can only judge plain directory - // names and silently drops anything else; dropping WIDENS the kind, so the - // rot would be quiet by construction. This pair is what makes it loud. - t('plain directory names are judged', isPlainTopLevelDir('packages') && isPlainTopLevelDir('.github')); - t('a nested path is not a plain directory name', !isPlainTopLevelDir('packages/objectql/src/engine.test.ts')); - t('nor is a pattern form', !isPlainTopLevelDir('*.test.ts') && !isPlainTopLevelDir('[abc]')); - t('nor an empty or non-string entry', !isPlainTopLevelDir('') && !isPlainTopLevelDir(null)); - t('the LIVE root tsconfig still consists only of the shape this reads', rootTsconfigExcludeEntries().every(isPlainTopLevelDir)); - - // The rendered line, anchored on the delimiters for the reason the pair - // above states at length: a bare `includes` survives a prefix-preserving - // rename, which is the one rot class the STALE branch exists to report. - const rootKind = changeKindLines(['scripts/bench/runtime-publish-gate.bench.mts'], resolved); - t('a root-program path emits the convention section', rootKind.length === 2 && rootKind[0].includes('ROOT tsc program')); - t('and it names the RATCHET half — the invocation that re-measures', rootKind.some((l) => l.includes('- pnpm check:type-check-debt —'))); - const rootLine = rootKind.find((l) => l.includes('- pnpm check:type-check-debt —')) ?? ''; - t('the root-program line refuses the baseline raise and states the real repair', /shrink-only/.test(rootLine) && /maintainer-only/.test(rootLine)); - t('and carries the built-closure prerequisite, like the other ratchet line', /closure BUILT/.test(rootLine) && rootLine.includes('turbo run build')); - // Re-pointed rather than deleted (#12074). This case pinned one property — - // a `.mjs` checker script is NOT in the ROOT tsc program, unlike the `.mts` - // bench file above — and the gate-script kind below now makes the same path - // emit a DIFFERENT section. So the property is asserted where it still lives: - // the root-program heading and its ratchet stay absent, and what does render - // is named, so a future kind that starts firing here reddens instead of - // hiding inside a `length` this case no longer checks. - const checkerKind = changeKindLines(['scripts/check-type-check-coverage.mjs'], resolved); - t('a checker script is still outside the ROOT tsc program', !checkerKind.some((l) => l.includes('ROOT tsc program'))); - t('…and the root ratchet is not named for it', !checkerKind.some((l) => l.includes('- pnpm check:type-check-debt —'))); - t('…and the ONE section it does emit is the gate-script kind', checkerKind.length === 3 && checkerKind[0].includes('GATE SCRIPT')); - - // -- The GATE SCRIPT entry (#12074) -------------------------------------- - // - // The card: a new gate carries LANDING OBLIGATIONS that no derivation - // enumerates, so they are learned from red CI after the dev has already - // reported -- which costs the reviewing seat a correction on a verdict it had - // issued. Measured at four devs and two obligations, twice inside one hour. - // - // The obligations are real gates that already know how to detect their own - // omission; what was missing is a pre-CI channel that ASKS them. This entry is - // that channel, and it is a KIND rather than a path derivation for the reason - // #10542 gives for check:cross-package-test-inputs: neither gate declares the - // population it judges. Both DISCOVER it -- they open exactly the files the - // families resolve to -- so the honest trigger is that same identity, and the - // precision is 100% by construction rather than by estimate. - const gateFiles = gateFamilyFiles(); - t('the gate-script population is derived and non-empty (this kind is not vacuous)', gateFiles.size > 50); - t('a gate script is one', isGateScriptPath('scripts/check-type-check-coverage.mjs', gateFiles)); - t('a leading ./ does not hide one', isGateScriptPath('./scripts/check-type-check-coverage.mjs', gateFiles)); - t('an ordinary source file is not', !isGateScriptPath('packages/objectql/src/engine.ts', gateFiles)); - // The two directions that make the FILENAME spelling wrong, pinned as - // directions rather than as counts, so they redden if someone swaps the - // identity test for the `check-*` regex the card proposed. Both were measured - // on this tree: the regex fabricates 10 leads and misses 31 real gate scripts, - // 93.3% precision and 81.8% recall against 100/100 here. - // The specimen is `check-test-typecheck.mts`, run only by package manifests. - // It was `check-dts-emitted.mjs` until ci.yml's Build Core began running that - // checker's `--self-test` (#21202), which made it a family file. - t('a name-shaped script no family runs is NOT a gate script — the fabrication direction', - !isGateScriptPath('scripts/check-test-typecheck.mts', gateFiles)); - t('…and a real gate that is not called check-anything IS one — the recall direction', - isGateScriptPath('packages/spec/scripts/build-schemas.ts', gateFiles)); - // The two instances this card measured. They are the whole reason the entry - // exists, so they are pinned as paths rather than described. - t('the first measured CI red is in the kind', isGateScriptPath('scripts/check-objectql-double-limit.mjs', gateFiles)); - t('the second measured CI red is in the kind', isGateScriptPath('scripts/check-i18n-stale-fill.mjs', gateFiles)); - - // The rendered section, anchored on the delimiters for the reason the entries - // above state at length: a bare `includes` survives a prefix-preserving - // rename, the one rot class the STALE branch exists to report. - const gateKind = changeKindLines(['scripts/check-objectql-double-limit.mjs'], resolved); - t('a gate-script path emits the convention section', gateKind.length === 3 && gateKind[0].includes('GATE SCRIPT')); - t('and it names the bare-root self-test, runnably', - gateKind.some((l) => l.includes('- pnpm scripts/pm/bare-root-worklist.mjs --self-test —'))); - t('and it names this tool own gate too — the SECOND obligation, which no path derivation reaches', - gateKind.some((l) => l.includes('- pnpm check:pm-dispatch-gates —'))); - // Each `why` has to carry the half a dev cannot re-derive, or the lead is a - // command with no obligation attached to it. - const bareLine = gateKind.find((l) => l.includes('bare-root-worklist')) ?? ''; - const escLine = gateKind.find((l) => l.includes('- pnpm check:pm-dispatch-gates —')) ?? ''; - t('the bare-root line states that an EDIT counts, by naming all three directions', - /FRESH/.test(bareLine) && /STALE/.test(bareLine) && /CONTRADICTED/.test(bareLine)); - t('…and refuses the two wrong repairs the failure text warns about', - /shrink-only/.test(bareLine) && /costlier error/.test(bareLine)); - t('the escapable-literal line states that identity is its ONLY route, so the silence is not a clearance', - /artifact roster/.test(escLine) && /IDENTITY/.test(escLine)); - t('…and refuses reaching for the declare remedy by default', /shrink-only/.test(escLine) && /by default/.test(escLine)); - // Both names pinned individually beside the census guard's own reasoning: a - // count alone stays green if one is dropped and another added. - t('the bare-root self-test is a live family, so naming it is not a guess', - [...discoverFamilies().byCheck.keys()].includes('scripts/pm/bare-root-worklist.mjs --self-test')); - // A non-gate script in no other kind still emits nothing — the genuine zero - // this block took over from the re-pointed case above. Read FROM THE TREE - // rather than spelled, so it cannot rot into a path that quietly became a - // gate and turned this case vacuous. - const nonGate = trackedFiles().find((f) => f.startsWith('scripts/') && f.endsWith('.mjs') && !gateFiles.has(f)); - t('a non-gate script exists to probe the zero with', Boolean(nonGate)); - t('…and it emits no convention section at all', changeKindLines([nonGate], resolved).length === 0); - - - // i18n change-kind derivation — the pure judgments first, each mirroring one - // line of the gate's own `findConfigs`. - t('an extract config under scripts/ is one', isExtractConfigPath('packages/services/service-messaging/scripts/i18n-extract.config.ts')); - t('the same filename OUTSIDE scripts/ is not', !isExtractConfigPath('packages/services/service-messaging/src/i18n-extract.config.ts')); - t('another config under scripts/ is not', !isExtractConfigPath('packages/platform-objects/scripts/build-docs.config.ts')); - t('owner is the package above scripts/', owningPackageOfExtractConfig('packages/plugins/plugin-audit/scripts/i18n-extract.config.ts') === 'packages/plugins/plugin-audit'); - t('an owner collapsing to a bare top-level dir is refused', owningPackageOfExtractConfig('packages/scripts/i18n-extract.config.ts') === null); - - const owners = ['packages/platform-objects', 'packages/services/service-messaging']; - t('a deep path inside an owning package qualifies', isInI18nBundlePackage('packages/services/service-messaging/src/objects/http-delivery.object.ts', owners)); - t('the config file itself qualifies (whole package, not just objects)', isInI18nBundlePackage('packages/services/service-messaging/scripts/i18n-extract.config.ts', owners)); - t('the package directory itself qualifies', isInI18nBundlePackage('packages/platform-objects', owners)); - t('a path in a package WITHOUT a config does not', !isInI18nBundlePackage('packages/objectql/src/engine.ts', owners)); - t('a sibling sharing a name prefix does not', !isInI18nBundlePackage('packages/services/service-messaging-extra/src/x.ts', owners)); - t('a parent directory does not drag in owners below it', !isInI18nBundlePackage('packages/services', owners)); - - // The walk itself, against the real tree — the half no fixture can prove. - const liveOwners = i18nBundlePackageDirs(); - t('the live walk discovers owning packages', liveOwners.length > 0 && liveOwners.every((d) => d.startsWith('packages/'))); - t('the live walk finds no duplicate owners', new Set(liveOwners).size === liveOwners.length); - t('the live walk excludes a package that owns no config', !liveOwners.includes('packages/objectql')); - // Regression pin for the measured miss (PR #8348): this exact path derived no - // check:i18n. If service-messaging ever stops owning a bundle, this case fails - // and the answer is to re-point it at a package that does, not to delete it. - t('the measured incident path now derives the kind', isInI18nBundlePackage('packages/services/service-messaging/src/objects/http-delivery.object.ts', liveOwners)); - - // The name assertions below anchor on the rendered DELIMITERS (`- pnpm x —`, - // `⚠ x: STALE`), not on a bare substring. Measured while reverse-verifying this - // entry: renaming the gate to `check:i18n-renamed-probe` made the live run - // print STALE exactly as designed, and a `includes('pnpm check:i18n')` pin - // stayed green through it — every prefix-preserving rename is invisible to a - // substring, which is the one class of rot the STALE branch exists to catch. - const i18nHit = changeKindLines(['packages/services/service-messaging/src/objects/http-delivery.object.ts'], resolved); - // One kind line plus one line per gate in the entry — TWO gates since #11671 - // added the stale-fill ratchet to the same file surface. The count is pinned - // (not `>= 1`) so a gate silently dropped from the entry fails here. - t('an owning-package path emits the i18n convention section', i18nHit.length === 3 && i18nHit[0].includes('owns an i18n-extract.config.ts')); - t('the i18n section names check:i18n exactly, runnably', i18nHit.some((l) => l.includes('- pnpm check:i18n —'))); - // #11671: the two gates answer DIFFERENT moves on the same surface — check:i18n - // sees a key set change, this one sees a source string REVISED under a stale - // translated leaf. The delimiter anchor keeps the check:i18n pin above from - // matching this line by prefix, and vice versa. - 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 first entry in this table judged from a file's CONTENT rather than its - // path (the HTTP-status entry below is the second), 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 HTTP-status CONTENT kind (#22320) ──────────────────────────────── - // - // The second content entry, pinned in the first one's shape: limbs through an - // injected reader, then the live tree for the specimen and the controls, then - // the rendered section. The specimen is the emit site PR #22311 lost a CI - // round on. Two of the limb cases are paired with the CODE predicate missing - // the same text, because that divergence is the whole reason this is a - // sibling entry and not a second gate on the one above. - const emits = (text, path = 'packages/x/src/a.ts') => emitsAnHttpStatus(path, codeSrc(text)); - t('a status literal in a status-and-body terminal is a hit', - emits("return { status: 409, body: { code: 'RESOURCE_CONFLICT' } };")); - t('an error class declaring its own status is a hit', - emits("class E extends Error { readonly code = 'X_Y'; readonly status = 422; }")); - const ENUM_PAIR = 'err.code = StandardErrorCode.enum.INVALID_FILTER;\nerr.status = 400;'; - t('an assignment pair whose code is an enum member is a hit', emits(ENUM_PAIR)); - t('…and the code predicate misses that text, so the status entry is not redundant', !stamps(ENUM_PAIR)); - const DOOR_CALL = "sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message);"; - t('the positional four-argument sendError door is a hit', emits(DOOR_CALL)); - t('…and the code predicate misses that one too', !stamps(DOOR_CALL)); - t('a status named by a SCREAMING_SNAKE constant is a hit', - emits('return { code: NOT_UPLOADER_CODE, status: NOT_UPLOADER_STATUS };')); - t('a status constant declared for another file to resolve is a hit', emits('export const NOT_UPLOADER_STATUS = 403;')); - t('a quoted status WORD is not a hit', !emits("const row = { status: 'active' };")); - t('a 2xx status is not a hit: the gate reconciles 4xx and 5xx only', !emits('return { status: 200, body: {} };')); - t('a comparison is not a binding', !emits('if (res.status === 404) return null;')); - t('a status read from a runtime value is not a hit: the gate cannot resolve it either', !emits('err.status = status;')); - t('a status discussed only in a comment is not a hit', !emits('// status: 409 is answered elsewhere\nexport const x = 1;')); - t('a .ts file with no status and no code at all is not a hit', - !emits('export function add(a: number, b: number) { return a + b; }')); - t('a test file binding a status is not a hit', !emits('const e = { status: 409 };', 'packages/x/src/a.test.ts')); - t('a d.ts binding a status is not a hit', !emits('const e = { status: 409 };', 'packages/x/src/a.d.ts')); - t('a non-TS file binding a status is not a hit', !emits('status: 409', 'packages/x/README.md')); - t('a path with nothing to read is not a hit, and does not throw', !emitsAnHttpStatus('packages/x/src/a.ts', () => null)); - - // The live tree: the specimen is reached, and the card's controls are not. - // Spelled rather than discovered, like the code specimen above: a derived - // probe would answer about some other file. - const STATUS_SPECIMEN = 'packages/services/service-storage/src/storage-routes.ts'; - t('the live tree still carries the #22311 emit site, and the predicate reaches it', emitsAnHttpStatus(STATUS_SPECIMEN)); - t('a docs-only page is not reached', !emitsAnHttpStatus('content/docs/ai/agents.mdx')); - t('the package README beside the specimen is not reached', !emitsAnHttpStatus('packages/services/service-storage/README.md')); - t('a live .ts with no status and no code (the package barrel) is reached by neither content entry', - !emitsAnHttpStatus('packages/services/service-storage/src/index.ts') - && !stampsAnErrorCodeLiteral('packages/services/service-storage/src/index.ts')); - const statusHits = codeCorpus.filter((f) => emitsAnHttpStatus(f)); - t(`the status trigger discriminates: ${statusHits.length} of ${codeCorpus.length} non-test TS files (neither vacuous nor tree-wide)`, - statusHits.length > 20 && statusHits.length < codeCorpus.length / 4); - - // The rendered section, through this entry alone and then through the whole - // table, which is where the card's controls have to hold. - const statusEntry = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === 'check:error-status-conformance')); - t('exactly one entry in the table names the status gate', statusEntry.length === 1); - const statusKind = changeKindLines([STATUS_SPECIMEN], resolved, statusEntry); - t('the emit-site path emits the status convention section', - statusKind.length === 2 && statusKind[0].includes('HTTP STATUS') && statusKind[0].includes('judged from CONTENT')); - t('and it names the status gate runnably, anchored on the delimiter', - statusKind.some((l) => l.includes('- pnpm check:error-status-conformance —'))); - const statusLine = statusKind.find((l) => l.includes('- pnpm check:error-status-conformance —')) ?? ''; - t('the status line states why no path derivation reaches it', /REFUSE-WIDE/.test(statusLine)); - t('…and pushes the repair to documenting the status, with the baseline remedy kept maintainer-only', - /DOCUMENTING/.test(statusLine) && /MAINTAINER-ONLY/.test(statusLine)); - t('…and says it needs no build', /needs NO build/.test(statusLine)); - const throughTable = (p) => changeKindLines([p], resolved).some((l) => l.includes('check:error-status-conformance')); - t('through the whole table, the specimen derives the status gate', throughTable(STATUS_SPECIMEN)); - t('…a docs-only path does not', !throughTable('content/docs/ai/agents.mdx')); - t('…the package README does not', !throughTable('packages/services/service-storage/README.md')); - t('…and the package barrel does not', !throughTable('packages/services/service-storage/src/index.ts')); - const statusStale = changeKindLines([STATUS_SPECIMEN], () => null, statusEntry); - t('an undiscoverable status gate renders STALE for this entry', - statusStale.filter((l) => l.includes('⚠ check:error-status-conformance: STALE')).length === 1); - - // ── The metadata-form edge (#9116) ──────────────────────────────────────── - // - // The bundles' OTHER producer, and the half no owning-package test can reach: - // the metadataForms surface is registry-driven, its source lives in - // packages/spec, and packages/spec owns no extract config. Pure judgments - // first, then the live tree, then both rendering directions. - t('a form module is one', isMetadataFormModulePath('packages/spec/src/data/object.form.ts')); - t('its sibling schema is not', !isMetadataFormModulePath('packages/spec/src/data/object.zod.ts')); - t('a bare .form.ts with no name is not one', !isMetadataFormModulePath('packages/spec/src/data/.form.ts')); - t('a form module test file is not one', !isMetadataFormModulePath('packages/spec/src/data/object.form.test.ts')); - - const formMods = ['packages/spec/src/data/object.form.ts', 'packages/spec/src/ui/view.form.ts']; - t('the module itself reaches', reachesMetadataFormModule('packages/spec/src/data/object.form.ts', formMods)); - t('a directory CONTAINING one reaches (a card surface is named before its files exist)', reachesMetadataFormModule('packages/spec/src/ui', formMods)); - t('a sibling sharing a name prefix does not', !reachesMetadataFormModule('packages/spec/src/dat', formMods)); - t('an unrelated package does not', !reachesMetadataFormModule('packages/objectql/src/engine.ts', formMods)); - // The over-broad direction, the expensive one: a bare top-level directory - // covers the whole tree below it, so it must not drag every form module in. - t('a bare top-level directory is refused', !reachesMetadataFormModule('packages', formMods)); - - // Applicability is READ from the configs, not assumed — the flag that decides - // whether any package still commits the shared baseline at all. - t('a config with no opt-out extracts the metadata-form surface', flagsExtractMetadataForms(['--locales=zh-CN', '--fill=default'])); - t('the opt-out flag removes it', !flagsExtractMetadataForms(['--objects-only', '--no-metadata-forms'])); - - // The live tree — the half no fixture can prove. - const liveForms = metadataFormModulePaths(); - t('the live walk discovers form modules', liveForms.length > 0 && liveForms.every((f) => f.endsWith('.form.ts'))); - t('the live walk finds no duplicates', new Set(liveForms).size === liveForms.length); - // If this flips, the entry stops firing BY DESIGN (every package opted out of - // the shared baseline) — read the entry's deletion criterion before "fixing" it. - t('some package still commits the shared metadata-form baseline', metadataFormsSurfaceIsExtracted()); - - // Regression pin for the measured incident (PR #9113): these two exact paths - // moved four platform-objects bundles, reddened check:i18n on CI, and derived - // NOTHING — the family appeared in neither half of the output. Anchored on the - // rendered delimiters for the reason the entry above states: a bare substring - // stays green through a prefix-preserving rename, the one rot the STALE branch - // exists to catch. - const formHit = changeKindLines(['packages/spec/src/data/object.form.ts', 'packages/spec/src/data/field.form.ts'], resolved); - // The `?? ''` is not defensive noise: reverse-verifying this block by making - // every config opt out emptied `formHit`, and the bare index CRASHED the whole - // self-test on a TypeError — one stack in place of 180-odd named verdicts. A - // case that stopped holding must fail BY NAME, with its reason, the way the - // i18n gate's own self-test says it (its `staleForDetail` fallback exists for - // exactly this). Ablating the entry now reddens these three and nothing else. - const formKindLine = formHit[0] ?? ''; - t('the measured incident paths now emit the metadata-form section', formHit.length === 2 && formKindLine.includes('metadata form module')); - t('that section names check:i18n exactly, runnably', formHit.some((l) => l.includes('- pnpm check:i18n —'))); - t('and it names both incident paths, not just the first', formKindLine.includes('object.form.ts') && formKindLine.includes('field.form.ts')); - // The over-trigger direction, which the card demanded in its own right: a spec - // change that touches no form must NOT be pushed into this gate. Both a schema - // beside a real form module and an unrelated package are pinned, because the - // first is the one a filename convention could plausibly over-reach into. - t('a spec schema next door to a form emits no i18n section', !changeKindLines(['packages/spec/src/data/filter.zod.ts'], resolved).some((l) => l.includes('check:i18n'))); - t('an unrelated package still emits no i18n section', !changeKindLines(['packages/rest/src/rest-server.ts'], resolved).some((l) => l.includes('check:i18n'))); - const formStale = changeKindLines(['packages/spec/src/data/object.form.ts'], () => null); - t('an undiscoverable check:i18n renders STALE for this entry too', formStale.filter((l) => l.includes('⚠ check:i18n: STALE')).length === 1); - - // The measured incident (#8410 / PR #8399), pinned against the REAL workflow - // rather than a fixture: a fixture proves the parser, only the live file - // proves that THIS repo's changeset gate is reachable. `Check Changeset` - // invokes check-adr-0087-registration.mjs from a block-scalar body, and that - // is the gate PR #8399's declared-breaking changeset went red on after a - // fully green local loop. If the step is ever rewritten as a one-liner this - // case still passes (it asserts discovery, not the YAML style); if the gate - // moves out of pr-automation.yml, re-point the case at its new home rather - // than deleting it. - const liveWf = readFileSync(nodePath.join(ROOT, '.github/workflows/pr-automation.yml'), 'utf8'); - const liveInvs = extractCheckInvocations(liveWf, 'pr-automation.yml').map((i) => i.check); - // ⚠️ Asserted on the SCRIPT, not on a key (#15083). All three of these gates - // are invoked by `pr-automation.yml` with `--base "$MERGE_BASE"` and by - // nothing bare, so their keys now carry that argv — the discovery this case - // is about is unchanged, and pinning the bare key here would pin the very - // invocation CI never makes. The keyed half is asserted immediately below, - // so a rewrite of the step cannot quietly satisfy this by discovering the - // script under some other argv. - const liveScripts = extractCheckInvocations(liveWf, 'pr-automation.yml').map((i) => i.script); - t('the live Check Changeset job discovers its ADR-0087 gate', liveScripts.includes('scripts/check-adr-0087-registration.mjs')); - t('the live Check Changeset job discovers its empty-changeset gate', liveScripts.includes('scripts/check-empty-changeset.mjs')); - t('the live one-line gate in that file still discovers', liveScripts.includes('scripts/check-changeset-no-major.mjs')); - t( - '…each under the argv that file really runs it with, merge base and all', - ['scripts/check-adr-0087-registration.mjs --base "$MERGE_BASE"', - 'scripts/check-empty-changeset.mjs --base "$MERGE_BASE"', - 'scripts/check-changeset-no-major.mjs --base "$MERGE_BASE"'].every((k) => liveInvs.includes(k)), - ); - // The end-to-end direction: a `.changeset/` path must now REACH the ADR-0087 - // gate through the ordinary watch-hint match. That gate names `.changeset` in - // its own source, so this asserts the whole chain (discover -> resolve -> - // hint -> cover) rather than the parser alone. - const adrHints = extractWatchHints(readFileSync(nodePath.join(ROOT, 'scripts/check-adr-0087-registration.mjs'), 'utf8'), 'scripts/check-adr-0087-registration.mjs'); - t('a .changeset path is covered by the ADR-0087 gate own hints', adrHints.some((h) => hintCovers(h, '.changeset/some-breaking-change.md'))); - - // ── The measured population (#8478), against the REAL scripts ───────────── - // - // A fixture proves the boundary; only these files prove that THIS tree's - // gates land on the right side of it. Each pin names a path the card measured - // before the narrowing, so a regression reads as the specific claim it broke - // rather than as a count. If a gate is renamed or moves, re-point the case at - // its new home — deleting one deletes the evidence, not the problem. - // - // Measured on this branch's base (commit 3208222) and after, coverage-capable - // hints per script: dispatch-gates 46 -> 5, check-empty-changeset 36 -> 3, - // check-adr-0087-registration 34 -> 6, check-skill-id-lint 2 -> 2 (already - // clean, the control). Across all 66 discoverable gate scripts: 1144 hints -> - // 473, with no hint gained that any repo path can reach. - const readHints = (rel) => extractWatchHints(readFileSync(nodePath.join(ROOT, rel), 'utf8'), rel); - const covers = (hs, p) => hs.some((h) => hintCovers(h, p)); - - t( - 'the ADR-0087 gate no longer claims a runtime path through its own fixtures', - !covers(adrHints, 'packages/runtime/src/index.ts'), - ); - const emptyHints = readHints('scripts/check-empty-changeset.mjs'); - t('the empty-changeset gate still reaches a .changeset path', covers(emptyHints, '.changeset/anything.md')); - t( - 'the empty-changeset gate no longer claims a skills path through its own fixtures', - !covers(emptyHints, 'skills/demo/SKILL.md'), - ); - // The load-bearing survivor: this gate's real literals are the per-file - // ceiling keys (repo-relative paths in its CEILINGS map) — before the - // narrowing it reached SKILL.md only through a path copy in its own header, - // a real input carried by prose. - const ratchetHints = readHints('scripts/pm/check-skill-line-ratchet.mjs'); - t('the skill ratchet still reaches the SKILL.md it counts', covers(ratchetHints, '.claude/skills/pm-dispatch/SKILL.md')); - t('the skill ratchet reaches the references files it now counts', covers(ratchetHints, '.claude/skills/pm-dispatch/references/dispatch-runbook.md')); - t( - 'the skill ratchet claims only its covered files, not all of references/', - !covers(ratchetHints, '.claude/skills/pm-dispatch/references/facts.md'), - ); - // The control the card called "what a clean one looks like": two hints, both - // real, unchanged by the narrowing. - const idLintHints = readHints('scripts/pm/check-skill-id-lint.mjs'); - t('the skill-id lint keeps both of its real inputs', covers(idLintHints, '.claude/skills/pm-dispatch/SKILL.md') && covers(idLintHints, '.claude/agents/os-dev.md')); - // This tool's own gate: its thin gate file's one literal is the tool, so a - // card editing the tool must still derive it. - t('the dispatch-gates gate still reaches the tool it runs', covers(readHints('scripts/pm/check-dispatch-gates.mjs'), 'scripts/pm/dispatch-gates.mjs')); - // And this file, the worst specimen in the card's table: the directory it - // really reads survives, the fixtures naming other packages do not. The spec - // contract surface DOES hint now — via the declared module-body suspect glob - // (a real constant, not a fixture; inert for gate matching for the reason the - // MANDATORY_TIER_GLOBS docblock records) — so the fixture-masking claim is - // pinned on a path only fixtures name. - const ownHints = readHints('scripts/pm/dispatch-gates.mjs'); - t('this tool still hints the workflow directory it reads', covers(ownHints, '.github/workflows/lint.yml')); - t('this tool hints the spec contract surface via the DECLARED suspect glob', covers(ownHints, 'packages/spec/src/data/filter.zod.ts')); - t('this tool still does not hint the paths only its fixtures name', !covers(ownHints, 'packages/objectql/src')); - // The LIVE trailing-dot specimen (#8534): this gate spells its own filename as - // the last word of a sentence, in a module-body array element that comment - // masking cannot reach, so the hint carried the period. Pinned live because - // the fixture above proves the trimming and only this file proves the tree - // still contains the shape. If that sentence is ever rewritten, re-point the - // case at whatever file then carries a trailing-dot literal — or, if none - // does, delete it together with the trim, never ahead of it. - const compatHints = readHints('scripts/check-skill-compatibility-version.mjs'); - t('the live trailing-dot hint is trimmed to the file it names', compatHints.includes('scripts/check-skill-compatibility-version.mjs')); - t('so it still reaches that file under the segment rule', covers(compatHints, 'scripts/check-skill-compatibility-version.mjs')); - - // ── The one DECLARED coupling (#8551) ───────────────────────────────────── - // - // The narrowing above is about gates that MENTION a path without reading it. - // This is its mirror image: a gate that really does move with a path it never - // opens. The type-check ledgers ratchet a count for the workspace root, whose - // program is the scripts tree, and one script accounts for 29 of that entry's - // 80 errors — so editing it moves a number this farm holds. The coupling was - // written down all along, inside the ledger note's prose, where whole-literal - // extraction discards it: the family then scored `silent` — neither matched - // nor undetermined, printed nowhere — and a card editing that script was told - // no family names its paths. - // - // The remedy is per-coupling and manual (a bare, whole-literal constant in - // the gate's own module body), which is exactly the kind of declaration that - // rots quietly. So it is pinned LIVE, against both real files: delete the - // constant and this gate reddens instead of the silence coming back. If the - // ledger's coupling genuinely ends, delete the constant AND these cases in - // the same change — the evidence goes with the claim, never ahead of it. - // - // This does NOT retire the test-file entry in CHANGE_KIND_GATES, whose - // deletion criterion is a discoverable literal for that KIND: the constant - // names one script carrying no `.test.` infix, while that entry answers for - // every test file in the tree. - const coverageHints = readHints('scripts/check-type-check-coverage.mjs'); - t( - 'the type-check ledger gate declares the root-program script whose errors it ratchets', - covers(coverageHints, 'scripts/check-test-typecheck.mts'), - ); - const coupledVerdict = classifyEntry( - { files: ['scripts/check-type-check-coverage.mjs'], hints: coverageHints }, - ['scripts/check-test-typecheck.mts'], - ); - t( - 'so a card editing that script is MATCHED through that constant, not dropped as silent', - coupledVerdict.verdict === 'matched' && coupledVerdict.hits[0]?.hint === 'scripts/check-test-typecheck.mts', - ); - - // The same coupling, for the module this file now IMPORTS its i18n walks from - // (#9116). Sharing one enumeration between the gate and this tool removed a - // mirror, and it would have opened a smaller hole of exactly the kind this - // card is about: an import specifier is not a discoverable hint, so a card - // editing the shared module could move two gates while deriving neither. - // Both are pinned LIVE against the real files — delete either constant and - // this reddens instead of the silence coming back. - const SHARED = 'scripts/i18n-bundle-surface.mjs'; - t( - 'the i18n gate declares the module its population is enumerated by', - covers(readHints('scripts/check-i18n-bundles.mjs'), SHARED), - ); - t( - 'the dispatch-gates gate declares it too, since the tool self-test drives those functions', - covers(readHints('scripts/pm/check-dispatch-gates.mjs'), SHARED), - ); - t( - 'and that gate still reaches the tool it runs — the new constant displaces nothing', - covers(readHints('scripts/pm/check-dispatch-gates.mjs'), 'scripts/pm/dispatch-gates.mjs'), - ); - // The shared module is a real file, so the two claims above are live rather - // than a pair of matching strings. - t('the declared shared module exists', existsSync(nodePath.join(ROOT, SHARED))); - - // The same coupling once more, for the frame-sync gate whose COPIES table - // the 2026-08-20 clause-① narrowing made a DEFINING input of the tier - // mandate. The tool's self-test reaches it through a spawned import — not a - // discoverable hint — so the gate declares it as a constant, and this pin - // keeps that declaration live: delete it and this reddens instead of a - // COPIES edit moving the gate's verdict while deriving nothing. - const FRAME = 'scripts/check-skill-frame-sync.mjs'; - t( - 'the dispatch-gates gate declares the frame-sync module the tier mandate is defined against', - covers(readHints('scripts/pm/check-dispatch-gates.mjs'), FRAME), - ); - t('the declared frame-sync module exists', existsSync(nodePath.join(ROOT, FRAME))); - - // The same shape again, for the TYPE-registry edge of walkMetadataForms - // (#9144) — two specific, known files rather than a runtime-enumerated - // population, so they are closed as coupling constants in - // check-i18n-bundles.mjs rather than a third CHANGE_KIND_GATES entry. Both - // directions pinned LIVE: delete either constant and this reddens instead - // of the derivation going silently blind on that edge again. - const TYPE_REGISTRY = 'packages/spec/src/kernel/metadata-plugin.zod.ts'; - const FORM_REGISTRY = 'packages/spec/src/system/metadata-form-registry.ts'; - const i18nGateHints = readHints('scripts/check-i18n-bundles.mjs'); - t('the i18n gate declares the type-level metadata registry module', covers(i18nGateHints, TYPE_REGISTRY)); - t('the i18n gate declares the form registry module too (not just its *.form.ts leaves)', covers(i18nGateHints, FORM_REGISTRY)); - const typeRegistryVerdict = classifyEntry({ files: ['scripts/check-i18n-bundles.mjs'], hints: i18nGateHints }, [TYPE_REGISTRY]); - const formRegistryVerdict = classifyEntry({ files: ['scripts/check-i18n-bundles.mjs'], hints: i18nGateHints }, [FORM_REGISTRY]); - t( - 'so a card editing the type registry is MATCHED through that constant, not dropped as silent', - typeRegistryVerdict.verdict === 'matched' && typeRegistryVerdict.hits[0]?.hint === TYPE_REGISTRY, - ); - t( - 'and a card editing the form registry module is MATCHED through its own constant', - formRegistryVerdict.verdict === 'matched' && formRegistryVerdict.hits[0]?.hint === FORM_REGISTRY, - ); - // Both declared paths are real files, so the four claims above are live - // rather than a pair of matching strings. - t('the declared type registry module exists', existsSync(nodePath.join(ROOT, TYPE_REGISTRY))); - t('the declared form registry module exists', existsSync(nodePath.join(ROOT, FORM_REGISTRY))); - - // ── A family's OWN script files as match keys (#8509) ───────────────────── - // - // Both directions are the product, and both are pinned: a card editing a - // gate's script must derive that gate, and a card that touches nothing of the - // gate's must gain nothing from the new key. The over-match direction is the - // expensive one here — this key is added to EVERY discovered family at once, - // so a key that covered too much would fabricate leads across the whole farm - // rather than in one gate. - const identityEntry = { files: ['scripts/check-empty-changeset.mjs'], hints: [] }; - t( - 'a gate script derives its own family, with the file path itself as provenance', - coveringKey(identityEntry, 'scripts/check-empty-changeset.mjs')?.key === 'scripts/check-empty-changeset.mjs', - ); - t('an unrelated path gains nothing from the identity key', coveringKey(identityEntry, 'packages/rest/src/server.ts') === null); - t('another gate script does not match through this one identity', coveringKey(identityEntry, 'scripts/check-adr-0087-registration.mjs') === null); - t('a family that resolves to no file at all matches nothing by identity', coveringKey({ files: [], hints: [] }, 'scripts/check-empty-changeset.mjs') === null); - // Precedence, in both of its directions. One answer per path either way — the - // question is only which provenance a reader is shown when both keys fire. - const bothKeys = { files: ['scripts/pm/check-x.mjs'], hints: ['scripts/pm'] }; - t('identity outranks a scanned hint that also covers', coveringKey(bothKeys, 'scripts/pm/check-x.mjs')?.key === 'scripts/pm/check-x.mjs'); - t('a scanned hint still answers a path identity does not cover', coveringKey(bothKeys, 'scripts/pm/other.mjs')?.key === 'scripts/pm'); - // The live thin-gate-file specimen, both directions. This tool's own gate is - // one file whose single module-body constant is the tool it runs, so the two - // keys answer DIFFERENT inputs and neither displaces the other. If that gate - // is renamed or its file moves, re-point these cases rather than deleting - // them — they are the evidence that the two keys compose. - const gateEntry = { files: ['scripts/pm/check-dispatch-gates.mjs'], hints: readHints('scripts/pm/check-dispatch-gates.mjs') }; - t('the gate FILE now derives its own family', coveringKey(gateEntry, 'scripts/pm/check-dispatch-gates.mjs')?.key === 'scripts/pm/check-dispatch-gates.mjs'); - t('the TOOL it runs still derives it through the module-body constant', coveringKey(gateEntry, 'scripts/pm/dispatch-gates.mjs')?.key === 'scripts/pm/dispatch-gates.mjs'); - // The card's own specimen, resolved through the REAL root package.json: the - // gate whose entire job is running that script's self-test names the script - // there and nowhere in the script's source, which is why the identity key is - // the only thing that can reach it. - const liveRootScripts = JSON.parse(readFileSync(nodePath.join(ROOT, 'package.json'), 'utf8')).scripts ?? {}; - const selfTestGateFiles = resolveCheckToFiles('check:changeset-gate-self-tests', liveRootScripts); - t('the changeset self-test gate really resolves to the script the card named', selfTestGateFiles.includes('scripts/check-empty-changeset.mjs')); - t( - 'so a card editing that script now derives that gate end to end', - coveringKey({ files: selfTestGateFiles, hints: [] }, 'scripts/check-empty-changeset.mjs')?.key === 'scripts/check-empty-changeset.mjs', - ); - - // The bucket the one-line spelling would empty. A family whose source names - // no path still resolves to a script file, so identity must decide matching - // WITHOUT being allowed to answer "does this gate's source name a path?". - const noLiterals = { files: ['scripts/check-silent.mjs'], hints: [] }; - t('a family with no scanned hints stays undetermined for an unrelated card', classifyEntry(noLiterals, ['packages/rest/src/server.ts']).verdict === 'undetermined'); - t('the same family is MATCHED, not undetermined, for a card editing its script', classifyEntry(noLiterals, ['scripts/check-silent.mjs']).verdict === 'matched'); - t('a family whose scanned hints all miss is neither matched nor undetermined', classifyEntry({ files: [], hints: ['packages/spec/src'] }, ['docs/adr/0112-x.md']).verdict === 'silent'); - const identityHits = classifyEntry(noLiterals, ['scripts/check-silent.mjs', 'packages/rest/src/server.ts']).hits; - t('an identity hit carries the path and the key that covered it, once', identityHits.length === 1 && identityHits[0].path === 'scripts/check-silent.mjs' && identityHits[0].hint === 'scripts/check-silent.mjs'); - - // ── CI's own trigger as a match key (#9171) ─────────────────────────────── - // - // The incident: a workflow declares the paths CI schedules its job on, and - // nothing here read them. The gates of the whole `Spec property liveness` job - // read a registry rather than a path, so they carry no watch hint and sat in - // the `undetermined` bucket for every card — including a card editing - // `packages/spec/**`, the job's own first trigger. A dev following the - // dispatch instruction exactly therefore never ran them. - // - // The extraction first. `paths` belongs to `pull_request` inside `on:` and - // nowhere else: the fixtures below put a decoy list under another event and - // under a job, because a walk that scooped either would widen every family in - // the file to paths CI never filters on. - const triggerWf = [ - 'name: Fixture', - 'on:', - ' pull_request:', - ' types: [opened, synchronize]', - ' paths:', - " - 'packages/spec/**'", - ' # a comment between entries', - ' - docs/audits/**', - ' merge_group:', - ' schedule:', - ' - cron: 0 3 * * 1', - 'jobs:', - ' build:', - ' paths:', - ' - never/read/**', - '', - ].join('\n'); - const triggerPaths = extractTriggerPaths(triggerWf); - t('the pull_request paths list is read in declaration order', triggerPaths.join('|') === 'packages/spec/**|docs/audits/**'); - t('a decoy paths list outside the on: mapping is NOT read', !triggerPaths.some((p) => p.includes('never/read'))); - t('a workflow with no paths filter yields an empty list, not a match-nothing list', extractTriggerPaths('on:\n pull_request:\n branches: [main]\njobs: {}\n').length === 0); - t('the flow-sequence spelling is read too', extractTriggerPaths("on:\n pull_request:\n paths: ['a/**', \"b/c\"]\n").join('|') === 'a/**|b/c'); - t('pull_request_target is not mistaken for pull_request', extractTriggerPaths("on:\n pull_request_target:\n paths:\n - 'x/**'\n").length === 0); - - // ── Derivation THROUGH a composite action (#19229) ───────────────────────── - // - // The card: six gates root their population at `.github/workflows` and none - // reads `.github/actions/**`, so a command executed through a composite - // action was audited by nothing while every scope line read as coverage. The - // repair is `followCompositeActions` + the `viaAction` provenance it carries; - // these cases are the firing control and the dark control for it. - // - // ⛔ The repair the card REFUSES, recorded here because this is where someone - // would take it: re-pointing the four live-specimen CONTROL assertions below - // at a different value-bearing family. That turns the pin green while leaving - // the derivation blind, which is the declaration-without-an-assertion shape - // this whole file exists to refuse. - const compositeCallerWf = [ - 'name: Fixture', - 'on:', - ' pull_request: {}', - 'jobs:', - ' sweep:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@v7', - ' - name: Through the action', - ' uses: ./.github/actions/fixture-gate', - '', - ].join('\n'); - const compositeActionYml = [ - 'name: Fixture gate', - 'description: >-', - ' A description whose folded body mentions run: and must never be read as a step.', - 'runs:', - ' using: composite', - ' steps:', - ' - name: Run the gate', - ' shell: bash', - ' run: |', - ' node scripts/check-nul-bytes.mjs', - ' pnpm check:agent-model-declared', - '', - ].join('\n'); - const compositeReader = (files) => (dir) => - (Object.hasOwn(files, dir) ? { file: `${dir}/action.yml`, text: files[dir] } : null); - t( - 'a local composite `uses:` is read out of a workflow, in its repo-relative spelling', - localCompositeActionUses(compositeCallerWf).join('|') === '.github/actions/fixture-gate', - ); - t( - 'the quoted spellings and a trailing comment are read too, and a repeat is read once', - localCompositeActionUses( - [ - " - uses: './.github/actions/a'", - ' - uses: "./.github/actions/b" # why', - ' - uses: ./.github/actions/a', - ].join('\n'), - ).join('|') === '.github/actions/a|.github/actions/b', - ); - t( - 'a third-party action and a local path outside .github/actions are NOT followed — a missing lead, never a fabricated one', - localCompositeActionUses( - [' - uses: actions/checkout@v7', ' - uses: ./tools/some-action', ' - uses: ./.github/workflows/x.yml'].join('\n'), - ).length === 0, - ); - t( - "an action's `runs:` body is what is read — a `run:` mentioned in a top-level description block scalar is not a step", - runCommandSteps(compositeActionRunsBlock(compositeActionYml)).length === 1 - && !compositeActionRunsBlock(compositeActionYml).includes('description'), - ); - // ⭐ THE FIRING CONTROL. The caller invokes no check of its own; both families - // exist only because the action's steps were read, and both are attributed to - // the CALLER, which is what CI schedules. - const compositeFollowed = followCompositeActions( - compositeCallerWf, - compositeReader({ '.github/actions/fixture-gate': compositeActionYml }), - ); - const compositeVia = compositeFollowed.steps.flatMap((s) => - extractCheckInvocations(s.text, 'fixture.yml', { via: s.action })); - t( - 'the caller itself invokes no check family, so the families below can only come from the action', - extractCheckInvocations(compositeCallerWf, 'fixture.yml').length === 0, - ); - t( - '⭐ a command executed THROUGH a composite action is derived exactly as an inline one is' - + ` (${compositeVia.map((i) => i.check).join(', ') || 'none'})`, - compositeVia.map((i) => i.check).sort().join('|') - === 'check:agent-model-declared|scripts/check-nul-bytes.mjs', - ); - t( - '…attributed to the CALLING workflow, with the action file carried beside it as provenance', - compositeVia.length > 0 - && compositeVia.every((i) => i.workflow === 'fixture.yml' - && i.viaAction === '.github/actions/fixture-gate/action.yml'), - ); - t( - 'and an INLINE invocation carries no viaAction at all, so the two spellings stay legible', - extractCheckInvocations(' - run: node scripts/check-nul-bytes.mjs\n', 'fixture.yml') - .every((i) => i.viaAction === undefined), - ); - // ⭐ THE DARK CONTROL, both halves: with the action file gone the families - // disappear (so they really came from it), and the absence is REPORTED rather - // than skipped — GitHub refuses to start a job whose `uses: ./…` resolves to - // nothing, so a derivation that dropped it quietly would describe a CI this - // repo does not have. - const compositeDark = followCompositeActions(compositeCallerWf, compositeReader({})); - t( - 'the dark control fires — with no action file behind the `uses:`, not one family is derived', - compositeDark.steps.length === 0 - && compositeDark.steps.flatMap((s) => extractCheckInvocations(s.text, 'fixture.yml')).length === 0, - ); - t( - '…and the absence is NAMED, never skipped (#4690)', - compositeDark.unresolved.join('|') === '.github/actions/fixture-gate', - ); - // An action may `uses:` a sibling. A one-hop follow would re-open this card's - // own blind spot one level down, so the walk recurses — and terminates on a - // cycle rather than spinning, which a fixture asserts rather than a comment. - const nestedOuter = ['runs:', ' using: composite', ' steps:', ' - uses: ./.github/actions/inner', ''].join('\n'); - const nestedInner = ['runs:', ' using: composite', ' steps:', ' - shell: bash', ' run: node scripts/check-nul-bytes.mjs', ''].join('\n'); - const nested = followCompositeActions( - ' - uses: ./.github/actions/outer\n', - compositeReader({ '.github/actions/outer': nestedOuter, '.github/actions/inner': nestedInner }), - ); - t( - 'the follow recurses — a gate an action reaches through a SECOND action is derived too', - nested.steps.map((s) => s.dir).join('|') === '.github/actions/outer|.github/actions/inner' - && nested.steps.flatMap((s) => extractCheckInvocations(s.text, 'fixture.yml')).length === 1, - ); - const cyclicA = ['runs:', ' using: composite', ' steps:', ' - uses: ./.github/actions/b', ''].join('\n'); - const cyclicB = ['runs:', ' using: composite', ' steps:', ' - uses: ./.github/actions/a', ''].join('\n'); - t( - 'and a cycle terminates with each action read exactly once, rather than spinning', - followCompositeActions( - ' - uses: ./.github/actions/a\n', - compositeReader({ '.github/actions/a': cyclicA, '.github/actions/b': cyclicB }), - ).steps.map((s) => s.dir).join('|') === '.github/actions/a|.github/actions/b', - ); - // ── LIVE: the card's own positive control, re-taken here ─────────────────── - // - // Fixtures cannot prove the live derivation opens the tree at all. The card's - // control is `.github/actions/setup-pnpm/action.yml` and its `run:` steps — - // audited by nothing on the day the card was filed, and read by the discovery - // pass now. A zero here is a follow that stopped following. - const liveComposites = discoverFamilies().compositeActions ?? []; - t( - `⭐ the live discovery really opens the composite action tree (${liveComposites.join(', ') || 'none'})`, - liveComposites.length > 0 && liveComposites.includes('.github/actions/setup-pnpm/action.yml'), - ); - const liveCompositeRunSteps = liveComposites.reduce( - (n, rel) => n + runCommandSteps(compositeActionRunsBlock(readFileSync(nodePath.join(ROOT, rel), 'utf8'))).length, - 0, - ); - t( - `…and really reads the steps in it — ${liveCompositeRunSteps} \`run:\` step(s) that no gate rooted at` - + ' .github/workflows could see, which is the card\'s positive control', - liveCompositeRunSteps > 0, - ); - // ── The BOUNDARY, measured rather than assumed ───────────────────────────── - // - // A script path that reaches the command through a step `env:` value is - // derived by NEITHER spelling — written inline in a workflow, or written in a - // composite action. That is one blind spot and it is not this one: the - // composite follow makes an action's step read EXACTLY like an inline step, - // including where an inline step is already not derived. Pinned so nobody - // reads a green follow as coverage of the env-carried class, and so the day - // that class is closed it is closed for both spellings at once. - const envCarriedStep = [ - ' - name: Run the sweep', - ' shell: bash', - ' env:', - ' SWEEPER: ${{ steps.sources.outputs.root }}/scripts/pm/check-half-states.mjs', - ' run: node "$SWEEPER" --format=markdown', - '', - ].join('\n'); - const envCarriedAction = ['runs:', ' using: composite', ' steps:', envCarriedStep].join('\n'); - t( - 'an env-carried script path is derived by neither spelling — the composite follow closes the ACTION' - + ' boundary, not the env-carrier one', - extractCheckInvocations(envCarriedStep, 'fixture.yml').length === 0 - && followCompositeActions(' - uses: ./.github/actions/c\n', compositeReader({ '.github/actions/c': envCarriedAction })) - .steps.flatMap((s) => extractCheckInvocations(s.text, 'fixture.yml')).length === 0, - ); - // The second declared deferral, sized rather than described: the always-runs - // tail walks `jobs:` and a composite action has none, so its rows still - // under-report by exactly the composite steps the follow now reads. Under- - // reporting is the safe direction (a MISSING lead), and this number is what - // makes the deferral honest instead of merely convenient. - t( - `the always-runs tail still reads no composite step — ${liveCompositeRunSteps} step(s) deferred, a` - + ' MISSING lead and never a fabricated one; when this number matters, extend that walk', - alwaysRunSteps(discoverFamilies().workflowEntries).rows.every((r) => r.workflow.endsWith('.yml')), - ); - - // ── The SCHEDULED-ONLY routing question, measured and answered ZERO (#14899) - // - // The card: the derivation named `node scripts/pm/check-half-states.mjs` — - // a live board sweep — for any diff carrying a changeset, on the reading - // that its only caller is a `schedule`-triggered workflow. Two things were - // measured against the tree instead of accepted: - // - // 1. `half-state-patrol.yml` DOES declare a `pull_request:` trigger, with - // a `paths:` filter naming the sweeper and the workflow — it already - // did on the day the card was filed. So the specimen was never a - // workflow no PR runs; it is a patrol that exercises itself on the PRs - // that change it, the posture every patrol in this tree keeps. - // 2. Across the whole tree, the number of discovered families whose - // source workflows ALL lack a PR-time trigger is ZERO — and it stays - // zero under the narrowest reading of "PR-time" as well. - // - // So the classification the card proposed has no members, and shipping it - // would be a capability with nothing in it. What ships instead is this pin: - // the reading is re-taken from the workflow text on every run, so the - // deferral goes loud the day the population stops being empty. ⛔ The cases - // below are the whole remedy for that day — they are not a roster to edit - // when one reds. See the header section of the same name for the exits. - const wfDirLive = nodePath.join(ROOT, '.github/workflows'); - const eventsWf = [ - 'name: Fixture', - '# a comment before the on: block', - 'on:', - ' schedule:', - " - cron: '37 1,7,13,19 * * *'", - ' workflow_dispatch: {}', - ' # a comment between events', - ' pull_request:', - ' types: [opened, synchronize]', - ' paths:', - " - 'scripts/pm/check-half-states.mjs'", - // A decoy at an event's OWN depth-plus-one: a key under `pull_request:` is - // not an event, however event-shaped its name. - ' push:', - ' branches: [main]', - 'jobs:', - ' sweep:', - // A decoy under `jobs:`, the shape a walk without the top-level reset eats. - ' merge_group:', - ' never: read', - '', - ].join('\n'); - const fixtureEvents = declaredTriggerEvents(eventsWf); - t('the on: mapping\'s events are read in declaration order', fixtureEvents.join('|') === 'schedule|workflow_dispatch|pull_request'); - t('a key nested UNDER an event is not an event, however event-shaped its name', !fixtureEvents.includes('push')); - t('a decoy event under jobs: is not read', !fixtureEvents.includes('merge_group')); - t('an event\'s own sub-keys never enter the list', !fixtureEvents.includes('types') && !fixtureEvents.includes('paths') && !fixtureEvents.includes('branches')); - t('the flow-sequence spelling is read', declaredTriggerEvents('on: [push, pull_request]\njobs: {}\n').join('|') === 'push|pull_request'); - t('the bare-scalar spelling is read', declaredTriggerEvents('on: push\njobs: {}\n').join('|') === 'push'); - t('the block-sequence spelling is read', declaredTriggerEvents('on:\n - push\n - schedule\njobs: {}\n').join('|') === 'push|schedule'); - // The YAML 1.1 coercion the two walkers above already accept: unquoted `on` - // is the boolean `true`, so all three spellings name the same key. - t('the quoted and YAML-1.1 spellings of the key are all read', ["'on'", '"on"', 'true'].every((k) => declaredTriggerEvents(`${k}:\n schedule:\n - cron: '0 1 * * *'\n`).join('|') === 'schedule')); - t('a workflow declaring no on: block yields an empty list, not a fabricated event', declaredTriggerEvents('name: X\njobs: {}\n').length === 0); - - // The same reader against REAL `on:` blocks, read from the tree rather than - // pasted: a quoted copy of a workflow is a second revision of it waiting to - // rot, which is what this whole file refuses. - const eventsOfWorkflow = new Map(); - for (const f of readdirSync(wfDirLive).filter((x) => /\.ya?ml$/.test(x))) { - eventsOfWorkflow.set(f, declaredTriggerEvents(readFileSync(nodePath.join(wfDirLive, f), 'utf8'))); - } - // ⚠️ The specimen lost its SCHEDULE on 2026-09-21 (ruling #208 on #19491, - // executed by #19497: the patrol is `workflow_dispatch`-only now, and no line - // of the sweeper was edited for it). Both cases below are re-pointed at the - // fact each was always about — the PR-time trigger, and the withholding class - // — and ⛔ nothing is added: pinning the absence of the schedule would be a - // new ratchet, which this file may not grow without the maintainer's word. - t( - '⭐ the card\'s own specimen declares a pull_request trigger beside its workflow_dispatch — half-state-patrol.yml is not a workflow no PR runs', - ['workflow_dispatch', 'pull_request'].every((e) => (eventsOfWorkflow.get('half-state-patrol.yml') ?? []).includes(e)), - ); - t( - 'and a genuinely scheduled-only workflow reads as one, so the predicate is not answering `pull_request` to everything (stale.yml)', - (eventsOfWorkflow.get('stale.yml') ?? []).join('|') === 'schedule|workflow_dispatch', - ); - - // The live half. Fixtures cannot prove the tree has no scheduled-only - // family; this reads it. - const reachesPRTime = (workflows, prTime = PR_TIME_TRIGGER_EVENTS) => - [...workflows].some((wf) => (eventsOfWorkflow.get(wf) ?? []).some((e) => prTime.includes(e))); - const isScheduled = (wf) => (eventsOfWorkflow.get(wf) ?? []).includes('schedule'); - const triggerFamilies = [...discoverFamilies().byCheck.values()]; - const scheduledWorkflows = [...eventsOfWorkflow.keys()].filter(isScheduled); - const scheduledContributors = scheduledWorkflows.filter((wf) => triggerFamilies.some((e) => e.workflows.has(wf))); - const fromScheduled = triggerFamilies.filter((e) => [...e.workflows].some(isScheduled)); - const scheduledOnly = triggerFamilies.filter((e) => !reachesPRTime(e.workflows) && [...e.workflows].every(isScheduled)); - // Non-vacuity, both halves — a zero over an empty sweep is a broken - // instrument wearing a clean result's clothes, which is #4690's shape. - t( - `the live tree really declares ${scheduledWorkflows.length} schedule-triggered workflow(s), so the sweep below has a population`, - scheduledWorkflows.length > 0, - ); - t( - `…and ${scheduledContributors.length} of them really contribute discovered families (${fromScheduled.length} famil(ies)), so the zero below is a reading`, - scheduledContributors.length > 0 && fromScheduled.length > 0, - ); - t( - `⭐ ZERO of the ${triggerFamilies.length} discovered families is SCHEDULED-ONLY — every one reaches a workflow that declares a PR-time` - + ' event, so no board sweep is routed into a per-PR gate list. If this reds, a scheduled-only family has ARRIVED: give its' - + ' workflow the pull_request paths trigger every patrol here already carries, or ship the withheld class the header defers', - scheduledOnly.length === 0, - ); - t( - '…and the reading does not depend on how wide PR-time is drawn: narrowing it to `pull_request` alone leaves the same zero', - triggerFamilies.filter((e) => !reachesPRTime(e.workflows, ['pull_request']) && [...e.workflows].every(isScheduled)).length === 0, - ); - // The complement, so the zero above cannot be the union quietly hiding a - // member: a family reached by NO PR-time event at all must come from a - // workflow that declares no `schedule` either. Today that is `cut-rc.yml`, - // the human release lane, which is `workflow_dispatch`-only. - const noPRTime = triggerFamilies.filter((e) => !reachesPRTime(e.workflows)); - t( - `the complement agrees: all ${noPRTime.length} famil(ies) reached by no PR-time event at all come from workflows that declare no schedule`, - noPRTime.every((e) => [...e.workflows].every((wf) => !isScheduled(wf))), - ); - // The control the card's ruling names: a family from a scheduled workflow - // that ALSO declares a PR-time trigger keeps the class it already had. The - // card's own specimen is the subject — it is withheld from `--commands` by - // the value-bearing class (#15083) and by nothing else, which is why the - // 3m09s it measured is gone without any scheduled-only rule existing. - const sweepEntry = triggerFamilies.find((e) => e.check.startsWith('scripts/pm/check-half-states.mjs')); - t( - 'the card\'s specimen is still discovered, still reached only through its patrol, and still classified VALUE-BEARING — withheld by that class and by nothing else', - Boolean(sweepEntry) - && [...sweepEntry.workflows].join('|') === 'half-state-patrol.yml' - && reachesPRTime(sweepEntry.workflows) - && Boolean(sweepEntry.notRunnable) - && !sweepEntry.ciOnly, - ); - // And the half this card must NOT move: the OFFLINE self-test lint.yml runs - // on every PR stays a runnable command. It is the same script's other - // spelling, and a rule keyed on the sweeper's name rather than on the - // workflow text — the per-script exclusion the ruling refused — would have - // taken this one with it. - const offlineHalf = triggerFamilies.find((e) => e.check === 'check:pm-half-states'); - t( - 'and the offline half CI runs on every PR is untouched — check:pm-half-states reaches lint.yml, carries neither withholding class, and still renders a runnable command', - Boolean(offlineHalf) - && offlineHalf.workflows.has('lint.yml') - && reachesPRTime(offlineHalf.workflows) - && !offlineHalf.ciOnly - && !offlineHalf.notRunnable, - ); - - // ── The population a job `if:` names one hop away (#12956) ──────────────── - // - // The card: an `.objectui-sha` diff derived NO pin-critical gate, because - // ci.yml declares no workflow `paths:` at all — its filtering lives in a - // `filter` job's dorny/paths-filter step, read by every other job's `if:`. - // The fixture carries every shape that must be READ and every shape that must - // be REFUSED, because the refusals are the half that keeps a widening from - // fabricating leads across a whole workflow at once. - const jobFilterWf = [ - 'name: Fixture', - 'on:', - ' pull_request:', - ' branches: [main]', - 'jobs:', - ' filter:', - ' runs-on: ubuntu-latest', - ' outputs:', - " console: ${{ steps.changes.outputs.console || 'true' }}", - // The indirection is real: the job output NAME and the filter name differ. - " area: ${{ steps.changes.outputs.core || 'true' }}", - ' steps:', - ' - uses: dorny/paths-filter@v4', - ' id: changes', - ' with:', - ' filters: |', - ' console:', - " - '.objectui-sha'", - ' core:', - " - 'packages/**'", - ' # a comment between entries', - " - 'apps/!(docs)/**'", - ' console-pin:', - ' name: Console Pin Gate', - ' needs: filter', - " if: ${{ !cancelled() && needs.filter.outputs.console != 'false' }}", - ' steps:', - ' - run: pnpm check:console-sha', - ' both:', - ' name: Two Areas', - " if: ${{ !cancelled() && (needs.filter.outputs.console != 'false' || needs.filter.outputs.area != 'false') }}", - ' steps:', - ' - run: pnpm check:two', - ' always-on:', - ' name: Always', - ' steps:', - ' - run: pnpm check:always', - ' intersected:', - ' name: Intersected', - " if: ${{ needs.filter.outputs.console != 'false' && needs.filter.outputs.area != 'false' }}", - ' steps:', - ' - run: pnpm check:intersected', - '', - ].join('\n'); - - const fixtureJobs = extractJobBlocks(jobFilterWf); - t( - 'every job under jobs: is segmented, and nothing above it is', - fixtureJobs.map((j) => j.id).join('|') === 'filter|console-pin|both|always-on|intersected', - ); - t( - "a job's declared name is read, and an unnamed job falls back to its id", - fixtureJobs.find((j) => j.id === 'console-pin')?.name === 'Console Pin Gate' - && fixtureJobs.find((j) => j.id === 'filter')?.name === 'filter', - ); - t( - 'a job block keeps its own steps and NOT the next job\'s', - fixtureJobs.find((j) => j.id === 'console-pin')?.text.includes('check:console-sha') - && !fixtureJobs.find((j) => j.id === 'console-pin').text.includes('check:two'), - ); - const fixtureSteps = extractPathsFilterSteps(jobFilterWf); - t('the paths-filter step is keyed by the id downstream references use', fixtureSteps.has('changes')); - t( - 'its filters block scalar is parsed into named glob lists', - fixtureSteps.get('changes')?.get('console')?.join('|') === '.objectui-sha' - && fixtureSteps.get('changes')?.get('core')?.join('|') === 'packages/**|apps/!(docs)/**', - ); - t( - "the job's outputs: mapping is resolved to the STEP output each value reads, not assumed to share its name", - (() => { - const src = extractJobOutputSources(fixtureJobs.find((j) => j.id === 'filter').text); - return src.get('console')?.output === 'console' && src.get('area')?.output === 'core' - && src.get('area')?.step === 'changes'; - })(), - ); - - // The `if:` whitelist, in both directions. Everything the live tree spells is - // read; everything else is refused rather than approximated. - t( - "the live spelling reads: !cancelled() is stripped and != 'false' is the run condition", - jobFilterOutputRefs("${{ !cancelled() && needs.filter.outputs.console != 'false' }}") - ?.map((r) => `${r.job}.${r.output}`).join('|') === 'filter.console', - ); - t( - 'an OR of two outputs reads as BOTH, in declaration order', - jobFilterOutputRefs("${{ !cancelled() && (needs.filter.outputs.core != 'false' || needs.filter.outputs.crosspkg != 'false') }}") - ?.map((r) => r.output).join('|') === 'core|crosspkg', - ); - t("the == 'true' spelling of the same condition reads too", jobFilterOutputRefs("${{ needs.filter.outputs.core == 'true' }}")?.length === 1); - t('an AND of two filter outputs is REFUSED — that is an intersection this does not compute', jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && needs.f.outputs.b != 'false' }}") === null); - t('an INVERTED comparison is refused, not read as its opposite', jobFilterOutputRefs("${{ needs.f.outputs.a == 'false' }}") === null); - t('a term this cannot read refuses the WHOLE expression', jobFilterOutputRefs("${{ github.event_name == 'push' || needs.f.outputs.a != 'false' }}") === null); - t('an if: naming no filter output at all yields no population', jobFilterOutputRefs("${{ github.ref == 'refs/heads/main' }}") === null); - t('an absent if: is not an expression', jobFilterOutputRefs(null) === null); - - const fixturePops = jobPathPopulations(jobFilterWf, 'fixture.yml'); - t( - 'only the jobs whose if: RESOLVED and that invoke a check family contribute a population', - fixturePops.map((p) => p.job).join('|') === 'console-pin|both', - ); - t( - 'the console job resolves to the globs its filter declares, and names the check it runs', - fixturePops[0].paths.join('|') === '.objectui-sha' && fixturePops[0].checks.join('|') === 'check:console-sha', - ); - t( - 'a two-output if: takes the UNION of both filters', - fixturePops[1].paths.join('|') === '.objectui-sha|packages/**', - ); - t( - 'the extglob entry is DROPPED and COUNTED, never translated with a language that lacks it', - fixturePops[1].dropped === 1 && !fixturePops[1].paths.some((p) => p.includes('!(')), - ); - t( - 'a job CI schedules unconditionally contributes nothing — it discriminates no path', - !fixturePops.some((p) => p.job === 'always-on'), - ); - t( - 'and the AND-joined job contributes nothing rather than an over-claimed union', - !fixturePops.some((p) => p.job === 'intersected'), - ); - t( - 'an extglob NEGATION refuses the whole population — dropping it would WIDEN what is claimed', - jobFilterPopulation( - { if: "${{ needs.f.outputs.a != 'false' }}" }, - new Map([['f.a', ['packages/**', '!(vendor)/**']]]), - ) === null, - ); - t( - 'a workflow with no paths-filter step at all yields no job populations', - jobPathPopulations("on:\n pull_request:\n branches: [main]\njobs:\n a:\n steps:\n - run: pnpm check:x\n", 'x.yml').length === 0, - ); - - // Matching, and the precedence question the new key raises. A job filter is a - // DECLARATION CI obeys, so it outranks a literal scanned out of a script and - // sits under the workflow trigger, which decides whether the job runs at all. - const jfEntry = { - files: [], hints: ['scripts/somewhere'], triggers: [], - jobFilters: [{ workflow: 'ci.yml', job: 'console-pin', name: 'Console Pin Gate', outputs: ['filter.console'], paths: ['.objectui-sha'], dropped: 0 }], - }; - t( - 'a job filter matches its path and names the JOB a dev will see go red', - coveringKey(jfEntry, '.objectui-sha')?.via === "CI job filter for 'Console Pin Gate' in ci.yml", - ); - t('a path the job filter does not cover gains nothing from it', coveringKey(jfEntry, 'packages/spec/src/x.ts') === null); - t( - 'the workflow trigger still outranks the job filter where both fire', - coveringKey({ ...jfEntry, triggers: [{ workflow: 'ci.yml', paths: ['.objectui-sha'] }] }, '.objectui-sha')?.via - === 'CI trigger in ci.yml', - ); - t( - 'and the job filter outranks a scanned hint that also covers', - coveringKey({ ...jfEntry, hints: ['.objectui-sha'] }, '.objectui-sha')?.via - === "CI job filter for 'Console Pin Gate' in ci.yml", - ); - t( - 'a family with no job filters is unchanged by the new key', - coveringKey({ files: [], hints: ['scripts/somewhere'], triggers: [] }, 'scripts/somewhere/x.mjs')?.via === 'gate source', - ); - - // ── LIVE: the card's acceptance criterion, pinned against the real ci.yml ── - // - // Pinned rather than left to the fixture, because the whole finding was that - // the FIXTURE-shaped question ("can it follow an indirection?") had a - // different answer from the LIVE one. If the console job is renamed or its - // filter re-spelled, re-point these cases — do not delete them: they are the - // measured statement that a pin bump derives its own gates. - const liveCiPops = jobPathPopulations(readFileSync(nodePath.join(ROOT, '.github/workflows/ci.yml'), 'utf8'), 'ci.yml'); - const livePinJob = liveCiPops.find((p) => p.checks.includes('check:console-sha')); - t('the live console job is found by the gate it runs', Boolean(livePinJob)); - t('and it is named Console Pin Gate — the name branch protection and the Checks tab use', livePinJob?.name === 'Console Pin Gate'); - t( - 'its derived population covers the pin file, which is the whole acceptance criterion', - Boolean(livePinJob && triggerListCovers(livePinJob.paths, '.objectui-sha')), - ); - t( - 'both console gates are reached, not just the one the card named', - Boolean(livePinJob?.checks.includes('check:console-sha') && livePinJob?.checks.includes('check:console-injection')), - ); - // The NEGATIVE control, and it is the half that keeps the widening honest: a - // path in none of the four filters must derive nothing extra. AGENTS.md is a - // repo-root file no filter names. - t( - 'a path none of the live filters covers matches NO job filter — the widening is not a blanket', - !liveCiPops.some((p) => triggerListCovers(p.paths, 'AGENTS.md')), - ); - t( - 'and the same sweep DOES cover a packages/ path, so that zero is a reading rather than a broken instrument', - liveCiPops.some((p) => triggerListCovers(p.paths, 'packages/spec/src/index.ts')), - ); - - // The pattern language. `*` must not cross a slash and `**` must, or a - // trigger reads as narrower or wider than the one CI obeys. - t('a double-star trigger covers a file any depth below it', triggerCovers('packages/spec/**', 'packages/spec/src/data/filter.zod.ts')); - t('a single star does NOT cross a path separator', !triggerCovers('packages/*', 'packages/spec/src/index.ts')); - t('the same single star still covers a direct child', triggerCovers('packages/*', 'packages/spec')); - t('a leading double-star reaches a nested file', triggerCovers('**/package.json', 'packages/spec/package.json')); - t('an exact-file trigger covers exactly that file', triggerCovers('pnpm-workspace.yaml', 'pnpm-workspace.yaml')); - t('an unrelated path is not covered', !triggerCovers('packages/spec/**', 'packages/rest/src/server.ts')); - t('a dot in a trigger is a literal dot, not a wildcard', !triggerCovers('pnpm-lock.yaml', 'pnpm-lockXyaml')); - // The directory-surface reach and the reach deliberately refused — a card's - // file surface is often given as a directory, but a pattern whose literal - // prefix is empty could sit under ANY directory and must not claim one. - t('a directory surface derives a trigger that reaches into it', triggerCovers('packages/spec/**', 'packages/spec')); - t('a leading-wildcard trigger does NOT claim an arbitrary directory surface', !triggerCovers('**/package.json', 'packages/spec')); - t('nor a sibling directory sharing a name prefix', !triggerCovers('packages/spec/**', 'packages/spec-extra')); - - // Ordered negation, both directions — CI evaluates the list in order and so - // must this, or an excluded path derives a job that will never run on it. - t('a plain list answers with the pattern that covered', triggerListCovers(['docs/**', 'packages/spec/**'], 'packages/spec/x.ts') === 'packages/spec/**'); - t('a later negation excludes what an earlier pattern included', triggerListCovers(['packages/**', '!packages/spec/**'], 'packages/spec/x.ts') === null); - t('a later positive re-includes it', triggerListCovers(['packages/**', '!packages/spec/**', 'packages/spec/src/**'], 'packages/spec/src/x.ts') === 'packages/spec/src/**'); - t('a list of negations alone covers nothing', triggerListCovers(['!packages/**'], 'packages/spec/x.ts') === null); - t('an empty list covers nothing — no filter is not a filter that matches all', triggerListCovers([], 'packages/spec/x.ts') === null); - - // Precedence and the bucket. A trigger match must outrank a scanned literal - // (a declaration beats an inference) and must never be allowed to answer the - // bucket's question, which is about the gate's SOURCE. - const triggered = { files: [], hints: [], triggers: [{ workflow: 'spec-liveness-check.yml', paths: ['packages/spec/**'] }] }; - t('a family with no hints at all is MATCHED when CI schedules it', classifyEntry(triggered, ['packages/spec/src/x.ts']).verdict === 'matched'); - // Read defensively: a regression here produces NO hit, and an assertion that - // indexed straight into `hits[0]` would throw and abort the whole self-test - // run — every case below it, the live liveness pins included, would then stop - // reporting. A gate that fails must still say what else it checked. - t('and its provenance says the claim came from CI, not from a string in a script', Boolean(classifyEntry(triggered, ['packages/spec/src/x.ts']).hits[0]?.via?.includes('spec-liveness-check.yml'))); - t('the same family is still undetermined for a card the workflow does not schedule', classifyEntry(triggered, ['packages/rest/src/server.ts']).verdict === 'undetermined'); - const allThreeKeys = { - files: ['scripts/check-x.mjs'], - hints: ['packages/spec/src'], - triggers: [{ workflow: 'w.yml', paths: ['packages/spec/**'] }], - }; - t('identity still outranks CI trigger', coveringKey(allThreeKeys, 'scripts/check-x.mjs')?.via === 'gate script'); - t('CI trigger outranks a scanned literal that also covers', coveringKey(allThreeKeys, 'packages/spec/src/x.ts')?.via === 'CI trigger in w.yml'); - t('a scanned literal still answers where no trigger covers', coveringKey({ hints: ['packages/rest/src'], triggers: [{ workflow: 'w.yml', paths: ['packages/spec/**'] }] }, 'packages/rest/src/x.ts')?.via === 'gate source'); - t('an entry with no triggers at all behaves exactly as before', coveringKey({ files: [], hints: ['packages/spec/src'] }, 'packages/spec/src/x.ts')?.via === 'gate source'); - - // The card's own specimen, end to end against the LIVE workflow: the trigger - // is read off the file rather than inferred from the one hit that surfaced - // this (a `packages/objectql/**` path, which is NOT in the list at all — the - // job ran because that PR also touched a path that is). If this workflow is - // renamed or its gates move, re-point these cases; do not delete them. - const livenessWf = readFileSync(nodePath.join(ROOT, '.github/workflows/spec-liveness-check.yml'), 'utf8'); - const livenessTriggers = extractTriggerPaths(livenessWf); - t('the liveness workflow really declares a path filter', livenessTriggers.length > 0); - const livenessFamilies = extractCheckInvocations(livenessWf, 'spec-liveness-check.yml').map((i) => i.check); - t('check:liveness really is one of that workflow\'s families', livenessFamilies.includes('check:liveness')); - const livenessEntry = { files: [], hints: [], triggers: [{ workflow: 'spec-liveness-check.yml', paths: livenessTriggers }] }; - t('so a card editing the spec now derives it', classifyEntry(livenessEntry, ['packages/spec/src/data/filter.zod.ts']).verdict === 'matched'); - t('a dogfood proof edit derives it too — the ADR-0054 half of the same job', classifyEntry(livenessEntry, ['packages/qa/dogfood/src/some.test.ts']).verdict === 'matched'); - t('and a hand-written doc page, which is why the trigger is read and not guessed at packages/spec', classifyEntry(livenessEntry, ['content/docs/reference/apps.mdx']).verdict === 'matched'); - t('while an unrelated package still derives nothing from it', classifyEntry(livenessEntry, ['packages/rest/src/server.ts']).verdict === 'undetermined'); - - // 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); - // Seven, not six, since #10542 added check:cross-package-test-inputs to the - // test-file kind. `a.test.ts` is a root-level TypeScript file, so it is BOTH - // a test file and inside the root tsc program and legitimately hits two - // kinds. The ratchet therefore renders twice, under a different `why` each - // time — pinned just below, because a bare count cannot tell that apart from - // one kind rotting away. - t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 7); - t('a root-level test file hits both kinds, so the ratchet renders STALE under each', stale.filter((l) => l.includes('\u26a0 check:type-check-debt: STALE')).length === 2); - // 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( - 'and so does the cross-package-inputs entry, anchored on both sides of its own name', - stale.some((l) => l.includes('⚠ check:cross-package-test-inputs: STALE')), - ); - t('the ratchet half renders STALE under its own name', stale.some((l) => l.includes('⚠ check:type-check-debt: STALE'))); - t('the engine-double ratchet renders STALE under its own name', stale.some((l) => l.includes('⚠ check:engine-double-contract: STALE'))); - t('the where-matcher ratchet renders STALE under its own name', stale.some((l) => l.includes('⚠ check:where-matcher: 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))); - - // ── The census guard (#8632) ────────────────────────────────────────────── - // - // CHANGE_KIND_GATES is the one enumerable list in this file, so it is the one - // list a guard can hold. The STALE branch reports a rotted name to whoever - // reads the output; this case makes the same rot fail CI, against the REAL - // workflow tree rather than a fixture. Discovery is repeated here rather than - // borrowed from `derive`, which prints instead of returning — the assertion is - // "every name in the table is a family the workflows really run", and it needs - // the live population to mean anything. - const liveFamilies = new Set(); - for (const wf of readdirSync(nodePath.join(ROOT, '.github/workflows')).filter((f) => /\.ya?ml$/.test(f))) { - for (const i of extractCheckInvocations(readFileSync(nodePath.join(ROOT, '.github/workflows', wf), 'utf8'), wf)) { - liveFamilies.add(i.check); - } - } - t('the live workflows discover a farm at all (the guard is not vacuous)', liveFamilies.size > 20); - const declared = CHANGE_KIND_GATES.flatMap((k) => k.gates.map((g) => g.name)); - const missing = declared.filter((n) => !liveFamilies.has(n)); - t(`every convention gate named in the table is a live family (missing: ${missing.join(', ') || 'none'})`, missing.length === 0); - // The two gates this card moved out of the closing prose, pinned individually - // — a count alone stays green if one is dropped and another added. - t('check:engine-double-contract is a live family, so naming it in the table is not a guess', liveFamilies.has('check:engine-double-contract')); - t('check:where-matcher is a live family too — the gate the prose never named', liveFamilies.has('check:where-matcher')); - t( - '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: - // its gate is reached only 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')); - // #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')); - - // ── The test-file entry's deletion criterion, MEASURED (#11199) ─────────── - // - // The card behind these cases reported that no local derivation ever named - // `check:cross-package-test-inputs` for an edited test file. That is closed — - // the entry above has been in the table since #10542 — and the reason these - // cases exist rather than a seventh entry is what the re-measurement found: - // the entry now READS redundant against its own stated deletion criterion, - // and it is not. The full measurement is in that criterion's bullet in this - // table's docblock; what is pinned here is every load-bearing half of it, so - // the claim reddens instead of ageing. - // - // Both directions matter. The positive case keeps the redundancy honest (the - // hint route really does reach an ordinary packages test file — Zone rule: - // two routes to one gate is redundancy, never a bug, and neither may be - // deleted BECAUSE of the other). The negative cases are the residue: a class - // the hint route cannot reach in principle, with live tracked specimens. - const XPKG = 'check:cross-package-test-inputs'; - const xpkgEntry = discoverFamilies().byCheck.get(XPKG); - // Live specimens, one per residue reason. If either file is ever deleted or - // renamed, re-point the case at another member of its class — and if a class - // ever EMPTIES, that is the measurement to redo, not a case to drop. - const OUTSIDE_PACKAGES = 'examples/app-crm/test/smoke.test.ts'; // not under packages/** - const TSX_TEST = 'packages/client-react/src/realtime-hooks.test.tsx'; // not *.ts - const APPS_TEST = 'apps/docs/src/x.test.ts'; // no tracked member today - t('the gate is discovered with hints at all, so these cases are not vacuous', (xpkgEntry?.hints ?? []).length > 0); - t('both residue specimens are real tracked files, so the negatives are live rather than a pair of matching strings', - existsSync(nodePath.join(ROOT, OUTSIDE_PACKAGES)) && existsSync(nodePath.join(ROOT, TSX_TEST))); - t('the hint route really does reach an ordinary packages test file — the redundancy #12300 recovered is real', - covers(xpkgEntry.hints, 'packages/spec/src/x.test.ts')); - t('but no hint of this gate reaches a test file outside packages/**', !covers(xpkgEntry.hints, OUTSIDE_PACKAGES)); - t('nor a .tsx test file inside it', !covers(xpkgEntry.hints, TSX_TEST)); - t('nor one under apps/**, the class with no tracked member to lose', !covers(xpkgEntry.hints, APPS_TEST)); - // The entry itself, anchored on both sides of the rendered name the way the - // STALE cases above are: a bare substring test stays green if some other - // gate's `why` ever quotes this gate's name. - t('the KIND names the gate for every one of them — delete the entry and this reddens', - [OUTSIDE_PACKAGES, TSX_TEST, APPS_TEST].every((p) => - changeKindLines([p], (n) => n).some((l) => l.includes(`- ${XPKG} —`)))); - // The fragility half: the covering hint is INHERITED from the declaration - // table this gate imports (one package's declared turbo `inputs` glob), not - // declared by the gate as its own population. `hintOrigin` carries exactly - // that provenance, and it is what the output prints as `gate source via …`. - const xpkgCovering = xpkgEntry.hints.find((h) => hintCovers(h, 'packages/spec/src/x.test.ts')); - t('and that covering hint is inherited from a module the gate imports, not a population the gate declares', - Boolean(xpkgEntry.hintOrigin?.get(xpkgCovering))); - // The class-level claim, against the real corpus rather than two specimens: - // while ANY tracked test file is unreachable by every hint this gate has, the - // entry's deletion criterion is unmet. The day this reddens, re-measure the - // criterion and either retire the entry with these cases or re-point them. - const xpkgResidue = trackedFiles().filter((f) => isTestFilePath(f) && !covers(xpkgEntry.hints, f)); - t(`the tree still holds test files no hint of this gate reaches (${xpkgResidue.length}), so the entry is not redundant`, - xpkgResidue.length > 0); - - // ── The test-file entry's hint-set prose, re-derived (#13232) ───────────── - // - // This entry's docblock used to TRANSCRIBE the three ratchets' hint sets and - // conclude from the copy that all three score `silent`. Both halves went - // false without anything editing this file — one ratchet grew a real - // population literal (#13231), and the git ref two of the rows named stopped - // being admitted as a hint at all — so the transcription is gone and what it - // asserted is re-derived here instead. A red in this block means the prose - // above is due a re-reading, not that the derivation broke. - const WM = 'check:where-matcher'; - const QOE = 'check:query-options-erasure'; - const EDC = 'check:engine-double-contract'; - const ratchetEntries = new Map([WM, QOE, EDC].map((c) => [c, discoverFamilies().byCheck.get(c)])); - t('all three ratchets are still discovered with hints, so nothing below is vacuous', - [...ratchetEntries.values()].every((e) => (e?.hints ?? []).length > 0)); - // The half that survived: two of the three still name only artifacts, so - // `silent` for every card in the tree is still the right description of them. - const ORDINARY_TEST = 'packages/spec/src/x.test.ts'; - t(`${QOE} still names nothing that can cover a card's test file — the surviving half of the old paragraph`, - !covers(ratchetEntries.get(QOE).hints, ORDINARY_TEST) && !covers(ratchetEntries.get(QOE).hints, OUTSIDE_PACKAGES)); - t(`…and so does ${EDC}`, - !covers(ratchetEntries.get(EDC).hints, ORDINARY_TEST) && !covers(ratchetEntries.get(EDC).hints, OUTSIDE_PACKAGES)); - // The half that broke, pinned in the direction that broke it: the moment this - // reddens, `check:where-matcher` is silent again and the ⚠ paragraph is wrong. - t(`${WM} IS reached by the ordinary path derivation for a packages test file — the exception the prose states`, - covers(ratchetEntries.get(WM).hints, ORDINARY_TEST)); - t('…and that covering hint is the gate\'s OWN declared population, not one inherited from a module it imports', - !ratchetEntries.get(WM).hintOrigin?.get(ratchetEntries.get(WM).hints.find((h) => hintCovers(h, ORDINARY_TEST)))); - // Why it stays in the table regardless — the same two-direction argument the - // #11199 block above makes for check:cross-package-test-inputs. - t(`but no hint of ${WM} reaches a test file outside its scan root, while the KIND does`, - !covers(ratchetEntries.get(WM).hints, OUTSIDE_PACKAGES) - && changeKindLines([OUTSIDE_PACKAGES], (n) => n).some((l) => l.includes(`- ${WM} —`))); - const wmResidue = trackedFiles().filter((f) => isTestFilePath(f) && !covers(ratchetEntries.get(WM).hints, f)); - t(`the tree still holds test files no hint of ${WM} reaches (${wmResidue.length}), so its line is not redundant either`, - wmResidue.length > 0); - // The drift the deleted transcription could not report, closed at the source - // rather than by re-copying: both sources still SPELL the ref, and neither - // yields it as a hint. This is the case that reddens if the refusal is ever - // relaxed and a row naming `origin/main` becomes writable again. - const REF = ['origin', 'main'].join('/'); - const refSpellers = [ratchetEntries.get(QOE), ratchetEntries.get(WM)] - .flatMap((e) => e.files ?? []) - .filter((f) => existsSync(nodePath.join(ROOT, f)) && readFileSync(nodePath.join(ROOT, f), 'utf8').includes(`'${REF}'`)); - t(`both ratchet sources still spell ${REF} (${refSpellers.length}), so the next case is about the extractor and not a missing literal`, - refSpellers.length === 2); - t(`…and not one of them yields it as a hint, which is why no row here may name it`, - refSpellers.every((f) => !extractWatchHints(readFileSync(nodePath.join(ROOT, f), 'utf8'), f).includes(REF))); - - // ── The check-family coverage guard (#9187) ─────────────────────────────── - // - // `docs-drift-check.yml` declared a `paths:` filter and ran a real self-test - // (`node scripts/docs-audit/affected-docs.mjs --self-test`) that discovery - // could never see, because the naming convention every OTHER family follows - // — `check:NAME` or `check-NAME.mjs` — is enforced nowhere: the tree just - // happened to comply 103 times running up to this card. This section rules - // it normative: a paths-filtered workflow with no discovered family is now - // a CI failure, not a lead nobody could see. Fixture cases pin the shape; - // the live case at the end pins it against the real tree, the same pairing - // the census guard above uses. - // - // #11404 RE-BASED THE FIXTURE, and the reason is the card itself. This - // section's original invisible-step fixture was verbatim the shape #9187 - // measured — `node scripts/some-mapper.mjs --self-test` — and the third - // matcher in extractCheckInvocations now DISCOVERS that shape, so the - // fixture stopped being an example of the thing it illustrates. The - // invisible step here is now a `node scripts/…` command that is neither a - // `check-` basename nor a self-test, which is what genuinely has no family - // today; the retired shape is pinned as discovered two cases below, so the - // pair records the move rather than losing it. - const noFamilyWf = [ - 'name: X', - 'on:', - ' pull_request:', - ' paths:', - " - 'packages/**'", - 'jobs:', - ' j:', - ' steps:', - ' - name: Run the mapper', - ' run: node scripts/some-mapper.mjs --emit', - ].join('\n'); - t( - 'a paths-filtered workflow discovering no check family is a coverage gap', - checkFamilyCoverageGaps([{ file: 'x.yml', text: noFamilyWf }]).includes('x.yml'), - ); - const familyWf = noFamilyWf.replace( - 'node scripts/some-mapper.mjs --emit', - 'pnpm check:some-mapper', - ); - t( - 'a paths-filtered workflow that DOES discover a family is not a gap', - checkFamilyCoverageGaps([{ file: 'x.yml', text: familyWf }]).length === 0, - ); - // The retired fixture, kept as the pin for what #11404 changed: the exact - // step #9187 recorded as undiscoverable is a family now, so the same - // workflow is no longer a coverage gap. - const selfTestFamilyWf = noFamilyWf.replace( - 'node scripts/some-mapper.mjs --emit', - 'node scripts/some-mapper.mjs --self-test', - ); - t( - "the step #9187 measured as invisible is discovered now, so its workflow is no longer a gap", - checkFamilyCoverageGaps([{ file: 'x.yml', text: selfTestFamilyWf }]).length === 0, - ); - const unfilteredNoFamilyWf = [ - 'name: X', - 'on:', - ' pull_request: {}', - 'jobs:', - ' j:', - ' steps:', - ' - name: Run the mapper', - ' run: node scripts/some-mapper.mjs --emit', - ].join('\n'); - t( - 'an UNFILTERED workflow with no family is not a gap — it runs on every PR regardless, the residue bucket already accounts for it', - checkFamilyCoverageGaps([{ file: 'x.yml', text: unfilteredNoFamilyWf }]).length === 0, - ); - t( - 'the declared opt-out reads its reason back', - declaredNoCheckFamiliesReason('# dispatch-gates: no-check-families -- e2e build, no named verification (#9187)\n') - === 'e2e build, no named verification (#9187)', - ); - t('no marker present reads as no declared reason', declaredNoCheckFamiliesReason('# just a comment\n') === null); - t('the marker with no reason text does not count as declared', declaredNoCheckFamiliesReason('# dispatch-gates: no-check-families\n') === null); - const exemptedWf = noFamilyWf.replace( - 'jobs:', - '# dispatch-gates: no-check-families -- fixture, not a real verification step\njobs:', - ); - t( - "a paths-filtered, zero-family workflow carrying the marker is NOT a gap — the declared opt-out this card's route requires", - checkFamilyCoverageGaps([{ file: 'x.yml', text: exemptedWf }]).length === 0, - ); - - // ── This marker's reason is WHOLE, or the declaration is REFUSED (#18662) ── - // - // The capture was a fourth hand-written copy of the reason-tail grammar, so - // the #18422 wholeness reading never reached it: a reason wrapped onto the - // comment line below read back as line ONE and `checkFamilyCoverageGaps` - // accepted the workflow without a sound. Measured on `origin/main` - // 034f5a3afd before this change — the reading the cases below turn green. - const wrappedNoFamilyWf = [ - 'name: scaffold-e2e', - 'on:', - ' pull_request:', - " paths: ['packages/create-objectstack/**']", - '# dispatch-gates: no-check-families -- steps are an install/build/boot pipeline, and the verdict is', - '# whether the scaffolded app boots at all, which no named local check family covers', - '', - 'jobs:', - ' e2e:', - ' steps:', - ' - run: pnpm install', - ].join('\n'); - { - let refused = null; - try { - declaredNoCheckFamiliesReason(wrappedNoFamilyWf, '.github/workflows/scaffold-e2e.yml'); - } catch (error) { - refused = String(error.message); - } - t( - 'a no-check-families reason that does not END on the marker line is REFUSED, naming the workflow, the line, the marker and the continuation', - refused !== null - && refused.includes('.github/workflows/scaffold-e2e.yml declares no-check-families') - && refused.includes('.github/workflows/scaffold-e2e.yml:6 continues it with') - && refused.includes('"whether the scaffolded app boots at all, which no named local check family covers"') - && refused.includes('The capture stops at the FIRST NEWLINE'), - refused, - ); - } - t( - 'and the refusal reaches the ONE consumer this marker has — the boolean read in checkFamilyCoverageGaps refuses rather than accepting half a sentence', - (() => { - try { - checkFamilyCoverageGaps([{ file: '.github/workflows/scaffold-e2e.yml', text: wrappedNoFamilyWf }]); - return false; - } catch (error) { - return String(error.message).includes('declares no-check-families and its reason does not END on the marker line'); - } - })(), - ); - t( - 'the WHOLE-reason control still reads back and is still not a gap — the repair refuses a cut, it does not refuse the marker', - (() => { - const whole = wrappedNoFamilyWf.replace( - '# whether the scaffolded app boots at all, which no named local check family covers\n', - '', - ); - return declaredNoCheckFamiliesReason(whole, '.github/workflows/scaffold-e2e.yml') - === 'steps are an install/build/boot pipeline, and the verdict is' - && checkFamilyCoverageGaps([{ file: '.github/workflows/scaffold-e2e.yml', text: whole }]).length === 0; - })(), - ); - t( - 'nor is an unrelated comment separated by a blank line a continuation — the terminator the three live declarations already write', - declaredNoCheckFamiliesReason( - '# dispatch-gates: no-check-families -- an e2e pipeline, not named local checks\n\n# an unrelated remark\njobs:\n', - 'x.yml', - ) === 'an e2e pipeline, not named local checks', - ); - // ⛔ `#` is the ONLY comment form YAML has, so the forms #18661 added cannot - // apply to THIS marker: a `//` or slash-star line in a workflow is document - // content, not a remark. Both directions are pinned — the restriction holds, - // and it is a restriction of the shared roster rather than a second grammar. - t( - 'a // or block-form spelling in a workflow is NOT a declaration — those are not comments in YAML', - declaredNoCheckFamiliesReason('// dispatch-gates: no-check-families -- not a YAML comment\n') === null - && declaredNoCheckFamiliesReason('/* dispatch-gates: no-check-families -- not a YAML comment */\n') === null - && declaredNoCheckFamiliesReason('/** dispatch-gates: no-check-families -- not a YAML comment */\n') === null, - ); - t( - "and that restriction is the shared roster FILTERED, not a second pattern: the key's head lists exactly the `#` form", - populationMarkerPattern('no-check-families').source.startsWith('^[ \\t]*(#)[ \\t]*dispatch-gates:'), - populationMarkerPattern('no-check-families').source, - ); - t( - 'the restriction NARROWS and nothing else — a key the table does not name keeps the whole roster', - markerFormsFor('no-check-families').map((f) => f.label).join(' ') === '#' - && markerFormsFor('no-path-population').length === MARKER_COMMENT_FORMS.length, - ); - t( - 'every label MARKER_KEY_FORMS restricts a key to is one MARKER_COMMENT_FORMS really carries (the live half)', - Object.values(MARKER_KEY_FORMS) - .every((labels) => labels.every((l) => MARKER_COMMENT_FORMS.some((f) => f.label === l))), - ); - t( - 'and a restriction naming a label the roster does NOT carry REFUSES, rather than emptying the alternation silently — a form set that quietly emptied would make every declaration of that key parse as nothing', - (() => { - try { - markerFormsFor('no-check-families', { 'no-check-families': ['rem'] }); - return false; - } catch (error) { - return String(error.message).includes('may only ever NARROW the roster'); - } - })(), - ); - t( - 'the wholeness reading now REACHES this marker, in the same shape it reaches the population three', - (() => { - const cut = populationReasonContinuation(wrappedNoFamilyWf, 'no-check-families', 'w.yml'); - return cut?.line === 6 && cut?.kind === 'line' && cut?.file === 'w.yml' - && cut?.text === 'whether the scaffolded app boots at all, which no named local check family covers'; - })(), - ); - - // ── The gate-level no-population declaration (#10542) ───────────────────── - // - // The workflow-level marker above says "this workflow names no gate"; this - // one says "this gate names no path, and here is why". Both directions are - // pinned, and so is the live tree, because the whole value of the second is - // that it separates families that have been READ from families nobody has - // looked at — and a marker that quietly stopped parsing would merge them back - // together while every count still printed. - t( - 'a gate-level no-population declaration reads its reason back', - declaredNoPathPopulation('// dispatch-gates: no-path-population -- CI runs the self-test only\n') - === 'CI runs the self-test only', - ); - t( - 'the shell comment spelling is read too (shell gates carry # comments, and the derivation discovers them)', - declaredNoPathPopulation('#!/usr/bin/env bash\n# dispatch-gates: no-path-population -- a shell gate reason\n') - === 'a shell gate reason', - ); - t('no marker present reads as no declared no-population', declaredNoPathPopulation('// just a comment\n') === null); - t( - 'the marker with no reason text does not count as declared (an opt-out with no reason reads exactly like a placeholder nobody will revisit)', - declaredNoPathPopulation('// dispatch-gates: no-path-population\n') === null, - ); - t( - 'the marker must be its OWN line — a mention inside prose is a discussion of the convention, not a declaration under it', - declaredNoPathPopulation('// see the dispatch-gates: no-path-population -- marker for how to opt out\n') === null, - ); - - // ── The gate-level WHOLE-TREE declaration (#14189) ──────────────────────── - // - // The marker above says "this gate names no path". This one says the exact - // opposite — "this gate reads every path" — and it exists because a gate - // whose population is the whole tree had no truthful thing to say: the - // honest literal is "every file", which this file's header prices as 22 - // leads and refuses. Every pin below is paired with the failure it catches; - // the placement pins are the discriminating ones, and the mutation that - // reddens them is deleting the declaration branch from `placeFamily`. - t( - 'a whole-tree declaration reads its reason back', - declaredWholeTreePopulation('// dispatch-gates: whole-tree-population -- it sweeps git ls-files\n') - === 'it sweeps git ls-files', - ); - t( - 'the shell comment spelling is read too (the two markers tolerate the same comment forms, deliberately)', - declaredWholeTreePopulation('#!/usr/bin/env bash\n# dispatch-gates: whole-tree-population -- a shell gate reason\n') - === 'a shell gate reason', - ); - t('no marker present reads as no declared whole-tree population', declaredWholeTreePopulation('// just a comment\n') === null); - t( - 'the marker with no reason text does not count as declared (the same refusal both other markers make, and for the same reason)', - declaredWholeTreePopulation('// dispatch-gates: whole-tree-population\n') === null, - ); - t( - 'the marker must be its OWN line — prose ABOUT the convention is not a declaration under it', - declaredWholeTreePopulation('// see the dispatch-gates: whole-tree-population -- marker for the inverse case\n') === null, - ); - // The two markers are OPPOSITE claims and must never read as each other. A - // shared prefix and a sibling regex is exactly the shape where one parser - // quietly answers for both. - t( - 'a no-path-population declaration does NOT read as a whole-tree one', - declaredWholeTreePopulation('// dispatch-gates: no-path-population -- CI runs the self-test only\n') === null, - ); - t( - 'and a whole-tree declaration does NOT read as a no-path one', - declaredNoPathPopulation('// dispatch-gates: whole-tree-population -- it sweeps git ls-files\n') === null, - ); - - // The liveness half. Each published spelling is pinned to the shape it was - // written for, and each pin names the gate it was read off. - t( - 'limb A reads the git enumeration of the tracked corpus (check-nul-bytes, check-refd-timer-probe, check-closing-keyword-parity)', - repoRootWalkSpelling("const out = execFileSync('git', ['ls-files', '-z'], { cwd: root });") - === REPO_ROOT_WALK_SPELLINGS[0].label, - ); - t( - 'limb B reads a walk CALLED on the repo-root binding (check-watch-hint-literal)', - repoRootWalkSpelling('const { rows } = audit(walk(REPO_ROOT));') === REPO_ROOT_WALK_SPELLINGS[1].label, - ); - t( - 'limb C reads a walk whose DEFAULT PARAMETER is the repo-root binding (check-comment-mask-corpus, check-refd-timer-probe)', - repoRootWalkSpelling('export function collectSources(root = REPO_ROOT) { return walk(root); }') - === REPO_ROOT_WALK_SPELLINGS[2].label, - ); - // The measured tightening. Written to accept a trailing argument, limb B - // selected `resolve(REPO_ROOT, maskerPath)` — a path BUILD — and would have - // vouched for practically any gate holding a REPO_ROOT constant, which is a - // liveness check that cannot fail. - t( - 'a path BUILD off the repo root is not a walk — the root must be the WHOLE argument', - repoRootWalkSpelling("const target = resolve(REPO_ROOT, maskerPath);\nconst dir = join(REPO_ROOT, 'scripts');") === null, - ); - // What a gate SAYS is not what it READS — the same normalization - // `payloadEnvDependence` applies, and the same two ways to fail it. - t( - 'a gate that only DESCRIBES a whole-tree walk in prose does not pass liveness', - repoRootWalkSpelling("// this gate used to run git ls-files over walk(REPO_ROOT)\nconst x = 1;\n") === null, - ); - t( - "nor does one whose --self-test body stages a fixture tree (the self-test is not the gate's work)", - repoRootWalkSpelling("function selfTest() {\n execFileSync('git', ['ls-files', '-z'], { cwd: tmp });\n}\n") === null, - ); - // The NEGATIVE direction: a walk seeded at a bounded subtree, which is why - // #14325's census of six is a census of five here. - // - // The fixture string below is data, not a read of any file, so it cannot go - // stale — but it also cannot show that the shape still EXISTS in this tree, - // and a specimen named in prose can rot while every case here stays green. - // That is exactly what happened to the name this comment used to carry - // (#15510): `check-self-test-workflow-commands.mjs` was cited as the live - // specimen and then had its walk removed, leaving a sentence pointing at a - // file with no walk in it. So the specimens below are MEASURED, and there - // are two of them at two different walk roots, so one file's repair cannot - // empty the claim again: - // - // scripts/check-self-test-wired.mjs `walkScripts(scriptsDir)`, seeded at - // `join(ROOT, 'scripts')` - // scripts/check-spec-parsed-alias.mjs `walkZodFiles(SPEC_SRC)`, seeded at - // `join(ROOT, 'packages/spec/src')` - // - // Both read `null` from this predicate on this tree — the reading is pinned - // LIVE two cases down, against their real source rather than against a - // string typed here. - t( - 'a walk seeded at a bounded subtree is not a repo-root walk', - repoRootWalkSpelling("const scriptsDir = join(ROOT, 'scripts');\nconst files = walkScripts(scriptsDir);") === null, - ); - - // LIVE, on this tree: the two specimens the comment above names, read off - // their real source rather than typed here (#15510). This is the half a - // fixture cannot carry — that the shape the negative direction is written - // for still exists in this repo — and it is the half that went stale last - // time. Their walk ROOTS are named in the case text, so a reader who opens - // one is told what to look for; a file that has gone is NOT MEASURED rather - // than a quiet pass, per the convention at the top of this battery. - const SUBTREE_WALK_SPECIMENS = [ - ['scripts/check-self-test-wired.mjs', "walkScripts(scriptsDir), seeded at join(ROOT, 'scripts')"], - ['scripts/check-spec-parsed-alias.mjs', "walkZodFiles(SPEC_SRC), seeded at join(ROOT, 'packages/spec/src')"], - ]; - // The floor (#13799): a loop over an emptied table runs zero cases and reads - // exactly like a pass, and this table's whole purpose is to not be down to - // one name again. - t('the bounded-subtree specimen table still names two files at two walk roots', SUBTREE_WALK_SPECIMENS.length === 2); - for (const [file, walk] of SUBTREE_WALK_SPECIMENS) { - const abs = nodePath.join(ROOT, file); - if (!existsSync(abs)) { - unmeasurable( - `the bounded-subtree walk specimen ${file}`, - 'the file is not in this tree, so its source cannot be read — name a specimen that is, or say plainly that only the fixture backs this direction.', - ); - continue; - } - const src = readFileSync(abs, 'utf8'); - t( - `LIVE: ${file} still holds a bounded-subtree walk (${walk})`, - /walk[A-Za-z]*\(/.test(src), - ); - t( - `LIVE: …and this predicate reads ${file} as NOT a repo-root walk, the negative direction the fixture above stands for`, - repoRootWalkSpelling(src) === null, - ); - } - - - // The refusals — the two contradictions the derivation must not resolve on - // the gate's behalf. - const wtLive = { wholeTreeReason: 'sweeps the tree', rootWalk: REPO_ROOT_WALK_SPELLINGS[0].label, noPopulationReason: null }; - t('a family declaring nothing is refused nothing', wholeTreePopulationRefusal({ wholeTreeReason: null }) === null); - t('a declaration backed by a root walk stands', wholeTreePopulationRefusal(wtLive) === null); - t( - 'a declaration with NO root walk behind it is refused, and the refusal publishes the recognised spellings', - (() => { - const why = wholeTreePopulationRefusal({ ...wtLive, rootWalk: null }); - return typeof why === 'string' - && REPO_ROOT_WALK_SPELLINGS.every((sp) => why.includes(sp.label)) - && why.includes('EVERY card'); - })(), - ); - t( - 'declaring BOTH whole-tree and no-path population is a contradiction, refused rather than resolved by a coin toss', - (wholeTreePopulationRefusal({ ...wtLive, noPopulationReason: 'CI runs the self-test only' }) ?? '') - .includes('BOTH whole-tree-population and no-path-population'), - ); - - // Placement. These are the pins the mutation reddens: delete the - // `wholeTreeReason` branch from `placeFamily` and every one of them fails. - const wtEntry = { files: ['scripts/check-nul-bytes.mjs'], hints: ['scripts/check-nul-bytes.mjs'], wholeTreeReason: 'sweeps the tree' }; - t('a declaring family is placed as always-runs', placeFamily(wtEntry, ['packages/rest/src/server.ts']).verdict === 'always-runs'); - t( - 'and its placement does not vary by card — that is the whole claim', - ['docs/adr/0001-x.md', 'content/docs/index.mdx', 'scripts/check-nul-bytes.mjs', 'pnpm-workspace.yaml'] - .every((p) => placeFamily(wtEntry, [p]).verdict === 'always-runs'), - ); - t( - 'NOT matched even for a card editing the gate\'s own script, where the identity key would otherwise hit', - classifyEntry(wtEntry, ['scripts/check-nul-bytes.mjs']).verdict === 'matched' - && placeFamily(wtEntry, ['scripts/check-nul-bytes.mjs']).verdict !== 'matched', - ); - t( - 'NOT silent and NOT undetermined either — the two buckets it used to fall into by accident', - !['silent', 'undetermined'].includes(placeFamily(wtEntry, ['packages/rest/src/server.ts']).verdict), - ); - t('a declaring family carries no hits, so no rendering can print a lead for it', placeFamily(wtEntry, ['scripts/check-nul-bytes.mjs']).hits.length === 0); - // The byte-identity half, and it is the constraint the card carries: this - // channel must not move a single NON-declaring family. `placeFamily` - // delegates unchanged, and this pin is what holds it to that. - t( - 'a family that declares nothing is placed byte-for-byte as classifyEntry places it', - [ - { files: ['scripts/check-silent.mjs'], hints: ['packages/spec/src'] }, - { files: ['scripts/check-empty.mjs'], hints: [] }, - { files: ['scripts/check-hit.mjs'], hints: ['packages/rest'] }, - ].every((e) => ['packages/rest/src/server.ts', 'docs/adr/0001-x.md', 'scripts/check-hit.mjs', 'packages/spec/src/index.ts'] - .every((p) => JSON.stringify(placeFamily(e, [p])) === JSON.stringify(classifyEntry(e, [p])))), - ); - - // The union and the reconciliation. - const wtRow = { check: 'check:nul-bytes', command: 'pnpm check:nul-bytes', workflows: ['lint.yml'], reason: 'sweeps the tree', rootWalk: REPO_ROOT_WALK_SPELLINGS[0].label, refused: null, ciOnly: null }; - t( - 'a declaring family IS in the runnable union --commands prints, on a card whose paths reach nothing else', - commandsFor({ matchedRows: [], kindGroups: [], alwaysRunsRows: [wtRow] }).includes('pnpm check:nul-bytes'), - ); - t( - 'a CI-MEASURED declaring family contributes NO command — the exclusion follows the command, not the section', - commandsFor({ matchedRows: [], kindGroups: [], alwaysRunsRows: [{ ...wtRow, ciOnly: { env: 'GITHUB_EVENT_PATH' } }] }).length === 0, - ); - const wtRecon = familyReconciliation({ - matchedRows: [{ check: 'check:x', command: 'pnpm check:x', workflows: [], via: [], ciOnly: null }], - kindGroups: [], - alwaysRunsRows: [wtRow], - }); - t('the reconciliation counts the whole-tree channel as its own term and still closes', wtRecon.total === 2 && wtRecon.alwaysRuns === 1 && wtRecon.alwaysRunsOnly === 1); - t( - 'and counts a command reached BOTH ways ONCE — the same dedupe the `both` term makes one column over', - (() => { - const r = familyReconciliation({ - matchedRows: [{ check: 'check:nul-bytes', command: 'pnpm check:nul-bytes', workflows: [], via: [], ciOnly: null }], - kindGroups: [], - alwaysRunsRows: [wtRow], - }); - return r.total === 1 && r.alwaysRuns === 1 && r.alwaysRunsOnly === 0; - })(), - ); - t( - 'the reconciliation lines STATE the third term rather than leaving the arithmetic unexplained', - familyReconciliationLines(wtRecon).some((l) => l.includes('DECLARED whole-tree (the always-runs block)')) - && familyReconciliationLines(wtRecon).some((l) => l.includes('DECLARE that their population is the WHOLE TREE')), - ); - - // The rendering. - const wtLines = alwaysRunsPopulationLines([wtRow]); - t('the always-runs section names the gate, its reason and the liveness spelling that vouches for it', wtLines.some((l) => l.includes('pnpm check:nul-bytes') && l.includes('sweeps the tree')) && wtLines.some((l) => l.includes(REPO_ROOT_WALK_SPELLINGS[0].label))); - t('and says out loud that these are NOT leads', wtLines.some((l) => l.includes('NOT leads'))); - t( - 'its heading cannot be confused with the always-runs STEP tail, which opens on the same three words', - wtLines[0].includes('Always runs (declared population)') && wtLines[0].includes('Not the always-runs STEP tail'), - ); - t( - 'a REFUSED declaration prints as refused rather than vanishing from every rendering', - alwaysRunsPopulationLines([{ ...wtRow, refused: 'declares whole-tree-population and its own source carries no recognised repo-root walk' }]) - .some((l) => l.includes('REFUSED')), - ); - t('no declaring family, no section', alwaysRunsPopulationLines([]).length === 0); - - // The residue partition. A fourth placement wired into the derivation and - // not into this sum shrinks the residue silently, which is the exact failure - // residueLines' own throw exists to catch. - const wtResidue = residueLines({ - discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 50, alwaysRuns: 5, - unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1, - }); - t('the residue accounts for the whole-tree bucket as a fourth term', wtResidue.some((l) => l.includes('5 always-runs'))); - t( - 'and REFUSES a derivation that placed families it did not count', - (() => { - try { - residueLines({ discovered: 98, documentedNoPopulation: 0, matched: 8, undetermined: 35, silent: 50, alwaysRuns: 0, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); - return false; - } catch (err) { - return /residue accounting is short/.test(err.message) && /always-runs/.test(err.message); - } - })(), - ); - - // ── The gate-level WIDE-population declaration (#15341) ─────────────────── - // - // The THIRD channel, and the pins below are written as the ruling framed it: - // one column it must reach (DECLARED, with its reason) and one it must not - // (MATCHED, on any card under the roots it walks). The discriminating pins - // are the placement ones; the mutation that reddens them is deleting the - // `widePopulationReason` branch from `placeFamily`. - t( - 'a wide-population declaration reads its reason back', - declaredWidePopulation('// dispatch-gates: wide-population -- walks packages/ entire\n') - === 'walks packages/ entire', - ); - t( - 'the shell comment spelling is read too (all three markers tolerate the same comment forms, deliberately)', - declaredWidePopulation('#!/usr/bin/env bash\n# dispatch-gates: wide-population -- a shell gate reason\n') - === 'a shell gate reason', - ); - t('no marker present reads as no declared wide population', declaredWidePopulation('// just a comment\n') === null); - t( - 'the marker with no reason text does not count as declared — and the reason carries MORE here than on either sibling, since it is the whole content of the channel', - declaredWidePopulation('// dispatch-gates: wide-population\n') === null, - ); - t( - 'the marker must be its OWN line — prose ABOUT the convention is not a declaration under it', - declaredWidePopulation('// see the dispatch-gates: wide-population -- marker for the refused-wide case\n') === null, - ); - // Three markers now share a prefix and a sibling regex, which is exactly the - // shape where one parser quietly answers for another. Every pair, both ways. - t( - 'a no-path-population declaration does NOT read as a wide one, and a wide one does NOT read as no-path', - declaredWidePopulation('// dispatch-gates: no-path-population -- CI runs the self-test only\n') === null - && declaredNoPathPopulation('// dispatch-gates: wide-population -- walks packages/ entire\n') === null, - ); - t( - 'a whole-tree declaration does NOT read as a wide one, and a wide one does NOT read as whole-tree', - declaredWidePopulation('// dispatch-gates: whole-tree-population -- it sweeps git ls-files\n') === null - && declaredWholeTreePopulation('// dispatch-gates: wide-population -- walks packages/ entire\n') === null, - ); - - // The refusals — a gate declares exactly ONE population shape. - const wpLive = { widePopulationReason: 'walks packages/ entire', hints: [], noPopulationReason: null, wholeTreeReason: null }; - t('a family declaring nothing is refused nothing', widePopulationRefusal({ widePopulationReason: null }) === null); - t('a wide declaration over an empty hint set stands', widePopulationRefusal(wpLive) === null); - t( - 'declaring BOTH wide and no-path population is a contradiction, refused rather than resolved by a coin toss', - (widePopulationRefusal({ ...wpLive, noPopulationReason: 'CI runs the self-test only' }) ?? '') - .includes('BOTH wide-population and no-path-population'), - ); - t( - 'declaring BOTH wide and whole-tree population is refused too, and the refusal names the OPPOSITE dispositions that make it one', - (() => { - const why = widePopulationRefusal({ ...wpLive, wholeTreeReason: 'sweeps git ls-files' }) ?? ''; - return why.includes('BOTH wide-population and whole-tree-population') && why.includes('runnable total'); - })(), - ); - t( - 'a wide declaration sitting above a live path population is refused, and the refusal NAMES the literals so the reader can decide which half is wrong', - (() => { - const why = widePopulationRefusal({ ...wpLive, hints: ['packages/rest/src', 'docs/adr'] }) ?? ''; - return why.includes('NAMES paths') && why.includes('packages/rest/src'); - })(), - ); - // #16828: the line is NOT "does the gate name any path" — an enumerated - // exact-file member never contradicts a wide declaration, because - // `hintCovers` can only ever match one by equality. `check:route-envelope` - // is the specimen: a `MODULES` table keyed by 30-plus exact file paths, - // none of them a claim about the population's width. - t( - 'a wide declaration over ONLY exact-file hints stands — an enumerated member is not a competing spelling of the population', - widePopulationRefusal({ - ...wpLive, - hints: ['packages/rest/src/storage-routes.ts', 'packages/rest/src/error-response.ts'], - }) === null, - ); - // A hint that reaches beyond itself (no extension — a bare directory) is - // still compatible when the marker's own reason text names it: the second, - // separately audited surface `check:route-envelope`'s DISPATCHER_DOMAIN_DIR - // is, held to the same "the reason is what a reader trusts" bar - // `wholeTreePopulationRefusal` holds a repo-root walk to. - t( - 'a directory hint that reaches beyond itself stands when the reason text names it by name', - widePopulationRefusal({ - ...wpLive, - widePopulationReason: 'walks packages/ entire; packages/runtime/src/domains is a second, separately audited surface', - hints: ['packages/runtime/src/domains'], - }) === null, - ); - // The SAME directory hint, unnamed in the reason, is still refused — the - // exemption is not "any directory a real gate happens to carry", it is - // "an account the reader can check", and a silent one is not that. - t( - 'the same directory hint is still refused when the reason does not name it — silence is not an account', - (() => { - const why = widePopulationRefusal({ ...wpLive, hints: ['packages/runtime/src/domains'] }) ?? ''; - return why.includes('NAMES paths') && why.includes('packages/runtime/src/domains'); - })(), - ); - // A glob is never exempt this way, even repeated verbatim in the reason: - // `judgedAsPattern` marks it as ITSELF a population spelling, and a reason - // that only echoes it back is the same contradiction typed twice, not an - // account of it. A bare trailing `/**` does NOT qualify — it COLLAPSES to - // the identical plain directory prefix (`collapseHint`'s own docblock: - // `packages/**` -> `packages`), so it is judged exactly like the directory - // case above, on purpose. The species this asserts against is the one - // `judgedAsPattern` actually flags: a glob in a NON-final segment. - t( - 'a glob hint is refused even when the reason text repeats it back verbatim', - (() => { - const why = widePopulationRefusal({ - ...wpLive, - widePopulationReason: 'walks packages/ entire; packages/*/src is already covered', - hints: ['packages/*/src'], - }) ?? ''; - return why.includes('NAMES paths') && why.includes('packages/*/src'); - })(), - ); - // The bare-trailing-`/**` spelling is the CONTRAST case: it is not a - // `judgedAsPattern` glob at all (it collapses to a plain prefix), so it is - // exempt under the SAME "reason names it" rule as any other directory hint. - t( - 'a bare trailing /** hint is not a glob for this purpose — it stands when the reason names it, same as a plain directory', - widePopulationRefusal({ - ...wpLive, - widePopulationReason: 'walks packages/ entire; packages/runtime/src/domains/** is a second, separately audited surface', - hints: ['packages/runtime/src/domains/**'], - }) === null, - ); - // A mix of the two compatible shapes together stands — the predicate is - // per-hint, not "the whole set must be one shape". - t( - 'a mix of exact-file hints and a reason-named directory hint stands together', - widePopulationRefusal({ - ...wpLive, - widePopulationReason: 'walks packages/ entire; packages/runtime/src/domains is a second, separately audited surface', - hints: ['packages/rest/src/storage-routes.ts', 'packages/runtime/src/domains'], - }) === null, - ); - - // ── A declaration's reason is WHOLE, or the declaration is RED (#18422) ── - // - // All three markers above capture their reason with `(\S.*)$` under the `m` - // flag, so the capture ends at the FIRST NEWLINE — and no refusal ever asked - // whether it ended where the AUTHOR did. Measured on card #17472: a reason - // wrapped over three comment lines reached the seat as a sentence that simply - // stops, and the reason is the one thing a seat reads off that row. The cases - // below are the contract in the marker docblock, one per clause. The - // mutation that reddens the refusal group is deleting the - // `populationReasonCutRefusal` call from either refusal; the group under it - // is the control that the repair did not buy its new answers by widening the - // marker to swallow whatever sits below a declaration. - // - // The #17472 first-draft shape, in its failing form: what the author wrote, - // and the fragment the capture hands a seat. - const wrappedDraft = [ - '// dispatch-gates: whole-tree-population -- the population is `git ls-files --stage`, the whole index, and the verdict is', - '// the count of entries whose mode this gate refuses', - '', - "const MODE = '100644';", - ].join('\n'); - t( - 'the #17472 draft shape: the capture still ends at the first newline — pinned as the DEFECT, not as a claim it went away', - declaredWholeTreePopulation(wrappedDraft) - === 'the population is `git ls-files --stage`, the whole index, and the verdict is', - ); - t( - 'and the wholeness reading NAMES the line that continues it, which is what makes the cut detectable at all', - (() => { - const cut = populationReasonContinuation(wrappedDraft, 'whole-tree-population', 'scripts/check-x.mjs'); - return cut?.line === 2 && cut?.file === 'scripts/check-x.mjs' - && cut?.text === 'the count of entries whose mode this gate refuses'; - })(), - ); - t( - 'a one-line reason is not continued by the code under it — the shape 26 of the 27 live declarations already use', - populationReasonContinuation('// dispatch-gates: wide-population -- walks packages/ entire\nconst X = 1;\n', 'wide-population') - === null, - ); - t( - 'nor by a blank line, a blank comment line, or EOF — a declaration is TERMINATED by any of the three', - populationReasonContinuation('// dispatch-gates: wide-population -- walks packages/ entire\n\n// a new paragraph\n', 'wide-population') === null - && populationReasonContinuation('// dispatch-gates: wide-population -- walks packages/ entire\n//\n// a new paragraph\n', 'wide-population') === null - && populationReasonContinuation('// dispatch-gates: wide-population -- walks packages/ entire', 'wide-population') === null, - ); - t( - 'a comment line that starts a NEW dispatch-gates key is a SECOND declaration, never a continuation of the first', - populationReasonContinuation( - '// dispatch-gates: whole-tree-population -- it sweeps git ls-files\n// dispatch-gates: no-path-population -- CI runs the self-test only\n', - 'whole-tree-population', - ) === null, - ); - t( - 'the comment FORM has to match — a # line under a // declaration is not a comment in that language, so it is continuing nothing', - populationReasonContinuation('// dispatch-gates: no-path-population -- CI runs the self-test only\n# a shell comment\n', 'no-path-population') - === null, - ); - t( - 'the shell spelling is read the same way, continuation and all (shell gates carry # comments, and the derivation discovers them)', - (() => { - const cut = populationReasonContinuation( - '#!/usr/bin/env bash\n# dispatch-gates: no-path-population -- every path this file writes or reads\n# lives inside a mktemp -d checkout\n', - 'no-path-population', - 'scripts/x.sh', - ); - return cut?.line === 3 && cut?.text === 'lives inside a mktemp -d checkout'; - })(), - ); - t( - 'an unknown marker key is REFUSED by both readings rather than answering "nothing is cut" — a channel is added to the roster, never by a fourth copy of the pattern', - (() => { try { populationReasonContinuation('', 'made-up-population'); return false; } catch { return true; } })() - && (() => { try { populationReasonCutRefusal({}, 'made-up-population'); return false; } catch { return true; } })(), - ); - t( - 'the field roster and the marker roster name the SAME three channels — neither can grow one alone', - Object.keys(POPULATION_DECLARATION_FIELDS).sort().join(' ') === [...POPULATION_MARKER_KEYS].sort().join(' '), - ); - // And the WHOLENESS roster covers every reason-bearing key BY CONSTRUCTION - // (#18662). The three cases above are the population channels' half; this is - // the half that closed the class. `no-check-families` and both path-list - // markers carried the same first-newline capture and sat outside the #18422 - // reading for two cards, because the roster that decided who got the reading - // was hand-written. Derived from the two grammar builders' own key rosters, - // a key cannot be added to either without the reading arriving with it. - t( - 'every key either grammar builder serves has a wholeness reading — the roster is derived, so none can be added without one', - Object.keys(MARKER_REASON_GRAMMARS).sort().join(' ') - === [...REASON_TAIL_MARKER_KEYS, ...PATH_LIST_MARKER_KEYS].sort().join(' '), - Object.keys(MARKER_REASON_GRAMMARS).join(' '), - ); - t( - 'and it names all SEVEN live marker keys, not the three the repair was filed on (`local-env` joined the path-list grammar with #20278)', - Object.keys(MARKER_REASON_GRAMMARS).sort().join(' ') - === 'inherited-population local-env no-check-families no-path-population self-test-reads whole-tree-population wide-population', - Object.keys(MARKER_REASON_GRAMMARS).sort().join(' '), - ); - t( - 'the reason GROUP is read off the roster rather than assumed — a path-list key carries its reason in group 3, a reason-tail key in group 2', - REASON_TAIL_MARKER_KEYS.every((k) => MARKER_REASON_GRAMMARS[k].reasonGroup === 2) - && PATH_LIST_MARKER_KEYS.every((k) => MARKER_REASON_GRAMMARS[k].reasonGroup === 3), - ); - t( - 'an unknown key is REFUSED by the shared refusal TEXT too, so the three markers with no entry cannot reach it by a back door', - (() => { try { markerReasonCutRefusal('made-up-marker', { line: 1, text: 'x', kind: 'line' }); return false; } catch { return true; } })() - && markerReasonCutRefusal('no-check-families', null) === null, - ); - t( - 'and the entry-shaped reading is that same text: one refusal, five keys, byte for byte', - (() => { - const cut = { file: 'scripts/probe.mjs', line: 9, text: 'and the rest of the sentence', kind: 'line' }; - return populationReasonCutRefusal({ widePopulationReason: 'r', widePopulationReasonCut: cut }, 'wide-population') - === markerReasonCutRefusal('wide-population', cut); - })(), - ); - - // ── The BLOCK comment forms, and where a reason written in one ENDS (#18661) ── - // - // The alternation listed `//` and `#` only, so a declaration written in the - // file's own block-comment idiom parsed as NOTHING — the two live specimens - // are pinned in the live half below. The cases here are the form roster's - // contract, one per clause: the three block spellings, each of the five - // places a block reason ends, and the one shape inside a block that is - // refused instead. The control that the widening did not buy its answers by - // swallowing whatever sits under a declaration is the group above, which is - // unchanged, plus the terminator cases here. - const blockOpener = [ - '/* dispatch-gates: no-path-population -- this module is the shared resolution', - ' * core and reads NO population of its own: each corpus gate declares its', - ' * own roots. */', - 'export const X = 1;', - ].join('\n'); - t( - 'a slash-star opener declares, and the star lines under it are JOINED into the reason — the scripts/symbol-anchors.mjs shape, which used to parse as nothing', - declaredNoPathPopulation(blockOpener) - === 'this module is the shared resolution core and reads NO population of its own: each corpus gate declares its own roots.', - ); - t( - 'and a joined block reason is NOT a cut one — the wholeness reading has nothing to refuse, because nothing was dropped', - populationReasonContinuation(blockOpener, 'no-path-population') === null, - ); - t( - 'a star-prefixed line INSIDE a docblock declares too — the scripts/release-verify-npm.mjs shape — and the closing delimiter ends the reason', - declaredNoPathPopulation( - '/**\n * Probes run SEQUENTIALLY.\n *\n * dispatch-gates: no-path-population -- the self-test drives synthetic package maps\n */\n', - ) === 'the self-test drives synthetic package maps', - ); - t( - 'the TWO-star opener captures whole rather than matching the one-star form and stranding a star in front of the key — the roster order is load-bearing', - declaredWidePopulation('/** dispatch-gates: wide-population -- walks packages/ entire */\n') - === 'walks packages/ entire', - ); - t( - 'a blank star line ENDS the reason: the paragraph the author separated is not swallowed into it', - declaredWholeTreePopulation( - '/**\n * dispatch-gates: whole-tree-population -- it sweeps git ls-files\n *\n * An unrelated paragraph about something else entirely.\n */\n', - ) === 'it sweeps git ls-files', - ); - t( - 'the next star-@tag line ENDS it as well — a docblock tag section is never part of a seat-facing reason', - declaredWholeTreePopulation( - '/**\n * dispatch-gates: whole-tree-population -- it sweeps git ls-files\n * @param {string} p the path\n */\n', - ) === 'it sweeps git ls-files', - ); - t( - 'and a SECOND dispatch-gates key ENDS it, in a block exactly as in a line comment — two declarations, never one wrapped reason', - declaredWholeTreePopulation( - '/**\n * dispatch-gates: whole-tree-population -- it sweeps git ls-files\n * dispatch-gates: no-path-population -- CI runs the self-test only\n */\n', - ) === 'it sweeps git ls-files', - ); - t( - 'a one-line block declaration ends at its own closing delimiter, and the code under it continues nothing', - declaredNoPathPopulation('/* dispatch-gates: no-path-population -- CI runs the self-test only */\nconst X = 1;\n') - === 'CI runs the self-test only' - && populationReasonContinuation('/* dispatch-gates: no-path-population -- CI runs the self-test only */\nconst X = 1;\n', 'no-path-population') - === null, - ); - t( - 'a line INSIDE the block with text and NO star prefix is the block form\'s CUT — the one shape a block walk cannot read, refused rather than silently dropped', - (() => { - const hangingIndent = [ - '/* dispatch-gates: no-path-population -- this module reads no population', - ' of its own, and this line has no star prefix */', - ].join('\n'); - const cut = populationReasonContinuation(hangingIndent, 'no-path-population', 'scripts/x.mjs'); - return cut?.line === 2 && cut?.kind === 'block' - && cut?.text === 'of its own, and this line has no star prefix */'; - })(), - ); - t( - 'and its refusal names the BLOCK repair, not the line forms\' one — the two kinds are cut by different shapes and send an author to different halves of their declaration', - (() => { - const why = populationReasonCutRefusal( - { - noPopulationReason: 'this module reads no population', - noPopulationReasonCut: { file: 'scripts/x.mjs', line: 2, text: 'of its own', kind: 'block' }, - }, - 'no-path-population', - ) ?? ''; - return why.includes('scripts/x.mjs:2') && why.includes("block's star prefix") - && !why.includes('Put the WHOLE reason on the marker line'); - })(), - ); - t( - 'while the LINE forms\' refusal text is untouched by the widening — #18422\'s refusal is not loosened, it is left exactly where it was', - (populationReasonCutRefusal( - { - noPopulationReason: 'CI runs the self-test only', - noPopulationReasonCut: { file: 'scripts/x.mjs', line: 2, text: 'and the rest', kind: 'line' }, - }, - 'no-path-population', - ) ?? '').includes('Put the WHOLE reason on the marker line'), - ); - t( - 'a line carrying TWO comment openers is documentation, not a declaration — which is the only reason this file can print examples of its own markers', - declaredNoPathPopulation(' * // dispatch-gates: no-path-population -- \n') === null - && declaredNoPathPopulation(' * * dispatch-gates: no-path-population -- \n') === null, - ); - - // ── A declaration the grammar could not read makes a SOUND (#18661) ──────── - // - // The form widening above repairs the two forms this tree happens to use. - // This is the half that outlives it: a line that READS as a declaration and - // does not PARSE as one produced exactly the output of a file that declares - // nothing — no reason, no refusal, no row, no count — so a dropped - // declaration and one nobody ever wrote were indistinguishable in every - // channel the tool has. The live half below is where this goes RED. - t( - 'an unrecognised comment form is FOUND, with the file, the line and the form it was written in', - (() => { - const [only, ...rest] = unparsedPopulationMarkers( - 'const X = 1;\n\n', - 'scripts/x.mjs', - ); - return rest.length === 0 && only?.file === 'scripts/x.mjs' && only?.line === 2 - && only?.key === 'no-path-population' && only?.form === '\n', + 'scripts/x.mjs', + ); + return rest.length === 0 && only?.file === 'scripts/x.mjs' && only?.line === 2 + && only?.key === 'no-path-population' && only?.form === '