Skip to content

[finding] The 15 gates that parse with ts.createSourceFile never read parseDiagnostics — a file with a syntax error is walked as a recovered partial AST and scores clean #10133

Description

@os-steve

Filed unassigned, found while implementing #10123. Nothing is red today. This is the
same defect class as #10123 — a file the gate could not read scores as a file with
nothing to report
— one parser over.

Measured

ts.createSourceFile() is error-tolerant by design: it never throws on a syntax error,
it returns a SourceFile built by error recovery and records what went wrong in
parseDiagnostics. Every gate in this repo that walks TypeScript uses it, and a sweep
of scripts/ finds no gate reading parseDiagnostics (grep -rn "parseDiagnostics" scripts/ returns nothing).

The recovery is partial, and how much it loses depends on the break. Counting
x as any sites in a 3-site file, with the repo's own typescript:

clean             : asAny=3 parseDiagnostics=0 statements=3
break at top      : asAny=2 parseDiagnostics=1 statements=3
break inside body : asAny=2 parseDiagnostics=3 statements=2
unterminated tpl  : asAny=0 parseDiagnostics=1 statements=1

The last row is the interesting one: one unterminated template literal takes the whole
file from 3 sites to 0, and the walker cannot tell that from a clean file. The gate
prints its green line.

The 15 gates on this parser today:

check-driver-memory-census.mjs        check-org-identifier.mjs
check-durability-degradation-log-level.mjs  check-resume-authority-declared.mjs
check-engine-double-contract.mjs      check-route-envelope.mjs
check-filter-alias-parity.mjs         check-startup-registry-verdict.mjs
check-init-service-contract.mjs       check-tenant-chokepoint.mjs
check-kernel-hook-pairs.mjs           check-verify-stand-in-erasure.mjs
check-meta-type-normalized.mjs        check-where-matcher-conformance.mjs
check-wildcard-fallthrough.mjs

check-verify-stand-in-erasure.mjs is worth naming: lint.yml calls it "the third and
narrowest member" of the two ESLint ratchets #10123 fixed, so the family whose hole was
just closed has a third member with the same hole through a different parser.

Why it matters, and why it is not urgent

Same argument as #10123: the failure mode is a quiet green, on gates that exist because
something else cannot see what they see. It is latent for the same reason — the tree
parses today, and pnpm lint fails loudly on a file that does not — so the exposure is
the window between a file breaking and lint being run, plus any file lint does not cover.

Direction (not a decision)

The cheap shape mirrors #10123: after createSourceFile, refuse when
sf.parseDiagnostics is non-empty, naming the file and the first diagnostic. Two things
a card here has to decide rather than assume:

Refs: #10123 (the same class, ESLint side, where this was found) · #9367 (the precedent
for a class-wide defect across source-scanning gates).

