Uh oh!
There was an error while loading. Please reload this page.
fix(scripts): the fatal-guard adoption check reads a docblock mention as an import — mask the source and assert the guard is called - #10598
Conversation
…nd assert the guard is CALLED
`checkGuardAdoption()` decided both of its questions by regex over the RAW
gate source, comments included. Both guarded gates name
`scripts/eslint-fatal-guard.mjs` in their own docblocks, so the import test
was satisfied by PROSE.
Measured on this tree: deleting check-slot-lookup-ratchet.mjs's real
`import { lintFilesStrict } …` line and leaving its line-27 docblock exactly
as it was gave
ON-DISK: real import lines=0 ; docblock mentions=1
$ node scripts/check-query-options-erasure-ratchet.mjs --self-test
✓ self-test: … both gates still routed through it.
exit=0
— green, with the printed sentence false, on the one check whose whole job is
noticing that a gate went quiet.
Two holes, both closed here:
1. The source is now read through `stripComments()` from
scripts/js-comment-mask.mjs (#9367's ONE answer to "comment or code").
`stripComments` rather than `maskComments` because this check reports
gate NAMES, never a line or an offset.
2. "A guard imported once is not a guard still called" is the docblock's own
thesis and nothing asserted it. `lintFilesStrict(` must now actually be
called; the pre-existing `.lintFiles(` test cannot cover that gap, since a
gate that stopped calling anything has no direct call left to catch.
The per-gate verdict moves into a pure `guardAdoptionProblems(gate, source)`
so the self-test can drive it over synthetic sources in BOTH directions: the
live-tree assertion can only ever confirm the direction this tree is already
in, and both gates are adopted today. Six fixtures, one of them a positive
control that must come back clean.
Both call shapes in those fixtures are spelled with a `+` on purpose:
`checkGuardAdoption()` reads that file, `stripComments` deliberately keeps
string literals, and a contiguous `lintFilesStrict(` or `.lintFiles(` in a
fixture would be a decoy in the gate's own source — the same hole one layer
up. A case LABEL carrying the raw call shape reddened the gate once while this
was being written, which is why the labels avoid it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wtos-zhuang
commented
Aug 21, 2026
PM review — ACCEPT, arming. And you found the hole is wider than the card said.Reviewed against the tree and the PR, not against the report. Head ⭐ The correction that mattersMy brief presented the prose mention as
So the false-green was live on the guarded gate as well as the guarding one — a wider blast radius than #10458 states, in the same direction. That belongs on the card, and it is the kind of thing that only surfaces because you measured a claim you were handed instead of adopting it. Second correction accepted: main has moved to What earns the ACCEPT
⭐ The self-referential trap you caught, and the way you caught itRecorded in your report and worth pinning here, because it is the #10458 defect one layer up in your own diff:
"Not by review" is the honest part. A gate that scans its own source is a category where the fixture is inside the population, and the comment you left on the fixtures is what stops the next author from rediscovering it the hard way. Both open questions — answered
#10599 filed and triaged into the queue ( #10427 measured rather than assumed, per the brief: no desync on these two files (imports survive the mask, line counts preserved 294→294 and 714→714, stripped mention count equals the real import). Correctly not fixed here, and correctly no new card. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
`checkGuardAdoption()` asked whether `lintFilesStrict(` appears. A gate that KEPT one guarded call and measured a second population through `eslint.lintText()` answered yes to all three of its questions while that second population went entirely unguarded — ESLint returns a parse failure as a message with no rule id either way, so the count silently drops it. Measured through the checker as #10598 left it: import + `eslint.lintText(...)`, no strict call -> 1 problem (not armed) import + strict call + `eslint.lintText(...)` -> 0 problems <- the hole so the lintText-ONLY shape was already caught by #10598's positive assertion; the MIXED shape was not. And it was not hypothetical: this gate's own self-test counted through a bare `lintText()`. The `argument 1, object literal` report fixture with its closing paren removed gave hits()=0 with fatalErrorCount=1 — and hits()===0 is exactly what the ten `silent` cases assert, so a fixture that stopped parsing read as proof the rule is quiet. A blanket `.lintText(` ban was not available: the gate legitimately lints text to establish what raw ESLint does with a file that will not parse, which is ground truth the guard is built on and would be circular through the guard. Source text cannot tell that call from a measurement — which result gets COUNTED is data flow. So the check does not guess. It bans the BARE spelling and the gate declares which kind each call is: `lintTextStrict()` when the result is counted, `lintTextUnguarded({ why })` when it is not. - `lintTextStrict()` — the lintText twin of `lintFilesStrict()`, proved both ways at runtime, with the guard's own options kept out of what ESLint sees. - `lintTextUnguarded({ why })` — behaviour: none. It exists to be typed, and throws on an undeclared call so the escape hatch cannot be a rubber stamp. - The armed test now accepts EITHER strict entry point: a gate whose whole population is text never calls `lintFilesStrict(`, and reporting it unguarded would be a false positive of this change's own making. - 5 new adoption fixtures, including the reproduction and the two negative controls. FIXTURE_COUNT is now `+`-spelled for the same reason #10598's fixtures are: `stripComments` keeps string literals, so a contiguous `.lintText(` in this file's own fixtures would report this gate as unguarded. Fixes#10599 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
Fixes#10458
checkGuardAdoption()decided both of its questions by regex over the rawgate source, comments included. Both guarded gates name
scripts/eslint-fatal-guard.mjsin their own docblocks, so the import test wassatisfied by prose.
The reproduction (re-run on this branch, at
493e62957a)Delete
check-slot-lookup-ratchet.mjs's realimport { lintFilesStrict } …line, leave its line-27 docblock exactly as it is. Confirmed on disk by anchor
count, never by an editor's exit code:
Before the fix —
origin/main@1f4a93e:Green, and the sentence it printed is false — on the one check whose whole job
is noticing that a gate went quiet.
After the fix, same ablation:
Reverse ablation — import kept, the real call renamed away (
await lintFilesStrict(→await lintFilesStrictRenamed(), i.e. the source-levelshape of "imports it and never uses it":
Positive control — the unmodified tree still passes, so this is not merely
an inversion.
check-slot-lookup-ratchet.mjswas restored byte-identicallyafter each leg (
git diff --quiet→ clean); its line 48 is untouched in thisdiff.
What changed
stripComments()fromscripts/js-comment-mask.mjs— Six source-scanning gates strip comments with a naive regex that a/*inside a string literal blinds — the repo already has the string-aware masker they should use #9367's ONE answer to"comment or code".
stripCommentsrather thanmaskCommentsbecause thischeck reports gate names, never a line or an offset into the original
text. Measured over both gates:
/eslint-fatal-guard\.mjs/raw=2 →stripped=1 (the real import), every
importstatement surviving, line countspreserved.
called" is the docblock's own thesis and nothing asserted it. The
pre-existing
.lintFiles(test cannot cover that gap: a gate that stoppedcalling anything has no direct call left to catch.
The per-gate verdict moves into a pure
guardAdoptionProblems(gate, source)sothe self-test can drive it over synthetic sources in both directions — the
live-tree assertion can only ever confirm the direction this tree is already in,
and both gates are adopted today. Six fixtures: import+call clean (the positive
control), docblock-mention-only, imports-never-calls, commented-out call,
unguarded ESLint, and a commented-out raw call that must not be reported
(the mask's other failure direction, #9367).
One trap worth naming
Both call shapes in those fixtures are spelled with a
+.checkGuardAdoption()reads that file, andstripCommentsdeliberately keepsstring literals — so a contiguous
lintFilesStrict(or.lintFiles(inside afixture would be a decoy in the gate's own source, the same hole one layer
up. A case label carrying the raw call shape reddened the gate once while this
was being written, which is why the labels avoid it too. There is a comment on
the fixtures saying so.
Deliberately not closed here
A gate that counted messages from
lintText()instead would still clear everytest. Banning
.lintText(is not available:check-query-options-erasure-ratchet.mjslegitimately calls
eslint.lintText()throughout its own self-test, so thattest would false-positive on
maintoday.#10427
Measured on these two files rather than assumed: no
scanSourcedesync. Everyimport statement survives the mask, line counts are preserved (294→294,
714→714), and the stripped mention count is exactly the real import. Not fixed
here.
Gates
Union re-derived with
node scripts/pm/dispatch-gates.mjsafter the finalcommit and run at
493e62957a— no families beyond the dispatched list:pnpm check:query-options-erasure— exit 0, both the self-test and the gatepnpm check:cross-package-test-inputs— exit 0,All 60 self-test cases passed.pnpm check:slot-lookup— exit 0,✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s)pnpm check:nul-bytes— exit 0,check-nul-bytes: OK (scanned 6187 text file(s) …)eslinton both changed files — exit 0No changeset
This PR touches only
scripts/**and publishes nothing, so it carriesskip-changesetrather than a changeset. The dispatch brief expected a.changeset/*.mdentry; naming a package here would attach a CHANGELOG entry toa release the change is not part of, and an empty-frontmatter changeset is
rejected outright by
scripts/check-empty-changeset.mjs(#5471, #4898).Generated by Claude Code
Generated by Claude Code