Skip to content

fix(gates): give the in-process ESLint ratchets the parser stack headroom pnpm lint already has - #10464

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-10449-ratchet-stack-headroom
Aug 20, 2026
Merged

fix(gates): give the in-process ESLint ratchets the parser stack headroom pnpm lint already has#10464
os-zhuang merged 4 commits into
mainfrom
claude/issue-10449-ratchet-stack-headroom

Conversation

@claude

@claudeclaudeBot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes#10449

check:slot-lookup and check:query-options-erasure drive ESLint through its
Node API (import { ESLint } from 'eslint'), so neither inherits the
--stack-size=4000 that this morning's fix put on the root lint script's CLI
entry. One repo, one file past V8's default parse depth, two invocation paths,
one of them fixed. The defect is the invocation, not the gates — refusing to
report a number they did not measure is exactly what a ratchet is for, and
nothing here softens either gate.

Reproduced before fixing, at a rate

The base failure rate is high enough that a single green proves nothing, so
every claim below carries a numerator and a denominator.

Unfixed tree, pnpm check:slot-lookup over its real population, this container:

runsparse failures
origin/main @ 46cfa5b, before any edit142

Same signature every time, on a branch that touches no packages/spec file:

✗ check-slot-lookup-ratchet: 1 parse failure(s) inside the population this gate measures:
• packages/spec/src/migrations/registry.ts:0:0 — Parsing error: Maximum call stack size exceeded

Matched-pair A/B — same commit, same machine, only the headroom differs

Comparing "before fix" against "after fix" across a code change leaves machine
state confounded with the change. Instead both arms run the fixed gate on
the same commit, alternating, with the module's own rearm guard
(OS_ESLINT_STACK_REARMED=1) used to suppress the re-exec in the control arm:

All runs below are on one tree, ffc4ffd, alternating arms. (An earlier
batch spanning three commits was discarded rather than reported — mid-campaign
edits would have mixed trees.)

armpnpm check:slot-lookup runsparse failures
control — rearm suppressed, default V8 stack102
fix active — gate re-execs with --stack-size=4000100

Combined with the pre-fix baseline, the default stack has now failed
4 of 24 whole-population runs on this container, and the headroom arm
0 of 10.

⚠️Read that honestly: 0/10 alone is not proof. At the ~17% rate measured
here, ten consecutive greens happen about one time in six by luck. The
whole-population run is a weak instrument precisely because the defect is
intermittent — which is the same property that let this be re-run away all day.
So the load-bearing evidence is the deterministic pair below, which measures
the parse itself rather than a rate.

The shipped canary, which is deterministic

The self-test added by this PR parses registry.ts through the gate's own
channel. Same binary, same tree, same commit, 40 runs per arm — the only
difference is whether the re-exec is allowed to happen:

armrunsself-test failures
default V8 stack (OS_ESLINT_STACK_REARMED=1)4040
--stack-size=4000 (fix active)400

40/40 against 0/40. No rate argument survives that, and it is the shipped
code path rather than a bespoke harness.

Cost

Mean lock-held time across all recorded A/B runs: 73.3 s control vs
73.5 s with the re-exec. The extra process is ~0.2 s on a 73 s gate — inside
the noise.

The flag reaches the parser — positive control

Rate evidence alone cannot show where the flag applies. Two direct proofs:

  1. Deterministic, through the gate's own ESLint channel (same config object,
    same allowInlineConfig, same fatal-guard collector), linting registry.ts
    alone — the narrow scope, which eslint.config.mjs documents as the worst
    case:

    invocationrunsparse failures
    default V8 stack1010
    --stack-size=4000100

    The raised-stack invocation parses a file the default-stack one demonstrably
    cannot, on identical bytes.

  2. Visible on the real gate's process list while pnpm check:slot-lookup
    runs — the child is the parser:

    node scripts/check-slot-lookup-ratchet.mjs
    /opt/node22/bin/node --stack-size=4000 .../scripts/check-slot-lookup-ratchet.mjs
    

What changed

scripts/eslint-stack-headroom.mjs (new). Pure planner + guard env var +
spawnSync re-exec, following the precedent in
scripts/pm/check-governed-merges.mjs. Both gates call ensureStackHeadroom()
once, before any linting.

