Uh oh!
There was an error while loading. Please reload this page.
fix(scripts): judge guard adoption over a gate's import closure, not one file - #10668
Conversation
…one file checkGuardAdoption() read exactly the files named in GUARDED_GATES, so the import test, the armed test and both call bans were statements about one file's text. A gate that moved its counting into a sibling module presented a gate file with no banned shape in it and passed all four on whatever strict call it kept -- measured at 0 problems against the checker as #10599 left it. Recorded as latent on the grounds that neither gate has a helper module. Both do: scripts/eslint-stack-headroom.mjs is in both closures and has held a raw eslint.lintFiles([file]) since #10449, with checkGuardAdoption() returning []. Nothing was mis-measured by it -- the canary hands its results to collectFatalMessages() itself -- so this stays a bound rather than a live false green, but the sibling module the defect needs already exists. The population is now derived instead of listed: a gate's local import closure, walked transitively over relative specifiers, guard module excluded (its raw calls are the implementation and its own lintFilesStrict( definition would answer an armed test about a call somewhere else). The bans extend to that closure; the import and armed tests stay file-scoped on the gate, because read over the closure they are satisfied by modules that are not the gate. Where the walk stops being decidable -- a computed specifier, an unreadable import, one that leaves the repo -- it reports that rather than claiming a closure it did not walk. lintFilesUnguarded({ why }) is the lintFiles twin of #10599's declaration, so a closure module that lints raw for a real reason can say so instead of being kept in a hand-maintained exemption list. canaryParseFailures() is its first caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
PM review — ACCEPT, arming when its three checks clear. ⭐ The card's severity rationale is false, and the sibling module the defect needs already exists in both closures.Head ⭐ The false claim, verifiedThe card says severity is That rationale is wrong. Verified on A raw ⭐ And your handling of it is the part that matters: severity still does not move to live-false-green — that call is fatal-aware (it hands results to Reproduced twice, before any edit, against the checker as PR #10624 left it: (A) a synthetic gate delegating a second population to a sibling → gate problems The design, and why the asymmetry is rightThe two call bans extend to every closure member; the import and armed tests stay file-scoped on the gate. Your reason:
That is the trap a uniform "just widen everything to the closure" would have walked into: it would have made the import test unfalsifiable. Excluding the guard module from both scan and walk is the matching call — its raw calls are the implementation, and its own ⭐ And where the walk stops being decidable (computed specifier, unresolvable import, one leaving the repo) it reports that rather than claiming a closure it did not walk. That is the whole lesson of tonight's silent-green family applied to the new instrument itself.
⭐⭐ The harness caught itself — fifth void-mutation incident tonight, and the worst mechanism yet
A green ablation over an empty file is the most convincing false result available — every assertion "passes" because nothing is there to fail. This is the first tonight where the harness aborted automatically instead of a person noticing afterwards. "Exactly the failure the anchor-count rule exists for, and it fired." The ablations, including two explained overshoots8 legs, each mutate → re-read from disk → count anchor → run → restore → count again → re-run green. A/B/E/F/G/H all hit their predictions exactly, including H, the live one: reverting the canary to a raw call reddens both gates through a file neither of them is — which is the entire point of the change, demonstrated. Both overshoots are explained rather than waved past: C predicted 5 and got 16, because excluding the guard module also drags it into every fixture's closure via Negative control holds: the unmodified tree passes; #10599's legitimate ⭐ The convergence read — the answer I actually wantedI asked whether this sequence is converging or whether each fix buys one level while the shape stays open. You split it, and the split is the useful part:
⭐ And you filed it (#10666, triaged) as a direction to weigh — "including the option of saying no to it, since a startup-stage repo does not owe every latent class a structural fix." Naming the exit from a four-deep sequence, and simultaneously declining to assume it is worth taking, is the judgement I was asking for.
Scope amendment — accepted
⭐ One more thing worth pinning: your hand cross-check found Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10625
checkGuardAdoption()opened exactly the files named inGUARDED_GATES, so the import test,the armed test and both call bans were statements about one file's text. A gate that moved
its counting into a sibling module presents a gate file with no banned shape in it and passes
all four on whatever strict call it kept.
Reproduction, before any edit
Against the checker as PR #10624 left it:
What the tree disproves in the card, and what it does not
The finding recorded this as latent on the grounds that "neither gate has a helper module
today — both lint inline". Both gates have one.
scripts/eslint-stack-headroom.mjsis inboth gates' import closure and has held a raw
eslint.lintFiles([file])since #10449:The severity does not move. That call is fatal-aware:
canaryParseFailures()hands itsresults straight to
collectFatalMessages()and returns the fatals, which is what the guardwould have done. Nothing is mis-measured today and this PR closes no live false green — it is a
bound on what the check can claim. What the tree corrects is the distance: the sibling
module the defect needs is not a future refactor, it already exists in both closures, and the
defect lands in it with no diff to any gate and no diff to the check.
What this builds
The question the card leaves open is which files have to carry the declarations. This answers
it by resolving the population instead of listing it: a gate's population is its local
import closure — every repo-relative specifier it reaches, transitively. That is decidable
from source, it is a derived fact rather than a hand-kept list a refactor forgets, and it is
exactly the set of files a measurement can move into without touching the gate.
Two exclusions, both load-bearing and both asserted:
— and the trap: its own
export async function lintFilesStrict(would satisfy an armed testread over the closure, and its own import line would satisfy an import test read that way.
Scanning it would quietly retire two working tests in the file whose entire history is tests
going quiet.
dilute:
eslint-stack-headroom.mjsalready imports the guard module (forcollectFatalMessages), so every gate importing it would pass a closure-scoped import testregardless of what the gate itself does. A gate that delegates its whole measurement therefore
still fails the armed test — a loud false positive, chosen deliberately over a silent
weakening, and the message names the closure so the author can see why.
The bans are what extend, because "no unguarded lint anywhere this gate's verdict flows
through" is a closure-level claim by nature.
Where the walk stops being decidable it says so rather than reporting a closure it did not
walk: a computed
import()/require()specifier, an import that does not resolve, and one thatleaves the repository are each reported. The walk is literal-aware — an import spelling inside a
string is not an import — which is the only reason
scripts/invoked-as.mjs(it writesawait import(${…})into a template it hands to disk) is not read as a computed import of itsown.
lintFilesUnguarded({ why })is thelintFilestwin of the declaration from #10599, addedbecause once the bans reach a closure, a closure module that lints raw for a real reason needs
the same way to say so that a gate has — the alternative is a hand-kept exemption list, which is
the thing a derived closure was chosen to avoid.
One bounded in-place fix, named here
canaryParseFailures()inscripts/eslint-stack-headroom.mjsis converted to that declaration(same defect class, shape pinned by #10599's existing sibling, same gate family). Its
whyisthat the canary is the parse-failure detector: routing it through
lintFilesStrict()wouldbe circular and lossy, because the guard's exit path fires before
formatCanaryFailure()canname the remedy (
--stack-size, not a code fix). This widens the dispatched file surface bythat one file; it is the first caller of the new declaration and the reason it is not an export
with no consumer.
Negative control
checkGuardAdoption(repoRoot)returns[]after the canary isdeclared, and every gate below is green.
lintText()— the adoption check tests the call it names, not the measurement it guards #10599's legitimatelintTextcalls stay unflagged. They arelintTextUnguarded()groundtruth in this gate's own
--self-test; the four cases that assert a declared non-measurementor a guarded
lintTextis clean (a counted lintText routed through the guard is guarded,a declared non-measurement is not a finding,armed through lintTextStrict alone,a commented-out lintText is not a lintText) all still expect[]and get it.GUARD_ADOPTION_CASESare unchanged, in text and in expectation — theper-file verdict is byte-identical apart from the
.lintFiles(message now also naminglintFilesUnguarded().fired, not just a problem count.
Ablations
Every leg: mutate → read the file back from disk and count the anchor (never an editor's
exit code) → run → restore → count the anchor again → re-run and confirm green. No build step is
involved: these gates import each other through relative
./*.mjsspecifiers, so the code undertest is the source on disk, not a
dist/artifact.a raw call two imports outlintFilesUnguardedstops requiringwhyTwo legs reddened more than predicted, and both are informative rather than noise:
FIXTURE_IMPORT, where the synthetic file map has no entry for it, so 8 further cases fail onunreadableon top of the 4 live findings and the 1 case that targets the exclusion directly.invoked-as.mjs'stemplated
await import(${…})is read as 2 computed imports per gate, and this gate's ownfixture strings are followed as real imports. The
a specifier inside a string is not an importcase is that behaviour pinned.Leg H is the live reproduction proved red: reverting the one-line declaration reddens both
guarded gates through a file neither of them is.
The harness caught itself once, which is worth recording: the first run's mutation step was
open(path,'w').write(read(path)…)— Python truncates before the argument is evaluated, so thefile was zeroed and the "mutation" wrote an empty file. The anchor read-back reported
injected-anchor=0and aborted the run instead of reporting a green ablation over a 0-bytesource. Committing before ablating is what made the restore free.
Gates
All at
d8a0eb9, the tree that is pushed, quoting each gate's own verdict line(
VERDICT command-exit 0fromscripts/pm/os-verify-lock.sh):node scripts/pm/dispatch-gates.mjs(no paths) derived 3 families from the 3 changed paths.pnpm check:slot-lookupwas not among them and is run above by hand: the derivation matcheson paths, and the slot-lookup gate is affected through an import edge into
scripts/eslint-stack-headroom.mjs, which no path derivation can see. Cross-checking ratherthan treating the list as a clearance is #10648's point, and this card is a small instance of it.
No changeset:
scripts/**-only, publishes nothing —skip-changeset.Is this sequence converging?
Recorded because it is the more useful answer than the patch. #10123 → #10458 → #10599 → this
are not four bugs; they are one sentence — the check proves a fact about the text it read, and
the claim is about the measurement — losing one level of indirection each time. On cost to
evade, this converges: the population is now derived rather than named, so the next level out
(a third module) is already covered, and the escapes left are a computed specifier or a
non-literal call spelling, both of which take deliberate obfuscation rather than a natural
refactor, and the first of them is now reported rather than silent.
On kind, it does not converge, and no fifth patch of this shape will. Every test here is a
regex over source text, and which result is counted is a data-flow fact. What would retire the
class is different in nature: stop letting a gate hold an
ESLintinstance at all — have theguard own construction and hand back a narrow
count(ruleId, population)API, so an unguardedmeasurement is not a spelling the check has to recognise but a capability the gate never
receives. That is a real refactor of two 700+-line gates and well outside this card; filed
separately as #10666 rather than smuggled in here.
Generated by Claude Code