From 8448b11d80b3f86ddf9da5592b12646b59d5b239 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 23:23:30 +0000 Subject: [PATCH] Derive a gate for the surface the program it RUNS reads scripts/pm/dispatch-gates.mjs already follows a gate to a module it IMPORTS and appends that module's declared population to the gate's own. It never followed the other edge by which a gate reaches a program in this repo: spawning it. check:pm-dispatch-gates runs the tool itself, and running it readdirs .github/workflows -- so a workflows-only diff derived 18 families, none of them the one gate whose subject is the file being added. The follow now traverses the exec edge through the same three pieces as the import edge: declaredInheritedPopulation narrows what a follower inherits, hintOrigin labels it, and a target that is itself a gate file is refused. resolvePathExpression gains one hop so a join component spelled as a named constant resolves, which is the idiom a gate uses to name the program it runs. Measured over 196 families x 7673 tracked files: +32 covered pairs, 0 lost, 0 re-attributed, exactly one verdict moved. Self-test 1073 -> 1090 cases. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- scripts/pm/dispatch-gates.mjs | 452 +++++++++++++++++++++++++++++++++- 1 file changed, 443 insertions(+), 9 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index fe5e0bf404..13dc7ff04a 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3752,7 +3752,13 @@ export function coveringKey(entry, inputPath) { // are different claims, so the label says which — the same reason the // trigger and the identity keys carry their own provenance above. const inherited = entry.hintOrigin?.get(hint); - return { key: hint, via: inherited ? `gate source via ${inherited}` : 'gate source' }; + if (!inherited) return { key: hint, via: 'gate source' }; + // WHICH edge carried it, not just that it was carried: a population a gate + // inherits by RUNNING a program is a different claim from one it inherits + // by importing a module, and a dev reading the line has to be able to go + // check the right thing (#13511). + const edge = entry.hintEdge?.get(hint) === 'run' ? 'the program it runs, ' : ''; + return { key: hint, via: `gate source via ${edge}${inherited}` }; } // LAST, and deliberately (#13000). The four keys above are all claims about a // POPULATION — a pattern or a literal that covers your path — and this one is @@ -4313,6 +4319,60 @@ function withProbeTail(segs) { return out; } +/** + * The literal text of one path COMPONENT of a `join()`/`resolve()` — directly + * when it is written as a literal, or through ONE hop when it is a name bound + * to one (#13511). + * + * The base of a path expression already resolves through `ctx.names`; only the + * components after it were required to be written out. That asymmetry refused + * the idiom this tree actually writes for running a sibling program — + * `spawnSync(process.execPath, [join(ROOT, TOOL)])`, where `TOOL` is the + * module-body constant a gate declares its subject in — so the whole expression + * came back `unknown` and the edge contributed nothing. + * + * ONE hop, and only to a LITERAL: an initialiser that is itself an expression + * is left to the base resolver above, which is where multi-hop reasoning + * belongs and where its cycle guard lives. A name with more than one + * initialiser is refused rather than guessed at — a rebound name has no single + * reading, and `combineReadings`' preference for an in-tree answer is a rule + * about BASES, where the readings are path expressions, not about a component + * where they are raw text. Refusal here costs a lead; a wrong reading costs a + * fabricated one, which this file's header prices higher. + * + * Template literals are returned as written, `${}` included: `walkSegments` + * already reads one as a prefix and refuses it where a runtime part could + * introduce a separator, so this hop inherits that judgement instead of + * repeating it. + * + * ## What the hop costs its OTHER callers, measured (c0770d0b7) + * + * This resolver is shared, so widening it is not free by inspection. Every + * consumer, before -> after: + * + * anchored read targets over the gate corpus 73 -> 101 + * ...of PROGRAM TEXT, which is what `entry.reads` takes 5 -> 15 + * (family, file) pairs those add through coveringKey +3 + * in-tree scratch-dir sites 17 -> 17 (unchanged) + * unresolved scratch-dir expressions 154 -> 154 (unchanged) + * + * The scratch scan does not move at all: its bases are anchors, not named + * constants. The ten new read targets are gates opening the very file they + * grade at `join(, )` — `check:pm-label-desc-cap` reading + * `scripts/pm/ensure-pm-labels.sh` is the plainest of them — reads that were + * invisible only because the path was spelled through a name. + */ +function componentLiteral(expr, ctx) { + const direct = expr.match(QUOTED_LITERAL); + if (direct) return direct[2]; + if (!PLAIN_IDENTIFIER.test(expr)) return null; + if (ctx.seen.has(expr)) return null; + const inits = ctx.names.get(expr); + if (!inits || inits.length !== 1) return null; + const lit = inits[0].trim().match(QUOTED_LITERAL); + return lit ? lit[2] : null; +} + /** * Where a path expression lands, relative to the repo root. * @@ -4368,9 +4428,9 @@ export function resolvePathExpression(expr, ctx, depth = 0) { } let segs = base.segs; for (let i = 1; i < args.length; i++) { - const lit = args[i].match(QUOTED_LITERAL); - if (!lit) return { kind: 'unknown', why: `a path component this scan cannot read: ${args[i]}` }; - const walked = walkSegments(segs, lit[2], i === args.length - 1); + const lit = componentLiteral(args[i], ctx); + if (lit === null) return { kind: 'unknown', why: `a path component this scan cannot read: ${args[i]}` }; + const walked = walkSegments(segs, lit, i === args.length - 1); if (!walked) return { kind: 'unknown', why: `a path component built at runtime or leaving the tree: ${args[i]}` }; segs = walked; } @@ -4606,6 +4666,153 @@ export function anchoredReadTargets(rel, source, isTracked) { return out; } +/** + * ── The PROGRAM a gate RUNS: the third spelling of the same fact (#13511) ─── + * + * `readProgramTargetsInSource` above draws the line this one is on the other + * side of. Its own docblock states it: "a gate that opens another file's + * PROGRAM TEXT depends on that PROGRAM, and the shapes that dependency takes — + * stage it, execute it, assert on it — are three spellings of the same fact." + * The scan there recognises ONE of the three, the `readFileSync`/`copyFileSync` + * spelling. This one recognises EXECUTE: + * + * spawnSync(process.execPath, [join(ROOT, TOOL), '--self-test']) + * + * ## Why the missing spelling cost a CI round + * + * `check:pm-dispatch-gates` runs `scripts/pm/dispatch-gates.mjs --self-test`, + * and that run READS EVERY WORKFLOW FILE IN THE TREE. A PR adding exactly one + * `.github/workflows/*.yml` derived its families WITH THIS TOOL, ran every one + * of them green, and reddened `Lint & Repo Gates` on that gate — on an + * assertion about the new workflow file. The derivation had scored the family + * `silent`: the gate script declares three literals and all three are tracked + * FILES under `scripts/`, so nothing in it could cover a path under + * `.github/workflows/`. The gate's read surface was a strict superset of the + * surface it was derived for, and the tool's promise — "these are the gates + * your diff implicates" — was not kept for that surface. + * + * ⛔ The fix is NOT this gate's name in a table. Adding one gate name to a + * derivation is the repair this lane's triage has ruled against three times, + * on the ground that the same red keeps shipping under the next gate's name. + * What is added here is an EDGE, and the class closes with it: any gate that + * runs an in-tree program inherits that program's declared population, today + * and for every gate written after this one, with nothing to keep in step. + * + * ## The rule this completes, rather than a new one beside it + * + * The general rule already exists in this file — `firstPartyImportTargets` and + * `hintsOfModule` follow a gate to a module it IMPORTS and append that module's + * declared population to the gate's own. Exec is the same relation over a + * different edge, so it routes through the same three pieces and adds none: + * `declaredInheritedPopulation` narrows what a follower inherits (the target's + * own declaration, checked against what it really spells and unable to invent), + * `entry.hintOrigin` labels the inherited hint so it never travels as a claim + * the gate made itself, and the follow refuses a target that is itself a + * discovered gate file for the reason recorded there. + * + * That the pieces were already in place is measurable rather than lucky: + * `scripts/pm/dispatch-gates.mjs` has carried an `inherited-population` + * declaration naming `.github/workflows` — and nothing else of its nine + * literals — since #11556, written for importers. This edge is what lets a + * caller that spawns it read that declaration too. + * + * ## Narrowings, each one measured + * + * ARGV FORMS ONLY — `spawnSync`, `spawn`, `execFileSync`, `execFile`. The shell + * forms (`execSync`, `exec`) take a COMMAND STRING, and a command string is a + * quoted literal that `resolvePathExpression` refuses by construction, so + * admitting them would add a scan that cannot return a target: dead code that + * reads as coverage. Live specimens of the refused class in this tree, both + * shell-quoted: `pnpm -s ${script}` and `git rev-parse --show-toplevel`. + * + * THE PROGRAM POSITION ONLY — argument 0, plus the elements of an argv ARRAY + * LITERAL in argument 1. That is where a program path is; an options object is + * not scanned, and an argv passed as a BINDING (`spawnSync(execPath, args)`) + * contributes nothing rather than a guess. Missing lead, never a fabricated + * one — the direction this file errs in everywhere. + * + * RESOLVED, NEVER MATCHED, and TRACKED PROGRAM TEXT only: the same three + * refusals `anchoredReadTargets` documents, from the same primitive. A bare + * `'git'` is a quoted literal with no anchor and comes back `unknown`; + * `process.execPath` is a base this scan cannot read; a `tscBin` under + * `node_modules/` resolves but is not tracked. + * + * THE GATE'S OWN FILE IS DROPPED, for the reason stated one function up: the + * PROXY REARM idiom in this tree re-execs the running script (`SELF_PATH`, + * `fileURLToPath(import.meta.url)`), and a family that runs a copy of itself is + * already matched by identity. + * + * NEVER A TARGET THAT IS ITSELF A DISCOVERED GATE FILE, and a `--self-test` + * family follows no edge at all — the two refusals the import follow makes, + * applied here unchanged because their arguments are about the RELATION, not + * about how it was spelled. Both are live rather than theoretical: two families + * spawn `scripts/docs-audit/affected-docs.mjs`, which is a gate file, and + * neither inherits its four literals. The self-test refusal costs zero on this + * tree — no `--self-test` family reaches an in-tree program by spawn. + * + * ## Blast radius, measured before the change and after it (c0770d0b7) + * + * A rule that moves rows moves them for EVERY family, so the price is the + * deliverable and not a footnote. Over 196 discovered families and 7673 tracked + * files, counted through `coveringKey` — the same key the printed block renders + * from — and including the component hop `componentLiteral` adds: + * + * (family, file) pairs the derivation covers 164087 -> 164119 (+32) + * pairs LOST 0 + * pairs RE-ATTRIBUTED (same pair, new via) 0 + * families whose VERDICT changes 1 + * + * The one verdict is `check:pm-dispatch-gates`, silent -> matched, and 29 of + * the 32 pairs are its: one per workflow file in the tree, which is the defect + * exactly. The other three arrive through the component hop, on the READ key + * next door. Nothing here names twenty gates for a diff — a derivation that did + * would be useless in a different way, and this file's header prices that + * direction as "22 leads is the same as none". + * + * @param {string} rel repo-relative path of the gate script + * @param {string} source its contents + * @param {(path: string) => boolean} isTracked + * @returns {string[]} repo-relative paths, in source order, deduped + */ +const SPAWN_CALL = /(? a.trim())); + } + for (const expr of positions) { + if (!expr) continue; + ctx.seen.clear(); + const at = resolvePathExpression(expr, ctx); + if (at.kind !== 'in-tree' || at.segs.length === 0) continue; + const path = at.segs.join('/'); + if (path === rel || out.includes(path) || !isTracked(path)) continue; + if (!PROGRAM_TEXT_TARGET.test(path)) continue; + out.push(path); + } + } + return out; +} + const SCANNED_SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/; /** @@ -7132,9 +7339,11 @@ export function discoverFamilies({ tree = watchHintTree() } = {}) { }; for (const entry of byCheck.values()) { entry.imports = []; + entry.runs = []; entry.reads = []; entry.readOrigin = new Map(); entry.hintOrigin = new Map(); + entry.hintEdge = new Map(); for (const f of entry.files) { const abs = join(ROOT, f); if (!existsSync(abs)) continue; @@ -7188,6 +7397,17 @@ export function discoverFamilies({ tree = watchHintTree() } = {}) { if (gateFiles.has(mod) || entry.imports.includes(mod)) continue; entry.imports.push(mod); } + // The SECOND edge to another program, under the same two refusals as the + // first and for the same reasons (#13511). It sits BELOW the self-test + // guard deliberately: the narrowing above is invocation-shaped — an + // inherited population describes the gate's WORK — and that argument does + // not change with the edge it arrives over. One rule, not two. Cost of + // placing it here, measured on this tree: zero, because no `--self-test` + // family reaches an in-tree program by spawn at all. + for (const ran of spawnedProgramTargets(f, source, (t) => trackedSet.has(t))) { + if (gateFiles.has(ran) || entry.runs.includes(ran)) continue; + entry.runs.push(ran); + } } // The gate's OWN hints keep their order and their place at the FRONT, so // every path this derivation already matched keeps the exact key and via @@ -7198,6 +7418,18 @@ export function discoverFamilies({ tree = watchHintTree() } = {}) { for (const hint of hintsOfModule(mod)) { if (own.has(hint) || entry.hintOrigin.has(hint)) continue; entry.hintOrigin.set(hint, mod); + entry.hintEdge.set(hint, 'import'); + entry.hints.push(hint); + } + } + // LAST, so every key an earlier edge already answered keeps the exact hint + // and via label it had — the widening adds leads, it never re-attributes + // one, and that is asserted in the self-test rather than argued here. + for (const ran of entry.runs) { + for (const hint of hintsOfModule(ran)) { + if (own.has(hint) || entry.hintOrigin.has(hint)) continue; + entry.hintOrigin.set(hint, ran); + entry.hintEdge.set(hint, 'run'); entry.hints.push(hint); } } @@ -11711,7 +11943,7 @@ function selfTest() { const inheriting = [...liveDiscovery.byCheck].filter(([, e]) => (e.hintOrigin?.size ?? 0) > 0); const inheritedHints = inheriting.reduce((n, [, e]) => n + e.hintOrigin.size, 0); t( - `the live tree inherits hints through imports at all (${inheriting.length} famil(ies), ${inheritedHints} hint(s):` + + `the live tree inherits hints through a FOLLOWED program at all (${inheriting.length} famil(ies), ${inheritedHints} hint(s):` + ` ${inheriting.map(([c, e]) => `${c} +${e.hintOrigin.size}`).join(', ') || 'none'})`, inheriting.length > 0, ); @@ -11729,10 +11961,18 @@ function selfTest() { if (liveGateFiles.has(mod) || direct.includes(mod)) continue; direct.push(mod); } + // The second followed edge (#13511), reconstructed here for the same + // reason as the first: the invariant is "own PLUS what the gate reaches", + // and a reconstruction that models only one edge stops describing the + // derivation the moment the other one fires. + for (const ran of spawnedProgramTargets(f, source, (x) => liveTree.files.has(x))) { + if (liveGateFiles.has(ran) || direct.includes(ran)) continue; + direct.push(ran); + } } - // A `--self-test` family follows no import (#11404), so its expectation is - // its own hints and nothing else. Reconstructed from the same `selfTest` - // flag `discoverFamilies` reads, never from a list here. + // A `--self-test` family follows NEITHER edge (#11404, #13511), so its + // expectation is its own hints and nothing else. Reconstructed from the + // same `selfTest` flag `discoverFamilies` reads, never from a list here. const expected = new Set( entry.selfTest ? own : [...own, ...direct.flatMap(liveModuleHints)], ); @@ -11746,7 +11986,8 @@ function selfTest() { } t( "a family's hints are exactly those of the scripts its COMMAND names PLUS those of the first-party modules" + - ` those scripts import — so a shared enumerator CAN carry a population declaration for its callers (off: ${offReconstruction.join(', ') || 'none'})`, + ' those scripts import AND the in-tree programs they run — so a shared enumerator CAN carry a population' + + ` declaration for its callers (off: ${offReconstruction.join(', ') || 'none'})`, offReconstruction.length === 0, ); t( @@ -11803,6 +12044,10 @@ function selfTest() { .flatMap(([, entry]) => [...entry.hintOrigin].map(([hint, mod]) => [entry, hint, mod])) .find( ([entry, hint]) => + // IMPORT-edge only. The run edge (#13511) renders a label of its own and + // is pinned in its own section below; letting the find drift onto it + // would silently turn this case into a test of the other edge. + entry.hintEdge?.get(hint) !== 'run' && !(entry.files ?? []).some((f) => hintCovers(f, hint)) && !coveringTrigger(entry, hint) && !coveringJobFilter(entry, hint) && @@ -11945,6 +12190,195 @@ function selfTest() { dataReads.length > 0 && dataReads.every((d) => !readEdges.some(([c, r]) => `${c} <- ${r}` === d)), ); + // ── The PROGRAM a gate RUNS (#13511) ────────────────────────────────────── + // + // The third of the three spellings the section above names — "stage it, + // execute it, assert on it" — and the one nothing recognised. Its live + // instance is this card's: `check:pm-dispatch-gates` RUNS this very tool, and + // running it reads every workflow file in the tree, but the derivation scored + // that family `silent` for a workflows-only diff. A dev derived with the tool, + // ran every family it named green, and reddened `Lint & Repo Gates` on the one + // gate that judges the file the PR added. + // + // ⛔ Every case below pins the FAMILY BEING PRESENT FOR THE SURFACE, never + // that a derivation ran. The defect does not crash and does not report + // nothing: it hands a dev a coherent, plausible, INCOMPLETE list, and every + // "it derived something" assertion passes straight through it. + const runFixture = [ + "const ROOT = new URL('..', import.meta.url).pathname;", // the fixture sits one level down, so ONE hop up is the root + "const TOOL = 'scripts/pm/dispatch-gates.mjs';", + "const r1 = spawnSync(process.execPath, [join(ROOT, TOOL), '--self-test'], { stdio: 'inherit' });", // followed: argv array, component NAMED + "const r2 = execFileSync('node', [join(ROOT, 'scripts/invoked-as.mjs')]);", // followed: component written out + "const r3 = spawnSync('git', ['ls-files'], { cwd: ROOT });", // a program that is not in this tree + 'const r4 = execSync(`pnpm -s ${script}`);', // shell form: a command STRING, never scanned + "const r5 = spawnSync(process.execPath, [join(ROOT, 'package.json')]);", // tracked, but not program text + "const r6 = spawnSync(process.execPath, [join(ROOT, 'scripts/does-not-exist.mjs')]);", // resolves, untracked + "const r7 = spawnSync(process.execPath, [join(ROOT, 'scripts/fixture.mjs')]);", // itself: the identity key owns it + 'const r8 = spawnSync(process.execPath, args);', // argv is a binding, not an array literal + "let PICK = 'scripts/invoked-as.mjs';", // a REBOUND component has no single reading + "PICK = 'scripts/js-comment-mask.mjs';", + 'const r9 = spawnSync(process.execPath, [join(ROOT, PICK)]);', + "// spawnSync(process.execPath, [join(ROOT, 'scripts/check-nul-bytes.mjs')]);", // a comment + 'const src = "spawnSync(process.execPath, [join(ROOT, \'scripts/check-role-word.mjs\')])";', // inside a string + ].join('\n'); + const runFixtureOut = spawnedProgramTargets('scripts/fixture.mjs', runFixture, (f) => liveTree.files.has(f)); + t( + 'the run scan follows an argv-array spawn whose program is a NAMED constant and one written out, and refuses the' + + ' shell form, data, untracked, self, a bound argv, a rebound component, commented and string-literal spellings', + runFixtureOut.join(' · ') === 'scripts/pm/dispatch-gates.mjs · scripts/invoked-as.mjs', + runFixtureOut.join(' · '), + ); + // The hop READS a binding; it never invents one. The fixture above already + // isolates the hop itself — r1 names its program through a constant and r2 + // writes it out, so removing the hop reds that case while leaving r2 — and + // this one pins the refusal side, which no count can show. + t( + 'and an unbound component name is refused rather than guessed at', + spawnedProgramTargets( + 'scripts/fixture.mjs', + "const ROOT = new URL('..', import.meta.url).pathname;\nspawnSync(process.execPath, [join(ROOT, NOT_BOUND_HERE)]);", + (f) => liveTree.files.has(f), + ).length === 0, + ); + t( + 'a spawn written inside a self-test body is a fixture the self-test drives, not the gate reaching a program', + spawnedProgramTargets( + 'scripts/fixture.mjs', + [ + "const ROOT = new URL('..', import.meta.url).pathname;", + 'function selfTest() {', + " spawnSync(process.execPath, [join(ROOT, 'scripts/invoked-as.mjs')]);", + '}', + ].join('\n'), + (f) => liveTree.files.has(f), + ).length === 0, + ); + + // ── LIVE: the card's own specimen, by name, on the surface that missed ───── + const PM_GATE = 'check:pm-dispatch-gates'; + const pmEntry = liveDiscovery.byCheck.get(PM_GATE); + const PM_TOOL = 'scripts/pm/dispatch-gates.mjs'; + const liveWorkflowFiles = [...liveTree.files].filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).sort(); + t( + `the tree has workflow files to derive for (${liveWorkflowFiles.length})`, + liveWorkflowFiles.length > 0 && Boolean(pmEntry), + ); + t( + `${PM_GATE} reaches the tool it runs over the RUN edge (runs: ${(pmEntry?.runs ?? []).join(' · ') || 'none'})`, + (pmEntry?.runs ?? []).includes(PM_TOOL), + ); + // ⭐ The regression itself. Not "a family matched" and not "the derivation + // produced 18 rows": THIS family, MATCHED, for a workflow file. + const pmSurface = liveWorkflowFiles.slice(0, 1); + t( + `⭐ a workflows-only surface derives ${PM_GATE} — the gate whose run reads that surface` + + ` (${classifyEntry(pmEntry, pmSurface).verdict} for ${pmSurface[0]})`, + classifyEntry(pmEntry, pmSurface).verdict === 'matched', + ); + t( + 'and for EVERY workflow file in the tree, not just the one sampled', + liveWorkflowFiles.every((f) => classifyEntry(pmEntry, [f]).verdict === 'matched'), + ); + t( + `and it is derived RUNNABLY, which is what a dev pastes (${runnableInvocation(pmEntry)})`, + runnableInvocation(pmEntry) === 'pnpm check:pm-dispatch-gates', + ); + t( + `and the via column names the program it runs, not a population this gate declares` + + ` (${coveringKey(pmEntry, pmSurface[0])?.via})`, + coveringKey(pmEntry, pmSurface[0])?.key === '.github/workflows' && + coveringKey(pmEntry, pmSurface[0])?.via === `gate source via the program it runs, ${PM_TOOL}`, + ); + // …and green for the RIGHT reason. Without this half the case above passes on + // any key at all, including one the gate already had — which is exactly the + // reading that would let someone "fix" this by widening an unrelated literal. + t( + 'and no hint this gate spells ITSELF covers a workflow file, which is why the edge was needed', + pmEntry.hints.filter((h) => !pmEntry.hintOrigin.has(h)).every((h) => !liveWorkflowFiles.some((f) => hintCovers(h, f))) && + !(pmEntry.files ?? []).some((f) => liveWorkflowFiles.includes(f)) && + !coveringTrigger(pmEntry, pmSurface[0]) && + !coveringJobFilter(pmEntry, pmSurface[0]), + ); + // The narrowing on THIS edge, proven non-vacuous in both directions: the tool + // really does spell more than a follower inherits, and what it does inherit + // really does still reach every workflow file. A declaration that took the + // real population with it would read exactly like a working one — fewer + // pairs, every gate green. + t( + `the run target spells ${ownHints.length} literal(s) and a follower inherits ${ownPopulation.length} of them,` + + ' so the declaration narrows rather than waves through', + ownHints.length > ownPopulation.length && ownPopulation.length > 0, + ); + t( + 'and the narrowing is not a coverage cut — every workflow file stays reachable through what is inherited', + liveWorkflowFiles.every((f) => ownPopulation.some((h) => hintCovers(h, f))), + ); + + // Reconstruction: `entry.runs` is what the scan says over the family's own + // files, never a list kept here — the same invariant the two edges above hold. + const offRuns = []; + for (const [check, entry] of liveDiscovery.byCheck) { + const expected = []; + if (!entry.selfTest) { + for (const f of entry.files ?? []) { + if (!existsSync(join(ROOT, f))) continue; + for (const r of spawnedProgramTargets(f, liveSource(f), (x) => liveTree.files.has(x))) { + if (!liveGateFiles.has(r) && !expected.includes(r)) expected.push(r); + } + } + } + if (expected.join(' · ') !== (entry.runs ?? []).join(' · ')) offRuns.push(check); + } + t( + `a family's run targets are exactly what the scan finds in the scripts its COMMAND names (off: ${offRuns.join(', ') || 'none'})`, + offRuns.length === 0, + ); + + // The gate-file exclusion, on THIS edge, priced rather than assumed. It is + // the same refusal the import follow makes and it is live here: two families + // spawn `scripts/docs-audit/affected-docs.mjs`, which IS a discovered gate + // file, so its population is left to its own family instead of inherited + // twice under weaker provenance. + const runGateEdges = []; + for (const [check, entry] of liveDiscovery.byCheck) { + for (const f of entry.files ?? []) { + if (!existsSync(join(ROOT, f))) continue; + for (const r of spawnedProgramTargets(f, liveSource(f), (x) => liveTree.files.has(x))) { + if (liveGateFiles.has(r) && !(entry.files ?? []).includes(r)) runGateEdges.push([check, r]); + } + } + } + t( + `the live tree HAS a gate spawning another gate's file, so the exclusion is not vacuous (${runGateEdges.length}:` + + ` ${runGateEdges.map(([c, r]) => `${c} -> ${r}`).join(' · ') || 'none'})`, + runGateEdges.length > 0, + ); + t( + 'and not one of those edges is followed — a gate script is left to its OWN family, exactly as on the import edge', + runGateEdges.every(([check, r]) => !(liveDiscovery.byCheck.get(check)?.runs ?? []).includes(r)), + ); + + // Additive BY CONSTRUCTION, the claim the wiring comment makes: the run edge + // appends AFTER own and imported hints, so it can only fill a hole. Both + // halves again — "nothing was re-attributed" is satisfied perfectly by a + // derivation that answers nothing at all. + const runInherited = [...liveDiscovery.byCheck] + .flatMap(([check, e]) => [...(e.hintEdge ?? new Map())].filter(([, kind]) => kind === 'run').map(([h]) => [check, e, h])); + t( + `the run edge contributes ${runInherited.length} inherited hint(s), so the cases below are not vacuous` + + ` (${runInherited.map(([c, , h]) => `${c} <- ${h}`).join(' · ') || 'none'})`, + runInherited.length > 0, + ); + const runReattributed = []; + for (const [check, entry, hint] of runInherited) { + const ownAnswer = (entry.hints ?? []).find((h) => !entry.hintOrigin.has(h) && hintCovers(h, hint)); + if (ownAnswer) runReattributed.push(`${check}: ${hint} was already answered by ${ownAnswer}`); + } + t( + `and no run-edge hint duplicates a population the gate already declared (${runReattributed.join(' | ') || 'none'})`, + runReattributed.length === 0, + ); + // ── A followed module's JOIN BASE is not a population (#12500) ───────────── // // `cli-build-prerequisite.mjs` spells `packages/cli` because it joins paths