Skip to content

The two ESLint ratchet gates swallow a fatal parse error as a non-matching message — an unparseable file scores clean instead of failing #10123

Closed

Description

@os-steve

Filed unassigned, found while implementing #10030. Nothing is red today; this is about what the ratchets claim to measure versus what they do measure when a file will not parse.

Measured

scripts/check-slot-lookup-ratchet.mjs and scripts/check-query-options-erasure-ratchet.mjs both run ESLint through its Node API (import { ESLint } from 'eslint') over packages/**/*.{ts,tsx,mts,cts} with the grandfathering ignores lifted, then count the messages matching their own rule text.

ESLint's Node API does not throw when a file fails to parse — it returns the failure as an ordinary message with ruleId: null and fatal: true. Measured in a worktree at origin/main @ 1800ffac2, against the file from #10030:

lintFiles returned normally (did NOT throw)
registry.ts messages: [{"fatal":true,"ruleId":null,"message":"Parsing error: Maximum call stack size exceeded"}]
errorCount=1

Both ratchets filter by their own rule's message text, so a ruleId: null fatal matches nothing and is discarded. The file contributes zero sites, the ratchet reports ✓ … holds, and exits 0 — indistinguishable from "this file was measured and is clean".

Confirmed both gates are green at origin/main today:

✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new.
✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new.

Why it matters

Both scripts carry docblocks arguing precisely that a green pnpm lint proves nothing for the files they cover, and that they exist to close that hole — check-slot-lookup-ratchet.mjs: "pnpm lint passing proves NOTHING for a file in the baseline". The parse-failure path reopens the same kind of hole one level up: an unparseable file is silently unmeasured by the very gate whose job is to measure it, and the failure mode is quiet green rather than red.

