#3332

Warn where a failed checked conversion would be swallowed (SC6009)

Draftcramforcewants to merge intofrom

Stacked on #3203 (type claims, whose pass decides what unverified data is) and #3221 (warnings from the lowered program, the 25-warning limit, the opt-out flag pattern). This branch holds both heads; its own commits are the ones after the two merges. It is a draft until they merge.

What

A build now warns (SC6009) at a runtime type check of unverified data whose failure would end in a handler that neither reads the error nor throws.

scriptc checks a value where untyped data enters typed code and throws a TypeError when it does not fit; Node.js checks nothing. That is by design. The trap is a handler that means something else:

function parse(line: string): Entry | null {
  try {
    return JSON.parse(line) as Entry;
  } catch {
    return null; // "skip a malformed line"
  }
}

Compiled, this also skips every well-formed line that lacks a member Entry declares, where Node.js keeps the entry, and nothing says so. pi loads its session files this way, and the compiled pi silently dropped entries written before a field existed.

$ scriptc build entries.ts -o entries
/app/entries.ts:12:12 - warning SC6009: a value that is not '{ type: string; usage: { input: number; total: number } }' throws a TypeError at this checked conversion, and that error is caught by the handler of the 'try' at entries.ts:11, which neither reads the error nor throws: the program takes the handler's path with no sign of why, while Node.js checks nothing here and continues with the value

  11 |   try {
  12 |     return JSON.parse(line) as Entry;
     |            ^~~~~~~~~~~~~~~~~~~~~~~~~
  13 |   } catch {

  hint: check the value before giving it this type (a schema, or tests on an 'unknown' value), declare the type the data really has, or let the handler tell failures apart: read the caught error, or rethrow what it does not expect

1 warning: the program compiled, but this site can behave differently from Node.
/app/entries

The warning changes no emitted code.

What counts

frontend/lowering/swallowed-conversions.ts reads the final function bodies and follows each conversion's failure outwards. Nothing reads the syntax tree, so the handler's spelling does not matter.

  • A handler has seen the error when its body refers to the caught value at all: logs it, passes it to a function, tests it, or creates a function that captures it. A catch without a binding, or with a binding nothing uses, has not.
  • A handler that has not seen it swallows it when it can complete (fall out of its body, return, break, continue). One that always throws, another error or the same one (throw e), hands the failure to the next enclosing handler: try { try { … } catch { throw new Error("bad") } } catch { return null } is a warning at the inner conversion.
  • A finally that leaves with return, break or continue discards whatever was thrown. A try/finally that runs on passes the error through to the handler outside it.
  • A promise's rejection handler is a catch. p.catch(() => fallback) lowers to a function try { return await p } catch { … } called with the receiver, and p.then(f) to one that awaits p and calls f. The scan knows no promise methods: it follows a function that awaits or calls one of its parameters to the argument at the call. A program's own attempt(() => JSON.parse(text) as T) helper has the same shape and is read the same way.
  • One call deep. A failure that leaves a function arrives where the function is called: a direct call of a named function is followed one level (the conversions that leave decode are swallowed by the handler around decode(text)), an async function only where its call is awaited inside the try (a promise returned without await settles after the handler is left, and is not reported), and a function literal called or awaited where it is written at no level.

Where it stops, and why: a conversion two calls away is not reported; neither is one behind a function held in a variable or a member, a dispatched method call, a callback handed to code that decides when to run it, a rejection handler that is not a function literal (it receives the error, which counts as reading it), a rejection handler passed as then's second argument, or Promise.all over calls. Every path that is followed is a fact of the lowered program, so a warning is true of the site it names; the count would more than triple on pi with every level, and what a conversion four calls away has to do with a handler's intent is weak. The helpers the lowering shares between sites (dynamic member reads, retags) are not followed either: their nodes carry the earliest caller's position, and a first version of this scan named a line of highlight.js for a handler in pi's manifest reader.

The warning is exactly the type claims that scriptc coverage marks "unseen if it fails": the mark now uses this scan (it was lexical), and a test asserts the two sets are equal. Whether a conversion is a claim is #3203's pass, run in a build only for the conversions the scan found, and only when it found one. It now reads "this conversion is an assertion's" from the syntax instead of a table recorded while lowering, so a build that applies stored units answers as one that lowers them, and it reads the bodies as they were before the dynamic tier's jsval rewrite, which turns a conversion to a number into another node.

--no-ignored-failure-warning leaves SC6009 out of a build or a coverage report, as --no-copy-warning does for SC6001 and for the same reason: the native bootstrap contracts compare the self-build's stderr exactly, and the compiler's own source has 41 such sites (its cache and manifest readers). It is refused with --fail-on=divergences.

Counts

programSC6009 sitesnotes
corpus, 4,842 flat entries (TypeScript and JavaScript)8, in 5 programs1004-json-parse-errors.ts 3, json-typed-parse-direct.ts 2, 1006-json-fs-config.ts, 1007-json-rc-stress.ts, 5568-fs-cp-errors.js. Each is JSON.parse(raw) as T (or an untyped value read as a number) under a handler with no binding. The corpus data fits its types, so the programs match Node; the warning is about data that does not.
the compiler's own source (native/cli.ts, coverage)41its cache and manifest readers, try { return JSON.parse(text) as Record } catch { return null }; the scan takes 1.0 s and the claims pass 0.8 s of a 438 s lowering
pi (e770f5efe, the fleet's integration tree c36692d8d3 with this change), coverage5955 in pi's own code, 4 in packages compiled from their JavaScript (semver 2, marked, cross-spawn); 43 in the handler's own reach, 16 through one call; all 59 are the sites the report marks, and no mark is without a warning. 2,122 conversions on 649 claims in all.
pi, one-round build on the same tree (16 errors, as without the change)5821 Node island values, 15 parsed data, 22 untyped values; the scan takes 0.46 s and the claims pass 0.55 s of a 243 s lowering. pi does not link yet, so it prints no warnings today.

How far to follow a call was decided on pi's numbers: 42 sites with no call level, 58 with one (taken), 80 with two, 123 with three, 128 with four.

Ten of pi's 58 sites, every sixth in function order, read by hand: seven are right (two reads of an island package's ignores() result under a handler that ends a directory scan, hljs.highlight(…).value under catch { return code.split("\n") }, an island image's get_bytes() under catch { return null }, JSON.parse(signature) as Partial<TextSignatureV1> under a handler that falls through to a legacy format, JSON.parse(output) as Array<…> in a method called from try { … } catch { return undefined }, and a dynamically imported module asserted to a module type under catch { return false }); one is in a package's minified JavaScript (true of the site, nothing for pi's authors to edit); two are false alarms, conversions that a test of the same value guards and the claims pass does not recognise (below, "Not in this PR"). The session loader that dropped entries (session-manager.ts) is among the 58.

Zero cost

No emitted code changes: the scan and the claims pass only produce diagnostics.

Zero-cost gate (--gate-corpus=all) of this branch against main 50bcfe1b3, so it covers #3203's and #3221's commits too, at 8ead0b2568 (the commits after it add one sentence to the limitations page and a merge of main): PASS, 5,295 programs identical, 108 fail to build on both sides (the Node island programs; the gate has no libnode pack), none differ, no runtime-only row. The self-hosted compiler builds itself. Compile time: bench +0.60% [−1.35%, +1.62%] and self-compile +0.86% [+0.46%, +1.43%] within tolerance, fixtures −0.32% [−3.12%, +2.47%] inconclusive. An earlier round of the same code before two test-only commits and the reordering of the warning list (bee43a929b) had all three within tolerance: bench +0.09%, self +0.00% [−3.05%, +1.71%], fixtures +0.35%.

What a build pays: one walk of the lowered functions that looks for handlers (0.46 s on pi's 243 s lowering, 1.0 s on the compiler's own source), and the claims pass only when that walk found a conversion (0.55 s and 0.8 s). A program with no such handler pays the walk.

Native bootstrap (tests/harness/self-hosting-native-driver.test.ts, 32 vCPU, plain): passed in 2,553 s on this change before the merge with main; the production CLI passes all bootstrap contracts with Node unavailable, with the self-builds passing --no-ignored-failure-warning. Without the flag the contracts would fail on the 41 warnings, which is what the flag is for.

Tests

  • packages/compiler/test/swallowed-conversions.test.ts (13 tests): which handlers swallow and which do not, by the line of the conversion: no binding, an unused binding, a logged, rethrown, tested or captured error, a handler that always throws alone and inside a swallowing one, a conditional throw, seen inside and swallowed outside, try/finally that passes and that returns, a break that stays inside the finally, a conversion in the handler itself, a loop's continue, one call and two calls, a callee that sees its own failure, an awaited and a returned async call, four promise-chain shapes, a handler that reads the reason and one that is not a literal, a program's own guarding helper, and a conversion that is not a claim. Each case also asserts that the warnings are the claims the report marks.
  • tests/harness/swallowed-conversions.test.ts (plain and sanitized lanes): tests/coverage-fixtures/swallowed-conversions.ts under Node and natively. Node keeps all seven values. Natively the four functions the build warns about drop the value and report nothing, and the three others report the TypeError.
  • tests/harness/coverage.test.ts: the fixture's report snapshot (coverage-swallowed-conversions.txt), its four warnings, their handler kinds, --fail-on=divergences; the type-claims fixture's snapshot gains its warning and the reworded mark, and nothing else in it changed when assertions moved from a recorded table to the syntax. The corpus sweep exempts SC6009 as it does SC6001.
  • packages/cli/test/ignored-failure-warning.test.ts: a build prints the four warnings, the flag leaves them out, --fail-on=divergences fails on them and refuses the flag, through the CLI and through the launcher's replayed verdict.
  • tests/harness/self-hosting-native-driver.test.ts: the self-builds pass the flag.
  • Both full lanes on the head 9b25108e9a (Linux x64, with a libnode pack): plain 22,451 passed and 7 failed, sanitized 19,103 passed and 6 failed. None of the failures is this change: executable-cache.test.ts "every build and run option is in the cache keys" in both lanes, for main's emit-lowered, from-lowered and from-lowered-accept (this branch adds the rules for its own flag and for --no-copy-warning); the five island-value-matrix cells main has failed since #3165, in both lanes; packages/runtime/test/regex.test.ts, whose sanitized fixture build ran past its 60 s hook limit (the flake #3299 describes; passes alone); and one dev-mode differential case, 6807-node-island-exit-in-island-timer, once in the plain lane (passes 3 of 3 alone on this branch; this change emits no code).
  • The native bootstrap contracts ran on the head in that round, plain and sanitized (the native driver's commands, rebuild, rebuild-emit and rebuild-frontend jobs), and pass.

Docs

  • Coverage, "Type claims": what a handler that reads the error is, how far a failure is followed, and the example with the build's warning (commands run against this build; output pasted). "Node divergences": SC6009.
  • CLI: --no-ignored-failure-warning. Limitations, "Runtime type checks": one sentence naming the warning.

Not in this PR

  • The run-time side, SCRIPTC_CONVERSION_TRACE (a dev build reports every failed conversion with its position, caught or not), is its own PR on main.
  • Two shapes the claims pass lists although the conversion cannot fail, so the warning can be a false alarm there: a typeof test of a dictionary's member (typeof args.edits === "string" over a Record<string, unknown>), whose read is a shared helper call the pass does not recognise as the tested value, and a member of a value an Array.isArray test proved (parsed.length). Two of ten sampled pi sites; tracked as a follow-up with a reduction.

🤖 Generated with Claude Code

Activity

26
vercel[bot]5d ago

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 2:36pm UTC
cramforce5d ago

Draft on purpose: this branch contains the heads of #3203 and #3221, which are not on main yet. It must not merge before them. Once both have merged, main gets merged in here (the diff then shows only this change), the focused tests are rerun, and the PR is marked ready.

This pull request is a draft

Merging is unavailable until it is marked ready for review.