Skip to content

fix(scripts): judge guard adoption over a gate's import closure, not one file - #10668

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10625-guard-adoption-file-bound
Aug 21, 2026
Merged

fix(scripts): judge guard adoption over a gate's import closure, not one file#10668
os-zhuang merged 1 commit into
mainfrom
claude/issue-10625-guard-adoption-file-bound

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#10625

checkGuardAdoption() opened 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 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:

(A) synthetic gate delegating a second population to a sibling helper
gate file problems : [] ← 0 problems
helper file problems : [2 problems] ← never read
GUARDED_GATES = ["scripts/check-slot-lookup-ratchet.mjs",
"scripts/check-query-options-erasure-ratchet.mjs"]

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.mjs is in
both gates' import closure and has held a raw eslint.lintFiles([file]) since #10449:

(B) live tree, unmodified
checkGuardAdoption(repoRoot) = []
scripts/eslint-stack-headroom.mjs:212 = const results = await eslint.lintFiles([file]);
imported by scripts/check-slot-lookup-ratchet.mjs,
scripts/check-query-options-erasure-ratchet.mjs

The severity does not move. That call is fatal-aware: canaryParseFailures() hands its
results straight to collectFatalMessages() and returns the fatals, which is what the guard
would 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:

  • The guard module is never scanned or walked through. Its raw calls are the implementation
    — and the trap: its own export async function lintFilesStrict( would satisfy an armed test
    read 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.
  • The import and armed tests stay file-scoped on the gate. Read over the closure they
    dilute: eslint-stack-headroom.mjs already imports the guard module (for
    collectFatalMessages), so every gate importing it would pass a closure-scoped import test
    regardless 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 that
leaves 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 writes
await import(${…}) into a template it hands to disk) is not read as a computed import of its
own.

