Skip to content

fix(lint): give the eslint parser stack headroom for migrations/registry.ts - #10124

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-10030-lint-parser-stack
Aug 20, 2026
Merged

fix(lint): give the eslint parser stack headroom for migrations/registry.ts#10124
os-elon merged 1 commit into
mainfrom
claude/issue-10030-lint-parser-stack

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#10030

Lint & Repo Gates is a required context and was ejecting innocent PRs with a parse error naming a file their diff never touched:

packages/spec/src/migrations/registry.ts
0:0 error Parsing error: Maximum call stack size exceeded

One line changes behaviour — the root lint script now runs eslint under node --stack-size=4000. Everything else in the diff is the measurement written down where the next person will look for it.

The fix changes no rule and no accept/reject semantics

It is the opposite of an ignore entry: the parser now finishes where it crashed, so registry.ts is linted for the first time rather than skipped. It is clean — node --stack-size=4000 … eslint --no-inline-config packages/spec/src/migrations/registry.ts exits 0 with no findings, so nothing new is being accepted or reported.

H1 — the crash is forced, not observed

Reproduced locally on the first attempt and deterministically thereafter, byte-identical to the CI signature. The card's "flaky" reading needed correcting: parsed on its own the file fails every time (5/5 through the parser directly, and through real eslint).

What actually flips is not the content and not luck — it is the other files in the same eslint invocation. Same bytes, same command, same default stack:

scopefiles lintedregistry.ts verdict
packages/spec/src/migrations205Parsing error: Maximum call stack size exceeded
packages/spec/src965✅ present in results, 0 messages
packages/spec1063✅ present in results, 0 messages

It is linted in all three (verified via --format json, not inferred from exit codes) and only the narrow scope crashes. That is a property of the run, not of the file — which is exactly why identical bytes gave ✅→❌→✅ on one card and ❌→❌ on another, and why a re-run re-rolls it and can lose twice.

⚠️ One obvious mechanism was falsified: JIT tiering (warm optimized frames being smaller). The minimum stack is 1085 KB warm and 1084 KB under --no-opt — optimized and interpreted frames cost the same here, so that is not the variable. I have left the honest version in the code comment rather than an appealing guess.

H2 — the margin, and it is negative

quantitymeasured
minimum --stack-size to parse registry.ts1085 KB
V8 default main-thread stack984 KB
margin~10% OVER budget already
cost of each additional + '…' fragment~1.10 KB (linear across 100→2000 operands)
headroom at the shipped 4000 KB~2650 more fragments, ~3.7x the current chain

Threshold located by bisection: fails at 1080, passes at 1090. The growth constant comes from synthetic chains of 100/300/500/700/970/1500/2000 operands needing 162/383/604/823/1122/1704/2255 KB.

H3 — the supported mechanism, and local/CI agreement

  • NODE_OPTIONS cannot carry it. Node rejects the flag outright: --stack-size= is not allowed in NODE_OPTIONS. The direction suggested on the card is not available.
  • It must be an argv flag on the node process running eslint. That is why the script spells out node_modules/eslint/bin/eslint.js rather than the eslint bin — the bin is a shell shim, so node cannot execute it (SyntaxError: missing ) after argument list).
  • Local and CI agree by construction. The flag lives in the root lint script, and the workflow step is pnpm lint. .github/workflows/lint.yml is deliberately not touched — putting it there is what would let the two drift, and it would also have collided with fix(pm): complete pm label vocabulary (pm:seat, priority:p0) and add desc-cap guard #10116, which is open on that file.
  • Upper bound measured, not assumed. The hard ceiling is the OS thread stack (ulimit -s = 8192 KB, locally and on ubuntu-latest). At or above it V8 runs off the real stack and SIGSEGVs instead of throwing: clean RangeError up to 8000, rc=139 at 8192, 9000, 12000, 16000. 4000 keeps a 2x margin under that while giving 3.7x the current need.
  • Worst case covered: --stack-size=4000 --no-opt (never-optimized frames) is still green.

H4 — nothing else is close, and size is not the predictor

Across all 4659 linted files, ranked by max AST depth:

AST depthlongest + chainbytesfile
976970525,477packages/spec/src/migrations/registry.ts
71647,276…/entries/semantic/18.driver-sql-unresolvable-where-column-refused.ts
61215,191packages/formula/src/stdlib.ts
56496,306…/entries/semantic/17.actor-user-roles-to-positions.ts
4840183,218scripts/check-type-check-coverage.mjs
4416236,272packages/cli/src/commands/serve.ts

The runner-up is 13.7x shallower. And depth does not track size: the two largest linted files (236 KB, 183 KB) sit at depth 44 and 40.

What this means for the card's framing

The depth is not the generated content. It is step17.rationale — a single + concatenation of 970 string fragments spanning lines 397–1367, while the nearest generated marker begins at line 1432. The file's own header says rationale is hand-written and outside the markers.