This is not currently biting: over a wide scope (thousands of files) registry.ts does parse, and both ratchets run over all of packages/**. It bites when a file becomes unparseable for any reason — the #10030 signature, a syntax error in a generated artifact, a future parser upgrade.

⚠️ Note the asymmetry with pnpm lint: the root lint script fails loudly on a fatal parse error (that is what made #10030 visible at all). These two gates fail silently on the same input. Whatever the disposition, they should not disagree about that.

Direction (not a decision)

The cheap, self-contained version: after lintFiles, assert that no result carries a fatal message, and fail with the file name and the parse error if one does. That is a handful of lines in each script, it turns a silent skip into a named red, and it changes no ratchet count — verifiable by running both gates before and after and seeing identical site counts.

Whether the two scripts should also get the stack headroom the root lint script now has (they run as plain node scripts/… at V8's default 984 KB stack) is a separate question and depends on the above landing first — with a fatal assertion in place, the stack question announces itself instead of hiding.

Refs: #10030 (where this surfaced) · #4251 (slot-lookup ratchet) · #4918 (query-options ratchet).

Activity

  1. added
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 20, 2026
  2. claude commented on Aug 20, 2026

    @claude
    Contributor

    Claim: PM loop round 29 (serial — one baton at a time)
    Session: session_01XqDQYVU5smx29ts9pAErja
    Branch: claude/issue-10123-ratchet-fatal-parse
    Worktree: objectstack-issue-10123
    Domain: domain:devx
    File surface: scripts/check-slot-lookup-ratchet.mjs, scripts/check-query-options-erasure-ratchet.mjs, plus whatever fixture the harm-reproduction needs (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — node scripts/pm/dispatch-gates.mjs --tier scripts/check-slot-lookup-ratchet.mjs scripts/check-query-options-erasure-ratchet.mjs run this round at origin/main e61ee68320: "no path-derived mandate: the surface hits none of the 1 declared glob(s) … This line is a FLOOR, never a clearance."
    Clause-②: no — this tightens two build gates' own failure reporting. It changes no spec contract's accept/reject behaviour and widens no public surface; the accept set of the ratchets themselves narrows (a file that scored clean by being unmeasurable now fails), which is the direction the card asks for. The dev re-declares this in its report against its actual diff.
    Serial constraints cleared: swept all 16 open PRs for any touching either ratchet script — none. Two adjacent-but-disjoint PRs named for read-coupling, neither a file collision: #10124 (eslint.config.mjs + root package.json — stack headroom for the root lint script) and #10116 (.github/workflows/lint.yml + package.json — pm label vocabulary). Neither touches scripts/check-*-ratchet.mjs, and both ratchets run as plain node scripts/…, so they do not inherit #10124's headroom — that is precisely the "separate question" the card defers.


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 20, 2026
  4. os-steve commented on Aug 20, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 10123,
      "status": "done",
      "branch": "claude/issue-10123-ratchet-fatal-parse",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10130",
      "premise_still_valid": true,
      "summary": "Clause-②: no — declared against the actual diff. Three files, all under root scripts/: the two ratchets plus a new shared module scripts/eslint-fatal-guard.mjs. No spec/contract accept-reject behaviour changes and no public surface widens; the two gates' own accept set NARROWS (a file that scored clean by being unmeasurable now fails). Nothing published, so skip-changeset (label applied and read back: [size/m, skip-changeset]).\n\nWHAT SHIPPED. Both gates now call lintFilesStrict() from the new guard instead of eslint.lintFiles(). The guard collects every message carrying `fatal`, cross-checks ESLint's own per-result fatalErrorCount (so a future ESLint that moves the flag cannot make this silent again), prints file:line:col plus the parser's message, and exits 2 — the code both gates already reserve for 'refusing to report clean', as distinct from 1 = 'the ratchet moved'. It covers the --update paths too (measurement precedes the write in both). Both docblocks now state the refusal. The query-options --self-test (CI runs it ahead of the gate) proves the guard in both directions over real ESLint output and asserts from source that BOTH gates still route through it — checkGuardAdoption().\n\nWHY SHARED, NOT COPIED (a deviation from the dispatch's 'two scripts + fixture' surface, flagged rather than taken silently): two copies of a guard drift and a drifted copy is invisible — the gate that lost the check keeps printing the same green line. The third file is a helper, not a gate; check:ratchet-remedy-authority sweeps it (98 scripts) and classifies it with the 89 handing out no ratchet-expanding remedy, so its control corpus needs no entry. The harm fixture was never committed. I did NOT touch root package.json (both #10124 and #10116 touch it): that means check:slot-lookup gets no --self-test hook of its own, and the wired coverage of ITS call site is the source assertion inside the query-options self-test. If you would rather have a second wired self-test, that is a package.json edit and a follow-up.",
      "tests": "All evidence at HEAD 87e46090c5 (merge of origin/main 2cd5f81132 into 72038565f9); the before-half is at the branch point 5262124388.\n\n=== HARM REPRODUCTION, BOTH SCRIPTS, BOTH HALVES ===\nFixture: packages/spec/src/__ratchet_fatal_probe__.ts, two lines, second is `export const brokenOnPurpose = (`. Never committed.\n\nMechanism re-derived first (my own measurement, not the card's):\n  lintFiles returned normally (did NOT throw)\n  file: packages/spec/src/__ratchet_fatal_probe__.ts\n  messages: [{\"ruleId\":null,\"fatal\":true,\"severity\":2,\"message\":\"Parsing error: Expression expected.\",\"line\":2,\"column\":32}]\n  errorCount=1 fatalErrorCount=1\n\nBEFORE (unmodified gates, probe present):\n  $ node scripts/check-slot-lookup-ratchet.mjs\n  ✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new. Every other file under packages/ is covered by `pnpm lint`.\n    baseline key set verified against 5262124: no files added.\n  SLOT_EXIT=0\n  $ node scripts/check-query-options-erasure-ratchet.mjs\n  ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new. Every other non-test file under packages/ is covered by `pnpm lint`.\n    test surface: 240 site(s) in 47 file(s) — at the ceiling, ...\n  QO_EXIT=0\n  $ pnpm exec eslint packages/spec/src/__ratchet_fatal_probe__.ts --no-inline-config\n    2:32  error  Parsing error: Expression expected\n  ✖ 1 problem (1 error, 0 warnings)\n  ROOTLINT_EXIT=1        <- the asymmetry the card names, confirmed directly\n\nAFTER (patched gates, same probe, HEAD 72038565f9 == the code now at 87e46090c5):\n  ✗ check-slot-lookup-ratchet: 1 parse failure(s) inside the population this gate measures:\n    • packages/spec/src/__ratchet_fatal_probe__.ts:2:32 — Parsing error: Expression expected.\n  [+ 7 lines explaining that a fatal matches no counted rule and nothing was counted this run]\n  EXIT=2\n  ✗ check-query-options-erasure-ratchet: 1 parse failure(s) inside the population this gate measures:\n    • packages/spec/src/__ratchet_fatal_probe__.ts:2:32 — Parsing error: Expression expected.\n  EXIT=2\n\n=== SITE COUNTS, SIDE BY SIDE (the card's acceptance criterion) ===\n  gate                        | 5262124388 (before) | 72038565f9 (after) | 87e46090c5 (merged)\n  slot-lookup                 | 107 in 25 files     | 107 in 25 files    | 107 in 25 files\n  query-options non-test      |  67 in 17 files     |  67 in 17 files    |  67 in 17 files\n  query-options test surface  | 240 in 47 files     | 240 in 47 files    | 240 in 47 files\n\n=== ABLATIONS (both legs plain node scripts — no package exports, no dist, so nothing to rebuild; each restored by checking the file out of this branch again and `git diff --stat HEAD` verified empty) ===\n  A. slot-lookup put back to a bare eslint.lintFiles() -> self-test RED (exit 1): 'scripts/check-slot-lookup-ratchet.mjs: calls `.lintFiles(` directly, so a parse failure in its population is discarded as a message matching no rule. Call lintFilesStrict() instead.'\n  B. guard's `m.fatal` filter swapped for a never-matching predicate -> self-test RED (exit 1) on the detail assertions. Direction observed, and it is NOT the predicted one: the file was STILL caught, by the fatalErrorCount cross-check, but with position and parser text lost. The second limb is real; that is what the assertions measure.\n\n=== GATES RUN AT 87e46090c5 (all green; verdict lines quoted, not $?) ===\n  pnpm check:slot-lookup -> '✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.'\n  pnpm check:query-options-erasure -> '✓ self-test: 10 reporting shape(s), 10 silent counterpart(s), ... 8 ratchet case(s), fatal-parse guard proved both ways over real ESLint output, both gates still routed through it.' + '✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed.'\n  pnpm check:cross-package-test-inputs -> 'All 57 self-test cases passed.' + 'OK: 12 package(s) read outside themselves, all declared'\n  pnpm check:ratchet-remedy-authority -> 'OK  check-ratchet-remedy-authority: 98 scripts swept ...; 89 hand out no ratchet-expanding remedy. Control corpus: 18 hand-classified scripts, set-equality audited both ways.'\n  pnpm check:nul-bytes -> 'check-nul-bytes: OK (scanned 6375 text file(s) ... no raw ASCII control bytes).'\n  pnpm check:pm-dispatch-gates -> '✓ dispatch-gates self-test: 351 cases pass.' (it reads the slot-lookup gate's source for its population declaration — my import line did not disturb it)\n  pnpm exec eslint on the three changed files, --no-inline-config -> exit 0, no output\n  Union re-derived AFTER the final commit: `node scripts/pm/dispatch-gates.mjs` (no paths) at merge base 2cd5f8113 -> 3 paths, 4 gates: check:cross-package-test-inputs, check:query-options-erasure, check:slot-lookup, node scripts/check-cross-package-test-inputs.mjs. All four run above. CI has not been waited on (per the dispatch contract).\n\n=== CORRECTIONS TO THE DISPATCH (on the record) ===\n  1. Branch point: the dispatch said origin/main was e61ee68320; by the time the worktree was cut it was 5262124388 ('feat(docs-gate): close the runtime-services stability label vocabulary #10008'). I based on 5262124388. Every count the dispatch quoted (107 / 67) reproduced exactly there, so the card's numbers were right.\n  2. #10124 DID land under me (eb2bebe532 on main). Merged into this branch and re-measured: it is comment-only in eslint.config.mjs plus the root lint script (now `node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config`), so it moved nothing I measured — all three counts identical after the merge. Merge, not rebase: force-push is banned.\n  3. I did NOT reproduce the card's exact 'Maximum call stack size exceeded' signature; I used a plain syntax error. Deliberate: the stack-overflow variant is scope-dependent (that is #10124's whole subject), while what the gates were blind to is the message SHAPE (ruleId null + fatal true), which any parse failure produces identically. The guard keys on `fatal`, not on the text.\n  4. A new, unexpected datum on the DEFERRED stack question, which exists only because this landed: both gates now go green only if every file in the population parsed, so the green runs above are positive evidence that packages/spec/src/migrations/registry.ts parses inside these gates' own wide `packages/**` run at the default 984 KB stack today. Consistent with #10124's measurement that only narrow scopes crash. The flip side, which the follow-up card should weigh: where the old behaviour was a silent skip, this guard turns that file's ~10% stack margin into a red gate if it is ever crossed. I did not fold the headroom change in, per the dispatch.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10133: the 15 gates that parse with ts.createSourceFile never read parseDiagnostics — same defect class one parser over. Measured: an unterminated template literal takes a 3-site file to 0 sites found with parseDiagnostics=1, and the gate scores it clean. Includes check-verify-stand-in-erasure.mjs, which lint.yml calls 'the third and narrowest member' of the two gates this PR fixes. Filed unassigned, labels tooling+finding, nothing red today; not fixed here because the fix needs a decision (parseDiagnostics is not on the public SourceFile type) across 15 call sites — the in-place exemption's 'mechanical, form already pinned' condition fails."
      ]
    }

    Generated by Claude Code

  5. removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 20, 2026
  6. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seattoolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions