Skip to content

[finding] check:query-options-erasure fails intermittently on a 533 KB generated file — ESLint blows its stack parsing migrations/registry.ts, and the margin shrinks every protocol major #10444

Description

@os-warren

Observed 2026-08-20 while driving PR #10422 (an unrelated plugin-sharing change) to green. Filed unassigned and unrouted — it is not domain:services work; triage should place it.

What happened

Lint & Repo Gates failed on PR #10422 with:

✗ check-query-options-erasure-ratchet: 1 parse failure(s) inside the population this gate measures:

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

ESLint returns a parse failure as a message with no rule id, so it matches no
rule this gate counts. […] Nothing was counted this run.

PR #10422's diff is 3 files, none of them in packages/spec (sharing-rule-service.ts, one new test, one changeset). The gate parses its whole population regardless of the diff, so the failing file is not reachable from the change.

Why it is intermittent rather than simply broken

packages/spec/src/migrations/registry.ts is byte-identical on main since f3996182b (16:17, #10252) — 533,578 bytes / 6,861 lines. Four PRs merged after that commit with this same gate green:

91f303cb4  18:56  #10415
965d370d5  18:52  #10421
24ba050f3  18:52  #10423
6439f8b42  18:36  #10411

Same file, same bytes, green then red. So this is not a content defect that someone introduced — it is V8 stack exhaustion while parsing one very large file, and how much stack is available depends on the runner and on what else is executing. That makes it load-dependent and therefore intermittent.

⚠️ Not reproduced locally, stated as such. The shared checkout has no installed dependencies and I declined to mutate it to get them, so the paragraph above rests on the CI evidence and the file's measured size, not on a local repro. Someone taking this card should reproduce before choosing a remedy.

Why it is worth a card and not a shrug

The file is an ADR-0087 "forever artifact" — its own header says every step back to MIGRATION_SUPPORT_FLOOR stays replayable, so it only ever grows. Measured across its three most recent commits:

commit date bytes
73cfddfa9 08-19 14:05 528,376
3ee8ddf01 08-20 13:37 533,578
f3996182b 08-20 16:17 533,578

(9a41e65d3, 08-19: 523,581.)

The margin to ESLint's stack limit shrinks monotonically and by design. Today it is an occasional red that costs a PR a cycle and reads as a flake. It does not stay occasional: each protocol major that carries a break appends another step, and at some size the parse fails on every run — at which point check:query-options-erasure stops measuring its entire population, on every PR, simultaneously.

That last part is the sharp end. The gate's own message says it refuses to count rather than report a file it could not read — which is the right refusal — but the failure mode is all-or-nothing: one unparseable file means nothing was counted this run. So the day this stops being intermittent, the gate goes from "occasionally noisy" to "structurally unable to run", and it will look like a sudden break rather than the arrival of a trend that was visible now.

Recording it as a shrinking-headroom residual rather than an observation: this lane has already paid once this week for treating "it survived, with less margin than expected" as a note instead of a condition (a single test's timing margin jammed the repo's merge queue for 3.5 hours).

Directions (not a recommendation — needs grading by whoever owns the gate)

  1. Raise the parser's stack for this run (node --stack-size=… around the ESLint invocation). Cheapest; treats the symptom and buys a bounded amount of time, and the bound is not obvious from the outside.
  2. Split the registry into one module per protocol major with a thin re-exporting index. Keeps the forever-artifact property while capping any single file's parse cost. Costs a generated-output shape change and whatever reads it.
  3. Exclude the generated file from the ESLint population and cover it by whatever checks generated output already gets. Requires arguing that nothing this gate measures can occur in it — the gate exists precisely to refuse unmeasured files, so this needs to be a positive argument, not an exemption of convenience.

Refs

scripts/check-query-options-erasure-ratchet.mjs · packages/spec/src/migrations/registry.ts · ADR-0087 D3 · PR #10422 (where it surfaced) · run 32404189532

⚠️ Free-text issue search returns total_count: 0 on this repo (measured), so I could not dedupe this against an existing card by content. If one exists, close this as a duplicate.

Activity

  1. os-warren commented on Aug 20, 2026

    @os-warren
    CollaboratorAuthor

    直接测量:同一 sha,一红一绿 —— 非内容决定,已从推断升级为实测

    原帖把「负载相关而非内容缺陷」写成推断(依据是该文件自 16:17 起未变、其后四个 PR 带同门禁绿色合并),并如实标注了未在本地复现。现在有更直接的一条:

    Lint & Type Check run 32404189532,head sha 857acf74f40e2d472b03cc952973c63d83d7c7b3:

    attempt 结论 head sha
    1 failure — Maximum call stack size exceeded @ packages/spec/src/migrations/registry.ts:0:0 857acf74f
    2 success 857acf74f(同一 sha,未推新提交)

    同一提交、同样的字节、同一条流水线定义,一次红一次绿。 这排除了内容缺陷,也排除了「某个改动引入了它」——重跑之间唯一变化的是 runner 与并发负载。V8 可用栈深度随之变化,而这个文件是 533,578 字节的单文件解析。

    ⚠️ 这条测量不缩小本卡的严重性,反而是本卡的要点:今天它以「偶发红」的形态出现,读起来像 flake,因此每一次都会被重跑掩盖过去;而 registry.ts 按 ADR-0087 D3 是只增不减的永久产物(523,581 → 528,376 → 533,578,见原帖表格)。余量单调收缩,所以「重跑一次就绿」这条经验本身是有保质期的 —— 它到期的那天,check:query-options-erasure 会在每个 PR 上同时失去对整个测量群体的读取能力(该门禁的设计是「有一个文件读不了就什么都不计数」,这个拒绝本身是对的),并且看起来会像一次突发故障,而不是一条今天就已经可见的趋势的到达。

    按仓库规则我只做了一次重跑(用于确认「非本 PR 所致」这一类判定),没有第二次。PR #10422 已据此入合并队列。


    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