So two of the three directions the card proposed would not have worked: splitting the generated output, and exempting the generated regions from parsing. The depth travels with step17 and is not between the markers to begin with. The permanent fix is to re-spell that one string (a template literal is depth ~4) — a packages/spec content change, outside this card's declared file surface, filed as #10122.

Reverse verification

Direction predicted in writing first, then observed. Decisive leg — identical registry.ts bytes (git hash-object = 5ff2ab96a0178a0ead8b72365b20d08469274edc both legs), identical scope, only the flag differs:

trialdefault (984 KB)--stack-size=4000
1exit 1exit 0
2exit 1exit 0
3exit 1exit 0

The red leg reproduces the CI message verbatim. Per the card's own demand, this is evidence that the stack ceiling moved — not a green CI run, which would prove nothing here since the gate already passed intermittently.

⚠️ A whole-repo pnpm lint at the default stack was green on this machine, which is why the narrow-scope leg is the one that carries the proof. Reporting that rather than hiding it: a full-repo green is exactly the non-evidence the card warned about.

Scope

Two files. package.json — one script line; the @changesets/cli range and the version script (the #9465 epic fence) are untouched. eslint.config.mjscomments only, no config change; it is the file that already documents how linting is wired, and it was named as possible surface on the claim. .github/workflows/lint.yml deliberately untouched.

No changeset: a root script line and a comment block publish nothing.

Gates

Union derived from the real diff by node scripts/pm/dispatch-gates.mjs (no paths — the script takes its own change set off the merge base), run after the final commit, at 8b4677b:

change set derived from git — 2 path(s) vs merge base 1800ffa · eslint.config.mjs, package.json
No check family names the given paths in its own source, and no workflow's path filter schedules one for them.

Run anyway, all at 8b4677b:

  • pnpm lintexit 0, no findings (the gate this PR changes);
  • check:nul-bytesOK (scanned 6369 text file(s) … no raw ASCII control bytes), self-test 75 assertions;
  • check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new;
  • check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new.

The last two import eslint.config.mjs, so they are the gates coupled to the edited file; both counts are unchanged from their origin/main readings. Control-byte scan of both edited files: clean. package.json re-parsed as JSON; eslint.config.mjs re-imported (6 config blocks).

Rebased state: origin/main was re-fetched immediately before pushing and had not moved from 1800ffac2 (0 commits behind), so #10116 had not landed and there was no conflict to resolve.

Related cards

Generated by Claude Code


Generated by Claude Code

…try.ts
`Lint & Repo Gates` is a required context and was failing intermittently on
PRs that never touched `packages/spec`, always with:
packages/spec/src/migrations/registry.ts
0:0 error Parsing error: Maximum call stack size exceeded
Measured cause: `step17.rationale` in that file is a single `+` concatenation
of 970 string fragments (AST depth 976). `@typescript-eslint/parser` converts
the AST by recursion, so the file needs a minimum `--stack-size` of 1085 KB
against V8's 984 KB default -- already ~10% over budget, which is why identical
bytes produced opposite verdicts.
Run eslint under `node --stack-size=4000`. This changes no rule and no
accept/reject semantics; it lets the parser finish where it crashed, so
registry.ts is now actually linted (it is clean) rather than skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@github-actionsgithub-actionsBot added size/s dependencies Pull requests that update a dependency file labels Aug 20, 2026
@os-steveos-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 20, 2026 — with Claude
@claude

claudeBot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PM review — ACCEPT. You falsified the card's attribution, and that is why the fix is the right one. Arming.

Verified at 8b4677b: 2 files, +63/-1, GOVERNED_HITS=NONE, no non-green gates.

⭐ The card blamed the wrong thing — and two of its three fixes would not have worked

The card is titled "the generatedmigrations/registry.ts", and I repeated that framing in the dispatch. Measured:

the depth is not the generated content. It is step17.rationale, one hand-written + concatenation of 970 string fragments (lines 397-1367), giving AST depth 976 against a runner-up of 71 across all 4659 linted files.

Confirmed independently here — lines 397-1367 are hand-written prose ('Protocol 17 removes the last three deprecated authorable aliases…' + …), outside any <os-generated> block.

two of the card's three proposed directions — split the generated output, exempt the generated regions — would NOT have fixed it, and the third (NODE_OPTIONS) is unavailable: Node rejects --stack-size there outright. A dev that took the card's framing would have shipped a fix, watched it fail, and had no idea why.

⭐ H1 — you did not just reproduce it, you found the flake variable

I asked for a forced reproduction. You produced one and explained the ✅→❌→✅ pattern that made this look mysterious. Same bytes, same command, default stack:

scopefilesresult
packages/spec/src/migrations205RED
packages/spec/src965GREEN, 0 messages
packages/spec1063GREEN, 0 messages

the flake variable is the OTHER files in the same eslint invocation, not the content

That is the whole mystery dissolved: the parse sits ~10% over budget, so whether it survives depends on what else has already run in that process. And you confirmed via --format json that the file was genuinely linted in each case rather than silently skipped — otherwise "green" could have meant "never parsed", which is the false-green this repo has been chasing all day.

⭐ H2 — the margin is the number that makes this urgent

minimum --stack-size is 1085 KB vs V8 default 984 KBalready ~10% OVER budget (bisection: 1080 fails, 1090 passes); each further + fragment costs ~1.10 KB.

So this was not drifting toward a cliff; it was already past it, and only batch composition was hiding it. 4000 absorbs ~2650 more fragments (~3.7×) — sized against a measured cost-per-fragment rather than picked round.

And the upper bound is measured, not assumed: clean RangeError to 8000, SIGSEGV rc=139 at 8192 — which equals ulimit -s, the same locally and on ubuntu-latest. So 4000 is a 2× margin below a real cliff, and --stack-size=4000 --no-opt is still green. JIT tiering falsified as the mechanism (1085 warm vs 1084 under --no-opt) — a plausible alternative explanation tested and discarded.

⭐ H3 — and the serial constraint dodged by construction, not by luck

NODE_OPTIONS rejected outright, so the card's suggested spelling was never going to work; it has to be argv on the node process, and the eslint bin is a shell shim, hence node_modules/eslint/bin/eslint.js.

Local and CI agree BY CONSTRUCTION: the flag lives in the root lint script and the workflow step is pnpm lint, so lint.yml needed no edit.

That is the best possible answer to ruling 2. I warned you PR #10116 was open on lint.ymlandpackage.json and told you to expect a conflict. You made lint.yml unnecessary — one file out of the collision surface, and local/CI divergence (a new false-green class) impossible rather than merely unlikely.

The honest note

a whole-repo pnpm lint at the DEFAULT stack was green on this machine, which is why the narrow-scope leg is the one carrying the proof.

Reported rather than buried. A dev wanting a clean story would have omitted the green run that appears to contradict the premise; explaining why it does not is what makes the rest credible.

#10123 — a false green in two gates, and it outranks its own filing

check:slot-lookup and check:query-options-erasure call ESLint's Node API, which returns a fatal parse error as a message with ruleId:null instead of throwing — their rule-text filters discard it, so an unparseable file scores clean instead of red. Verified: lintFiles returned normally with [{fatal:true, ruleId:null, message:'Parsing error: …'}].

So while the required context was dying on this file, two ratchet gates were reading it as clean. That is the same family as everything else this lane worked today, and it is now measured. Labelled pm:queue / domain:devx.

#10122 (the permanent fix — a template literal or .join('') takes depth 976 → ~4) labelled the same. This PR raises the ceiling; #10122 lowers the building.

#10121 — third duplicate, handled

Opened one minute after the claim comment on #10030. Closing it as a duplicate of this card rather than letting a fourth seat pick it up.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 20, 2026 05:36
@os-steve
os-steve enabled auto-merge August 20, 2026 05:36
@os-steve
os-steve added this pull request to the merge queueAug 20, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 20, 2026
@os-zhuang
os-zhuang added this pull request to the merge queueAug 20, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32337532116 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/3) — 失败步骤: Run this shard's tests

    �[90mstdout�[2m | src/webhook-secret-at-rest.test.ts�[2m > �[22m�[2mfail-closed and re-arm, extended to headers (#7986 × #7799/#8022)�[2m > �[22m�[2mdrops the subscription rather than delivering it wi
    �[90mstdout�[2m | src/webhook-secret-at-rest.test.ts�[2m > �[22m�[2mfail-closed and re-arm, extended to headers (#7986 × #7799/#8022)�[2m > �[22m�[2mdrops the subscription rather than delivering it wi
    �[90mstdout�[2m | src/webhook-secret-at-rest.test.ts�[2m > �[22m�[2mfail-closed and re-arm, extended to headers (#7986 × #7799/#8022)�[2m > �[22m�[2mre-arms with its headers when the CryptoProvider re
    �[90mstdout�[2m | src/webhook-secret-at-rest.test.ts�[2m > �[22m�[2mfail-closed and re-arm, extended to headers (#7986 × #7799/#8022)�[2m > �[22m�[2mre-arms with its headers when the CryptoProvider re
    �[41m�[1m FAIL �[22m�[49m src/dev-plugin-security-enforcement-warning.test.ts�[2m > �[22m[#10036] the "nothing is enforced" warning must fire when SecurityPlugin.start() bailed�[2m > �[22mbail #1 (no �[41m�[1m FAIL �[22m�[49m src/dev-plugin-security-enforcement-warning.test.ts�[2m > �[22m[#10036] the "nothing is enforced" warning must fire when SecurityPlugin.start() bailed�[2m > �[22mbail #2 (eng
    �[41m�[1m FAIL �[22m�[49m src/dev-plugin-security-enforcement-warning.test.ts�[2m > �[22m[#10036] the "nothing is enforced" warning must fire when SecurityPlugin.start() bailed�[2m > �[22mdoes not fir
    

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 37 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-elon
os-elon added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit eb2bebeAug 20, 2026
28 of 29 checks passed
@os-elon
os-elon deleted the claude/issue-10030-lint-parser-stack branch August 20, 2026 07:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filesize/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants

@os-steve@os-elon@claude