Uh oh!
There was an error while loading. Please reload this page.
fix(lint): report an unparseable source instead of scoring it clean (#10653) - #10697
Conversation
…10653) Four validators in packages/lint parsed authored source with `ts.createSourceFile` and never read `parseDiagnostics`. The call cannot throw, so a source with syntax errors came back as a recovery tree, was walked like any other, and produced no findings. Two of the sites wrapped the parse in a `try/catch` that never ran; deleting only those would have closed nothing. Adds a lint-local checked parse (`checked-parse.ts`) that reads the diagnostics and RETURNS them, and wires four advisory rule ids onto it. Additive: every finding these rules produce today they still produce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
📓 Docs Drift Check24 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 631617a1fefdf017e30a5e0a84db30f244bbb3fa && git checkout 631617a1fefdf017e30a5e0a84db30f244bbb3fa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 47cd3ec1f8d7defc207b7a6669edafc5e9a565f4 2dec2dfaf52906228818b6c8ab92ab26613bd204 && git checkout -B drift-repro 47cd3ec1f8d7defc207b7a6669edafc5e9a565f4 && git merge --no-ff 2dec2dfaf52906228818b6c8ab92ab26613bd204
node scripts/docs-audit/affected-docs.mjs --json 47cd3ec1f8d7defc207b7a6669edafc5e9a565f4 |
PM review — verified against the tree and the diff, not the report. Approving. The judgment call I flagged as genuinely open, you got right
I checked its most falsifiable sentence. It says "Two of the sites carried a The premise was verified before building, which is the right order0 of 10 wrecks threw — merge markers, truncated body, unterminated string/comment/template, JSX-under-TS, both The non-vacuity proof is the part that makes reading an internal API acceptable. Under A1 (checked-parse never sees ⭐ The process incident, and why reporting it raises my confidence rather than lowering itYou restored with
That is the rule working on a methodology failure rather than a code one. Four different ablations cannot legitimately produce the same number; the constant 21 was the signal, and you chased it instead of banking four confirmations. Everything reported is from the second pass, off a committed tree. This is the third time tonight that "the ablation didn't move the way I predicted" has caught something real, and the first time it caught the harness rather than the subject. Related, and equally right: the first The rest that makes it reviewable
⭐ One thing your hand cross-check surfaced that is bigger than this PRYou found that That is the fourth instance of one root cause: #10648 ( #10696 correctly filed, and filed after measuring both directions on something the card had recorded as deliberately unmeasured. Arming once the 16 in-flight checks clear. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10653
Three
packages/lintvalidators scored an unparseable source as CLEAN. This is option A from the card's ruling: apackages/lint-local checked parse that returns a finding, with the two deadcatchblocks deleted along with it.The premise, verified before building
The card's central correction is that the
try/catchwrappers are not where the false-clean verdict comes from. Verified on this tree (TypeScript 6.0.3) before writing any fix — 10/10 wrecks return a tree and throw nothing:createSourceFilecannot throw, so the live path at all three sites was the unreadparseDiagnostics. Deleting the catch blocks alone would have closed nothing, and this PR does not rest on them.Population, re-derived on today's tree
node scripts/check-parse-guard.mjsat55809a09a5: 28 parses outsidescripts/in 20 files — 4 in shipped package source, unchanged from the card's four rows. Three are the lint validators in scope; the fourth (packages/cli/src/utils/detect-free-identifiers.ts) is CLI runtime and out of scope by the card.After this change the same census reads 2 parses in 2 files in that tier: the three validators drop out (they no longer call
ts.createSourceFiledirectly) and the single newchecked-parse.tscall site takes their place, next to the untouched CLI row. The census moved because the tree moved, which is what it is for.The harm, reproduced
Not "it throws and we skip" — it parses, the finding vanishes, and the verdict reads clean. Each pair below is the same defect with and without a syntax error, measured before the fix:
validateReactPageProps<ObjectForm>behind an unterminated commentreact-page-source-unparseable)findStartupRegistryVerdictsstartup-source-unparseable)extractHookBodyWriteSetctx.input.amout = 0behind an unterminated commentparseFailureEvery row has a positive control in the tests: the repaired twin produces the finding the wreck loses, so the zero above is a loss and not an empty fixture.
What the fix does, and deliberately does not do
packages/lint/src/checked-parse.tsreads the diagnostics and returns them. It never throws and never exits: a publish-time validator is handed metadata by someone else, so ending the process on their input is not its call. The compiler is a parameter, never an import, so the lazy-load contract (lazy-deps.test.ts) is untouched.The change is additive. The recovered tree is still walked, so every finding these rules produce today they still produce — including from a wrecked tree. Measured: a source containing
0755still yields itsreact-prop-missing-required, and gains the parse finding beside it. Ablation A4 below is the guard on that property; the alternative design (skip the walk on failure) silently drops it.Severity is
warningat all four sites. Three of them haveseverity: 'warning'as the only inhabitant of their severity type. For the react props gate it is a measured choice, not caution — see the constant's docblock and #10696.False-positive control
ScriptKind.TSXgenerics, type annotations, fragments, spreads;ScriptKind.TSenums and non-null assertions).HOOK_BODY_WRITE_PATTERNS/ACTION_*_PATTERNSledger examples are swept through the same assertion, so a declared pattern cannot become a false positive.lint-startup-registry-verdict.corpus.test.tswalks every.tsunderpackages/. Measured before implementing: 10 files actually reach the parse (the rest are cut by the raw-text pre-filter) and 0 of them carry a parse diagnostic — so this change cannot redden that ratchet today, and if a future unparseable file lands there, going red is the behaviour that test's own header asks for.The synthesised wrapper — whose fault an unparseable body is
validate-hook-body-writes.tsparsesasync function __body(ctx) { … }, so "unparseable" there could mean the synthesis is wrong rather than the author's source. It cannot be, and this is asserted rather than argued: the wrapper is a constant, and a test pins that it parses clean around a trivial body and around every example in the pattern ledger. Positions are reported in the body's own coordinates (synthesizedLinesBefore: 1, clamped), so a diagnostic can never point at a line the author did not write.A fixture that had to be re-judged, not edited
lint-startup-registry-verdict.test.tscarriedexpect(findStartupRegistryVerdicts('class { { { getRegisteredNodeTypes(')).toEqual([])under the heading "an empty or unparseable source is not a verdict about anyone". Its assertion passed precisely because nothing was produced — it pinned the branch this PR changes. Its two halves came apart on inspection and both are now asserted separately: declining to draw a startup verdict from wreckage still holds, and returning nothing at all no longer does.Ablations
Each mutation was confirmed on disk by anchor count before the run (
old:1->0 new:0->1), and each restore leg verified the anchor was gone and the tree clean. Predictions were written before running.checked-parsenever seesparseDiagnostics(the internal-API rename hazard)is ADDITIVE)Under A1 the two independent controls stay green by design —
cannot throw(which callscreateSourceFileraw) andthe property is really there(which asserts on the compiler, not on this module). That is the non-vacuity proof: if TypeScript ever renamesparseDiagnostics, every checked parse silently reverts to scoring wreckage clean and nothing else in the tree would notice.A process note, since it affected the record: the first ablation pass restored with
git checkout --against files that were still untracked, which silently reverted two source files toHEADand left A1's mutation live under A2–A4. The tell was that all four runs reported an identical 21 failures against very different predictions. The work was reconstructed, committed, and all four ablations re-run from a committed tree — the numbers above are from that second pass.Verification
Gate union derived with
node scripts/pm/dispatch-gates.mjs(no paths passed) and run at2dec2dfaf5, the final commit. All 16 green, each quoting its own verdict line:check:nul-bytes·check:parse-guard·check:cross-package-test-inputs·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:changeset-gate-self-tests·check:objectui-changeset·check-empty-changeset·check-adr-0087-registration·check-changeset-no-major·check-affected-docs·check:query-options-erasure·check:type-check-coverage·check:engine-double-contract·check:where-matcherCross-checked by hand where the derivation cannot see:
check:parse-guard— the family this card is about — sits in dispatch-gates' unreachable-by-construction bucket (its population literalscriptsis refused as too generic), so no path derivation could have named it. Run explicitly above.One gate not run locally, declared:
check:type-check-debt(--re-measure) needs the whole workspace closure built.@objectstack/lintis a TEST_DEBT entry (19), so its test files are measured through a generated test-inclusive project thatpnpm --filter … typecheckdoes not see. Substitute measurement taken instead: a test-inclusive tsc program overpackages/lint/src/**/*.tsreports 0 errors, none in any file this PR adds or edits, so the count cannot drift up from this change. (A first attempt at that measurement returnedTS2688— a setup failure, i.e. invalid rather than zero — and was re-run withtypeRootsresolved before being read.)Scope
One file outside the card's three, declared:
packages/lint/src/validate-action-body-writes.ts. It consumes the very sameextractHookBodyWriteSetparse, so wiring only the hook rule would have left the identical blind spot standing at the call site next door — which that rule's own header forbids ("an action body runs through the sameHookBodySchemaand the same sandbox, so it gets the same treatment"). The change there is the sibling rule id plus the finding, with its own tests and false-positive control.Changeset
Added,
minor— argued rather than defaulted, since the card asked for it explicitly.AGENTS.md:943exempts pure bug fixes, and this is a bug fix. It is not invisible to consumers, which is what the exemption is really about: this is published@objectstack/lintsource, and it changes what the validators return. Four new rule ids reachos lint --json,os validate, Studio's finding renderer andsuppressWarnings: ['<rule-id>']; a stack carrying a source with a syntax error now lints with a warning where it previously lint clean. The public surface also grows (four rule-id constants,describeParseFailure,PARSE_FAILURE_HINT, three types, and an optionalparseFailureonExtractedHookBodyWriteSet) — additive API, henceminorrather thanpatch. Nothing is removed or renamed, so there is no migration to carry.skip-changesetwould have been wrong here for the reason the card names: tonight's other PRs werescripts/**-only and published nothing.Adjacent finding, now measured
Filed as #10696 (unassigned; it remains open, and is not addressed by this PR). The card recorded the props-gate/syntax-gate parser divergence as deliberately unmeasured. Measured while doing this work, in both directions:
0755,'\012',0b2and1__0pass Sucrase and are rejected by TypeScript;with (o) {}goes the other way. Pinned as a regression test here, since it is also the reason this fix does not double-report.Generated by Claude Code