Skip to content

fix(scripts): guard the lintText half of the fatal-parse adoption check - #10624

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10599-fatal-guard-linttext
Aug 21, 2026
Merged

fix(scripts): guard the lintText half of the fatal-parse adoption check#10624
os-zhuang merged 1 commit into
mainfrom
claude/issue-10599-fatal-guard-linttext

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#10599

What was actually open, and what was not

The card's title says the adoption check "still passes a gate that counts messages
from lintText()". Half of that turned out to be already handled by PR #10598, so
the first thing this branch did was measure which half. Both fixtures, run through
guardAdoptionProblems() exactly as #10598 left it:

A) import + `eslint.lintText(...)`, no strict call -> 1 problem
"…imports the guard but never calls lintFilesStrict(). Importing the
guard does not arm it…"
B) import + lintFilesStrict(...) + `eslint.lintText(...)` -> 0 problems <- the hole

So the lintText-only gate was already caught — by #10598's new positive
assertion, which has nothing left to find when a gate stopped calling anything.
The shape still open is the mixed one: a gate that keeps one guarded call for
population 1 and measures population 2 through lintText(). It imports the guard,
it calls lintFilesStrict(, it never touches .lintFiles( — three green answers
over a population with no guard on it at all.

It was not latent

The card rated this "latent — neither gate measures anything through lintText()
today". That is true of the ratchet population and false of the self-test that
asserts the ratchet. hits(), the helper the 10 reporting and 10 silent cases
count through, was a bare eslint.lintText() with no fatal check. The
argument 1, object literal report fixture with its closing paren removed:

parses -> hits()=1 fatalErrorCount=0
typo -> hits()=0 fatalErrorCount=1 "Parsing error: ')' expected."

hits(code) === 0 is precisely what every silent case asserts. A fixture that
stopped parsing would have read as proof that the rule is correctly quiet about
it — the guard's own failure mode, inside the self-test that asserts the guard.
Same for the blocking-config call, whose assertion is also === 0.

Why the naive fix was unavailable, re-measured on this tree

The filing seat's reason holds, and it undercounted. stripComments keeps string
literals, so a blanket /\.lintText\s*\(/ ban sees 5 hits in
check-query-options-erasure-ratchet.mjs on main today, not 4:

linewhat it is
L292FIXTURE_COUNT, a string literal in #10598's own fixtures — the decoy
L348hits(), the counted helper
L419the blocking-config call
L463the deliberately-unparseable ground truth for the guard
L483its parses-cleanly control

L463 and L483 are the ones no ban can take: they exist to establish what raw
ESLint returns for a file that will not parse, which is the premise the guard is
built on. Routing them through the guard would prove the guard with the guard.

The distinction, drawn where it is decidable

Which lint result gets counted is a data-flow fact and the check reads source
text, so no regex can separate "measuring a population" from "exercising the
linter". Rather than ship a heuristic that fires on the next author, the check
stops guessing: it bans the bare spelling, and the gate declares which kind
each call is.

Verification — all on 46dad29f40

Negative control (the load-bearing one). The real gate, with its four
legitimate lintText calls, is not flagged: after the migration the masked
sources of both guarded gates carry 0 bare .lintText(, and both gates run
green. check-slot-lookup-ratchet.mjs had none and is untouched.

✓ self-test: … every counted lint call in both gates routed through it
(adoption proved both ways over 11 synthetic gate source(s), files AND text) …
✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s) …
✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s) …
check:cross-package-test-inputs OK
check-nul-bytes: OK (scanned 6203 text file(s) …)
os-verify-lock: VERDICT command-exit 0 · held the lock 172s (2m52s) · waited 34s

Ablations — each mutation confirmed on disk by anchor count before the run,
each restore confirmed by an empty git diff HEAD:

ablatedon-disk confirmationresult
the .lintText( bantest lines 1→0, marker 1red, 3 failures — including a guarded call plus a second population measured through lintText: expected [...], got []
the armed disjunctiondiff hunk shown, disjunction 1→0red, 1 failurearmed through lintTextStrict alone: expected [], got [...not armed...]
lintTextStrict's fatal checkcollectFatalMessages call sites 2→1red, 2 failures
lintTextUnguarded's required whyguard clause 1→0, marker 1red, 1 failure

Restored tree green each time; final restored run exit=0.

⚠️ One ablation reading was void on the first attempt and is reported as such:
the armed-disjunction mutation was first written with perl -0pi, which matched
zero times, exited 0, and left the file unchanged — so the self-test went green
and would have read as "this assertion does not fail". The on-disk anchor count
caught it (disjunction present = 1 where 0 was required); it was redone with a
verified anchor and the diff hunk printed. The row above is the redo.

os-verify-lock.sh behaved correctly throughout, including a 132s queue wait
behind another worktree's build with the budget counting down — nothing odd to
report against PR #10609.

No changeset

scripts/**-only, publishes nothing; AGENTS.md:943 — pure bug fixes do not
require a changeset. Declared with the skip-changeset label, per PR #10502.


Generated by Claude Code

`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
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

PM review — ACCEPT, arming. ⭐ The card was wrong about its own severity, and you measured it in the right direction — worse, not better.

Head 46dad29f40, 28/28 checks green, 0 failing, 0 running (latest run per check name). Reviewed against the tree and the PR, not the report.

⭐ The finding that changes the card

My brief told you to check whether #10598 already caught the lintText shape, and to tell me if the remaining surface was narrower than the title — I'd rather hear that than have you build something unnecessary. You measured both directions and both answers are useful:

⭐ And then the part the card got flatly wrong: it rated this latent. It is not.

this gate's own self-test helper hits() was a bare eslint.lintText() with no fatal check, and hits(code) === 0 is precisely what its ten silent cases assert, so a fixture that stopped parsing would have read as proof the rule is quiet

Measured: the argument 1, object literal report fixture minus its closing paren gives hits() = 0, fatalErrorCount = 1, "Parsing error: ')' expected." ⇒ ten assertions that a rule is silent could have been satisfied by ten fixtures that never parsed. That is the exact defect class this gate exists to prevent, sitting inside the gate's own self-test, and it was live rather than hypothetical. Finding it required disbelieving the card's own grading.

The design — data flow, not text

My brief said a blanket ban was unavailable (the ratchet legitimately calls lintText in its own self-test) and that a heuristic guessing which result is counted would false-positive on the next author. You didn't guess:

it bans the BARE spelling and the gate declares each call — lintTextStrict() when counted, lintTextUnguarded({ why }) when it is ground truth for the guard

The author states the intent; the check enforces the declaration. That is the same move #10612 made with createProgramChecked/transpileChecked, and it's the shape that survives a new author. Requiring why on the unguarded form is the detail that keeps it from becoming a rubber stamp.

Widening the armed test to accept either strict entry point is right and self-aware: a text-only gate never calls lintFilesStrict(, so reporting it unguarded would have been a false positive of this change's own making.

⭐ The void ablation — the most valuable paragraph in your report

Ablation (2) was VOID on its first attempt … the mutation was written with perl -0pi, which matched zero times, exited 0, and left the file unchanged — the self-test then went green, which would have read as "this assertion cannot fail."

That is precisely the trap the on-disk-anchor-count rule exists for, caught by the rule itself, and then reported rather than quietly redone. A tool that exits 0 having done nothing is the same defect class as every gate in this PR's neighbourhood — a partial result that reads like a complete one. Redoing it with a verified Python anchor and printing the diff hunk alongside the count is the right repair. I am carrying this into the seat's standing guidance: perl -0pi and sed -i both exit 0 on zero matches, so a mutation is only real when its anchor count moved.

What else earns the ACCEPT

  • Four ablations, each with anchor counts before the run and an empty git diff HEAD after, and each failure message quoted — including the decisive one: a guarded call plus a second population measured through lintText: expected [...], got [].
  • The negative control is the load-bearing one and it holds: the real check-query-options-erasure-ratchet.mjs, with its four legitimate lintText calls, is not flagged; after migration both guarded gates carry 0 bare .lintText( and both run green; check-slot-lookup-ratchet.mjs had none and is untouched.
  • ESLint verified to have actually inspected the files via -f json → 2 results, messages=0, fatal=0 each, with the reason stated: "an empty stylish report alone does not distinguish 'clean' from 'matched nothing'." That is the zero-hit discipline applied to the linter itself.
  • Gate union derived by dispatch-gates.mjs with no paths (3 families, all run) — which is now the standing instruction precisely because my hand-written lists have under-counted twice tonight.
  • skip-changeset applied additively and read back twice, surviving the size bot's write.

#10625 filed and triaged. It is the honest next layer: the call bans read only the gate file, so a measurement moved into a sibling helper module is invisible to all three tests — latent today, but "pull the measuring part into a module" is the natural next refactor of two 700+ line gates, and it would silently remove the gate from its own guard with no diff to the check. Filing that rather than speculatively widening the check here is the right boundary.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 06:55
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 6b0be02Aug 21, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10599-fatal-guard-linttext branch August 21, 2026 07:10
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

1 participant

@os-zhuang