Activity

  1. added theissue type on Aug 20, 2026
  2. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    Contributor

    Triage first-touch: promoted — Task · pm:queue · domain:devx. Restore-invariant class (a gate that cannot read a file must refuse, not score clean), measured with a minimal repro, direction given. The two open items in the body (internal parseDiagnostics vs another read of the same fact; shared helper vs per-gate) are implementation-level calls for the dev, not product semantics — decide them in the PR with reasons, mirroring what #10123 landed.

    ⚠️ Serial note for the dispatching seat: hard-serialize after #10123 (in flight) — the cheap shape is to generalize its eslint-fatal-guard.mjs pattern, and check-verify-stand-in-erasure.mjs sits in the same gate family its PR touches.


    Generated by Claude Code

  3. added a commit that references this issue on Aug 21, 2026
    d7b6876
  4. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 10133,
      "status": "done",
      "branch": "claude/issue-10133-ts-createsourcefile-parse-errors",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10573",
      "premise_still_valid": true,
      "summary": "Re-derived the population mechanically: 15 scripts/ files, but 32 raw ts.createSourceFile CALL SITES (the card gave only the file count). Added scripts/ts-parse.mjs -- reads parseDiagnostics and REFUSES (exit 3, distinct from 1), naming file, line:column and TypeScript's own message; it exits rather than throws because packages/lint/src already carries two try/catch wrappers that would turn a throw into a silent skip. Added scripts/check-parse-guard.mjs so a raw call anywhere in scripts/** outside the parser home fails, removing the first source a helper could drift from. Converted all 32 call sites. THE CARD'S 'nothing is red today' WAS WRONG: check-engine-double-contract.mjs forced ts.ScriptKind.TSX over all 2504 of its test files, and 32 of them were wreckage under it (up to 633 diagnostics) while the gate printed 'OK -- 342 pinned'. Letting the file name pick the ScriptKind turns that into 6 problem(s): three files pin six engine doubles the ledger had never recorded. engine-double-contract.pinned.json regenerated 329->335 rows, 6 added or grown, 0 lost; pinned count 342->348. That ScriptKind correction is the bounded in-place fix the refusal FORCED -- the refusal cannot land in that gate while it mis-parses 32 real files -- and it is declared in the PR body with its evidence. NOT DONE, deliberately: check:parse-guard is not wired into CI, because that needs root package.json (fenced by #9465) and .github/workflows/lint.yml (claimed by #8331). Filed as #10574. The conversions and the refusal are fully live regardless; only the anti-regression guard is dormant. OVERLAP REPORTED, NOT ABSORBED: open PR #10562 rewrites scripts/check-route-envelope.mjs (+312/-24), a file this PR converts; noted in the PR body for whoever lands second. PLATFORM NOTE: the PR body now carries TWO attribution footers -- mine in session-URL form survived the PATCH intact and the platform APPENDED a bare one on top. Left alone rather than 'corrected'.",
      "tests": "All measurements at HEAD d7b687604b (clean tree). [POPULATION, with numerator + positive control] node scripts/check-parse-guard.mjs over 120 scripts/ *.mjs/*.js/*.cjs files -> '32 raw TypeScript parse(s) in scripts/' across 15 files. POSITIVE CONTROL: all 15 files the card names appear; NEGATIVE control pinned in --self-test (13 cases) -- a line comment, block comment, string payload and template payload naming the call are NOT findings, while plain / aliased / destructured spellings are, and only ts-parse.mjs is exempt. Verified the masker is load-bearing by breaking the detector regex: the same live violation then went GREEN with the identical pass line. [IS A GATE HIDING A REAL UNPARSEABLE FILE TODAY? YES -- one was] Repo-wide sweep of 4591 .ts/.tsx/.mts/.cts files, TypeScript 6.0.3: 0 have parseDiagnostics under the ScriptKind their extension implies; 40 are wreckage under forced TSX and 15 under forced TS. Intersecting with check-engine-double-contract's real population (packages+examples, *.{test,spec}.{ts,tsx,mts}, 2504 files, forced TSX): 32 wreckage files, 21 of them also past its census pre-filter. Verdict moved OK(exit 0) -> 6 problem(s)(exit 1); delete doubles 236/215 files -> 239/218, update 272/246 -> 275/249, pinned 170/172 -> 173/175. AFTER the fix, OS_TOOLING_PARSE_CENSUS over all 15 gates: 5157 parses, 0 refusals (per-gate distinct file names summing to 3251). POSITIVE CONTROL for that zero: restoring the ScriptKind.TSX main ships makes the refusal fire immediately on the unmodified tree -- 'file packages/mcp/src/__tests__/plugin.test.ts / parsed as TSX / 147 parse diagnostic(s)'. [HARM REPRODUCED] Genuine syntax error injected into packages/qa/dogfood/test/read-coercion-conformance.test.ts (in check-verify-stand-in-erasure's scan set). origin/main's gate: EXIT=0, 'OK ... 10 call site(s) reached', ZERO mentions of the file. This branch's gate, same tree: EXIT=3, names the file, line:column and the message. [ABLATIONS -- 4, each mutation confirmed ON DISK by anchor count before the reading was taken, each restore by git hash-object + empty git status --porcelain] (1) re-introduced a raw call in check-kernel-hook-pairs.mjs -> guard EXIT=1 naming 'scripts/check-kernel-hook-pairs.mjs:108'; restored, EXIT=0. (2) broke the guard's detector regex with the violation still present -> EXIT=0 and the identical green line (assertion is load-bearing); restored byte-clean. (3) neutered 'if (diagnostics.length > 0)' in ts-parse.mjs, same converted gate + same poisoned tree -> EXIT=0 and the identical 'OK ... 10 call site(s) reached' (isolates the refusal as the load-bearing part); restored. (4) restored ScriptKind.TSX in engine-double-contract -> EXIT=3 on the real tree; restored. NOTE on ablation 4: a first attempt reverted only the censusRecognizer call site and read EXIT=0 -- that path runs only under --census, so the reading was a no-op, not a refutation; re-run against the scanSource site (2 anchors) it gave EXIT=3. [SUB-FINDING, honest] The COUNT does not reliably move. TypeScript recovery kept all 10 call sites through 27 parse diagnostics; controlled fixtures show both directions (an unterminated template took a 3-site file to 1 site; a broken statement left all 3). The guaranteed harm is the silent green, not a smaller number -- which is exactly why the number cannot be trusted. [GATE UNION] node scripts/pm/dispatch-gates.mjs with NO path arguments named 19 families; all run at d7b687604b under scripts/pm/os-verify-lock.sh (VERDICT: command-exit 0, held 64s, waited 0s), plus check:nul-bytes, check:entry-guard and both new self-tests. ALL GREEN except node scripts/pm/check-half-states.mjs, EXIT=3 'PREREQUISITE NOT MET -- the token in the environment is not a valid GitHub token' (environment, not this diff; its pnpm check:pm-half-states form passes 715 cases). Verdict lines quoted, not bare $?: 'check:parse-guard: 120 scripts/ file(s) -- every TypeScript parse goes through ts-parse.mjs'; 'ts-parse self-test: 17 cases pass'; 'check:parse-guard self-test: 13 cases pass'; 'check-engine-double-contract: OK -- 348 pinned, 133 in the DEBT ledger, 2 exempt'; 'check:entry-guard: 121 scripts/ file(s)'; 'check-nul-bytes: OK (scanned 6182 text file(s))'. [ENTRY-GUARD CONSTRAINT, verified not inherited] node scripts/check-entry-guard.mjs exits 0; its source names scripts/invoked-as.mjs as the ONE module allowed to read process.argv[1]. It shaped three things: ts-parse.mjs imports isEntrypoint rather than typing its own guard; check-entry-guard.mjs is the template for check-parse-guard.mjs INCLUDING the js-comment-mask literal masking (load-bearing here because ts-parse.mjs's own header discusses the banned call at length); and invoked-as.mjs's stated reason for NOT importing its packages/cli sibling set the scope boundary at scripts/**.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10574: check:parse-guard is not wired into CI -- needs a root package.json check:* entry (fenced by #9465) and a lint.yml step (claimed by #8331); until then the anti-regression guard is dormant",
        "filed as #10575: the same class outside ts-parse.mjs's reach -- ts.createProgram (check-published-readme-exports.mjs:775) and ts.transpileModule (check-where-matcher-conformance.mjs:481) inside scripts/**, plus 10 sites under packages/ (lint/src x3 -- two behind a try/catch that would become a silent skip -- lint/scripts, spec/scripts x2, cli/src)",
        "NOT filed, reported here instead: open PR #10562 rewrites scripts/check-route-envelope.mjs (+312/-24), a file this PR converts. Whoever lands second re-runs check-parse-guard. Reported rather than absorbed, per the dispatch."
      ]
    }

    Generated by Claude Code

  5. added a commit that references this issue on Aug 23, 2026
    0eca1c8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions