Uh oh!
There was an error while loading. Please reload this page.
fix(gates): give the in-process ESLint ratchets the parser stack headroom pnpm lint already has - #10464
Conversation
…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.
os-zhuang
commented
Aug 20, 2026
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. ⇒ 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 Constraints, all verified:
27/27 green. Flipped ready and armed. This unblocks Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10449
check:slot-lookupandcheck:query-options-erasuredrive ESLint through itsNode API (
import { ESLint } from 'eslint'), so neither inherits the--stack-size=4000that this morning's fix put on the rootlintscript's CLIentry. 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-lookupover its real population, this container:origin/main@46cfa5b, before any editSame signature every time, on a branch that touches no
packages/specfile: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 earlierbatch spanning three commits was discarded rather than reported — mid-campaign
edits would have mixed trees.)
pnpm check:slot-lookupruns--stack-size=4000Combined 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.
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.tsthrough the gate's ownchannel. Same binary, same tree, same commit, 40 runs per arm — the only
difference is whether the re-exec is allowed to happen:
OS_ESLINT_STACK_REARMED=1)--stack-size=4000(fix active)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:
Deterministic, through the gate's own ESLint channel (same config object,
same
allowInlineConfig, same fatal-guard collector), lintingregistry.tsalone — the narrow scope, which
eslint.config.mjsdocuments as the worstcase:
--stack-size=4000The raised-stack invocation parses a file the default-stack one demonstrably
cannot, on identical bytes.
Visible on the real gate's process list while
pnpm check:slot-lookupruns — the child is the parser:
What changed
scripts/eslint-stack-headroom.mjs(new). Pure planner + guard env var +spawnSyncre-exec, following the precedent inscripts/pm/check-governed-merges.mjs. Both gates callensureStackHeadroom()once, before any linting.
precedent probes flag support with
process.allowedNodeEnvironmentFlags.has(FLAG).allowedNodeEnvironmentFlagsis the set of flags permitted in NODE_OPTIONS, and
--stack-sizeis exactlythe flag that is not (
node: --stack-size= is not allowed in NODE_OPTIONS, exit9 — re-confirmed on Node 22.22.2). It returns
falseon a node where the argvflag 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.jsonapplies only to the exact spelling that carries it, sonode scripts/check-slot-lookup-ratchet.mjsby hand silently drops the headroomagain — the same footgun
eslint.config.mjsalready has to warn about twice forpnpm exec eslint. A gate that carries its own headroom cannot be invokedwithout it. (This also keeps the diff clear of the root
package.json, which isfenced 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.tsis anADR-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
OS_ESLINT_STACK_REARMED=1)ensureStackHeadroom(...)in the other gateThe 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
1b53374by stripping whole-line comments first — the assertion only existsbecause the ablation ran.
Deliberately not done
check:slot-lookupandcheck:query-options-erasurefail ~1 run in 4 with a phantom parse error onpackages/spec/src/migrations/registry.ts— non-deterministic, not memory pressure #10451 ranks it cheapest and weakest, and it is: it hides anedge that returns as the file grows. With 4000 KB against a measured need of
1085 KB the parse is deterministic, and the canary is the designed early
warning — a retry would only delete that warning.
ignores, no fatal downgraded to a count.registry.tsuntouched ([finding] The permanent fix for the registry.ts parser cliff:step17.rationaleis a 970-operand+chain (AST depth 976) — a template literal is depth ~4 #10122 / PR fix(spec): re-spell step17.rationale as a joined fragment array, collapsing registry.ts AST depth 977 to 76 #10446 owns the depth fix), andeslint.config.mjs's rationale untouched ([finding] Once the registry.ts depth fix lands,eslint.config.mjs's--stack-size=4000and its 60-line rationale describe a state that no longer exists #10448). This PR is orthogonalto both: it fixes the invocation, they fix the input. When fix(spec): re-spell step17.rationale as a joined fragment array, collapsing registry.ts AST depth 977 to 76 #10446 lands the
canary keeps passing and simply stops being the tightest one.
scripts/eslint-fatal-guard.mjs— checked, does not share the defectBoth 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.argventry point, no
import { ESLint }. It never runs in a process of its own — itruns 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:
Nothing to widen to.
Gates
Derived from the real diff with
node scripts/pm/dispatch-gates.mjs(no pathspassed — it reads the change set itself), re-run after the final commit.
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]Also run, because the diff touches
scripts/**and adds a gate-shaped file: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 remedypnpm check:entry-guard✓ 117 scripts/ file(s) — every entry guard goes through invoked-as.mjspnpm check:single-claim-paths·check:partof-closing-keywordnode scripts/js-comment-mask.mjs --self-testlintspelling over the three changed filesExit codes were captured before any pipe (
cmd > file 2>&1; EXIT=$?), and eachrow quotes the gate's own verdict line rather than a shell
$?.No changeset: the root package is
private: trueand repo-rootscripts/isinside no published package's
files, so this diff publishes nothing —skip-changesetapplied.Generated by Claude Code