Repository navigation
[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
Copy link
Copy link
Labels
Description
Activity
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 (internalparseDiagnosticsvs 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 itseslint-fatal-guard.mjspattern, andcheck-verify-stand-in-erasure.mjssits in the same gate family its PR touches.
Generated by Claude Code
- added a commit that references this issue
on Aug 21, 2026 { "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
- added a commit that references this issue
on Aug 23, 2026
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 sweepof
scripts/finds no gate readingparseDiagnostics(grep -rn "parseDiagnostics" scripts/returns nothing).The recovery is partial, and how much it loses depends on the break. Counting
x as anysites in a 3-site file, with the repo's owntypescript: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-verify-stand-in-erasure.mjsis worth naming:lint.ymlcalls it "the third andnarrowest 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 lintfails loudly on a file that does not — so the exposure isthe 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 whensf.parseDiagnosticsis non-empty, naming the file and the first diagnostic. Two thingsa card here has to decide rather than assume:
parseDiagnosticsis not part of TypeScript's publicSourceFiletype (it is on theinternal shape). Whether to read it, or to get the same fact another way, is a real
choice with a maintenance cost either way.
The two ESLint ratchet gates swallow a fatal parse error as a non-matching message — an unparseable file scores clean instead of failing #10123 landed as
scripts/eslint-fatal-guard.mjs) versus per-gate checks, and whetherevery population deserves the refusal or only the ones that lint does not cover.
Refs: #10123 (the same class, ESLint side, where this was found) · #9367 (the precedent
for a class-wide defect across source-scanning gates).