Description
Filed unassigned while implementing #10030 (PR raises the lint parser's stack headroom). That PR moves the ceiling; this card is the fix that removes the problem instead of deferring it.
The measurement that reframes the class
Lint & Repo Gates was flipping red on packages/spec/src/migrations/registry.ts with 0:0 error Parsing error: Maximum call stack size exceeded. The received framing (in #10030, #10071 and #10121) is "the file is 525 KB / 6795 lines, machine-generated, and growing". Measured on origin/main @ 1800ffac2, that framing is wrong about the mechanism:
@typescript-eslint/parser recurses over the AST, so what it costs is depth, not size. The depth here is one expression:
| what | value |
|---|---|
registry.ts max AST depth |
976 |
longest + chain in it |
970 operands, lines 397–1367 |
| what that expression is | step17.rationale |
nearest <os-generated …> marker |
line 1432 — i.e. the chain is entirely outside the generated regions |
| runner-up AST depth across all 4659 linted files | 71 |
Two consequences:
- The generated regions are innocent. They are flat arrays of string literals — shallow. The file's own header says it plainly: "Everything OUTSIDE the markers — this header, each step's
rationaleandconversionIds… — is still hand-written and still merges as text."rationaleis hand-written, and it is the whole cost. - Depth does not track size. The two largest linted files in the repo — 236 KB (
packages/cli/src/commands/serve.ts) and 183 KB (scripts/check-type-check-coverage.mjs) — sit at depth 44 and 40 and are nowhere near the ceiling. Size is not the predictor; a single left-nested operator chain is.
So the two directions proposed in #10030 that target the generated output — "split the generated output across several files" and "exempt the generated regions from parsing" — would not have fixed this. The depth would travel with step17 wherever it went, and it is not between the markers to begin with.
Why it is worth its own card
The stack bump shipped for #10030 buys headroom, and the headroom was measured, not guessed: each appended + '…' fragment costs ~1.10 KB of stack, need is 1085 KB, and the shipped setting is 4000 KB — about 2650 more fragments. That is years at the current rate, not months. But:
- it is still a growing quantity against a fixed ceiling, and the ceiling cannot be raised indefinitely:
--stack-sizeat or above the OS thread stack (ulimit -s= 8192 KB onubuntu-latestand locally) makes V8 run off the real stack and SIGSEGV instead of throwing (measured: cleanRangeErrorup to 8000,rc=139at 8192 and above). So the total remaining budget is bounded, not open-ended; rationaleis appended to by retirement cards as routine, so the growth is one-directional by design.
The fix
Rebuild that one string without a 970-deep left-nested + chain. Either spelling collapses the depth from 976 to ~4:
- a template literal, or
- an array of fragments with
.join('').
No behaviour changes — it is the same string value. Cheap to verify: parse the file and assert max AST depth, or simply confirm the minimum --stack-size drops back under the 984 KB default.
step17. Worth landing on a quiet moment, and worth doing as a pure mechanical re-spelling with the resulting string asserted byte-identical.
packages/spec/src/migrations/entries/semantic/*.ts (depths 71, 64, 49, 49, 47 …). Those are harmless today and are not part of this card — but they are the same authoring habit, so if a lint rule capping operator-chain length is ever considered, that is the population it would touch.
Refs: #10030 (the flake card this came out of) · #10071, #10121 (duplicate reports of the same signature) · #7297 (the per-entry generation mechanism).
Activity
os-zhuang commentedon Aug 20, 2026
⬆️ This is no longer latent — it evicted a PR from the merge queue today, and the file has grown
Evidence from the
domain:devxPM seat (#6023), sessionsession_01DdCnBGcHeufjrq7drTD3wt. ⛔ No grading changed; recording a state change in the finding's severity, for triage to weigh.It cost a merge-queue eviction at 18:41Z
PR #10396 (#9748 — durability summary reports) was dequeued with
reason: CI_FAILURE. Its own head was green: 24 checks, 0 failing,mergeable_state: clean. The queue build failed, and the failure is this card, verbatim:packages/spec/src/migrations/registry.tsis not in #10396's diff — that PR touchesplugin-email,plugin-securityand one rootscripts/gate. The eviction was collateral.The gate's own message is the right one, and worth quoting
⭐ That is #4690 handled correctly at the point of failure — the gate refuses to convert "I could not parse this" into "there is nothing here". It is the reason this surfaces as a red instead of a silent pass, and nothing in what follows argues for softening it.
The file has grown since this card measured it
1800ffac2)origin/maintoday+chain, not bulk. But the file is machine-generated and still growing, and every generated addition is another chance for the depth to move. #10030 raised the parser's stack headroom; that moved the ceiling, and the floor is still rising toward it.Which is exactly why it passes on a PR head and fails in a queue build: the same file, a different runner and a different memory profile, sitting close enough to the limit that the outcome is decided by conditions rather than by content. A defect whose verdict depends on which machine ran it will keep costing full-queue rebuilds at random.
Why this seat is not fixing it
⛔ Out of scope for every card in flight here, and the fix is this one's: replacing a 970-operand
+chain with a template literal (depth ~4) means editingpackages/spec/src/migrations/registry.ts— or, since it is generated, its generator. Not a rider on a durability-logging PR.⇒ Recorded so the severity is visible: this stopped being "a finding about a latent cliff" and became "a live cause of merge-queue evictions on unrelated PRs." Refs: #10030 (raised the headroom), #10071, #10121 (same family), #9748 / PR #10396 (today's eviction).
Generated by Claude Code