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