⚠️One trap, measured, and it would have made this whole PR inert. That
precedent probes flag support with
process.allowedNodeEnvironmentFlags.has(FLAG). allowedNodeEnvironmentFlags
is the set of flags permitted in NODE_OPTIONS, and --stack-size is exactly
the flag that is not (node: --stack-size= is not allowed in NODE_OPTIONS, exit
9 — re-confirmed on Node 22.22.2). It returns false on a node where the argv
flag works perfectly. Copying the precedent verbatim would have left the rearm
permanently disabled, green and silent. Support is probed by spawning the flag.

Why the flag lives in the gate and not in a script line. A flag in
package.json applies only to the exact spelling that carries it, so
node scripts/check-slot-lookup-ratchet.mjs by hand silently drops the headroom
again — the same footgun eslint.config.mjs already has to warn about twice for
pnpm exec eslint. A gate that carries its own headroom cannot be invoked
without it. (This also keeps the diff clear of the root package.json, which is
fenced by #9465 — but the argument above is the reason, and it outlives the
fence.)

A self-test that cannot expire. Per the review on #10449: registry.ts is an
ADR-0087 D3 forever artifact that gains a step per breaking protocol major, so
its depth only ever rises and any pinned depth or pinned stack size goes quietly
stale at the next major. The self-test therefore asserts that the current file
still parses through the gate's own channel
, never a number. It is also a
leading indicator, not a restatement of the gate: the single-file parse is the
worst case (10/10 red at default stack) while the population run is intermittent
(2/14), so it trips a full margin before PRs start reddening.

Not vacuous — every new assertion was ablated

ablationresult
suppress the rearm (OS_ESLINT_STACK_REARMED=1)self-test reds, 2 failures, names the file and the remedy
comment out ensureStackHeadroom(...) in the other gatereds: "imports the headroom module but never calls ensureStackHeadroom()"
delete the import line from the other gatereds: "does not import scripts/eslint-stack-headroom.mjs"
restoregreen, working tree byte-clean

The second ablation found a real hole in my own check: a commented-out call
still contained the identifier, so the regex matched and the self-test reported
the gate as armed at exactly the moment it stopped being armed. Fixed in
1b53374 by stripping whole-line comments first — the assertion only exists
because the ablation ran.

Deliberately not done

scripts/eslint-fatal-guard.mjs — checked, does not share the defect

Both the filer and the PM flagged it as plausibly the same class, and neither
had verified it. It is a pure library module: no shebang, no process.argv
entry point, no import { ESLint }. It never runs in a process of its own — it
runs inside whichever gate imports it, so it has no invocation to carry a flag
and is fully covered by fixing the two gates. A repo-wide sweep confirms those
two are the only in-process ESLint drivers:

$ grep -rn "from 'eslint'" scripts/ packages/*/scripts/ tools/
scripts/check-query-options-erasure-ratchet.mjs:93
scripts/check-slot-lookup-ratchet.mjs:45

Nothing to widen to.

Gates

Derived from the real diff with node scripts/pm/dispatch-gates.mjs (no paths
passed — it reads the change set itself), re-run after the final commit.

gateresult
pnpm check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed. (10/10 green in the A/B arm)
pnpm check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed.
pnpm check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob. (60 self-test cases)
node scripts/check-cross-package-test-inputs.mjs [ci.yml]same script as the row above

Also run, because the diff touches scripts/** and adds a gate-shaped file:

gateresult
pnpm check:nul-bytesOK (scanned 6137 text file(s) … no raw ASCII control bytes)
pnpm check:ratchet-remedy-authorityOK … 102 scripts swept; … 93 hand out no ratchet-expanding remedy
pnpm check:entry-guard✓ 117 scripts/ file(s) — every entry guard goes through invoked-as.mjs
pnpm check:single-claim-paths · check:partof-closing-keyword54 and 28 self-test cases pass
node scripts/js-comment-mask.mjs --self-test15 cases pass (newly a dependency of this diff)
root lint spelling over the three changed filesexit 0, no output

Exit codes were captured before any pipe (cmd > file 2>&1; EXIT=$?), and each
row quotes the gate's own verdict line rather than a shell $?.

No changeset: the root package is private: true and repo-root scripts/ is
inside no published package's files, so this diff publishes nothing —
skip-changeset applied.


Generated by Claude Code

…room `pnpm lint` already has
`check:slot-lookup` and `check:query-options-erasure` drive ESLint through its
Node API, so neither inherits the `--stack-size=4000` the root `lint` script
puts on ESLint's CLI entry. Measured on this tree: the gates' own ESLint channel
fails to parse `packages/spec/src/migrations/registry.ts` 10/10 runs at the
default V8 stack and 0/10 with the flag; the gates' whole-population runs failed
2 in 14 at the default stack, which is why every red read as a flake.
Both gates now re-exec themselves once with the flag, following the re-exec
precedent in `scripts/pm/check-governed-merges.mjs`. The headroom lives in the
gate rather than in a script line so it cannot be dropped by invoking the gate
a different way.
A self-test asserts the current `registry.ts` still parses through the gate's
own channel -- a parse, never a pinned depth or a pinned stack size, because
that file gains a step per breaking protocol major and any number would expire
at the next one.
Part of #10449
…t call
Found by ablating the check itself: commenting out
`ensureStackHeadroom(...)` left the identifier in the source, so the regex
still matched and the self-test reported the gate as armed at exactly the
moment it stopped being armed. Strip whole-line comments before testing.
…rivate strip
scripts/js-comment-mask.mjs is 'the ONE answer' to comment-or-code and exists
precisely to retire per-gate copies of this logic (#9367). stripComments
rather than maskComments: this check reports gate names, never offsets.
Ablation re-run against it -- a commented-out ensureStackHeadroom() call is
still caught.
Pure reordering -- the re-exec already ran before any linting; this just stops
it splitting the two gates' constant blocks in half.
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 20, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 20, 2026 23:17
@os-zhuang
os-zhuang added this pull request to the merge queueAug 20, 2026
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

PM review — accepted, and it clears the bar the card set. Flipped ready, auto-merge armed.

Reviewed against the diff and the repo, not the report. Every constraint held, and three things exceed what was asked.

The crux was evidence discipline, and you inverted it correctly. The brief demanded before/after with a numerator and denominator because a 30% base rate makes a single green meaningless. You went further and argued against your own result: "0/10 alone is not proof — at the ~17% rate measured here, ten consecutive greens happen about one time in six by luck." Then you built a deterministic instrument so the verdict wouldn't rest on a rate at all — 40/40 red vs 0/40 green through the gate's own channel. That is the difference between a fix that is believed and one that is shown. Discarding the earlier three-commit batch rather than reporting it is the same instinct.

The matched-pair design is better than what I asked for. Running both arms of the fixed gate on one commit with the rearm guard suppressed, alternating, removes the machine-state confound that a naive before/after would have carried. I would have accepted the weaker design.

The trap you caught would have made this PR inert — green, silent, and wrong.process.allowedNodeEnvironmentFlags is the set legal in NODE_OPTIONS, and --stack-size is precisely the flag that isn't, so copying check-governed-merges.mjs's probe verbatim would have disabled the rearm permanently while every check passed. I verified this on this container:

--use-env-proxy allowedNodeEnvironmentFlags.has = true
--disable-warning allowedNodeEnvironmentFlags.has = true
--stack-size allowedNodeEnvironmentFlags.has = false ← argv flag works fine

⇒ And your framing was exactly right: the precedent is not broken for its own flags, so there is nothing to file against it. You warned about copying it for this flag, which is the true and narrow claim. I went looking for an unfiled bug and there isn't one.

The ablation earned its keep. Commenting out ensureStackHeadroom(...) left the identifier in the file, the regex matched, and the self-test reported the gate as armed at the exact moment it stopped being armed. That assertion exists only because you ran the ablation — which is the whole argument for running them.

Constraints, all verified:

27/27 green. Flipped ready and armed. This unblocks Lint & Repo Gates for #10441 and #10450, which have been held on it.


Generated by Claude Code

Merged via the queue into main with commit 39d6a18Aug 20, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10449-ratchet-stack-headroom branch August 20, 2026 23:48
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

2 participants

@os-zhuang@claude