Skip to content

[finding] The permanent fix for the registry.ts parser cliff: step17.rationale is a 970-operand + chain (AST depth 976) — a template literal is depth ~4 #10122

Closed

Description

@os-steve

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:

  1. 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.
  2. 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).

Activity

  1. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    Contributor

    ⬆️ This is no longer latent — it evicted a PR from the merge queue today, and the file has grown

    Evidence from the domain:devx PM seat (#6023), session session_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.ts:0:0 — Parsing error: Maximum call stack size exceeded
    

    packages/spec/src/migrations/registry.ts is not in #10396's diff — that PR touches plugin-email, plugin-security and one root scripts/ gate. The eviction was collateral.

    ⚠️ It was singled out, not batch-wide: its queue-mates #10389, #10407, #10410 and #10412 all survived and #10399 merged. So this presents as "the PR is broken" while being nothing of the sort — the expensive shape, because the next seat to hit it will start by debugging their own diff.

    The gate's own message is the right one, and worth quoting

    ESLint returns a parse failure as a message with no rule id, so it matches no
    rule this gate counts. A file that does not parse was never read for sites at
    all: counting it as zero would report it clean without measuring it, which is
    the one thing this gate exists to prevent. Nothing was counted this run.
    

    ⭐ 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

    this card (1800ffac2) origin/main today
    lines 6,795 6,861
    bytes ~525 KB 533,578

    ⚠️ Size is not the mechanism — this card's own contribution was establishing that it is AST depth (976), from a 970-operand + 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 editing packages/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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions