From e630c86dcb15097980c31611f9b8981e0cc9ed9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 15:01:30 +0000 Subject: [PATCH 1/2] fix(devx): let a gate declare a scan surface, not just its baseline artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch-gates` never named `check:entry-guard` for a NEW `scripts/` file — the gate whose population is exactly that directory. Its only module-body literals were the ten `KNOWN_IMPORT_UNSAFE` entries, an enumeration of the files that already violate the import-safety half, so the declared population was a roster of current members and a newly added script could never be in it, by construction. The sibling `check:parse-guard` was invisible the opposite way: it declared the bare root `scripts`, which the covering rule refuses as too generic. Both gates now declare the scan surface with the existing `ROOT_DIR_WATCH_HINTS` idiom, keeping their artifacts as data. Declaring it discharged the `check:parse-guard scripts` row in `ESCAPABLE_LITERAL_LEDGER`, which failed as STALE by name; the row is deleted, the sanctioned shrink-only path. Also splits the `silent` verdict in the residue output: a family whose declared literals are all tracked FILES has named artifacts, not a population, and where that roster sits in a directory the card edits the verdict is not evidence in either direction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- scripts/check-entry-guard.mjs | 96 ++++++++++- scripts/check-parse-guard.mjs | 91 +++++++++- scripts/pm/dispatch-gates.mjs | 315 ++++++++++++++++++++++++++++++++-- 3 files changed, 485 insertions(+), 17 deletions(-) diff --git a/scripts/check-entry-guard.mjs b/scripts/check-entry-guard.mjs index 11e18e3f39..5850ec46e1 100644 --- a/scripts/check-entry-guard.mjs +++ b/scripts/check-entry-guard.mjs @@ -447,6 +447,60 @@ const KNOWN_IMPORT_UNSAFE = new Set([ 'scripts/ts-parse.mjs', ]); +/** + * The SCAN SURFACE, written in the syntax `scripts/pm/dispatch-gates.mjs` can + * read. It is a DIFFERENT claim from the roster above, and keeping the two + * apart is the whole of this declaration. + * + * ── The defect this repairs (#10784) ──────────────────────────────────────── + * + * That derivation scans a gate's module body for path-ish string literals and + * reads what it finds as the gate's DECLARED POPULATION. The only literals this + * file carried were the ten KNOWN_IMPORT_UNSAFE entries — an enumeration of the + * files that ALREADY violate the import-safety half. So the derivation believed + * this gate's population was a roster of ten existing files, and a NEWLY ADDED + * file under the walked root could never appear in it, BY CONSTRUCTION. + * Measured on a card whose surface was one new script: + * + * pnpm check:entry-guard [lint.yml] names: scripts/check-changeset-no-major.mjs, + * scripts/check-empty-changeset.mjs, scripts/check-error-status-conformance.mjs, … + * + * — filed under `Silent`, "source names paths, none of which cover yours", for + * exactly the input this gate is most likely to fail on. Silent is that + * derivation's weakest claim; here it was not weak but INVERTED, and the cost + * was paid rather than hypothetical: a new script went green in a local gate + * union and red in CI on `Lint & Repo Gates`, after the dev had reported. + * + * `main()` walks the root and judges every file it finds, new ones included. + * The walk is the population; the roster is DATA the walk is compared against. + * This declares the first in a form the derivation can match and leaves the + * second alone — the same separation the derivation's own docblock draws for + * gates that "compute their own population and name only their baseline + * artifact". + * + * ── Why the subtree spelling, and not a wider extractor ───────────────────── + * + * `hintCovers` refuses a bare single-segment literal as too generic, and the + * refusal is measured rather than incidental: teaching the extractor to accept + * bare top-level directory words was priced at +139084 fabricated (gate, file) + * pairs, because `packages`, `apps` and `examples` are path COMPONENTS in + * dozens of gates that never read those roots. A declared subtree is a + * different claim — an author stating what this gate reads — and the glob + * collapse reduces it back to this one root and to nothing else. One gate pays + * for its own precision instead of every gate paying for one gate's. + * + * ── Provenance, never a lookup key ────────────────────────────────────────── + * + * Nothing in this gate reads this array; `walk(SCRIPTS)` does the walking, and + * the glob form handed to `walk` would name a directory that does not exist. + * The self-test derives BOTH directions from SCRIPTS rather than re-spelling + * the root, so moving or renaming the scanned directory cannot leave the + * declaration describing the old one. The literal has to be written out here — + * assembling it at runtime would put it out of reach of the very extractor it + * exists for, which is a silent way to keep the defect while looking fixed. + */ +const ROOT_DIR_WATCH_HINTS = ['scripts/**']; + /** Every exporting file, with the statements that would run on import. */ function importSafetyCensus(files) { const rows = []; @@ -674,6 +728,45 @@ export function selfTest() { t('else continues the statement before it', topLevelStatements(codeOnly('if (a) { x(); } else { y(); }\n')).length === 1); t('catch continues the statement before it', topLevelStatements(codeOnly('try { x(); } catch (e) { y(); }\n')).length === 1); + // ── the dispatch-gates scan surface (#10784) ───────────────────────────── + // + // Enforcement cannot hold any of these: ROOT_DIR_WATCH_HINTS is read by + // another tool entirely, so a wrong or stale one runs green here forever and + // pays itself out as a dev dispatched on a new-script card with this gate + // missing from the brief — which is the round that was actually paid. Both + // directions are derived from the walked root rather than re-spelled, so + // moving or renaming it cannot leave the declaration describing the old one. + const walkedRoot = relative(REPO_ROOT, SCRIPTS); + const declaredRoots = ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*+$/, '')); + t( + 'the scan surface is declared for the root this gate actually walks', + ROOT_DIR_WATCH_HINTS.includes(`${walkedRoot}/**`), + JSON.stringify({ walkedRoot, ROOT_DIR_WATCH_HINTS }), + ); + t( + 'and it declares no root this gate does not walk (a declaration that can drift from the scan is worse than none — it replaces a silent gate with a lying one)', + declaredRoots.every((r) => r === walkedRoot), + JSON.stringify(declaredRoots), + ); + // The load-bearing one for #10784: the roster and the surface must not be the + // same claim. A file this gate would judge tomorrow is covered by the + // declaration and, by construction, can never be in a list of the members it + // already has — so a derivation reading only the roster answers "silent" for + // the one input most likely to fail here. + const unwritten = `${walkedRoot}/the-one-nobody-has-written-yet.mjs`; + t( + 'the declared surface covers a file this gate has never seen…', + declaredRoots.some((r) => unwritten.startsWith(`${r}/`)), + unwritten, + ); + t( + '…which the baseline roster can never contain, which is why the two are separate declarations', + !KNOWN_IMPORT_UNSAFE.has(unwritten) && [...KNOWN_IMPORT_UNSAFE].every((rel) => rel !== `${walkedRoot}/**`), + ); + // Provenance, never a lookup key: the glob form appearing where the walk root + // is read would send readdirSync at a directory that does not exist. + t('the declared form is NOT the walk root itself', !ROOT_DIR_WATCH_HINTS.includes(walkedRoot)); + const failed = cases.filter((c) => !c.ok); for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); if (failed.length) { @@ -682,7 +775,8 @@ export function selfTest() { } console.log( `✓ check-entry-guard self-test: ${cases.length} cases pass — all 11 measured spellings rejected, canonical form and masked prose/payloads accepted, ` + - `and the import-safety rule recognised on both sides (dispatch/exit/argv-branch/try rejected; declarations, non-exporters and all three guard spellings accepted).`, + `and the import-safety rule recognised on both sides (dispatch/exit/argv-branch/try rejected; declarations, non-exporters and all three guard spellings accepted) — ` + + `plus the dispatch-gates scan surface, derived from the walked root and held apart from the baseline roster.`, ); return 0; } diff --git a/scripts/check-parse-guard.mjs b/scripts/check-parse-guard.mjs index 1cc53e7df4..1dee44e21a 100644 --- a/scripts/check-parse-guard.mjs +++ b/scripts/check-parse-guard.mjs @@ -131,6 +131,66 @@ const SCRIPTS = HERE; /** The one module allowed to reach the TypeScript parser directly. */ const PARSER_HOME = join(SCRIPTS, 'ts-parse.mjs'); +/** + * The SCAN SURFACE, written in the syntax `scripts/pm/dispatch-gates.mjs` can + * read. + * + * ── The defect this repairs (#10784) ──────────────────────────────────────── + * + * That derivation scans a gate's module body for path-ish string literals. The + * only literal describing this gate's population was the bare single-segment + * word `scripts` (from the `'scripts/'` prefix test in `packsScripts`, whose + * trailing slash the extractor trims), and `hintCovers` refuses a + * separator-less literal as too generic. So this gate scored, for EVERY card in + * the tree: + * + * pnpm check:parse-guard [lint.yml] dead: 'scripts' — the tree HAS it; the + * covering rule refuses the literal as too generic (no path separator) + * + * A gate in that state is named by no dispatch brief — including a brief for + * the one edit most likely to break it. It is the sibling half of the same + * blind spot #10784 records for `check-entry-guard`: both gates walk this + * directory, and both were invisible to the derivation, by two different + * routes — one declaring a population too generic to match, the other declaring + * a roster of the files it already has. Anyone adding a script got neither. + * + * The row this discharges lived in that tool's `ESCAPABLE_LITERAL_LEDGER`, + * which is SHRINK-ONLY and fails a discharged row as STALE by name. Declaring + * the subtree here is the sanctioned remedy; deleting the row is the other half + * of the same step, and both land together. + * + * ── Why the subtree spelling, and not a wider extractor ───────────────────── + * + * The refusal is measured, not incidental, and it is not this file's to relax: + * `hintCovers`' docblock prices teaching the extractor to accept bare top-level + * directory words at +139084 fabricated (gate, file) pairs, precisely because + * `packages`, `apps` and `examples` are path COMPONENTS in dozens of gates that + * never read those roots. A declared subtree is a different claim — an author + * stating what this gate reads — and the glob collapse reduces it back to this + * root and to nothing else. + * + * ── Why the OUTSIDE walk is deliberately NOT declared ─────────────────────── + * + * `walkOutside(REPO_ROOT)` really does read the whole repo, but it CENSUSES; + * it cannot fail this gate. The failing population is the scanned root alone, + * which is what the green line claims and what `TIERS` exists to keep straight. + * Declaring the repo root would name this gate for every card in the tree to + * reach the one directory whose edits can turn it red — the "22 leads is the + * same as none" failure the derivation's own header prices a fabricated lead + * against. The refusal is pinned in the self-test rather than left in this + * paragraph. + * + * ── Provenance, never a lookup key ────────────────────────────────────────── + * + * Nothing in this gate reads this array; `walk(SCRIPTS)` does the walking, and + * the glob form handed to `walk` would name a directory that does not exist. + * The self-test derives both directions from SCRIPTS rather than re-spelling + * the root. The literal has to be written out — assembling it at runtime would + * put it out of reach of the very extractor it exists for, which is a silent + * way to keep the defect while looking fixed. + */ +const ROOT_DIR_WATCH_HINTS = ['scripts/**']; + /** * The three parser entry points, each with the checked call that replaces it. * @@ -644,6 +704,34 @@ export function selfTest() { codeOnly('// gone\nconst a = 1;\n').split('\n').length === 3 && !codeOnly('// gone\nconst a = 1;\n').includes('gone')); + // -- the dispatch-gates scan surface (#10784) ----------------------------- + // + // Enforcement cannot hold any of these: ROOT_DIR_WATCH_HINTS is read by + // another tool entirely, so a wrong or stale one runs green here forever and + // pays itself out as a dev dispatched on a scripts/ card with this gate + // missing from the brief. Both directions are derived from the walked root + // rather than re-spelled. + const walkedRoot = relative(REPO_ROOT, SCRIPTS); + const declaredRoots = ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*+$/, '')); + t('the scan surface is declared for the root this gate actually walks', + ROOT_DIR_WATCH_HINTS.includes(`${walkedRoot}/**`), + JSON.stringify({ walkedRoot, ROOT_DIR_WATCH_HINTS })); + t('and it declares no root this gate does not walk (a declaration that can drift from the scan is worse ' + + 'than none -- it replaces a silent gate with a lying one)', + declaredRoots.every((r) => r === walkedRoot), + JSON.stringify(declaredRoots)); + t('the declared literal carries a path separator, which is the whole reason it is written this way -- a ' + + 'bare root word is refused as too generic and reaches nothing', + ROOT_DIR_WATCH_HINTS.every((h) => h.includes('/'))); + // The census side stays undeclared: walkOutside reads the whole repo but + // cannot fail this gate, and naming the repo root would put this gate in + // every card's brief to reach the one directory whose edits turn it red. + t('the repo root is NOT declared -- the outside walk is a census, not the failing population', + !declaredRoots.some((r) => r === '' || r === '.' || relative(REPO_ROOT, join(REPO_ROOT, r)) === '')); + // Provenance, never a lookup key: the glob form appearing where the walk root + // is read would send readdirSync at a directory that does not exist. + t('the declared form is NOT the walk root itself', !ROOT_DIR_WATCH_HINTS.includes(walkedRoot)); + const failed = cases.filter((c) => !c.ok); for (const c of failed) console.error(` x ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); if (failed.length) { @@ -654,7 +742,8 @@ export function selfTest() { `✓ check:parse-guard self-test: ${cases.length} cases pass (every spelling of all three parser entry ` + `points is caught, their checked replacements are not, prose and payloads are not, only ts-parse.mjs ` + `is exempt, and the out-of-tree census counts what this gate does not govern — TIERED by a read of ` - + `the owning package.json, so no row is printed under a reason that is false of it).`, + + `the owning package.json, so no row is printed under a reason that is false of it) -- plus the ` + + `dispatch-gates scan surface, derived from the walked root, with the census side held out of it.`, ); return 0; } diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index a677b7b1bf..cd315cccd3 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -76,7 +76,13 @@ * be invisible: a gate that computes its population and names only its own * baseline artifact scores silent for every card in the tree. All three * buckets are now accounted for in the closing summary, and `--residue` - * names the two unmatched ones runnably — see residueLines; + * names the two unmatched ones runnably — see residueLines. Silence is also + * SPLIT there (#10784): a family whose declared literals are all tracked + * FILES has named artifacts, not a population, and a roster of the files + * that already exist can never contain one added tomorrow — so for a card + * under that roster's own directory the verdict is not evidence in either + * direction. That is the shape that read as a clearance and was not; see + * artifactOnlySilence for what it does and does not claim; * - an UNREACHABLE check is one whose whole declared population matches * nothing in the tree — every path literal its own source names is a path * this repo does not have. It is not a fourth bucket and it is not about @@ -1122,6 +1128,136 @@ export function classifyEntry(entry, paths) { return { verdict: (entry.hints ?? []).length === 0 ? 'undetermined' : 'silent', hits }; } +// --------------------------------------------------------------------------- +// Telling a WEAK silence from an INVERTED one (#10784) +// --------------------------------------------------------------------------- + +/** + * The deepest directory containing every one of these paths, compared on + * SEGMENT boundaries, or '' when they share nothing above the repo root. + * + * Segment boundaries for the same reason `hintCovers` uses them: a string + * prefix would report `packages/spec` as the shared home of `packages/spec.ts` + * and `packages/species/x.ts`, which is a directory neither one is in. + */ +export function commonDirectory(paths) { + if (!paths.length) return ''; + let shared = paths[0].split('/').slice(0, -1); + for (const p of paths.slice(1)) { + const other = p.split('/').slice(0, -1); + let i = 0; + while (i < shared.length && i < other.length && shared[i] === other[i]) i++; + shared = shared.slice(0, i); + if (!shared.length) break; + } + return shared.join('/'); +} + +/** + * A silent family whose ENTIRE declared population is tracked FILES — an + * artifact roster rather than a population — or null. + * + * ## Why `silent` needed splitting at all (#10784) + * + * `silent` is this derivation's weakest claim, and the residue block already + * says so and names two ways to earn it that have nothing to do with the + * caller's paths. A third way was measured, and it is worse than weak: a gate + * whose declared literals are an ENUMERATION OF THE FILES THAT ALREADY EXIST. + * `check-entry-guard` was the specimen — its only module-body literals were the + * ten allowlisted files that already violate its import-safety half, while at + * runtime it walked the whole directory and judged new files too. So the + * derivation answered `silent` for a NEW file under that root: not a weak + * verdict there but an INVERTED one, for exactly the input most likely to fail + * the gate. One CI round was paid for it before the shape had a name. + * + * A reader could not tell that from an ordinary silence, because the output + * said the same words for both. This is the distinction, printed. + * + * ## The test, and why it is FILES and not a shape heuristic + * + * Every declared literal must collapse to a path the tree tracks as a FILE. A + * gate that names one directory has declared a population, whatever else it + * names; a gate that names only files has declared ARTIFACTS — a baseline it + * maintains, an allowlist of current members, a sibling tool it reads — and + * artifacts are not a population. Both known sub-shapes fall out of the one + * test rather than needing to be told apart: one artifact is the "names only + * its baseline artifact" case the residue prose already describes, and several + * under a common root is the enumeration above. + * + * Deliberately NOT inferred: whether the author meant the roster as the + * population. Intent is not in the tree — the same refusal `unreachableFamilies` + * makes — so this reports the SHAPE and hands the reader the discriminator. + * A gate that really does read only those files is silent correctly, and the + * note says which question to answer rather than answering it. + * + * `coversYourPath` is the half that makes it a lead instead of a standing fact: + * the artifacts' common directory contains one of the caller's paths. It is + * NOT a claim that the gate reads that file — see `artifactOnlyNote` for the + * two live shapes that are indistinguishable from the tree, and for the claim + * that is exactly true of both. It is the one place where a roster's silence + * could have been read as a clearance about the card, which is the only place + * this note raises its voice. + * + * @param {{hints?: string[]}} entry + * @param {string[]} paths the card's file surface + * @param {Set} trackedFiles every file git tracks, repo-relative + */ +export function artifactOnlySilence(entry, paths, trackedFiles) { + const artifacts = [...new Set(entry.hints ?? [])].map(collapseHint); + if (artifacts.length === 0) return null; + if (!artifacts.every((a) => trackedFiles.has(a))) return null; + const dir = commonDirectory(artifacts); + const coversYourPath = Boolean(dir) && paths.some((p) => p === dir || p.startsWith(`${dir}/`)); + return { artifacts, dir, coversYourPath }; +} + +/** + * The note printed under an artifact-roster family in the `--residue` listing. + * + * ## What it claims, and the claim it deliberately stops short of + * + * It does NOT say the gate reads your file. It cannot: whether a roster is a + * baseline sitting in a directory or a census taken of it is exactly the intent + * this tool refuses to read out of the tree, and measured on this repo the two + * live side by side — `check:where-matcher` names one baseline JSON under + * `scripts/` and walks `packages/**` test files, while `check-entry-guard` + * named ten files under `scripts/` and walked all of it. An alarm that read the + * first as "this gate very likely reads your file" would be a FABRICATED lead, + * which this file's header prices as the expensive direction. + * + * What it says instead is exactly true of both: a list of files that already + * exist can never contain one added tomorrow, so `silent` here is not evidence + * about your path in either direction. That is the whole defect — the verdict + * read as a clearance and was not — stated without inventing the half the tree + * cannot answer, and with the discriminator handed to the reader. + * + * The remedy is spelled from the roster's OWN common directory at runtime, not + * from a literal here: a worked example baked into this file would be a path + * this tool does not read entering its own declared population, which is the + * trap `DEFAULT_BASE_REF` is assembled in two halves to avoid. + */ +export function artifactOnlyNote({ artifacts, dir, coversYourPath }) { + const what = + `⚠ artifact roster: all ${artifacts.length} declared literal(s) are tracked FILES` + + (dir ? `, under ${dir}` : '') + + ' — artifacts this gate names (a baseline, an allowlist of current members), not a population it declares.'; + if (!coversYourPath) { + return [ + ` ${what}`, + dir + ? ` Nothing of yours is under ${dir}, so this silence is an ordinary one.` + : ' They share no directory, so this silence is an ordinary one.', + ]; + } + return [ + ` ${what}`, + ` ⛔ One of YOUR paths is under ${dir}. A list of the files that already exist can never contain one added tomorrow, so this`, + ' `silent` is not evidence about your path in EITHER direction — it is the shape that reads as a clearance and is not.', + ` Read the gate before treating it as one. If it scans ${dir}, the fix belongs there: declare the scan surface beside the`, + ` roster (the subtree spelling, ${dir}/**), after which it is MATCHED here. If it really reads only those files, the silence is correct.`, + ]; +} + // --------------------------------------------------------------------------- // The reachability sweep — a declared population that matches NOTHING (#9883) // --------------------------------------------------------------------------- @@ -1487,9 +1623,11 @@ export function escapableLiteralKey({ check, hint }) { * this, so the rule fails rather than needing to be remembered. */ const ESCAPABLE_LITERAL_LEDGER = new Set([ - // Its ONLY hint, so this family is unnameable for every card in the tree — - // including a card about its own blind spot, which is how it was found. - 'check:parse-guard scripts', + // DISCHARGED and deleted (#10784): `check:parse-guard scripts`. It declared + // the subtree spelling beside the literal, the row failed as STALE by name, + // and the row came out — the shrink the docblock above describes, walked once + // end to end. Recorded here as the worked instance, not as a row. + // Reaches `scripts` through a package-relative predicate over tarball // contents (`rel.startsWith('scripts/')`), not through the repo root it // appears to name. Its remedy is therefore the other one the idiom allows: @@ -2339,7 +2477,10 @@ export function unreachableLines(unreachable, swept) { return lines; } -export function residueLines({ discovered, matched, undetermined, silent, unfiltered, unreachable, swept }, kinds = CHANGE_KIND_GATES) { +export function residueLines( + { discovered, matched, undetermined, silent, unfiltered, unreachable, swept, artifactRosters, invertedRosters }, + kinds = CHANGE_KIND_GATES, +) { const placed = matched + undetermined + silent; if (placed !== discovered) { throw new Error( @@ -2365,6 +2506,22 @@ export function residueLines({ discovered, matched, undetermined, silent, unfilt '(a reachability count without the corpus it swept cannot be read: zero unreachable and a zero-file sweep print alike — #4690)', ); } + // The silence split (#10784), held to the same standard as the two counts + // above and for the same reason: this one sizes the part of `silent` that is + // not weak but INVERTED, and a count that could go missing quietly would + // render the one line a reader needs as a line with `undefined` in it. + if (!Number.isInteger(artifactRosters) || artifactRosters < 0 || artifactRosters > silent) { + throw new Error( + `artifact-roster count is not derivable: got ${String(artifactRosters)} of ${silent} silent ` + + '(it is a subset of the silent families, counted from the tree — never omitted)', + ); + } + if (!Number.isInteger(invertedRosters) || invertedRosters < 0 || invertedRosters > artifactRosters) { + throw new Error( + `inverted-roster count is not derivable: got ${String(invertedRosters)} of ${artifactRosters} artifact roster(s) ` + + "(the rosters whose common directory contains one of the caller's paths — a subset, never omitted)", + ); + } const unplaced = undetermined + silent; return [ `Residue — all ${discovered} discovered famil(ies) placed, derived at runtime:`, @@ -2377,6 +2534,14 @@ export function residueLines({ discovered, matched, undetermined, silent, unfilt ' nothing that can match it. That second one is escapable, and gates have escaped it: a gate whose population really' + ' is a root file reaches it by declaring the subtree spelling (`AGENTS.md/**`), after which it is no longer silent' + " for that file. hintCovers' docblock carries the measurement and what the refusal buys.", + ` ${artifactRosters} of those ${silent} declare ONLY tracked FILES — an artifact roster, not a population: a baseline the gate maintains, or an` + + ' allowlist of the members it already has. A list of the files that already exist can never contain one added tomorrow, so its silence is a' + + ` fact about the roster rather than about your paths.${ + invertedRosters + ? ` ⛔ For ${invertedRosters} of them the roster sits in a directory one of YOUR paths is in, and there this verdict is not evidence in` + + ' EITHER direction — it is the shape that reads as a clearance and is not. Named, with the discriminator and the remedy, under --residue.' + : ' None of their rosters sits in a directory your paths are in. They are named, with the discriminator, under --residue.' + }`, ` ${unfiltered} of the ${discovered} sit only in workflows that declare no pull_request path filter — CI schedules those on` + ' EVERY pull request, so no path derivation can narrow them and their verdict above is about relevance, never schedule.', ` ${unreachable} of the ${discovered} declare a population that reaches NOTHING in the tree, swept over ${swept} tracked file(s) —` + @@ -2732,12 +2897,18 @@ function derive(paths, { showResidue = false } = {}) { const matched = new Map(); const undetermined = []; const silent = []; + // The corpus the reachability sweep already read, as a membership test: the + // artifact-roster split asks whether a declared literal is a tracked FILE, + // and re-reading the tree for it would be a second answer to a question this + // run has already asked once. + const trackedSet = new Set(swept); for (const [check, entry] of byCheck) { const { verdict, hits } = classifyEntry(entry, paths); if (verdict === 'matched') matched.set(check, { entry, hits }); else if (verdict === 'undetermined') undetermined.push([check, entry]); else silent.push([check, entry]); } + const rosters = silent.map(([, entry]) => artifactOnlySilence(entry, paths, trackedSet)).filter(Boolean); console.log(`dispatch-gates: ${byCheck.size} check famil(ies) discovered across ${workflows.length} workflow file(s) — derived at runtime, nothing listed in this script.\n`); // The tier verdict prints on EVERY run, hit or not. Printing it only on a hit @@ -2788,6 +2959,12 @@ function derive(paths, { showResidue = false } = {}) { ? ` names: ${[...new Set(entry.hints)].slice(0, 3).join(', ')}${entry.hints.length > 3 ? ', …' : ''}` : ''; console.log(` - ${runnableInvocation(entry)} [${[...entry.workflows].join(', ')}]${names}`); + // The silence split (#10784): a family that declared only ARTIFACTS + // said the same words in this listing as one that really does not read + // your file. The note says which, and raises its voice only where the + // roster was taken from a directory the card edits. + const roster = withHints ? artifactOnlySilence(entry, paths, trackedSet) : null; + if (roster) for (const line of artifactOnlyNote(roster)) console.log(line); } }; listing('Undetermined (source names no path at all — NOT known irrelevant)', undetermined, false); @@ -2810,6 +2987,8 @@ function derive(paths, { showResidue = false } = {}) { unfiltered: [...byCheck.values()].filter((e) => e.triggers.length === 0).length, unreachable: unreachable.length, swept: swept.length, + artifactRosters: rosters.length, + invertedRosters: rosters.filter((r) => r.coversYourPath).length, })) { console.log(line); } @@ -3569,9 +3748,10 @@ function selfTest() { [...ESCAPABLE_LITERAL_LEDGER].every((row) => extractWatchHints(`const L = ${JSON.stringify(row)};`).length === 0), ); // The ledger describes the derivation, so it must agree with what the - // derivation actually reports. Both current rows name the root `scripts`, - // which the tree has and the covering rule refuses — the pair that puts them - // in this species rather than in the dead one. + // 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. const ledgerPrefixes = trackedPrefixes(ledgerSwept); t( 'every ledger row names a root the tree HAS and the covering rule refuses', @@ -3587,6 +3767,88 @@ function selfTest() { ), ); + // ── 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 what it was before this landed. + // 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. + const undeclared = entry ? { ...entry, hints: entry.hints.filter((h) => !h.includes('/*')) } : null; + t( + `…and it is the subtree declaration doing it: strip it and ${gate} goes back to silent`, + undeclared ? classifyEntry(undeclared, [unwrittenScript]).verdict === 'silent' : false, + JSON.stringify(undeclared?.hints), + ); + } + + // ── 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 = new Set(['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'), + ); + // ── 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 @@ -4400,11 +4662,19 @@ function selfTest() { // every discovered family, and it names no gate. The second is the one that // rots — a hand-written list of gate names in this paragraph is exactly what // was wrong with it — so it is asserted directly rather than by inspection. - const residue = residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000 }); + const residue = residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); t('the residue summary states the discovered total', residue.some((l) => l.includes('98'))); t('the residue summary states each bucket', residue.some((l) => l.includes('35 undetermined')) && residue.some((l) => l.includes('55 silent'))); t('the residue summary points at the flag that lists the unplaced families', residue.some((l) => l.includes('--residue') && l.includes('90'))); t('the residue summary names NO gate — the property the deleted prose lacked', !/check:[\w:-]+/.test(residue.join('\n'))); + // The silence split (#10784). The summary must SIZE the inverted part of + // `silent`, not only describe the weak part in prose, and it must say so + // differently when none of the rosters touches the caller's paths — a + // constant sentence would be a line the reader learns to skip. + t('the residue summary sizes the artifact rosters inside silent', residue.some((l) => l.includes('4 of those 55'))); + t('and calls out the ones whose roster sits where the card is', residue.some((l) => l.includes('For 1 of them') && l.includes('EITHER direction'))); + const noInverted = residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 0 }); + t('with none of them there it says THAT instead, rather than printing the warning at zero', noInverted.some((l) => l.includes('None of their rosters')) && !noInverted.join('\n').includes('EITHER direction')); // The same rot, one noun over (#10012). The top-level-FILE clause used to // illustrate the unreachable class with `README.md`, which was honest until // that gate declared `README.md/**` — after which the sentence offered, as @@ -4428,7 +4698,7 @@ function selfTest() { // which is the failure class this whole card is about. let refused = false; try { - residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 54, unfiltered: 80, unreachable: 5, swept: 6000 }); + residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 54, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); } catch { refused = true; } @@ -4438,7 +4708,7 @@ function selfTest() { // a derivation rather than as the absent measurement it is. let refusedUnfiltered = false; try { - residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unreachable: 5, swept: 6000 }); + residueLines({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 1 }); } catch { refusedUnfiltered = true; } @@ -4458,19 +4728,34 @@ function selfTest() { }; t( 'an omitted unreachable count is REFUSED, never printed as undefined', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, swept: 6000 }), + refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, swept: 6000, artifactRosters: 4, invertedRosters: 1 }), ); t( 'an unreachable count with NO corpus size is REFUSED — the number is unreadable without it', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5 }), + refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, artifactRosters: 4, invertedRosters: 1 }), ); t( 'a sweep that swept zero files is REFUSED at the summary too, not printed as a clean repo', - refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 0 }), + refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 0, artifactRosters: 4, invertedRosters: 1 }), ); t( 'zero unreachable over a real corpus is a legitimate answer, not a refusal', - !refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 6000 }), + !refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 0, swept: 6000, artifactRosters: 4, invertedRosters: 1 }), + ); + // The silence split is held to the same standard as the counts above: it is + // a SUBSET count, so both directions of the subsetting are refused rather + // than trusted, and an omitted one must not print as `undefined`. + t( + 'an omitted artifact-roster count is REFUSED, never printed as undefined', + refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, invertedRosters: 0 }), + ); + t( + 'a roster count larger than the silent bucket it subsets is REFUSED', + refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 56, invertedRosters: 0 }), + ); + t( + 'and an inverted count larger than the rosters it subsets is REFUSED', + refusedFor({ discovered: 98, matched: 8, undetermined: 35, silent: 55, unfiltered: 80, unreachable: 5, swept: 6000, artifactRosters: 4, invertedRosters: 5 }), ); // ── The families a changeset will add (#10309) ──────────────────────────── From 2f61b2e878b90cb7fed30f782eba19ea7b322644 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 15:03:41 +0000 Subject: [PATCH 2/2] test(devx): make the subtree-declaration ablation case non-vacuous Measured on the ablation run: with both declarations removed there is no subtree hint to strip, so `silent === silent` read as a pass while the two cases above it went red. The length check makes the strip real. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- scripts/pm/dispatch-gates.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index cd315cccd3..575464334e 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3799,8 +3799,14 @@ function selfTest() { const undeclared = entry ? { ...entry, hints: entry.hints.filter((h) => !h.includes('/*')) } : null; t( `…and it is the subtree declaration doing it: strip it and ${gate} goes back to silent`, - undeclared ? classifyEntry(undeclared, [unwrittenScript]).verdict === 'silent' : false, - JSON.stringify(undeclared?.hints), + // The length check is what stops this passing VACUOUSLY. With no subtree + // hint to remove, `undeclared` is the entry itself and `silent === silent` + // 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 && + classifyEntry(undeclared, [unwrittenScript]).verdict === 'silent', + JSON.stringify({ before: entry?.hints?.length, after: undeclared?.hints?.length }), ); }