lintFilesUnguarded({ why }) is the lintFiles twin of the declaration from #10599, added
because 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() in scripts/eslint-stack-headroom.mjs is converted to that declaration
(same defect class, shape pinned by #10599's existing sibling, same gate family). Its why is
that the canary is the parse-failure detector: routing it through lintFilesStrict() would
be circular and lossy, because the guard's exit path fires before formatCanaryFailure() can
name the remedy (--stack-size, not a code fix). This widens the dispatched file surface by
that one file; it is the first caller of the new declaration and the reason it is not an export
with no consumer.

Negative control

  • The unmodified tree passes: checkGuardAdoption(repoRoot) returns [] after the canary is
    declared, and every gate below is green.
  • checkGuardAdoption still passes a gate that counts messages from lintText() — the adoption check tests the call it names, not the measurement it guards #10599's legitimate lintText calls stay unflagged. They are lintTextUnguarded() ground
    truth in this gate's own --self-test; the four cases that assert a declared non-measurement
    or a guarded lintText is 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.
  • All 11 pre-existing GUARD_ADOPTION_CASES are unchanged, in text and in expectation — the
    per-file verdict is byte-identical apart from the .lintFiles( message now also naming
    lintFilesUnguarded().
  • 12 new synthetic closure trees, each asserting which file is named and which test
    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 ./*.mjs specifiers, so the code under
test is the source on disk, not a dist/ artifact.

#ablatedpredictedobserved
Aclosure sweep removed4 closure cases red4
Bwalk made depth-1only a raw call two imports out1
Cguard-module exclusion removed1 synthetic + 4 live16 — see below
Dliteral-awareness removed from the walk1 synthetic + live noise8
Ecomputed-specifier reporting removedonly that case1
Frepo-escape detection removedonly that case1
GlintFilesUnguarded stops requiring whyonly that assertion1
Hlive: canary back to a raw call2 live findings2

Two legs reddened more than predicted, and both are informative rather than noise:

  • C — scanning the guard module also drags it into every fixture's closure through
    FIXTURE_IMPORT, where the synthetic file map has no entry for it, so 8 further cases fail on
    unreadable on top of the 4 live findings and the 1 case that targets the exclusion directly.
  • D — literal-blindness costs exactly what fix(scripts): the fatal-guard adoption check reads a docblock mention as an import — mask the source and assert the guard is called #10598 predicted one level out: invoked-as.mjs's
    templated await import(${…}) is read as 2 computed imports per gate, and this gate's own
    fixture strings are followed as real imports. The a specifier inside a string is not an import case 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 the
file was zeroed and the "mutation" wrote an empty file. The anchor read-back reported
injected-anchor=0 and aborted the run instead of reporting a green ablation over a 0-byte
source. 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 0 from scripts/pm/os-verify-lock.sh):

node scripts/check-query-options-erasure-ratchet.mjs --self-test EXIT=0
✓ self-test: 10 reporting shape(s), 10 silent counterpart(s), … (adoption proved both ways
over 11 synthetic gate source(s), files AND text, and the closure walk over 12 synthetic
tree(s)), and packages/spec/src/migrations/registry.ts parses at --stack-size=4000 …
node scripts/check-query-options-erasure-ratchet.mjs EXIT=0
✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new
node scripts/check-slot-lookup-ratchet.mjs EXIT=0
✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
node scripts/check-cross-package-test-inputs.mjs EXIT=0
OK: 13 package(s) read outside themselves, all declared
node scripts/check-nul-bytes.mjs EXIT=0

node scripts/pm/dispatch-gates.mjs (no paths) derived 3 families from the 3 changed paths.
pnpm check:slot-lookup was not among them and is run above by hand: the derivation matches
on 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 rather
than 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 ESLint instance at all — have the
guard own construction and hand back a narrow count(ruleId, population) API, so an unguarded
measurement 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

…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
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

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 d8a0eb9a, 26 checks, 0 failing (3 running at review time). Reviewed against the tree and the PR, not the report.

⭐ The false claim, verified

The card says severity is latent because "neither gate has a helper module today — both lint inline." I repeated that in the brief and told you not to write it up as a live false green.

That rationale is wrong. Verified on origin/main:

scripts/eslint-stack-headroom.mjs
212: const results = await eslint.lintFiles([file]);
213: return collectFatalMessages(results, repoRoot);
imported by scripts/check-query-options-erasure-ratchet.mjs ✓
imported by scripts/check-slot-lookup-ratchet.mjs ✓

A raw eslint.lintFiles([file]) has sat in a shared closure member of both guarded gates since #10449, invisible to the old file-scoped check.

⭐ 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 collectFatalMessages and returns the fatals), so nothing is mis-measured. "What moves is the DISTANCE: the sibling module the defect needs already exists in both closures." Correcting the rationale without inflating the severity is harder than either, and it is what makes the correction trustworthy.

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 [], helper problems 2 but never read; (B) the live treecheckGuardAdoption(repoRoot) = [] with that call sitting there.

The design, and why the asymmetry is right

The two call bans extend to every closure member; the import and armed tests stay file-scoped on the gate. Your reason:

read over the closure they DILUTE — eslint-stack-headroom.mjs already imports the guard for collectFatalMessages, so any gate importing it would pass a closure-scoped import test regardless of what the gate does

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 export async function lintFilesStrict( would answer an armed test about a call somewhere else.

⭐ 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.

lintFilesUnguarded({ why }) as the lintFiles twin of #10599's declaration is the right shape — a closure module that lints raw for a real reason must be able to say so instead of living in a hand-kept exemption list.

⭐⭐ The harness caught itself — fifth void-mutation incident tonight, and the worst mechanism yet

the first ablation run used open(path,'w').write(read(path)…), and Python truncates before the argument is evaluated, so the file was zeroed and the "mutation" wrote an empty file. The on-disk anchor read-back reported injected-anchor=0 and ABORTED the run rather than reporting a green ablation over a 0-byte source.

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 overshoots

8 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 FIXTURE_IMPORT where the synthetic file map has no entry, so 8 more fail on unreadable. D predicted "1 + live noise" and got 8 — "literal-blindness costs exactly what #10598 predicted one level out", with invoked-as.mjs's templated await import(${…}) read as computed imports and the gate's own fixture strings followed as real imports.

Negative control holds: the unmodified tree passes; #10599's legitimate lintText calls stay unflagged; all 11 pre-existing GUARD_ADOPTION_CASES unchanged in text and expectation.

⭐ The convergence read — the answer I actually wanted

I 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:

On cost to evade this converges: the population is derived rather than named, so the next level out is already covered and the remaining escapes take deliberate obfuscation rather than a natural refactor.
On kind it does NOT, and no fifth patch of this shape will — every test is a regex over source text, and which result is COUNTED is data flow, which is exactly why #10599 had to move the decision to an author declaration.
The answer that retires the class is different in nature: stop letting a guarded gate hold an ESLint instance at all.

⭐ 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.

considered_and_rejected earns its place too: checkHeadroomAdoption() has the same file-bound shape but is not the same defect — ensureStackHeadroom() re-execs the process, so it must be called from the gate's own top level, and moving it into a helper makes that test go red loudly, never silently green. Correctly left alone, no card filed.

Scope amendment — accepted

scripts/eslint-stack-headroom.mjs added to the surface (canaryParseFailures()lintFilesUnguarded({ why })). That is the bounded in-place clause used correctly: same defect class — it IS the instance this card's own check discovers — shape pinned by #10599's existing sibling declaration, same gate family, no new verification surface, named in the PR body with its evidence.

⭐ One more thing worth pinning: your hand cross-check found check:slot-lookup is affected through an import edge into eslint-stack-headroom.mjs, "which no path derivation can see — a small live instance of #10648." That card is in the queue as PR #10662, and this is a second, independent sighting of its consequence.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 08:17
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 844bf29Aug 21, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10625-guard-adoption-file-bound branch August 21, 2026 08:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] checkGuardAdoption's call bans only read the gate FILE — a measurement moved one import out is invisible to all three

2 participants

@os-zhuang@claude