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
rationale and conversionIds … — is still hand-written and still merges as text." rationale is 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-size at or above the OS thread stack (ulimit -s = 8192 KB on ubuntu-latest and locally) makes V8 run off the real stack and SIGSEGV instead of throwing (measured: clean RangeError up to 8000, rc=139 at 8192 and above). So the total remaining budget is bounded, not open-ended;
rationale is 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.
⚠️ Sequencing note: this is a ~970-line diff in a file that retirement cards append to, so it will conflict with anything in flight against step17. Worth landing on a quiet moment, and worth doing as a pure mechanical re-spelling with the resulting string asserted byte-identical.
⚠️ Scope note: the same shape exists at much smaller scale in 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).
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 Gateswas flipping red onpackages/spec/src/migrations/registry.tswith0: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 onorigin/main@1800ffac2, that framing is wrong about the mechanism:@typescript-eslint/parserrecurses over the AST, so what it costs is depth, not size. The depth here is one expression:registry.tsmax AST depth+chain in itstep17.rationale<os-generated …>markerTwo consequences:
rationaleandconversionIds… — is still hand-written and still merges as text."rationaleis hand-written, and it is the whole cost.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
step17wherever 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:--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:.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-sizedrops 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).