From 1ef31a1b74bed82519026588f2aca845e1eb034c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 14:08:42 -0700 Subject: [PATCH 1/2] Continue a failed checked cast with its value in a dynamic tail `JSON.parse(text) as T`, `const x: T = anyValue` and `await res.json() as T` threw `TypeError: expected X at $.path, got Y` when the value did not fit `T`. Node never checks an assertion and continues with the value it has, so the build-container listener rejected an order Node accepts and a JWT with a string `exp` became a 500. A failed cast now keeps its value when it initializes a `const`/`let` binding declared in a function or block and only the code after the cast reads the binding. That code (the statements after the cast in each enclosing statement list, up to the declaration) lowers twice: the static copy, unchanged, runs while the value fits; a dynamic copy, in which the binding and everything derived from it is typed `any`, runs after a failure, so missing members read undefined, wrong-typed members keep their values, extra members stay visible in source order, and objects keep their identity. The cast sets the flag that selects the copy: the fused JSON.parse helper's slow path and the plain check call scr_dyn_check_recover, which takes back the TypeError and parks the value for checkedCast.fallbackTaken/fallbackTake. A failed value that flows into a typed slot outside the region is checked there, as any `any` value is: it continues when it fits and throws at that use when it does not. A statement of the dynamic copy with no `any` lowering becomes a runtime fence that throws only when a failed value reaches it. Module-level bindings, regions with hoisted declarations or super() calls, union targets and targets holding class instances, and --tier=static-only (and library mode) keep the throwing cast. `a?.b.c` on an `any` value now short-circuits only on `a`, so `.c` throws when `a.b` is undefined, as in Node. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/cli.mdx | 2 +- docs/content/docs/coverage.mdx | 2 +- docs/content/docs/index.mdx | 15 +- .../limitations/runtime-type-checks.mdx | 9 +- packages/compiler/src/backend/llvm/dyn.ts | 16 +- .../compiler/src/backend/llvm/expr-async.ts | 8 +- .../compiler/src/backend/llvm/lib-dispatch.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 3 + packages/compiler/src/cli/usage.ts | 5 +- .../src/frontend/lowering/checked-fallback.ts | 851 ++++++++++++++++++ .../lowering/expressions/element-access.ts | 6 +- .../lowering/expressions/identifiers.ts | 5 + .../lowering/expressions/optional-chains.ts | 14 +- .../lowering/expressions/property-access.ts | 12 +- .../src/frontend/lowering/lower-exprs.ts | 21 + .../src/frontend/lowering/lower-stmts.ts | 52 +- .../compiler/src/frontend/lowering/lowerer.ts | 25 +- .../compiler/src/ir/builtin-signatures.ts | 5 + packages/compiler/src/ir/ir.ts | 9 + .../test/checked-cast-fallback.test.ts | 149 +++ packages/compiler/test/json-callbacks.test.ts | 10 +- .../json-parse-fallback-extra-keys.ts.json | 6 + .../corpus/json-parse-fallback-flows.ts.json | 6 + .../json-parse-fallback-missing.ts.json | 6 + .../corpus/json-parse-fallback-nested.ts.json | 6 + .../json-parse-fallback-partial.ts.json | 6 + .../json-parse-fallback-wrong-types.ts.json | 6 + packages/runtime/src/scr_dyn.h | 10 + packages/runtime/src/scr_json.c | 28 + .../corpus/json-parse-fallback-extra-keys.ts | 37 + tests/corpus/json-parse-fallback-flows.ts | 57 ++ tests/corpus/json-parse-fallback-missing.ts | 36 + tests/corpus/json-parse-fallback-nested.ts | 42 + tests/corpus/json-parse-fallback-partial.ts | 59 ++ .../corpus/json-parse-fallback-wrong-types.ts | 33 + tests/harness/dyncheck.test.ts | 114 ++- 36 files changed, 1613 insertions(+), 59 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/checked-fallback.ts create mode 100644 packages/compiler/test/checked-cast-fallback.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-extra-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-flows.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-missing.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-nested.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-partial.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-wrong-types.ts.json create mode 100644 tests/corpus/json-parse-fallback-extra-keys.ts create mode 100644 tests/corpus/json-parse-fallback-flows.ts create mode 100644 tests/corpus/json-parse-fallback-missing.ts create mode 100644 tests/corpus/json-parse-fallback-nested.ts create mode 100644 tests/corpus/json-parse-fallback-partial.ts create mode 100644 tests/corpus/json-parse-fallback-wrong-types.ts diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 786cf2c634..df82742589 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -128,7 +128,7 @@ Verifies and loads the installed precompiled runtime objects and vendored librar
Coverage only. Map an exact bare module specifier to a local declaration file supplied by an embedder. Repeat the option for multiple modules. The declaration supplies checker types so application coverage can continue; runtime imports and values remain explicit SC1010 blockers. Relative paths resolve from the current working directory. Accepted files end in .d.ts, .d.mts, or .d.cts.
--tier <auto|static-only>
-
The tier policy for code that has no static lowering. auto is the default. static-only guarantees that the program contains no dynamic-tier demotions: every such construct stays a compile error in TypeScript and a deferred runtime error in JavaScript, which is how both modes behave today. Use it in CI to keep a program fully static. Library mode is always static-only. See Dynamic tier.
+
The tier policy for code that has no static lowering. auto is the default. static-only guarantees that the program contains no dynamic-tier demotions: every such construct stays a compile error in TypeScript and a deferred runtime error in JavaScript, which is how both modes behave today, and a checked cast whose value does not match its type throws instead of continuing with the value. Use it in CI to keep a program fully static. Library mode is always static-only. See Dynamic tier.
--explain-dynamic[=<name>]
Coverage only. After the report, list the functions that already operate on values typed only at runtime and the bindings, fields, functions, classes and modules that unsupported constructs would move to the dynamic tier, each with the diagnostic behind it. With a name such as x, Point, or Point.x, show only the matching units. See Dynamic tier.
diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx index 1bb645bd93..b1f6e2c646 100644 --- a/docs/content/docs/coverage.mdx +++ b/docs/content/docs/coverage.mdx @@ -60,7 +60,7 @@ Code that operates on values whose types are known only at runtime, such as `any `--explain-dynamic` lists those functions and the operation that makes each one dynamic. It also lists the units an unsupported construct would move to the dynamic tier: the binding, field, class, function, or module that has no static representation for the construct, with the diagnostic behind each one. These are the constructs the dynamic tier is planned to compile; today they remain blockers in TypeScript and deferred runtime errors in JavaScript. Add a name, such as `--explain-dynamic=Point`, to show only the matching units. With `--print=diagnostics`, the document gains a `tiers` object with the same information. -`--tier=static-only` keeps every unsupported construct a blocker instead of moving it to the dynamic tier; code that already operates on runtime-typed values compiles as before. Today `auto`, the default, behaves the same way. +`--tier=static-only` keeps every unsupported construct a blocker instead of moving it to the dynamic tier; code that already operates on runtime-typed values compiles as before. It also makes a checked cast whose value does not match its type throw, where `auto`, the default, continues with the value in the dynamic tier (see [runtime type checks](/docs/limitations#runtime-type-checks)). Otherwise `auto` behaves the same way today. ## Diagnostic codes diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index de6c5bd24b..f771ec78ca 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -68,25 +68,24 @@ Programs are checked against the `es2025` standard library, scriptc's own declar ## Runtime type checks -Compiled code relies on types, so scriptc checks a value whose type is only known at runtime before using it as a specific type. For example, parsed JSON must match the type you cast it to: +Compiled code relies on types, so scriptc checks a value whose type is only known at runtime before using it as a specific type. Parsed JSON that matches the type you cast it to uses the type's native layout. A value that does not match continues as the value it is, as under Node.js, which does not check the `as Config` assertion: ```ts:cast.ts type Config = { port: number }; -try { - const config = JSON.parse('{"port": "eighty"}') as Config; - console.log(config.port); -} catch (error) { - if (error instanceof Error) console.log(error.message); +for (const text of ['{"port": 8080}', '{"port": "eighty"}']) { + const config = JSON.parse(text) as Config; + console.log(config.port, typeof config.port); } ``` ```console $ scriptc run cast.ts -expected number at $.port, got string +8080 number +eighty string ``` -Under Node.js, the same `as Config` assertion performs no check. See [runtime type checks](/docs/limitations#runtime-type-checks) for details. +See [runtime type checks](/docs/limitations#runtime-type-checks) for where a mismatch still throws. ## Build-time evaluation diff --git a/docs/content/limitations/runtime-type-checks.mdx b/docs/content/limitations/runtime-type-checks.mdx index f6ead482bf..bf325b713e 100644 --- a/docs/content/limitations/runtime-type-checks.mdx +++ b/docs/content/limitations/runtime-type-checks.mdx @@ -1,5 +1,12 @@ Casts from values whose type is only known at runtime validate the value. -For example, `JSON.parse(s) as Config` throws a catchable error if the data does not match `Config`, identifying the offending path, such as `expected number at $.port, got string`. +A value that matches the type uses the type's native layout. +When the cast initializes a `const` or `let` binding declared in a function or block, and the binding is only read by the code after the cast, a value that does not match continues as the value it is, as under Node.js: with `JSON.parse(s) as Config` and `{"port": "eighty"}`, `config.port` is the string `"eighty"`, missing properties read `undefined`, and extra properties stay visible to `Object.keys`, `JSON.stringify`, and `util.inspect`. +That code then runs on dynamic values, which is slower than native records. +A statement of it that has no dynamic lowering throws a `TypeError` naming the construct and its location when such a value reaches it. + +In the other cases a mismatch throws a catchable `TypeError` identifying the offending path, such as `expected number at $.port, got string`: casts that initialize module-level bindings or appear in other positions, and a mismatched value that leaves the code after its cast through a typed parameter, field, return value, or outer binding, where it is checked against that type. +A value that matches there continues, copied if it is an object. +`--tier=static-only` makes every mismatch throw at the cast. A TypeScript assertion alone does not perform this validation when run under Node.js. Casts between program classes are checked too: casting an instance to a class it does not extend throws a `TypeError`, where Node.js reads the members structurally. diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 731385825d..95e6a4efb8 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1407,22 +1407,29 @@ export class LlDyn { * differently), runs the checked-dynamic route — scr_json_parse plus the * dynCheck builder — which reports the exact error. Returns +1, or null * with the pending flag set. */ - jsonParseHelper(t: IrType): string | null { + jsonParseHelper(t: IrType, fallback = false): string | null { if (t.kind !== "record" && t.kind !== "array") return null; const key = typeKey(t); - const existing = this.jsonParsers.get(key); + const cacheKey = fallback ? `fallback:${key}` : key; + const existing = this.jsonParsers.get(cacheKey); if (existing) return existing; if (!this.jsonSchemaSupported(t, new Set())) return null; const host = this.host; const name = `sc_jp_${this.jsonParsers.size}`; - this.jsonParsers.set(key, name); + this.jsonParsers.set(cacheKey, name); const schema = this.jsonSchema(t); const check = this.dynCheckHelper(t); host.declare(`declare ptr @scr_json_parse_schema(ptr, ptr)`); host.declare(`declare ptr @scr_json_parse(ptr)`); host.declare(`declare void @scr_dyn_release(ptr)`); + // A fallback site (dynCheck.fallback): a value that fails validation + // parks as the checked value (scr_dyn_check_recover) and the helper + // returns the dummy with no exception pending. Syntax errors still + // throw. + if (fallback) host.declare(`declare zeroext i1 @scr_dyn_check_recover(ptr)`); + const recover = fallback ? [` call zeroext i1 @scr_dyn_check_recover(ptr %d)`] : []; this.defs.push( - `define internal ptr @${name}(ptr %text) ${FN_ATTRS} { ; JSON.parse as ${llvmCommentText(key)}`, + `define internal ptr @${name}(ptr %text) ${FN_ATTRS} { ; JSON.parse as ${llvmCommentText(key)}${fallback ? " (fallback)" : ""}`, `entry:`, ` %fast = call ptr @scr_json_parse_schema(ptr %text, ptr @${schema})`, ` %hit = icmp ne ptr %fast, null`, @@ -1437,6 +1444,7 @@ export class LlDyn { ` ret ptr null`, `check:`, ` %v = call ptr @${check}(ptr %d, ptr null)`, + ...recover, ` call void @scr_dyn_release(ptr %d)`, ` ret ptr %v`, `}`, diff --git a/packages/compiler/src/backend/llvm/expr-async.ts b/packages/compiler/src/backend/llvm/expr-async.ts index 6da63977dc..901a8749e6 100644 --- a/packages/compiler/src/backend/llvm/expr-async.ts +++ b/packages/compiler/src/backend/llvm/expr-async.ts @@ -374,7 +374,7 @@ export function emitSerializationExpr( // falls back to exactly this route whenever the fast path declines. const parsed = e.value; if (!e.preserveRefs && parsed.kind === "libCall" && parsed.fn === "json.parse") { - const fused = host.dyn.jsonParseHelper(e.type); + const fused = host.dyn.jsonParseHelper(e.type, e.fallback === true); if (fused) { const text = emitBorrowedInput(host, parsed.args[0]!); const r = B.tmp(); @@ -400,6 +400,12 @@ export function emitSerializationExpr( B.line( `${t} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr ${dynV.name}, ptr null)`, ); + // A fallback site: a value that fails validation parks as the + // checked value instead of throwing (dynCheck.fallback). + if (e.fallback) { + host.declare(`declare zeroext i1 @scr_dyn_check_recover(ptr)`); + B.line(`call zeroext i1 @scr_dyn_check_recover(ptr ${dynV.name})`); + } const out = host.own({ name: t, type: e.type }); host.emitPendingCheck(); return out; diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 924027ee29..283c5b38dd 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -865,6 +865,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "blob": case "wasm": case "record": + case "checkedCast": case "disposable": case "punycode": case "constants": diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 8cd40240f2..90cb061a3c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -643,6 +643,9 @@ export const LIB_FN_SYMS: Record = { "insp.circular": "scr_insp_circular", // Undeclared source properties kept beside a checked record (scr_json.c). "record.sourceHas": "scr_record_source_has", + // Checked-cast fallback reads (scr_json.c); take answers +1. + "checkedCast.fallbackTaken": "scr_dyn_check_fallback_taken", + "checkedCast.fallbackTake": "scr_dyn_check_fallback_take", // WHATWG URL + URLSearchParams (scr_url.c / scr_url_params.c): // constructions +1; url.new, the fileURLToPath pair, the win32 // pathToFileURL flavor, and sp.fromPairs throw catchably (may-throw). diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index 26b4391675..69d30b76e3 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -73,8 +73,9 @@ Options: --tier tier policy (default: auto). static-only keeps every construct without a static lowering a compile error, so - the program has no dynamic-tier demotions. Both modes - currently compile identically + the program has no dynamic-tier demotions, and makes a + checked cast whose value does not fit its type throw + instead of continuing with the value --explain-dynamic[=] coverage only: list the functions that operate on dynamic values and the bindings, fields, functions, classes and diff --git a/packages/compiler/src/frontend/lowering/checked-fallback.ts b/packages/compiler/src/frontend/lowering/checked-fallback.ts new file mode 100644 index 0000000000..13d0abc368 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/checked-fallback.ts @@ -0,0 +1,851 @@ +/* Checked-cast fallback: a value that fails a checked cast keeps running. + * + * `JSON.parse(text) as T` (and `const x: T = anyValue`, `await res.json() + * as T`) is a checked conversion: the static code after it reads `T`'s + * native layout, so a value that does not fit `T` used to throw a + * TypeError at the cast. Node never checks: the program continues with the + * value it has. This module makes the failing case continue too, without + * changing the code that runs when the value fits. + * + * The unit is a FALLBACK SITE: a function-local binding whose only write + * is the checked cast (the ROOT), declared with `const`/`let` in a function + * body. The code that can observe the root is the statements after the + * cast in each enclosing statement list up to the root's declaration (the + * TAILS; every other reference disqualifies the site). Each tail is lowered + * twice: + * + * - the static lowering, exactly as before, used while the cast held; + * - the dynamic lowering, used after a failure: the root reads its checked + * dynamic value (a shadow local), and every expression derived from it is + * typed `any` (fallbackTainted), so the region lowers through scriptc's + * ordinary `any` path, which has Node's semantics: missing members read + * undefined, values keep their real types, extra members stay visible, + * and objects keep their identity. + * + * `if (flag) dynamic else static` selects the lowering after each tail's + * first statement; the cast sets the flag. A derived value that flows into + * a typed slot outside the region (a typed parameter, a field, an outer + * binding) passes the same checked conversion as any `any` value does + * there: it continues when it fits and throws that conversion's TypeError + * at that use when it does not. A statement of the dynamic lowering that + * has no `any` lowering compiles to a runtime fence that throws at that + * statement only when a failed value reaches it. + * + * Code without a fallback site is unchanged; code with one runs its static + * tail unchanged behind one flag test per tail, and the cast reads one + * thread-local word after the check (checkedCast.fallbackTaken). + * `--tier=static-only` (and library mode) opens no sites: casts throw. */ +import * as ts from "../ts7/adapter.js"; +import { + BOOL, + DYN, + type IrExpr, + type IrLocal, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import { PoisonError, type Lowerer } from "./lowerer.js"; + +export interface FallbackSite { + root: ts.Symbol; + /** The root's declared name (a module-level root is a module binding). */ + name: ts.Identifier; + /** The statement holding the checked cast (the declaration itself, or + * the `root = value as T` expression statement). */ + boundaryStmt: ts.Statement; + /** Statements after which a tail starts, innermost first; removed as + * their lists split. */ + anchors: Set; + flag: IrLocal; + shadow: IrLocal; + armed: boolean; + /** An initializer-less root's flag and shadow declarations, and the + * statement list they were emitted into. */ + decls: { stmts: IrStmt[]; out: IrStmt[] } | null; +} + +export interface FallbackTaint { + symbols: Set; + memo: Map; +} + +/** Tails larger than this keep the throwing cast: every node is lowered + * twice. */ +const MAX_TAIL_NODES = 20000; + +/** At most this many sites share a split point (2^n lowerings). */ +const MAX_SHARED_SITES = 2; + +type StatementList = { statements: { nodes: readonly ts.Statement[] } }; + +function isStatementList(node: ts.Node | undefined): boolean { + return ( + node !== undefined && (ts.isBlock(node) || ts.isModuleBlock(node) || ts.isSourceFile(node)) + ); +} + +function listOf(node: ts.Node): readonly ts.Statement[] { + return (node as unknown as StatementList).statements.nodes; +} + +function strip(e: ts.Expression): ts.Expression { + let v = e; + while (ts.isParenthesizedExpression(v)) v = v.expression; + return v; +} + +function enclosingFunction(node: ts.Node): ts.Node | undefined { + let n: ts.Node | undefined = node.parent; + while (n !== undefined && !ts.isFunctionLike(n) && !ts.isSourceFile(n)) n = n.parent; + return n; +} + +/** `value as T` / `value` over an `any` or `unknown` value, or a + * dynamic value flowing into an annotated declaration. */ +function isCheckedCast(lowerer: Lowerer, e: ts.Expression, declared: ts.Type | null): boolean { + const dynamicFlags = ts.TypeFlags.Any | ts.TypeFlags.Unknown; + const dynamic = (x: ts.Expression): boolean => + (lowerer.checker.getTypeAtLocation(x).flags & dynamicFlags) !== 0; + const v = strip(e); + if (ts.isAsExpression(v) || ts.isTypeAssertion(v)) { + const target = lowerer.checker.getTypeFromTypeNode(v.type); + if (target.flags & dynamicFlags) return false; + return dynamic(strip(v.expression)); + } + return declared !== null && (declared.flags & dynamicFlags) === 0 && dynamic(v); +} + +function writesSymbol(lowerer: Lowerer, n: ts.Node, sym: ts.Symbol): ts.Node | null { + const names = (e: ts.Node): boolean => + ts.isIdentifier(e) && lowerer.resolveValueSymbol(e) === sym; + const targetNames = (t: ts.Expression): boolean => { + const e = strip(t); + if (names(e)) return true; + if (ts.isArrayLiteralExpression(e) || ts.isObjectLiteralExpression(e)) { + let found = false; + const walk = (x: ts.Node): void => { + if (names(x)) found = true; + else ts.forEachChild(x, walk); + }; + walk(e); + return found; + } + return false; + }; + if (ts.isBinaryExpression(n)) { + const k = n.operatorToken.kind; + if ( + k >= ts.SyntaxKind.FirstAssignment && + k <= ts.SyntaxKind.LastAssignment && + targetNames(n.left) + ) + return n; + } else if ( + (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && + (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken) && + targetNames(n.operand as ts.Expression) + ) { + return n; + } else if ( + (ts.isForOfStatement(n) || ts.isForInStatement(n)) && + !ts.isVariableDeclarationList(n.initializer) && + targetNames(n.initializer as ts.Expression) + ) { + return n; + } + return null; +} + +/** Hoisted or registered-once declarations, and `super()` calls (which + * initialize the instance once), that a second lowering would duplicate. */ +function tailIsRelowerable(stmts: readonly ts.Statement[]): boolean { + let ok = true; + let count = 0; + const visit = (n: ts.Node): void => { + if (!ok) return; + if (++count > MAX_TAIL_NODES) { + ok = false; + return; + } + if ( + ts.isFunctionDeclaration(n) || + ts.isClassDeclaration(n) || + ts.isClassExpression(n) || + ts.isModuleDeclaration(n) || + ts.isEnumDeclaration(n) || + (ts.isCallExpression(n) && n.expression.kind === ts.SyntaxKind.SuperKeyword) || + (ts.isVariableDeclarationList(n) && + (n.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const | ts.NodeFlags.Using)) === 0) + ) { + ok = false; + return; + } + ts.forEachChild(n, visit); + }; + for (const s of stmts) visit(s); + return ok; +} + +interface Candidate { + root: ts.Symbol; + name: ts.Identifier; + boundaryStmt: ts.Statement; + anchors: ts.Statement[]; +} + +/** The fallback site a declaration statement starts, or null. */ +function fallbackCandidate(lowerer: Lowerer, stmt: ts.VariableStatement): Candidate | null { + const list = stmt.declarationList; + if ((list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) === 0) return null; + if ((list.flags & ts.NodeFlags.Using) !== 0) return null; + if (list.declarations.nodes.length !== 1) return null; + if (ts.canHaveModifiers(stmt) && (ts.getModifiers(stmt)?.length ?? 0) > 0) return null; + const decl = list.declarations.nodes[0]!; + if (!ts.isIdentifier(decl.name)) return null; + if (isJsSourceFile(ts.getSourceFileOfNode(stmt))) return null; + // Cheap gates before any scan: a checked-cast initializer, or a typed + // declaration without one. + const declared = decl.type !== undefined ? lowerer.checker.getTypeFromTypeNode(decl.type) : null; + if ( + decl.initializer !== undefined + ? !isCheckedCast(lowerer, decl.initializer, declared) + : declared === null + ) + return null; + const rootList = stmt.parent; + // Module-level bindings are module state (and module statements lower + // one at a time): roots live in blocks. + if (rootList === undefined || !ts.isBlock(rootList)) return null; + const fn = enclosingFunction(stmt); + if (fn === undefined) return null; + const root = lowerer.checker.getSymbolAtLocation(decl.name); + if (root === undefined) return null; + // A root in a module-level block is scanned across its file. + const body = ts.isSourceFile(fn) ? fn : (fn as ts.FunctionLikeDeclaration).body; + if (body === undefined) return null; + + // The only write: the initializer, or one `root = value as T` statement. + let boundaryStmt: ts.Statement | null = null; + let boundaryWrite: ts.Node | null = null; + const writes: ts.Node[] = []; + const findWrites = (n: ts.Node): void => { + const w = writesSymbol(lowerer, n, root); + if (w) writes.push(w); + ts.forEachChild(n, findWrites); + }; + findWrites(body); + if (decl.initializer !== undefined) { + if (writes.length > 0) return null; + boundaryStmt = stmt; + } else { + if (writes.length !== 1) return null; + const w = writes[0]!; + if ( + !ts.isBinaryExpression(w) || + w.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(strip(w.left)) || + !ts.isExpressionStatement(w.parent) || + !isCheckedCast(lowerer, w.right, declared) + ) + return null; + if (enclosingFunction(w) !== fn) return null; + boundaryStmt = w.parent; + boundaryWrite = w; + } + + // The split points: the statement holding the cast in each enclosing + // statement list, up to the root's list. + const anchors: ts.Statement[] = []; + let cur: ts.Node = boundaryStmt; + for (;;) { + const parent: ts.Node | undefined = cur.parent; + if (parent === undefined || ts.isFunctionLike(parent)) return null; + if (ts.isCaseClause(parent) || ts.isDefaultClause(parent)) return null; + if (isStatementList(parent)) { + anchors.push(cur as ts.Statement); + if (parent === rootList) break; + if (ts.isSourceFile(parent)) return null; + } + cur = parent; + } + const anchorIndex = new Map(); + for (const a of anchors) + anchorIndex.set(a, { list: a.parent!, index: listOf(a.parent!).indexOf(a) }); + const tails: ts.Statement[] = []; + for (const a of anchors) { + const { list, index } = anchorIndex.get(a)!; + tails.push(...listOf(list).slice(index + 1)); + } + if (!tailIsRelowerable(tails)) return null; + + // Every other reference sits in a tail. + const inTail = (ref: ts.Node): boolean => { + let n: ts.Node = ref; + while (n.parent !== undefined && n !== body) { + const parent = n.parent; + if (isStatementList(parent)) { + for (const a of anchors) { + if (a.parent !== parent) continue; + const at = anchorIndex.get(a)!.index; + const mine = listOf(parent).indexOf(n as ts.Statement); + if (mine > at) return true; + if (mine < at) return false; + } + } + n = parent; + } + return false; + }; + let referencesOk = true; + const scanRefs = (n: ts.Node): void => { + if (!referencesOk) return; + if (ts.isIdentifier(n) && n !== decl.name && lowerer.resolveValueSymbol(n) === root) { + const isBoundaryTarget = + boundaryWrite !== null && + ts.isBinaryExpression(boundaryWrite) && + strip(boundaryWrite.left) === n; + if (!isBoundaryTarget && !inTail(n)) referencesOk = false; + return; + } + ts.forEachChild(n, scanRefs); + }; + scanRefs(body); + if (!referencesOk) return null; + return { root, name: decl.name, boundaryStmt, anchors }; +} + +function declareSiteLocals( + lowerer: Lowerer, + candidate: Candidate, + loc: SrcLoc, +): { site: FallbackSite; decls: IrStmt[] } { + const name = ts.symbolName(candidate.root); + const flag = lowerer.declareHiddenLocal(`%fb.${name}`, BOOL); + flag.mutable = true; + const shadow = lowerer.declareHiddenLocal(`%fbv.${name}`, DYN); + shadow.mutable = true; + const decls: IrStmt[] = [ + { + kind: "varDecl", + localId: flag.id, + init: { kind: "boolLit", value: false, type: BOOL, loc }, + loc, + }, + { kind: "varDecl", localId: shadow.id, init: null, loc }, + ]; + const site: FallbackSite = { + root: candidate.root, + name: candidate.name, + boundaryStmt: candidate.boundaryStmt, + anchors: new Set(candidate.anchors), + flag, + shadow, + armed: false, + decls: null, + }; + return { site, decls }; +} + +/** Before an initializer-less typed declaration lowers: open the fallback + * site its later `root = value as T` statement starts, declaring the flag + * (false) and the shadow that holds a failed value where the root is + * declared. Declarations with an initializer open when they arm. */ +export function openFallbackSite(lowerer: Lowerer, stmt: ts.Statement, out: IrStmt[]): void { + if (!ts.isVariableStatement(stmt) || lowerer.fnStack.length === 0) return; + if (lowerer.tierMode === "static-only") return; + const decls = stmt.declarationList.declarations.nodes; + if (decls.length !== 1 || decls[0]!.initializer !== undefined || decls[0]!.type === undefined) + return; + const candidate = fallbackCandidate(lowerer, stmt); + if (candidate === null) return; + const opened = declareSiteLocals(lowerer, candidate, locOf(stmt)); + out.push(...opened.decls); + opened.site.decls = { stmts: opened.decls, out }; + lowerer.fallbackSites.set(candidate.root, opened.site); +} + +/** A site that will not arm: drop its declarations again, so the program + * lowers exactly as it did without the site. */ +function closeUnarmed(lowerer: Lowerer, site: FallbackSite): void { + const decls = site.decls; + if (decls !== null) { + for (const d of decls.stmts) { + const at = decls.out.indexOf(d); + if (at >= 0) decls.out.splice(at, 1); + } + } + const locals = lowerer.ctx.locals; + for (const local of [site.flag, site.shadow]) { + const at = locals.indexOf(local); + if (at >= 0) locals.splice(at, 1); + } +} + +const FALLBACK_TARGETS = new Set(["record", "array", "f64", "string", "bool"]); + +/** A plain check of a record, array or scalar target that holds no class + * instance: casts to program classes keep their checked semantics (a + * documented scriptc boundary: a class cast checks the instance's class). */ +function fallbackCheck(lowerer: Lowerer, check: IrExpr & { kind: "dynCheck" }): boolean { + if (check.preserveRefs || check.exact || !FALLBACK_TARGETS.has(check.type.kind)) return false; + return !holdsClassInstance(lowerer, check.type, new Set()); +} + +function holdsClassInstance(lowerer: Lowerer, t: IrType, seen: Set): boolean { + switch (t.kind) { + case "object": + case "classval": + return true; + case "array": + return holdsClassInstance(lowerer, t.elem, seen); + case "record": { + if (seen.has(t.shapeId)) return false; + seen.add(t.shapeId); + const shape = lowerer.shapes.get(t.shapeId); + if (shape === undefined) return true; + if (shape.indexValue !== undefined && holdsClassInstance(lowerer, shape.indexValue, seen)) + return true; + return shape.fields.some((f) => holdsClassInstance(lowerer, f.type, seen)); + } + case "union": { + const def = lowerer.unions.get(t.unionId); + return def === undefined || def.arms.some((arm) => holdsClassInstance(lowerer, arm, seen)); + } + default: + return false; + } +} + +/** After a statement lowers: when it holds a fallback site's cast, mark the + * check as a fallback check and read its outcome into the flag and the + * shadow. A cast whose lowering is not a plain check keeps throwing. */ +export function armFallbackSite(lowerer: Lowerer, stmt: ts.Statement, lowered: IrStmt[]): void { + // `const root = value as T`: the check is the declaration's initializer. + if ( + ts.isVariableStatement(stmt) && + lowerer.fnStack.length > 0 && + lowerer.tierMode !== "static-only" && + lowered.some((s) => s.kind === "varDecl" && s.init?.kind === "dynCheck") + ) { + const decls = stmt.declarationList.declarations.nodes; + const decl = decls.length === 1 ? decls[0]! : undefined; + if (decl?.initializer !== undefined && ts.isIdentifier(decl.name)) { + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + const local = symbol ? lowerer.bindingIn(lowerer.ctx, symbol) : null; + const at = + local === null + ? -1 + : lowered.findIndex( + (s) => s.kind === "varDecl" && s.localId === local.id && s.init?.kind === "dynCheck", + ); + const write = at >= 0 ? lowered[at]! : null; + const init = write?.kind === "varDecl" ? write.init : null; + if (init?.kind === "dynCheck" && fallbackCheck(lowerer, init)) { + const candidate = fallbackCandidate(lowerer, stmt); + if (candidate !== null) { + const opened = declareSiteLocals(lowerer, candidate, locOf(stmt)); + if (armSite(lowerer, opened.site, lowered, local!.id)) + lowered.splice(at, 0, ...opened.decls); + else closeUnarmed(lowerer, opened.site); + } + } + } + } + if (lowerer.fallbackSites.size === 0) return; + for (const site of [...lowerer.fallbackSites.values()]) { + if (site.boundaryStmt !== stmt || site.armed) continue; + lowerer.fallbackSites.delete(site.root); + const id = lowerer.bindingIn(lowerer.ctx, site.root)?.id; + if (id === undefined || !armSite(lowerer, site, lowered, id)) closeUnarmed(lowerer, site); + } +} + +function armSite(lowerer: Lowerer, site: FallbackSite, lowered: IrStmt[], id: string): boolean { + const at = lowered.findIndex( + (s) => + (s.kind === "varDecl" && s.localId === id && s.init?.kind === "dynCheck") || + (s.kind === "assign" && s.localId === id && s.value.kind === "dynCheck"), + ); + if (at < 0) return false; + const write = lowered[at]!; + let check: IrExpr | null = null; + if (write.kind === "varDecl") check = write.init; + else if (write.kind === "assign") check = write.value; + if (check === null || check.kind !== "dynCheck" || !fallbackCheck(lowerer, check)) return false; + // Every tail must split: each split point's list is lowering whole. + const whole = [...site.anchors].every((anchor) => { + const real = listOf(anchor.parent!); + const index = real.indexOf(anchor); + return lowerer.activeStmtLists.some( + (entry) => + entry.stmts[entry.index] === anchor && + entry.stmts.length - entry.index === real.length - index, + ); + }); + if (!whole) return false; + // Sites sharing a split point multiply the lowerings. + let shared = 0; + for (const other of lowerer.fallbackArmed) { + for (const a of other.anchors) if (site.anchors.has(a)) shared++; + } + if (shared >= MAX_SHARED_SITES) return false; + check.fallback = true; + const loc = check.loc; + lowered.splice( + at + 1, + 0, + { + kind: "assign", + localId: site.flag.id, + value: { kind: "libCall", fn: "checkedCast.fallbackTaken", args: [], type: BOOL, loc }, + loc, + }, + { + kind: "if", + cond: { kind: "varRef", localId: site.flag.id, type: BOOL, loc }, + then: [ + { + kind: "assign", + localId: site.shadow.id, + value: { kind: "libCall", fn: "checkedCast.fallbackTake", args: [], type: DYN, loc }, + loc, + }, + ], + else_: null, + loc, + }, + ); + site.armed = true; + lowerer.fallbackArmed.push(site); + return true; +} + +/** Armed sites whose tail starts after `stmt`, consumed. */ +export function fallbackSplitsAt(lowerer: Lowerer, stmt: ts.Statement): FallbackSite[] { + if (lowerer.fallbackArmed.length === 0) return []; + const here: FallbackSite[] = []; + for (const site of lowerer.fallbackArmed) { + if (site.anchors.delete(stmt)) here.push(site); + } + if (here.length > 0) + lowerer.fallbackArmed = lowerer.fallbackArmed.filter((s) => s.anchors.size > 0); + return here; +} + +/** The tail after a split point: `if (flag) else ` per + * site. The static lowering runs first and is exactly the lowering the + * tail had without a site. */ +export function lowerFallbackTail( + lowerer: Lowerer, + rest: readonly ts.Statement[], + sites: readonly FallbackSite[], +): IrStmt[] { + if (sites.length === 0) return lowerer.lowerStmts(rest); + // An empty tail (the cast ends its block) needs no split. + if (rest.length === 0) return []; + const [site, ...others] = sites as [FallbackSite, ...FallbackSite[]]; + const at = locOf(rest.length > 0 ? rest[0]! : site.boundaryStmt); + const statics = scoped(lowerer, () => lowerFallbackTail(lowerer, rest, others)); + const dynamic = lowerDynamicTail(lowerer, rest, site, others); + return [ + { + kind: "if", + cond: { kind: "varRef", localId: site.flag.id, type: BOOL, loc: at }, + then: dynamic, + else_: statics, + loc: at, + }, + ]; +} + +function scoped(lowerer: Lowerer, lower: () => T, frame = new Map()): T { + lowerer.scopes.push(frame); + try { + return lower(); + } finally { + lowerer.scopes.pop(); + } +} + +function lowerDynamicTail( + lowerer: Lowerer, + rest: readonly ts.Statement[], + site: FallbackSite, + others: readonly FallbackSite[], +): IrStmt[] { + const saved = { + taint: lowerer.fallbackTaint, + sink: lowerer.diagSink, + stats: lowerer.suppressStats, + sites: new Map(lowerer.fallbackSites), + armed: lowerer.fallbackArmed, + scopes: lowerer.scopes.length, + }; + lowerer.fallbackTaint = fallbackTaintOf(lowerer, saved.taint, site.root, rest); + lowerer.diagSink = []; + lowerer.suppressStats = true; + lowerer.fallbackArmed = []; + try { + return scoped( + lowerer, + () => lowerFallbackTail(lowerer, rest, others), + new Map([[site.root, site.shadow]]), + ); + } catch (error) { + if (!(error instanceof PoisonError) && process.env.SCRIPTC_FALLBACK_DEBUG) throw error; + while (lowerer.scopes.length > saved.scopes) lowerer.scopes.pop(); + const loc = rest.length > 0 ? locOf(rest[0]!) : locOf(site.boundaryStmt); + return [ + { + kind: "runtimeFence", + code: "SC1090", + message: `a value that failed its checked cast reached code with no dynamic lowering`, + loc, + }, + ]; + } finally { + lowerer.fallbackTaint = saved.taint; + lowerer.diagSink = saved.sink; + lowerer.suppressStats = saved.stats; + lowerer.fallbackSites = saved.sites; + lowerer.fallbackArmed = saved.armed; + } +} + +/* ── taint: which expressions of a dynamic tail read a failed value ── */ + +/** True when `node`'s value derives from a failed checked value in the + * dynamic lowering being built: it is typed `any` there. */ +export function fallbackTainted(lowerer: Lowerer, node: ts.Node): boolean { + const taint = lowerer.fallbackTaint; + if (taint === null) return false; + const known = taint.memo.get(node); + if (known !== undefined) return known; + taint.memo.set(node, false); + const result = computeTainted(lowerer, taint, node); + taint.memo.set(node, result); + return result; +} + +function computeTainted(lowerer: Lowerer, taint: FallbackTaint, node: ts.Node): boolean { + const t = (n: ts.Node): boolean => fallbackTainted(lowerer, n); + if (ts.isIdentifier(node)) { + const sym = lowerer.resolveValueSymbol(node); + return sym !== null && taint.symbols.has(sym); + } + if ( + ts.isParenthesizedExpression(node) || + ts.isNonNullExpression(node) || + ts.isAsExpression(node) || + ts.isTypeAssertion(node) || + ts.isSatisfiesExpression(node) || + ts.isAwaitExpression(node) + ) + return t(node.expression); + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) + return t(node.expression); + if (ts.isCallExpression(node)) return t(node.expression); + if (ts.isConditionalExpression(node)) return t(node.whenTrue) || t(node.whenFalse); + if (ts.isBinaryExpression(node)) { + const k = node.operatorToken.kind; + switch (k) { + case ts.SyntaxKind.QuestionQuestionToken: + case ts.SyntaxKind.BarBarToken: + case ts.SyntaxKind.AmpersandAmpersandToken: + case ts.SyntaxKind.PlusToken: + return t(node.left) || t(node.right); + case ts.SyntaxKind.CommaToken: + case ts.SyntaxKind.EqualsToken: + return t(node.right); + case ts.SyntaxKind.PlusEqualsToken: + case ts.SyntaxKind.QuestionQuestionEqualsToken: + case ts.SyntaxKind.BarBarEqualsToken: + case ts.SyntaxKind.AmpersandAmpersandEqualsToken: + return t(node.left) || t(node.right); + default: + return false; + } + } + // A literal beside a failed value (`value ?? {}`, `ok ? value : []`) + // is built dynamically too: its contextual type is the static one. + if (ts.isObjectLiteralExpression(node) || ts.isArrayLiteralExpression(node)) { + // A literal spreading a failed value is built dynamically, and so is + // one holding a failed value that initializes a binding (the binding + // turns dynamic) or sits in such a literal. A literal flowing straight + // into a typed slot keeps its layout: each failed member is checked + // into its field, so members that fit continue. + let holds = false; + if (ts.isObjectLiteralExpression(node)) { + for (const p of node.properties.nodes) { + if (ts.isSpreadAssignment(p) && t(p.expression)) return true; + if ( + (ts.isPropertyAssignment(p) && t(p.initializer)) || + (ts.isShorthandPropertyAssignment(p) && t(p.name)) + ) + holds = true; + } + } else { + for (const e of node.elements.nodes) + if (t(ts.isSpreadElement(e) ? e.expression : e)) holds = true; + } + if (holds) { + // The outermost literal of a nest decides for all of it. + let top: ts.Node = node; + for (;;) { + const host: ts.Node | undefined = top.parent; + if (host !== undefined && ts.isPropertyAssignment(host) && host.parent !== undefined) { + top = host.parent; + continue; + } + if (host !== undefined && ts.isArrayLiteralExpression(host)) { + top = host; + continue; + } + break; + } + // An untyped destination (`console.log`, `JSON.stringify`) takes it + // dynamically. + const contextual = lowerer.checker.getContextualType(top); + if ( + contextual !== undefined && + (contextual.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 + ) + return true; + const host = top.parent; + if (host !== undefined && ts.isVariableDeclaration(host) && host.initializer === top) + return true; + } + let child: ts.Node = node; + let parent = node.parent; + while (parent !== undefined && ts.isParenthesizedExpression(parent)) { + child = parent; + parent = parent.parent; + } + if (parent === undefined) return false; + if (ts.isBinaryExpression(parent)) { + const k = parent.operatorToken.kind; + if ( + k === ts.SyntaxKind.QuestionQuestionToken || + k === ts.SyntaxKind.BarBarToken || + k === ts.SyntaxKind.AmpersandAmpersandToken + ) + return t(parent.left === child ? parent.right : parent.left); + } + if (ts.isConditionalExpression(parent) && parent.condition !== child) + return t(parent.whenTrue === child ? parent.whenFalse : parent.whenTrue); + return false; + } + if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + if ( + node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken + ) + return t(node.operand); + return false; + } + return false; +} + +/** The taint of a dynamic tail: the root, and every binding declared in the + * tail that a tainted value is written to (a fixpoint over the tail, + * nested functions included). Parameters of callbacks passed to a tainted + * callee are tainted too: the dynamic call passes them dynamic values. */ +function fallbackTaintOf( + lowerer: Lowerer, + outer: FallbackTaint | null, + root: ts.Symbol, + region: readonly ts.Statement[], +): FallbackTaint { + const symbols = new Set(outer?.symbols ?? []); + symbols.add(root); + const taint: FallbackTaint = { symbols, memo: new Map() }; + const saved = lowerer.fallbackTaint; + lowerer.fallbackTaint = taint; + try { + growTaint(lowerer, taint, region); + } finally { + lowerer.fallbackTaint = saved; + } + return taint; +} + +function growTaint(lowerer: Lowerer, taint: FallbackTaint, region: readonly ts.Statement[]): void { + const symbols = taint.symbols; + const declared = new Set(); + const collectDeclared = (n: ts.Node): void => { + if ( + (ts.isVariableDeclaration(n) || ts.isParameter(n) || ts.isBindingElement(n)) && + ts.isIdentifier(n.name) + ) { + const s = lowerer.checker.getSymbolAtLocation(n.name); + if (s) declared.add(s); + } + ts.forEachChild(n, collectDeclared); + }; + for (const s of region) collectDeclared(s); + const bindNames = (name: ts.BindingName): boolean => { + let changed = false; + const visit = (n: ts.Node): void => { + if (ts.isIdentifier(n)) { + const s = lowerer.checker.getSymbolAtLocation(n); + if (s && !symbols.has(s)) { + symbols.add(s); + changed = true; + } + return; + } + if (ts.isBindingElement(n)) { + if (n.name !== undefined) visit(n.name); + return; + } + ts.forEachChild(n, visit); + }; + visit(name); + return changed; + }; + for (let round = 0; round < 32; round++) { + let changed = false; + taint.memo.clear(); + const visit = (n: ts.Node): void => { + if ( + ts.isVariableDeclaration(n) && + n.initializer !== undefined && + fallbackTainted(lowerer, n.initializer) + ) { + if (bindNames(n.name)) changed = true; + } else if (ts.isBinaryExpression(n)) { + const k = n.operatorToken.kind; + if ( + k >= ts.SyntaxKind.FirstAssignment && + k <= ts.SyntaxKind.LastAssignment && + ts.isIdentifier(strip(n.left)) + ) { + const s = lowerer.resolveValueSymbol(strip(n.left) as ts.Identifier); + if (s && declared.has(s) && !symbols.has(s) && fallbackTainted(lowerer, n.right)) { + symbols.add(s); + changed = true; + } + } + } else if ( + ts.isForOfStatement(n) && + ts.isVariableDeclarationList(n.initializer) && + fallbackTainted(lowerer, n.expression) + ) { + for (const d of n.initializer.declarations.nodes) if (bindNames(d.name)) changed = true; + } else if (ts.isCallExpression(n) && fallbackTainted(lowerer, n.expression)) { + for (const arg of n.arguments.nodes) { + const a = strip(arg); + if (ts.isArrowFunction(a) || ts.isFunctionExpression(a)) { + for (const p of a.parameters.nodes) if (bindNames(p.name)) changed = true; + } + } + } + ts.forEachChild(n, visit); + }; + for (const s of region) visit(s); + if (!changed) break; + } + taint.memo.clear(); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 7ef4b9f257..bede930725 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -235,7 +235,7 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre const receiver = tryLowerExpression(lowerer, expr.expression); if (receiver?.type.kind === "dyn") { const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); - const optional = hasOptionalChainGuard(expr.expression); + const optional = hasOptionalChainGuard(expr.expression, lowerer); return lowerer.maybeNarrow( { kind: "dynKeyGet", @@ -378,7 +378,7 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre lowerer.lowerExpr(expr.argumentExpression), expr.argumentExpression, ); - const optional = hasOptionalChainGuard(expr.expression); + const optional = hasOptionalChainGuard(expr.expression, lowerer); return lowerer.maybeNarrow( { kind: "dynKeyGet", @@ -488,7 +488,7 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre ? lowerer.coerceToExpected(rawKey, DYN) : lowerRecordPropertyKey(lowerer, rawKey, expr.argumentExpression); if (key.type.kind === "string" || key.type.kind === "dyn") { - const opt = hasOptionalChainGuard(expr.expression); + const opt = hasOptionalChainGuard(expr.expression, lowerer); return lowerer.maybeNarrow( { kind: "dynKeyGet", diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index b5f99d6826..a7596cc6d7 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -211,6 +211,11 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: if (arithmetic && !runtimeOptionalUseOf(expr)) { return { kind: "varRef", localId: local.id, type: local.type, loc }; } + // A failed checked value in a fallback tail reads its dynamic value + // plainly: `undefined` is a value it can hold (checked-fallback.ts). + if ((active || captured) && lowerer.fallbackTaint !== null && local.type.kind === "dyn") { + return { kind: "varRef", localId: local.id, type: local.type, loc }; + } if (active || captured) { const use = runtimeOptionalUseOf(expr); if (use?.complex) { diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 5dc512a6cd..fbcecdc328 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -98,7 +98,11 @@ export function isRequireMainFilename(lowerer: Lowerer, expr: ts.Expression): bo export function isOptionalChainTail(lowerer: Lowerer, expr: ts.Expression): boolean { const tail = chainTailDot(lowerer, expr); if (!tail) return false; - const recvT = lowerer.mapTypeOf(lowerer.typeOf(tail.expression)); + const recvTs = lowerer.typeOf(tail.expression); + // An `any` receiver short-circuits the whole tail too: `a?.b.c` reads + // `.c` normally once `a` is present. + if (recvTs.flags & ts.TypeFlags.Any) return true; + const recvT = lowerer.mapTypeOf(recvTs); if (recvT?.kind === "dyn") return true; if (recvT?.kind === "union") { const def = lowerer.unions.get(recvT.unionId); @@ -119,7 +123,7 @@ export function isOptionalChainTail(lowerer: Lowerer, expr: ts.Expression): bool * throwing. Walks the receiver spine only; the argument of the access * itself is not part of the guard. Parentheses end the chain * (`(a?.b).c` reads `.c` from undefined); `!` does not. */ -export function hasOptionalChainGuard(expr: ts.Expression): boolean { +export function hasOptionalChainGuard(expr: ts.Expression, lowerer?: Lowerer): boolean { let cur: ts.Expression = expr; for (;;) { if (ts.isNonNullExpression(cur)) { @@ -131,7 +135,11 @@ export function hasOptionalChainGuard(expr: ts.Expression): boolean { ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) ) { - if (cur.questionDotToken) return true; + // Inside an optional chain's guarded body (dynamic reads pass the + // lowerer), the enclosing optChain already short-circuits on the + // consumed token, so the steps above it read normally: `a?.b.c` + // throws when `a.b` is undefined. + if (cur.questionDotToken) return lowerer === undefined || !lowerer.chainHandled.has(cur); cur = cur.expression; continue; } diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 36ea0e8c22..e3a24ea1ae 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -869,7 +869,7 @@ export function lowerPropertyAccessExpr( type: STRING, loc: locOf(expr.name), }; - const opt = hasOptionalChainGuard(expr.expression); + const opt = hasOptionalChainGuard(expr.expression, lowerer); return lowerer.maybeNarrow( { kind: "dynKeyGet", @@ -978,7 +978,7 @@ export function lowerPropertyAccessExpr( type: STRING, loc: locOf(expr.name), }; - const opt = hasOptionalChainGuard(expr.expression); + const opt = hasOptionalChainGuard(expr.expression, lowerer); return lowerer.maybeNarrow( { kind: "dynKeyGet", @@ -1110,7 +1110,7 @@ export function lowerPropertyAccessExpr( type: STRING, loc: locOf(expr.name), }; - const opt = hasOptionalChainGuard(expr.expression); + const opt = hasOptionalChainGuard(expr.expression, lowerer); const read: IrExpr = { kind: "dynKeyGet", key, @@ -1205,7 +1205,7 @@ export function lowerPropertyAccessExpr( type: STRING, loc: locOf(expr.name), }; - const opt = hasOptionalChainGuard(expr.expression); + const opt = hasOptionalChainGuard(expr.expression, lowerer); return lowerer.maybeNarrow( { kind: "dynKeyGet", @@ -2723,7 +2723,7 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi kind: "dynKeyGet", value, key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, - ...(expr.questionDotToken || hasOptionalChainGuard(expr.expression) + ...(expr.questionDotToken || hasOptionalChainGuard(expr.expression, lowerer) ? { optional: true as const } : {}), type: DYN, @@ -3079,7 +3079,7 @@ export function lowerUnionProperty( kind: "dynKeyGet", value: lowerer.coerceToExpected(value, DYN), key, - ...(hasOptionalChainGuard(expr.expression) ? { optional: true as const } : {}), + ...(hasOptionalChainGuard(expr.expression, lowerer) ? { optional: true as const } : {}), type: DYN, loc: locOf(expr), }; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 7789f8a860..3e52f0cdc9 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,3 +1,5 @@ +import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; +import { fallbackTainted } from "./checked-fallback.js"; import { lowerWebAssemblyValue, refuseWebAssemblyUse } from "./builtins/webassembly.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; @@ -269,6 +271,25 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const chainRecv = lowerer.chainRecvByNode.get(expr); if (chainRecv) return { ...chainRecv, loc }; + // A failed checked value in a fallback tail: assertions check nothing + // (checked-fallback.ts). + if ( + lowerer.fallbackTaint !== null && + (ts.isAsExpression(expr) || + ts.isTypeAssertion(expr) || + ts.isNonNullExpression(expr) || + ts.isSatisfiesExpression(expr)) && + fallbackTainted(lowerer, expr.expression) + ) + return lowerer.lowerExpr(expr.expression); + if (lowerer.fallbackTaint !== null && fallbackTainted(lowerer, expr)) { + if (ts.isObjectLiteralExpression(expr)) return lowerDynObjectLiteral(lowerer, expr); + if (ts.isArrayLiteralExpression(expr) && !expr.elements.nodes.some(ts.isSpreadElement)) { + const elems = expr.elements.nodes.map((el) => lowerer.lowerExprExpecting(el, DYN)); + return { kind: "dynArrLit", elems, type: DYN, loc }; + } + } + // --external-types supplies CHECKER truth only. A value rooted in one // of those declarations has no runtime implementation in scriptc, so // every use fences at its expression instead of accidentally lowering diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 69f6cc99bd..e6512ca2dd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,3 +1,10 @@ +import { + armFallbackSite, + fallbackSplitsAt, + fallbackTainted, + lowerFallbackTail, + openFallbackSite, +} from "./checked-fallback.js"; import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js"; import { declaredMemberOf } from "./expressions/property-access.js"; import { hostsAll } from "./expressions/hosted-narrowing.js"; @@ -686,7 +693,10 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // position — instead of failing the whole build). TypeScript keeps // compile fences. ICEs (SC9001) always stay compile errors. const sf = stmts.length === 0 ? undefined : ts.getSourceFileOfNode(stmts[0]!); - const deferFences = sf !== undefined && isJsSourceFile(sf); + // The dynamic lowering of a checked-cast fallback tail fences like + // JavaScript: a statement with no `any` lowering throws when a failed + // value reaches it (checked-fallback.ts). + const deferFences = sf !== undefined && (isJsSourceFile(sf) || lowerer.fallbackTaint !== null); // Register this list as open for the forward-capture machinery: a // nested function lowering mid-list may pre-declare a LATER const of // this list as a TDZ box (predeclareForwardCapture), pushing the @@ -733,6 +743,7 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir lowered = accounting; appended = true; } else { + openFallbackSite(lowerer, stmt, out); lowered = lowerer.lowerStmt(stmt); } // The lib-boundary chokepoint (lib-boundary.ts): checked-dynamic @@ -747,8 +758,17 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir else body.push(lowered); const rewritten = lowerSurplusCalls(lowerer, body); enforceLibBoundary(lowerer, rewritten); + armFallbackSite(lowerer, stmt, rewritten); if (!appended) out.push(...rewritten); } + // A checked-cast fallback site's tail starts after this + // statement: the rest of the list lowers statically and + // dynamically, selected by the site's flag. + const splits = fallbackSplitsAt(lowerer, stmt); + if (splits.length > 0) { + out.push(...lowerFallbackTail(lowerer, stmts.slice(i + 1), splits)); + break; + } // A JS statement that lowered CLEAN can still have flushed // DEFERRED collection diagnostics (a symbol lookup on an alias // resolves through flushDeferred — informational here, not a @@ -6891,6 +6911,36 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm ); return { kind: "if", cond, then, else_, loc: locOf(expr) }; } + // A property write to a failed checked value in a fallback tail writes + // the dynamic object (checked-fallback.ts). + if ( + lowerer.fallbackTaint !== null && + ts.isBinaryExpression(expr) && + expr.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(expr.left) && + !expr.left.questionDotToken && + fallbackTainted(lowerer, expr.left.expression) + ) { + const loc = locOf(expr); + const recv = lowerer.lowerExpr(expr.left.expression); + if (recv.type.kind === "dyn") { + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [ + recv, + { kind: "strLit", value: expr.left.name.text, type: STRING, loc }, + lowerer.lowerExprExpecting(expr.right, DYN), + ], + type: VOID, + loc, + }, + loc, + }; + } + } // Assignment statements over the no-storage binding families: // - `f1 = f2` where the RHS roots at an ambient-undefined name: // Node evaluates the RHS first and dies on the root's diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e2236f5922..6b7665bdc3 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -135,6 +135,7 @@ import { earlyReadVarSymbols } from "./var-hoisting.js"; import { recordTextCodecClass } from "../../ir/ir.js"; import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js"; import { promoteJsvals } from "../../ir/jsval.js"; +import { fallbackTainted, type FallbackSite, type FallbackTaint } from "./checked-fallback.js"; import { analyzeTiers, dynamicStatementCount, @@ -2209,6 +2210,10 @@ export class Lowerer { runtimeOptionalBindingType(node: ts.Node): IrType | null; runtimeOptionalBindingType(node: ts.Node, fallback: IrType): IrType; runtimeOptionalBindingType(node: ts.Node, fallback?: IrType): IrType | null { + // A binding holding a failed checked value is dynamic: undefined is one + // of its values already (checked-fallback.ts). + if (this.fallbackTaint !== null && ts.isIdentifier(node) && fallbackTainted(this, node)) + return fallback ?? null; const patternType = this.runtimeOptionalPatternTypes.get(node); if (patternType) return patternType; const symbol = ts.isIdentifier(node) @@ -2608,6 +2613,12 @@ export class Lowerer { /** True while re-lowering a base function's 2nd+ instance: the same source * statements were already counted for the first instance. */ suppressStats = false; + /** Checked-cast fallback (checked-fallback.ts): sites opened by their + * root's declaration, by root; armed sites whose tails have not split + * yet; and the taint of the dynamic tail being lowered. */ + fallbackSites = new Map(); + fallbackArmed: FallbackSite[] = []; + fallbackTaint: FallbackTaint | null = null; /** Comparisons interned by operand representations and equality semantics. */ readonly equalityHelpers = new Map(); /** Synthetic array-HOF loop functions (map/filter/forEach desugar), @@ -7918,10 +7929,14 @@ export class Lowerer { ): IrStmt & { kind: "runtimeFence" } { const loc = locOf(node); const code = first?.code ?? fallback?.code ?? "SC1090"; - const baseMessage = + const plainMessage = first?.message ?? fallback?.message ?? "this statement uses a construct with no static lowering"; + const baseMessage = + this.fallbackTaint !== null + ? `a value that failed its checked cast reached code with no dynamic lowering: ${plainMessage}` + : plainMessage; const messageLoc = first?.loc ?? loc; const source = first ? (this.program.getSourceFile(messageLoc.file) ?? ts.getSourceFileOfNode(node)) @@ -8229,6 +8244,9 @@ export class Lowerer { } stdlibMemberFence(access: ts.PropertyAccessExpression): void { + // A member of a failed checked value reads dynamically, whatever the + // static member resolves to (checked-fallback.ts). + if (this.fallbackTaint !== null && fallbackTainted(this, access.expression)) return; return stdlibMemberFence(this, access); } @@ -8278,6 +8296,10 @@ export class Lowerer { } typeOf(node: ts.Node): ts.Type { + // The dynamic lowering of a checked-cast fallback tail reads values + // derived from the failed value as `any`. + if (this.fallbackTaint !== null && fallbackTainted(this, node)) + return this.checker.getAnyType(); if (ts.isPropertyAccessExpression(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) { const receiver = node.expression.kind === ts.SyntaxKind.ThisKeyword && this.fnStack.length > 0 @@ -11956,6 +11978,7 @@ export class Lowerer { } npmMemberFence(access: ts.PropertyAccessExpression): void { + if (this.fallbackTaint !== null && fallbackTainted(this, access.expression)) return; return npmMemberFence(this, access); } diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 1ffae75360..365a837bc6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4172,6 +4172,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * (inspect, Object.keys/values/entries) then answer through its live * checked view. The receiver is any such record type. Never throws. */ "record.sourceHas": { argTypes: [null], result: BOOL }, + /** A checked-cast fallback site's reads right after the check + * (dynCheck.fallback): whether its value failed validation, and that + * value (+1), taken once. */ + "checkedCast.fallbackTaken": { argTypes: [], result: BOOL }, + "checkedCast.fallbackTake": { argTypes: [], result: DYN }, "insp.key": { argTypes: [STRING], result: STRING }, "insp.moreItems": { argTypes: [F64], result: STRING }, "insp.end": { argTypes: [STRING, STRING, STRING, F64, BOOL, BOOL], result: STRING }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 4c5bbd183f..0059965323 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3824,11 +3824,20 @@ export type IrExpr = /** `exact`: the value is known to be a capsule this program boxed from * exactly the class `type` names (a `dyn.typedRefKeyIs` test guards the * expression), so the check is one load of the capsule's instance. */ + /** `fallback`: a checked-cast fallback site (frontend + * checked-fallback.ts). A value that fails validation does not throw: the + * result is the dummy, and the checked dyn value parks for the + * `checkedCast.fallbackTaken`/`checkedCast.fallbackTake` reads the + * frontend emits right after the site, whose code then continues in the + * dynamic lowering of the region the value flows through. Other + * exceptions (a JSON syntax error) still throw. Only record, array, + * number, string and boolean targets carry it. */ | { kind: "dynCheck"; value: IrExpr; preserveRefs?: true; exact?: true; + fallback?: true; type: IrType; loc: SrcLoc; }; diff --git a/packages/compiler/test/checked-cast-fallback.test.ts b/packages/compiler/test/checked-cast-fallback.test.ts new file mode 100644 index 0000000000..6f5e7e1b19 --- /dev/null +++ b/packages/compiler/test/checked-cast-fallback.test.ts @@ -0,0 +1,149 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrExpr, IrFunction, IrModule, IrStmt } from "../src/ir/ir.js"; +import { everyStmtList } from "../src/ir/traverse.js"; +import type { TierMode } from "../src/frontend/tier-analysis.js"; + +async function lowerSource(source: string, tier?: TierMode): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-cast-fallback-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { + outPath, + outDir: dir, + outputKind: "ir", + ...(tier ? { tier } : {}), + }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function fnNamed(mod: IrModule, name: string): IrFunction { + const fn = mod.functions.find((f) => f.name === name); + if (!fn) throw new Error(`no function ${name}`); + return fn; +} + +function checks(fn: IrFunction): (IrExpr & { kind: "dynCheck" })[] { + const found: (IrExpr & { kind: "dynCheck" })[] = []; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "dynCheck") found.push(e); + return true; + }, + stmt: () => true, + }); + return found; +} + +/** The `if (flag) dynamic else static` split a fallback tail lowers to. */ +function splits(fn: IrFunction): (IrStmt & { kind: "if" })[] { + const found: (IrStmt & { kind: "if" })[] = []; + everyStmtList(fn.body, { + expr: () => true, + stmt: (s) => { + if ( + s.kind === "if" && + s.cond.kind === "varRef" && + s.cond.localId.startsWith("%fb.") && + s.else_ !== null + ) + found.push(s); + return true; + }, + }); + return found; +} + +const listener = ` +interface Order { deployment: { id: string; plan: string } } +interface Req { buildOrder: Order; traceCarrier?: Record } +function accept(body: string): string { + let request: Partial; + try { + request = JSON.parse(body) as Partial; + } catch { + return "400"; + } + const id = request.buildOrder?.deployment?.id; + return id ? "200 " + id : "400 missing"; +} +console.log(accept('{"buildOrder":{"deployment":{"id":"d"}}}')); +`; + +test("a block-local checked cast continues in a dynamic tail behind one flag", async () => { + const fn = fnNamed(await lowerSource(listener), "accept"); + const [check] = checks(fn); + expect(check?.fallback).toBe(true); + const split = splits(fn); + expect(split).toHaveLength(1); + // The static tail reads the record natively; the dynamic one reads the + // failed value's shadow. + const shadowReads = (body: IrStmt[]): number => { + let n = 0; + everyStmtList(body, { + expr: (e) => { + if (e.kind === "varRef" && e.localId.startsWith("%fbv.")) n++; + return true; + }, + stmt: () => true, + }); + return n; + }; + expect(shadowReads(split[0]!.then)).toBeGreaterThan(0); + expect(shadowReads(split[0]!.else_!)).toBe(0); +}); + +test("--tier=static-only keeps the throwing cast", async () => { + const fn = fnNamed(await lowerSource(listener, "static-only"), "accept"); + expect(checks(fn).some((c) => c.fallback)).toBe(false); + expect(splits(fn)).toHaveLength(0); + expect(fn.locals.some((l) => l.id.startsWith("%fb"))).toBe(false); +}); + +test("casts whose binding is read outside the code after them, or written again, keep throwing", async () => { + const mod = await lowerSource(` +interface P { x: number } +const top = JSON.parse('{"x":1}') as P; +console.log(top.x); +function before(text: string): number { + let p: P | undefined; + for (let i = 0; i < 2; i++) { + if (p) console.log(p.x); + p = JSON.parse(text) as P; + } + return p?.x ?? 0; +} +function rewritten(text: string): number { + let p = JSON.parse(text) as P; + p = { x: 2 }; + return p.x; +} +function hoisted(text: string): number { + const p = JSON.parse(text) as P; + function read(): number { return p.x; } + return read(); +} +console.log(before('{"x":1}'), rewritten('{"x":1}'), hoisted('{"x":1}')); +`); + for (const name of ["before", "rewritten", "hoisted"]) { + const fn = fnNamed(mod, name); + expect( + checks(fn).some((c) => c.fallback), + name, + ).toBe(false); + expect( + fn.locals.some((l) => l.id.startsWith("%fb")), + name, + ).toBe(false); + } + expect(mod.functions.flatMap(checks).some((c) => c.fallback)).toBe(false); +}); diff --git a/packages/compiler/test/json-callbacks.test.ts b/packages/compiler/test/json-callbacks.test.ts index 308d626e39..d10721d0ba 100644 --- a/packages/compiler/test/json-callbacks.test.ts +++ b/packages/compiler/test/json-callbacks.test.ts @@ -53,7 +53,8 @@ for (const backend of ["llvm"] as const) { } catch (error) { if (error instanceof Error) console.log(error.message); } - // A checked cast still validates the result AFTER revival. + // A checked cast validates the result AFTER revival; a revived value + // that does not fit continues as that value (checked-cast fallback). try { const value = JSON.parse('{"n":1}', (key: string, value: unknown): unknown => key === "n" ? "wrong" : value) as { n: number }; console.log(value.n); @@ -75,6 +76,8 @@ for (const backend of ["llvm"] as const) { console.log(result === undefined, typeof result); } local(); + // Values that do not fit continue as their parsed values, every + // iteration (and release cleanly under the sanitizer lane). for (let i = 0; i < 20; i++) { try { const record = JSON.parse('{"opaque":{"deep":[1,2]},"typed":"wrong"}') as { opaque: unknown; typed: number }; @@ -105,13 +108,12 @@ for (const backend of ["llvm"] as const) { expect(result.stdout.toString()).toBe( [ "JSON.parse reviver deleting array elements is not supported yet", - "true", + "wrong", "true", "true", "true", "true undefined", - "true", - "true", + ...Array.from({ length: 20 }, () => ["wrong", "2"]).flat(), "1", "", ].join("\n"), diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-extra-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-extra-keys.ts.json new file mode 100644 index 0000000000..8f56949053 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-extra-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-parse-fallback-extra-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-flows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-flows.ts.json new file mode 100644 index 0000000000..2084d8a65c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-flows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-parse-fallback-flows.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-missing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-missing.ts.json new file mode 100644 index 0000000000..17562fe471 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-missing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-parse-fallback-missing.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-nested.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-nested.ts.json new file mode 100644 index 0000000000..3585687e2a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-nested.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-parse-fallback-nested.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-partial.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-partial.ts.json new file mode 100644 index 0000000000..26bae04450 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-partial.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-parse-fallback-partial.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-wrong-types.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-wrong-types.ts.json new file mode 100644 index 0000000000..fd445ea1c6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-parse-fallback-wrong-types.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-parse-fallback-wrong-types.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 20392e3a51..d7002cf6e4 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1237,6 +1237,16 @@ ScrClassObj *scr_dyn_class_check(const ScrDyn *value, const char *type_key, cons * scriptc-specific behavior — JS `as` never checks (SEMANTICS.md). */ void scr_dyn_check_fail(const ScrDynPath *path, const char *want, const ScrDyn *got); +/* Checked-cast fallback (a `JSON.parse(text) as T` whose value fails + * validation keeps its dynamic value instead of throwing). Right after a + * fallback site's check, scr_dyn_check_recover takes back the exception the + * check raised (Node runs nothing at a cast, so nothing it raises is + * observable) and parks the checked value, which the lowered code reads + * with scr_dyn_check_fallback_taken and takes (+1) with _take. */ +bool scr_dyn_check_recover(ScrDyn *d); +bool scr_dyn_check_fallback_taken(void); +ScrDyn *scr_dyn_check_fallback_take(void); + /* Matchers and builders of recursive shapes (records and arrays whose type * reaches itself) track the dyn values they visit, so cyclic values match * and convert without unbounded recursion. Every matcher and builder of a diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 9746e8eb11..4b0ef7d1c1 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8567,6 +8567,34 @@ static const char *scr_dyn_kind_name(const ScrDyn *d) { return "unknown"; } +static SCR_TL ScrDyn *scr_dyn_fallback_value; +static SCR_TL bool scr_dyn_fallback_registered; + +static void scr_dyn_fallback_cleanup(void) { + scr_dyn_release(scr_dyn_fallback_value); + scr_dyn_fallback_value = NULL; +} + +bool scr_dyn_check_recover(ScrDyn *d) { + if (!scr_exc_pending() || scr_exc_active->kind == SCR_EXC_TERMINATE) return false; + scr_exc_clear(); + if (!scr_dyn_fallback_registered) { + scr_dyn_fallback_registered = true; + scr_atexit(scr_dyn_fallback_cleanup); + } + scr_dyn_release(scr_dyn_fallback_value); + scr_dyn_fallback_value = scr_dyn_retain(d); + return true; +} + +bool scr_dyn_check_fallback_taken(void) { return scr_dyn_fallback_value != NULL; } + +ScrDyn *scr_dyn_check_fallback_take(void) { + ScrDyn *value = scr_dyn_fallback_value; + scr_dyn_fallback_value = NULL; + return value; +} + void scr_dyn_check_fail(const ScrDynPath *path, const char *want, const ScrDyn *got) { ScrJsonBuf b; scr_jb_init(&b); diff --git a/tests/corpus/json-parse-fallback-extra-keys.ts b/tests/corpus/json-parse-fallback-extra-keys.ts new file mode 100644 index 0000000000..7eba7b2a8f --- /dev/null +++ b/tests/corpus/json-parse-fallback-extra-keys.ts @@ -0,0 +1,37 @@ +// A failed checked cast keeps every member of the parsed object: extra +// members stay visible to Object.keys, JSON.stringify, for...in and +// util.inspect, in the source object's key order. +interface Point { + x: number; + y: number; +} + +function show(text: string): void { + const p = JSON.parse(text) as Point; + console.log(Object.keys(p).join(",")); + console.log(Object.values(p).length, Object.entries(p).length); + console.log(JSON.stringify(p)); + console.log(JSON.stringify(p, null, 2)); + const seen: string[] = []; + for (const k in p) seen.push(k); + console.log(seen.join("|")); + console.log(p); + console.log("has z:", "z" in p, Object.hasOwn(p, "z")); +} + +show('{"x":1,"y":2}'); +show('{"z":3,"x":"1","y":2,"label":{"text":"hi","size":[1,2]}}'); +show('{"x":1}'); +show('{"b":1,"a":2,"x":null,"y":[]}'); + +// Spread copies every member of the failed value, in source order. +function copyOf(text: string): void { + const p = JSON.parse(text) as Point; + const copy = { ...p, y: 10 }; + console.log(copy, Object.keys(copy).join(",")); + const pair = [p.x, p.y]; + console.log(pair, JSON.stringify({ point: p, at: [p.x] })); +} + +copyOf('{"x":1,"y":2}'); +copyOf('{"w":0,"x":"1","extra":true}'); diff --git a/tests/corpus/json-parse-fallback-flows.ts b/tests/corpus/json-parse-fallback-flows.ts new file mode 100644 index 0000000000..82232959f4 --- /dev/null +++ b/tests/corpus/json-parse-fallback-flows.ts @@ -0,0 +1,57 @@ +// Checked-cast fallback across declaration forms and control flow: an +// annotated declaration from an untyped value, `unknown as T`, a site in a +// loop whose iterations alternate between fitting and failing values, +// closures and callbacks over the failed value, and a value that fits a +// typed parameter once it reaches it. +interface Job { + name: string; + retries: number; + steps: string[]; +} + +function retriesOf(n: number): string { + return n === 1 ? "1 retry" : `${n} retries`; +} + +function annotated(text: string): void { + const job: Job = JSON.parse(text); + console.log("annotated", job.name, job.retries, job.steps?.length); +} + +function fromUnknown(value: unknown): void { + const job = value as Job; + console.log("unknown", job.name ?? "(unnamed)", typeof job.retries); +} + +function loop(texts: string[]): void { + for (const text of texts) { + const job = JSON.parse(text) as Job; + const label = job.name + ":" + job.retries; + console.log("loop", label, typeof job.retries === "number" ? retriesOf(job.retries) : "n/a"); + } +} + +async function later(text: string): Promise { + const job = JSON.parse(text) as Job; + const steps = job.steps ?? []; + await new Promise((resolve) => setTimeout(resolve, 1)); + steps.forEach((step, i) => console.log("step", i, step)); + const upper = steps.map((s) => String(s).toUpperCase()); + console.log("upper", upper.join(" ")); + const describeJob = (): string => `${job.name} (${job.retries})`; + console.log("closure", describeJob()); +} + +annotated('{"name":"a","retries":1,"steps":["x"]}'); +annotated('{"name":"b","retries":"2"}'); +fromUnknown(JSON.parse('{"name":"c","retries":3,"steps":[]}')); +fromUnknown(JSON.parse('{"retries":"3"}')); +loop([ + '{"name":"d","retries":1,"steps":[]}', + '{"name":"e","retries":"x","steps":[]}', + '{"name":"f","retries":2,"steps":[]}', + '{"name":"g"}', +]); +later('{"name":"h","retries":0,"steps":["s1","s2"]}').then(() => + later('{"name":"i","retries":"0","steps":["s1",2]}'), +); diff --git a/tests/corpus/json-parse-fallback-missing.ts b/tests/corpus/json-parse-fallback-missing.ts new file mode 100644 index 0000000000..b84f8af2f5 --- /dev/null +++ b/tests/corpus/json-parse-fallback-missing.ts @@ -0,0 +1,36 @@ +// A `JSON.parse(text) as T` whose value lacks required members keeps the +// parsed value: missing members read undefined, as in Node. +interface Address { + city: string; + zip: string; +} +interface User { + id: number; + name: string; + admin: boolean; + address: Address; + tags: string[]; +} + +function describe(text: string): void { + const user = JSON.parse(text) as User; + console.log("id", user.id, typeof user.id); + console.log("name", user.name, user.name === undefined); + console.log("admin", user.admin ? "yes" : "no", !user.admin); + console.log("city", user.address?.city ?? "(no city)"); + console.log("zip?", "zip" in (user.address ?? {})); + console.log("tags", user.tags?.length ?? 0); + if (!user.address) console.log("no address"); + else console.log("address", user.address.city, user.address.zip); + try { + console.log("street", user.address.city.length); + } catch (e) { + console.log("read threw", e instanceof TypeError, (e as Error).message); + } + console.log(`label: ${user.name}#${user.id}`); +} + +describe('{"id":1,"name":"ada","admin":true,"address":{"city":"London","zip":"N1"},"tags":["a"]}'); +describe('{"id":2}'); +describe('{"name":"bob","address":{"city":"Paris"}}'); +describe("{}"); diff --git a/tests/corpus/json-parse-fallback-nested.ts b/tests/corpus/json-parse-fallback-nested.ts new file mode 100644 index 0000000000..96abddaf9c --- /dev/null +++ b/tests/corpus/json-parse-fallback-nested.ts @@ -0,0 +1,42 @@ +// Nested records and arrays of a value that failed its checked cast keep +// their identity: reads, writes and comparisons reach the same objects. +interface Line { + sku: string; + qty: number; +} +interface Cart { + id: string; + lines: Line[]; + owner: { name: string; email: string }; +} + +function lineTotal(lines: Line[]): number { + let n = 0; + for (const line of lines) n += line.qty; + return n; +} + +function inspectCart(text: string): void { + const cart = JSON.parse(text) as Cart; + const lines = cart.lines; + console.log("same lines:", lines === cart.lines, cart.owner === cart.owner); + lines.push({ sku: "added", qty: 1 }); + console.log("count:", cart.lines.length, lines.length); + const first = lines[0]; + first.qty = first.qty + 10; + console.log("first:", cart.lines[0].sku, cart.lines[0].qty); + console.log("skus:", lines.map((l) => l.sku).join(",")); + console.log("big:", lines.filter((l) => l.qty > 5).length); + console.log("found:", lines.find((l) => l.sku === "b")?.qty ?? "none"); + console.log("some string qty:", lines.some((l) => typeof l.qty === "string")); + console.log("owner:", cart.owner?.name ?? "(anonymous)", cart.owner?.email); + console.log(JSON.stringify(cart)); + // A typed function receives the lines when they fit its parameter. + if (lines.every((l) => typeof l.qty === "number" && typeof l.sku === "string")) + console.log("total:", lineTotal(lines)); + else console.log("total: skipped"); +} + +inspectCart('{"id":"c1","lines":[{"sku":"a","qty":1},{"sku":"b","qty":2}],"owner":{"name":"n","email":"e"}}'); +inspectCart('{"id":"c2","lines":[{"sku":"a","qty":1},{"sku":"b","qty":2}]}'); +inspectCart('{"id":3,"lines":[{"sku":"a","qty":"1"}],"owner":{"name":"n"}}'); diff --git a/tests/corpus/json-parse-fallback-partial.ts b/tests/corpus/json-parse-fallback-partial.ts new file mode 100644 index 0000000000..ec3e2c7698 --- /dev/null +++ b/tests/corpus/json-parse-fallback-partial.ts @@ -0,0 +1,59 @@ +// `Partial` and optional members: a request whose nested required +// members are missing is accepted the way Node accepts it (the listener +// pattern: the binding is assigned inside try, read after it). +interface Deployment { + id: string; + name: string; + billing: { plan: string }; +} +interface Order { + deployment: Deployment; + owner: { id: string }; +} +interface ListenerRequest { + buildOrder: Order; + traceCarrier?: Record; +} + +const delivered: string[] = []; + +async function readBody(body: string): Promise { + return body; +} + +async function handle(body: string): Promise { + const text = await readBody(body); + let request: Partial; + try { + request = JSON.parse(text) as Partial; + } catch { + console.log("400 invalid JSON body"); + return 400; + } + const buildOrder = request.buildOrder; + const deploymentId = buildOrder?.deployment?.id; + if (!deploymentId) { + console.log("400 missing buildOrder.deployment.id"); + return 400; + } + delivered.push(deploymentId); + console.log("200 accepted", deploymentId, Object.keys(request).join(",")); + console.log("carrier:", request.traceCarrier?.traceparent ?? "(none)"); + try { + console.log("plan:", buildOrder?.deployment.billing.plan); + } catch (e) { + console.log("plan threw:", (e as Error).message); + } + return 200; +} + +async function main(): Promise { + await handle('{"buildOrder":{"deployment":{"id":"dpl_1","name":"n","billing":{"plan":"pro"}},"owner":{"id":"o"}}}'); + await handle('{"buildOrder":{"deployment":{"id":"dpl_2"}},"traceCarrier":{"traceparent":"00-ab"}}'); + await handle('{"buildOrder":{"deployment":{}}}'); + await handle('{"traceCarrier":{}}'); + await handle("not json"); + console.log(delivered.join(",")); +} + +main(); diff --git a/tests/corpus/json-parse-fallback-wrong-types.ts b/tests/corpus/json-parse-fallback-wrong-types.ts new file mode 100644 index 0000000000..06ed792558 --- /dev/null +++ b/tests/corpus/json-parse-fallback-wrong-types.ts @@ -0,0 +1,33 @@ +// Members whose JSON type differs from the declared one keep their real +// values and JavaScript semantics after a failed checked cast. +interface Reading { + sensor: string; + value: number; + ok: boolean | number; + samples: number[]; +} + +function report(text: string): string { + const r = JSON.parse(text) as Reading; + const doubled = r.value * 2; + const sum = r.value + 1; + console.log(r.sensor, typeof r.sensor, r.value, typeof r.value, doubled, sum); + console.log("ok:", r.ok, typeof r.ok, r.ok === true, r.ok == 1); + console.log("cmp:", r.value > 10, r.value < 10, r.value === 5); + console.log("samples:", Array.isArray(r.samples), r.samples.length); + let total = 0; + for (const s of r.samples) total = total + s; + console.log("total:", total, typeof total); + try { + const parts = r.samples.map((s) => typeof s); + console.log("kinds:", parts.join(",")); + } catch (e) { + console.log("map threw:", (e as Error).message); + } + return `${r.sensor}=${r.value}`; +} + +console.log(report('{"sensor":"t1","value":5,"ok":true,"samples":[1,2,3]}')); +console.log(report('{"sensor":7,"value":"5","ok":1,"samples":[1,"2",3]}')); +console.log(report('{"sensor":"t3","value":null,"ok":"yes","samples":"abc"}')); +console.log(report('{"sensor":["x"],"value":{"n":1},"ok":false,"samples":[]}')); diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 2ab323f370..2764c37fcd 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -159,7 +159,8 @@ console.log("unreachable", c.host); test("schema-directed JSON.parse declines to the checked route with identical errors", async () => { // `JSON.parse(text) as T` for plain record/array targets parses in one // pass and reruns the checked-dynamic route whenever it declines, so - // syntax, depth, and checked-cast failures keep their exact messages. + // syntax and depth failures keep their exact messages, and a value the + // check rejects continues as its parsed value (checked-cast fallback). const r = await compileAndRun( "schema-parse-errors", `interface Item { sku: string; qty: number } @@ -197,9 +198,9 @@ for (const text of inputs) { 'SyntaxError: Unexpected token \']\', ...","x":[1,],"qty":"... is not valid JSON (same)', "SyntaxError: Bad Unicode escape in JSON at position 21 (same)", "RangeError: Maximum call stack size exceeded (same)", - "TypeError: expected number at $[0].qty, got undefined (plain ok)", - "TypeError: expected number at $[1].qty, got boolean (plain ok)", - "TypeError: expected number at $[0].qty, got string (plain ok)", + "ok 1 undefined (plain ok)", + "ok 2 1 (plain ok)", + "ok 1 x (plain ok)", "ok 1 2 (plain ok)", "", ]); @@ -211,7 +212,9 @@ for (const text of inputs) { // string arrays; null for object and number; missing fields; object vs // array at a field and at the root; duplicate keys; truncated JSON; 1e400 // and a lone surrogate; extra nested members; reordered keys; and an Item[] - // root. Every line equals the perf/harness (checked-route-only) output. + // root. A value the check rejects continues as its parsed value, so every + // line but the truncated input's (whose SyntaxError text is approximate) + // equals Node's. const r = await compileAndRun( "schema-parse-wrong-types", `interface Item { sku: string; qty: number; ok: boolean } @@ -255,23 +258,23 @@ try { expect(r.stderr).toBe(""); expect(r.stdout.split("\n")).toEqual([ "ok 1 a 1 x:2:true t", - "err TypeError: expected number at $.id, got string", - "err TypeError: expected string at $.name, got number", - "err TypeError: expected number at $.items[0].qty, got string", - "err TypeError: expected boolean at $.items[0].ok, got undefined", - "err TypeError: expected boolean at $.items[0].ok, got number", - "err TypeError: expected array at $.items, got object", - "err TypeError: expected object at $.items[0], got null", - "err TypeError: expected string at $.tags[0], got number", + "ok 1 a 0 ", + "ok 1 2 0 ", + "ok 1 a 1 x:2:true ", + "ok 1 a 1 x:2:undefined ", + "ok 1 a 1 x:2:1 ", + "err TypeError: o.items.map is not a function", + "err TypeError: Cannot read properties of null (reading 'sku')", + "ok 1 a 0 1", "ok 1 a 0 ", "ok 2 a 0 ", - "err TypeError: expected number at $.id, got undefined", - "err TypeError: expected number at $.id, got null", - "err TypeError: expected object at $, got array", + "ok undefined a 0 ", + "ok null a 0 ", + "err TypeError: Cannot read properties of undefined (reading 'length')", "err SyntaxError: Unexpected end of JSON input", "ok Infinity \uFFFD 0 ", "ok 7 reordered 0 ", - "err expected number at $[1].qty, got string", + "ok 2", "", ]); }); @@ -468,7 +471,60 @@ console.log("unreachable", t[1]); expect(r.stderr).toContain("Uncaught TypeError: expected array at $, got object"); }); + test("checked-cast fallback: a failed value leaving its region is checked where it leaves", async () => { + // A block-local `JSON.parse(text) as T` whose value fails validation + // continues as its parsed value (checked-cast fallback): reads in the + // binding's region behave as in Node. Where the value flows into a typed + // slot outside that region (a typed parameter, a typed outer binding, a + // typed return), it is checked there: a value that fits continues, one + // that does not throws that conversion's TypeError at that use, where + // Node continues. Module-level bindings keep the check at the cast. + const r = await compileAndRun( + "fallback-escapes", + `interface Order { id: string; deployment: { id: string; plan: string } } +function deliver(o: Order): string { return o.deployment.id + "/" + o.deployment.plan; } +function plan(text: string): string { + const order = JSON.parse(text) as Order; + console.log("read", order.deployment?.id, order.deployment?.plan); + const id: string = order.id; + return id; +} +function handle(text: string): void { + const order = JSON.parse(text) as Order; + console.log("id", order.id); + try { console.log(deliver(order)); } catch (e) { console.log((e as Error).message); } +} +handle('{"id":"a","deployment":{"id":"d","plan":"pro"},"extra":1}'); +handle('{"id":"b","deployment":{"id":"d"}}'); +console.log(plan('{"id":"c","deployment":{}}')); +try { console.log(plan('{"id":7,"deployment":{"id":"d","plan":"p"}}')); } +catch (e) { console.log((e as Error).message); } +let top: Order; +try { top = JSON.parse('{"id":"t"}') as Order; console.log(top.id); } +catch (e) { console.log((e as Error).message); } +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + [ + "id a", + "d/pro", + "id b", + "expected string at $.deployment.plan, got undefined", + "read undefined undefined", + "c", + "read d p", + "expected string at $, got number", + "expected object at $.deployment, got undefined", + "", + ].join("\n"), + ); + }); + test("tuple failure-path RC stress: partial tuples release on unwind", async () => { + // The rejected value continues as its parsed value (checked-cast + // fallback); the partial tuple the check built still releases. const r = await compileAndRun( "tuple-rc-failure", `let recovered = 0; @@ -482,14 +538,18 @@ for (let i = 0; i < 100; i = i + 1) { } console.log(recovered); `, + "ts", + true, ); expect(r.exitCode).toBe(0); - expect(r.stdout).toBe("100\n"); + expect(r.stdout).toBe("-200\n"); }); test("failure-path RC stress: repeated mismatches through catch stay clean", async () => { // Meaningful mostly under SCRIPTC_SAN=1 (ASan + RC audit at exit): every - // iteration builds a partial record/array that must release on unwind. + // iteration builds a partial record/array that must release when the + // check rejects it, and the parsed value continues (checked-cast + // fallback), so `e2.v` reads "no" and the count becomes NaN as in Node. const r = await compileAndRun( "rc-failure-stress", `type Entry = { tags: string[]; v: number }; @@ -510,9 +570,11 @@ for (let i = 0; i < 100; i = i + 1) { } console.log(recovered); `, + "ts", + true, ); expect(r.exitCode).toBe(0); - expect(r.stdout).toBe("200\n"); + expect(r.stdout).toBe("NaN\n"); }); test("index-signature shapes CAPTURE extras — and a wrong-typed extra throws with its key path", async () => { @@ -685,13 +747,19 @@ console.log(total); // The static-any lane's exit stance (the checked cast's rule applied // to implicit any→typed flows): Node assigns the mismatched value and // carries on; scriptc throws the catchable TypeError instead of - // returning a wrong result — the divergence is deliberate and documented. + // returning a wrong result where the value leaves the binding it was + // checked into. A block-local binding read only after the check keeps + // the value instead (checked-cast fallback), as Node does. const r = await compileAndRun( "any-lying-exit", `const a: any = "not a number"; +function width(n: number): number { + return n * 2; +} try { const n: number = a; - console.log("unreachable", n); + console.log("kept", n, typeof n); + console.log(width(n)); } catch (e) { console.log((e as Error).name); } @@ -699,7 +767,7 @@ console.log("recovered"); `, ); expect(r.exitCode).toBe(0); - expect(r.stdout).toBe("TypeError\nrecovered\n"); + expect(r.stdout).toBe("kept not a number string\nTypeError\nrecovered\n"); }); test("a lying cast into a union that cannot represent the value throws the stranded trap catchably", async () => { From 8441f452d723728e2e0e49334b4863bbc073e8e7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 18:10:51 -0500 Subject: [PATCH 2/2] Bound the tail copies of sequential checked casts Each armed site lowers the code after it twice, and a cast inside another site's tail is lowered in both copies, so n sequential casts produced 2^n copies of the code after the last one. A site now arms only while fewer than two splits enclose its tail or share its split point; later casts keep the throwing cast. --- .../src/frontend/lowering/checked-fallback.ts | 26 +++++++++++++------ .../compiler/src/frontend/lowering/lowerer.ts | 4 ++- .../test/checked-cast-fallback.test.ts | 23 ++++++++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/checked-fallback.ts b/packages/compiler/src/frontend/lowering/checked-fallback.ts index 13d0abc368..b1d746c1de 100644 --- a/packages/compiler/src/frontend/lowering/checked-fallback.ts +++ b/packages/compiler/src/frontend/lowering/checked-fallback.ts @@ -75,8 +75,10 @@ export interface FallbackTaint { * twice. */ const MAX_TAIL_NODES = 20000; -/** At most this many sites share a split point (2^n lowerings). */ -const MAX_SHARED_SITES = 2; +/** A site arms only while fewer than this many splits enclose its tail + * or share its split point: each one lowers the tail again, so a tail is + * lowered at most 2^n times. */ +const MAX_NESTED_SPLITS = 2; type StatementList = { statements: { nodes: readonly ts.Statement[] } }; @@ -485,12 +487,13 @@ function armSite(lowerer: Lowerer, site: FallbackSite, lowered: IrStmt[], id: st ); }); if (!whole) return false; - // Sites sharing a split point multiply the lowerings. - let shared = 0; + // Splits around this site (sequential casts each split the code after + // them) and sites sharing a split point multiply the lowerings. + let nested = lowerer.fallbackSplitDepth; for (const other of lowerer.fallbackArmed) { - for (const a of other.anchors) if (site.anchors.has(a)) shared++; + for (const a of other.anchors) if (site.anchors.has(a)) nested++; } - if (shared >= MAX_SHARED_SITES) return false; + if (nested >= MAX_NESTED_SPLITS) return false; check.fallback = true; const loc = check.loc; lowered.splice( @@ -547,8 +550,15 @@ export function lowerFallbackTail( if (rest.length === 0) return []; const [site, ...others] = sites as [FallbackSite, ...FallbackSite[]]; const at = locOf(rest.length > 0 ? rest[0]! : site.boundaryStmt); - const statics = scoped(lowerer, () => lowerFallbackTail(lowerer, rest, others)); - const dynamic = lowerDynamicTail(lowerer, rest, site, others); + let statics: IrStmt[]; + let dynamic: IrStmt[]; + lowerer.fallbackSplitDepth++; + try { + statics = scoped(lowerer, () => lowerFallbackTail(lowerer, rest, others)); + dynamic = lowerDynamicTail(lowerer, rest, site, others); + } finally { + lowerer.fallbackSplitDepth--; + } return [ { kind: "if", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 6b7665bdc3..523e528214 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2615,10 +2615,12 @@ export class Lowerer { suppressStats = false; /** Checked-cast fallback (checked-fallback.ts): sites opened by their * root's declaration, by root; armed sites whose tails have not split - * yet; and the taint of the dynamic tail being lowered. */ + * yet; the taint of the dynamic tail being lowered; and the number of + * splits whose tails are being lowered (each lowers its tail twice). */ fallbackSites = new Map(); fallbackArmed: FallbackSite[] = []; fallbackTaint: FallbackTaint | null = null; + fallbackSplitDepth = 0; /** Comparisons interned by operand representations and equality semantics. */ readonly equalityHelpers = new Map(); /** Synthetic array-HOF loop functions (map/filter/forEach desugar), diff --git a/packages/compiler/test/checked-cast-fallback.test.ts b/packages/compiler/test/checked-cast-fallback.test.ts index 6f5e7e1b19..1eda2a7afe 100644 --- a/packages/compiler/test/checked-cast-fallback.test.ts +++ b/packages/compiler/test/checked-cast-fallback.test.ts @@ -147,3 +147,26 @@ console.log(before('{"x":1}'), rewritten('{"x":1}'), hoisted('{"x":1}')); } expect(mod.functions.flatMap(checks).some((c) => c.fallback)).toBe(false); }); + +test("sequential casts split the code after them at most twice", async () => { + const source = ` +interface P { x: number } +function sum(a: string, b: string, c: string, d: string): number { + const p = JSON.parse(a) as P; + const q = JSON.parse(b) as P; + const r = JSON.parse(c) as P; + const s = JSON.parse(d) as P; + return p.x + q.x + r.x + s.x; +} +console.log(sum('{"x":1}', '{"x":2}', '{"x":3}', '{"x":4}')); +`; + const fn = fnNamed(await lowerSource(source), "sum"); + // p splits the code after it, and q splits each copy of that code; r and + // s keep the throwing cast instead of doubling the copies again. + expect(splits(fn)).toHaveLength(3); + const binding = (c: { loc: { start: number } }) => + /const (\w) = [^\n]*$/.exec(source.slice(0, c.loc.start))?.[1]; + const casts = checks(fn); + expect(new Set(casts.filter((c) => c.fallback).map(binding))).toEqual(new Set(["p", "q"])); + expect(new Set(casts.filter((c) => !c.fallback).map(binding))).toEqual(new Set(["r", "s"])); +});