From e52688095d4b62e6747bcec58a9dd4d908ce8ded Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:46:53 -0700 Subject: [PATCH 1/4] Convert missing number arguments with ToNumber String.fromCharCode, String.fromCodePoint, the global isNaN and isFinite, DataView offsets and values, and typed-array slice/subarray bounds took a `number | undefined` value (an out-of-range read the checker types as number) through a checked narrow, which threw "undefined is not representable" or refused to compile. Node converts undefined with ToNumber: code unit 0, a RangeError for fromCodePoint, isNaN true, offset 0; an undefined slice end is the length. The Math argument conversion becomes a shared helper that also handles field reads and any other value lowered as a number-or-undefined union. --- .../src/frontend/lowering/builtins/strings.ts | 5 +- .../src/frontend/lowering/containers/bytes.ts | 21 +++- .../src/frontend/lowering/lower-calls.ts | 16 +++- .../src/frontend/lowering/lower-exprs.ts | 27 ++++++ .../src/frontend/lowering/lower-island.ts | 27 +----- .../test/ts7/baselines/order-parity.json | 6 ++ .../corpus/missing-read-number-conversions.ts | 95 +++++++++++++++++++ 7 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 tests/corpus/missing-read-number-conversions.ts diff --git a/packages/compiler/src/frontend/lowering/builtins/strings.ts b/packages/compiler/src/frontend/lowering/builtins/strings.ts index 57e650feb..b117c24ff 100644 --- a/packages/compiler/src/frontend/lowering/builtins/strings.ts +++ b/packages/compiler/src/frontend/lowering/builtins/strings.ts @@ -3,6 +3,7 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { lowerOptionalArgument, lowerStringSearchArgument } from "../optional-arguments.js"; +import { lowerToNumberArgument } from "../lower-exprs.js"; import { F64, type IrExpr, STRING, arrayOf, isUnitType, typeEquals } from "../../../ir/ir.js"; /** `String.fromCharCode/fromCodePoint(...codes)` on THE String global: every argument @@ -97,7 +98,9 @@ export function lowerStringStaticCall( const packed = lowerer.lowerExprExpecting(spread.expression, arrayOf(F64)); return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc }; } - const elems = call.arguments.map((a) => lowerer.lowerExprExpecting(a, F64)); + // Each code converts with ToNumber: a missing value is NaN (code unit 0 + // for fromCharCode, a RangeError for fromCodePoint), exactly Node. + const elems = call.arguments.map((a) => lowerToNumberArgument(lowerer, a)); const packed: IrExpr = { kind: "arrayLit", elems, type: arrayOf(F64), loc }; return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc }; } diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 5b8285f12..255165530 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -32,6 +32,7 @@ import { lowerStaticallyUndefinedArgument, } from "../optional-arguments.js"; import { lowerCheckedArrayFrom } from "./array-construction.js"; +import { lowerToNumberArgument } from "../lower-exprs.js"; /** Uint8Array.prototype.toSorted. The receiver/comparator expressions are * evaluated before entering the helper; the helper snapshots with @@ -486,7 +487,16 @@ export function lowerBytesMethodCall( lowerer.noLowering(`.${name} with ${nArgs} arguments on typed arrays`, call); } const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); - const args = call.arguments.map((a) => lowerer.lowerExprExpecting(a, F64)); + // A missing start is 0 and a missing end is the length (+Infinity + // clamps to it), as for omitted arguments. + const args = call.arguments.map((a, i) => + lowerOptionalArgument(lowerer, a, F64, { + kind: "numLit", + value: i === 0 ? 0 : Infinity, + type: F64, + loc, + }), + ); // subarray is a VIEW (TypedArray.prototype.subarray aliases), and // Buffer's slice() is subarray's deprecated Node alias — resolved by // where the member is declared, the toString discipline below. Only @@ -796,7 +806,8 @@ export function lowerBytesMethodCall( lowerer.noLowering(`.${name} with ${nArgs} arguments`, call); } const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); - const args = [lowerer.lowerExprExpecting(call.arguments[0]!, F64)]; + // ToIndex(undefined) is ToIndex(NaN): a missing offset reads offset 0. + const args = [lowerToNumberArgument(lowerer, call.arguments[0]!)]; if (nArgs === 2) args.push(lowerer.lowerExprExpecting(call.arguments[1]!, BOOL)); return { kind: "bytesIntrinsic", method: dvGetter.method, receiver, args, type: F64, loc }; } @@ -829,9 +840,11 @@ export function lowerBytesMethodCall( lowerer.noLowering(`.${name} with ${nArgs} arguments`, call); } const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); + // The offset (ToIndex) and the value (ToNumber) treat a missing value + // as NaN. const args = [ - lowerer.lowerExprExpecting(call.arguments[0]!, F64), - lowerer.lowerExprExpecting(call.arguments[1]!, F64), + lowerToNumberArgument(lowerer, call.arguments[0]!), + lowerToNumberArgument(lowerer, call.arguments[1]!), ]; if (nArgs === 3) args.push(lowerer.lowerExprExpecting(call.arguments[2]!, BOOL)); return { kind: "bytesIntrinsic", method: dvSetter.method, receiver, args, type: VOID, loc }; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 6335a317e..7db085f22 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -107,8 +107,10 @@ import { lowerProcStreamMethodCall } from "./builtins/process.js"; import { lowerReflectCall } from "./builtins/reflection.js"; import { lowerAbsenceProbe, + lowerOptionalNumber, lowerPromiseAllTupleCall, lowerPromiseRejectCall, + lowerToNumberArgument, stringWrapperToString, symbolFieldInfo, templateRawTextOf, @@ -9520,7 +9522,8 @@ export function lowerStaticNumberGlobal( ); } if (name === "isNaN") { - const x = lowerer.lowerExprExpecting(args[0]!, F64); + // The global's ToNumber turns a missing value into NaN. + const x = lowerToNumberArgument(lowerer, args[0]!); return { kind: "libCall", fn: "num.isNaN", args: [x], type: BOOL, loc }; } const radix: IrExpr = args[1] @@ -9650,6 +9653,17 @@ export function lowerStaticNumberGlobal( if (name === "isFinite" && probed?.type.kind === "f64") { return { kind: "libCall", fn: "number.isFinite", args: [probed], type: BOOL, loc }; } + // A number the checker trusts but storage may hold missing (an + // out-of-range read): the global's ToNumber makes undefined NaN. + if ( + name === "isFinite" && + probed?.type.kind === "union" && + lowerer.armTag(probed.type.unionId, UNDEFINED_T) >= 0 && + lowerer.stripUndefinedArm(probed.type).kind === "f64" + ) { + const x = lowerOptionalNumber(lowerer, probed, loc, args[0]); + return { kind: "libCall", fn: "number.isFinite", args: [x], type: BOOL, loc }; + } } return null; } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 677b29e67..8e3dab778 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -6487,6 +6487,33 @@ export function lowerOptionalNumber( : { kind: "seqExpr", stmts: prefix, result: value, type: F64, loc }; } +/** A number argument of a builtin that converts it with ToNumber (Math + * functions, String.fromCharCode, the global isNaN/isFinite, DataView + * offsets and values). ToNumber(undefined) is NaN, so a missing value — an + * out-of-range read, a local or field holding one, or a hole — converts to + * NaN instead of trusting the checker's number type. */ +export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): IrExpr { + let inner = node; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + const loc = locOf(inner); + const isOptionalNumber = (value: IrExpr): boolean => + value.type.kind === "union" && + lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 && + lowerer.stripUndefinedArm(value.type).kind === "f64"; + const optional = ts.isIdentifier(inner) + ? runtimeOptionalStorageOperand(lowerer, inner) + : ts.isElementAccessExpression(inner) + ? lowerAbsenceProbe(lowerer, inner) + : null; + if (optional && isOptionalNumber(optional)) + return lowerOptionalNumber(lowerer, optional, loc, inner); + // A conditional builds each branch for the destination. + if (ts.isConditionalExpression(inner)) return lowerer.lowerExprExpecting(node, F64); + const value = lowerer.lowerExpr(node); + if (isOptionalNumber(value)) return lowerOptionalNumber(lowerer, value, loc, inner); + return lowerer.coerceInto(node, value, F64); +} + /** `a[i]` reads. Only f64 indices into array receivers are modeled; JS * string-key element access (`a["length"]`) and string indexing (`s[0]` * typechecks against the lib's index signature; use .charAt) stay out. */ diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 5f9301c76..88c2d34df 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -5,11 +5,7 @@ import { InternalCompilerError } from "../../errors.js"; * surface (Math and number/string methods under --dynamic), and the npm * package boundary fences for node_modules-declared symbols. */ import * as ts from "../ts7/adapter.js"; -import { - lowerAbsenceProbe, - lowerOptionalNumber, - runtimeOptionalStorageOperand, -} from "./lower-exprs.js"; +import { lowerToNumberArgument } from "./lower-exprs.js"; import type { Lowerer } from "./lowerer.js"; import { arrayOf, @@ -3401,23 +3397,10 @@ export function lowerIslandMethodCall( // Static numeric Math calls precede the island path. The scalar methods // use their declared arity; min/max and hypot accept their variadic forms. const staticMath = isMath ? own(STATIC_MATH_FNS, name) : undefined; - // A missing element (a hole bound by a loop, a local holding a missing - // read, or the read itself) converts to NaN, ToNumber's answer for - // undefined, instead of trusting the checker's number type. - const mathArg = (a: ts.Expression): IrExpr => { - let inner = a; - while (ts.isParenthesizedExpression(inner)) inner = inner.expression; - const optional = ts.isIdentifier(inner) - ? runtimeOptionalStorageOperand(lowerer, inner) - : ts.isElementAccessExpression(inner) - ? lowerAbsenceProbe(lowerer, inner) - : null; - return optional && - optional.type.kind === "union" && - lowerer.stripUndefinedArm(optional.type).kind === "f64" - ? lowerOptionalNumber(lowerer, optional, loc, inner) - : lowerer.lowerExprExpecting(a, F64); - }; + // A missing element (a hole bound by a loop, a local or field holding a + // missing read, or the read itself) converts to NaN, ToNumber's answer + // for undefined, instead of trusting the checker's number type. + const mathArg = (a: ts.Expression): IrExpr => lowerToNumberArgument(lowerer, a); if (staticMath && name === "hypot") { const elems = call.arguments.map((a) => ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : mathArg(a), diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1c7788be6..c46f5d49e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,6 +10493,12 @@ ], "diags": [] }, + "/tests/corpus/missing-read-number-conversions.ts": { + "order": [ + "/tests/corpus/missing-read-number-conversions.ts" + ], + "diags": [] + }, "/tests/corpus/module-fallback-functions/main.js": { "order": [ "/tests/corpus/module-fallback-functions/platform.js", diff --git a/tests/corpus/missing-read-number-conversions.ts b/tests/corpus/missing-read-number-conversions.ts new file mode 100644 index 000000000..abbe50879 --- /dev/null +++ b/tests/corpus/missing-read-number-conversions.ts @@ -0,0 +1,95 @@ +// Builtins that convert a number argument with ToNumber see a missing +// read (an out-of-range typed-array or array read the checker types as +// `number`) as NaN, ToNumber's answer for undefined: String.fromCharCode +// yields code unit 0, fromCodePoint throws a RangeError, Math answers NaN, +// isNaN is true. The missing value reaches them from a module binding, a +// local, a class field and a record field. +const base: number = process.argv.length; +const chars = new Uint16Array(4); +chars[0] = 108; +chars[1] = 66; +const nums: number[] = [7]; +const far = base + 100; + +const missing = chars[far]; +const present = chars[1]; +const missingNum = nums[far]; + +function show(label: string, value: unknown): void { + console.log(label, typeof value === "string" ? JSON.stringify(value) : value); +} + +function attempt(label: string, run: () => unknown): void { + try { + show(label, run()); + } catch (e) { + console.log(label, "threw", (e as Error).name, (e as Error).message); + } +} + +// String.fromCharCode / fromCodePoint. +show("fcc module", String.fromCharCode(chars[0], missing)); +show("fcc single", String.fromCharCode(missing)); +show("fcc present", String.fromCharCode(chars[0], present)); +show("fcc array", String.fromCharCode(missingNum, 65)); +attempt("fcp module", () => String.fromCodePoint(missing)); +show("fcp present", String.fromCodePoint(present)); + +// Math functions. +show("abs", Math.abs(missing)); +show("floor", Math.floor(missingNum)); +show("max", Math.max(missing, 1)); +show("min3", Math.min(1, 2, missing)); +show("imul", Math.imul(missing, 3)); +show("pow", Math.pow(present, 2)); +show("hypot", Math.hypot(missing, 3)); + +// The global number predicates convert; the Number statics do not. +show("isNaN", isNaN(missing)); +show("isNaN present", isNaN(present)); +show("isFinite", isFinite(missing)); +show("isFinite present", isFinite(present)); +show("Number.isNaN", Number.isNaN(missing)); +show("Number.isFinite", Number.isFinite(missing)); + +// DataView offsets (ToIndex) and values (ToNumber). +const view = new DataView(new ArrayBuffer(4)); +view.setUint8(1, 9); +show("dv get", view.getUint8(missing)); +view.setUint8(missing, 5); +show("dv set offset", view.getUint8(0)); +view.setUint8(2, missing); +show("dv set value", view.getUint8(2)); + +// Typed-array ranges: a missing start is 0, a missing end is the length. +show("slice start", chars.slice(missing).length); +show("slice end", chars.slice(1, missing).length); +show("subarray start", chars.subarray(missing).length); +show("subarray end", chars.subarray(2, missing).length); + +// The same reads held in a local, a class field and a record field. +function local(i: number, label: string): void { + let code: number | undefined = 0; + code = chars[i]; + show(`local fcc ${label}`, String.fromCharCode(97, code)); + show(`local max ${label}`, Math.max(code, 1)); + show(`local isNaN ${label}`, isNaN(code)); +} +local(far, "missing"); +local(1, "present"); + +class Cursor { + code: number | undefined = 0; +} +const cursor = new Cursor(); +cursor.code = chars[far]; +show("field fcc", String.fromCharCode(97, cursor.code)); +show("field abs", Math.abs(cursor.code)); +cursor.code = chars[1]; +show("field fcc present", String.fromCharCode(97, cursor.code)); + +const record: { code: number | undefined } = { code: 0 }; +record.code = chars[far]; +show("record fcc", String.fromCharCode(97, record.code)); +show("record max", Math.max(record.code, 1)); +show("record isNaN", isNaN(record.code)); From 0027fa32265c5427833772b180e841dc91c0216c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:47:12 -0700 Subject: [PATCH 2/4] Keep undefined in narrowed optional fields and locals After `obj.field = bytes[i]` on a `number | undefined` field, TypeScript narrows later reads to number, so the read extracted the number arm unchecked and an out-of-range read printed 0 instead of undefined. Locals declared `T | undefined` had the same problem inside functions (`let x: number | undefined; x = xs[i]; x + 1` gave 1, not NaN). The optional-read analysis now records instance fields declared exactly `T | undefined` that receive a possibly missing value; their narrowed reads keep the stored union, as runtime-optional record fields already do, and the value propagates into parameters and returns like other optional reads. Locals declared that way join the runtime-optional storage tracking, so bare reads keep the union until a guard proves presence. A member read through such a field throws Node's "Cannot read properties of undefined" TypeError. --- .../src/frontend/lowering/lower-exprs.ts | 31 +++++++ .../compiler/src/frontend/lowering/lowerer.ts | 46 +++++++++- .../test/ts7/baselines/order-parity.json | 6 ++ tests/corpus/missing-read-narrowed-storage.ts | 88 +++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 tests/corpus/missing-read-narrowed-storage.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8e3dab778..2bae9c82e 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2595,6 +2595,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { lowerer.isRuntimeOptionalField(handled.shapeId, handled.field) ) return handled; + // A class field assigned a missing read: the checker narrows this + // read to the assigned value's bare type, but the stored undefined + // arm is real (`obj.n = bytes[i]; obj.n` is undefined in Node). + if (runtimeOptionalClassField(lowerer, handled, expr)) return handled; return lowerer.maybeNarrow(handled, expr); } // `globalThis.` that no lowering above claimed @@ -7622,6 +7626,24 @@ export function runtimeOptionalStorageOperand( return optional.value; } +/** A `T | undefined` class field read that the checker narrowed to `T` + * where the field was assigned a value that may be missing (an unchecked + * indexed read). TypeScript's assignment narrowing is not a runtime fact + * there, so the read keeps the stored union instead of extracting `T`. */ +function runtimeOptionalClassField( + lowerer: Lowerer, + value: IrExpr, + node: ts.PropertyAccessExpression, +): boolean { + if (value.kind !== "fieldGet" || value.type.kind !== "union") return false; + if (lowerer.armTag(value.type.unionId, UNDEFINED_T) < 0) return false; + if ((lowerer.unions.get(value.type.unionId)?.arms.length ?? 0) !== 2) return false; + const symbol = lowerer.checker.getSymbolAtLocation(node.name); + if (!symbol || !lowerer.runtimeOptionalAssignedSymbols.has(symbol)) return false; + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); + return narrowed !== null && typeEquals(narrowed, lowerer.stripUndefinedArm(value.type)); +} + /** A presence-test operand: an absence-aware element or field read, an * identifier's runtime-optional storage, or the ordinary lowering. */ export function lowerPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrExpr { @@ -14407,6 +14429,15 @@ function classFieldTarget( const lowerObjectReceiver = (): IrExpr => { let obj = represented; if (obj.type.kind === "union") { + // A receiver that may hold a missing value (a field assigned an + // unchecked read): Node's member-read TypeError for the unit arm. + const present = lowerer.runtimeOptionalPropertyReceiver( + receiverNode, + obj, + receiverType, + fieldName, + ); + if (present) return present; const helper = lowerer.narrowedArmHelper(obj.type.unionId, receiverType, locOf(receiverNode)); obj = helper ? lowerer.checkedNarrowCall(helper, obj, receiverType, locOf(receiverNode)) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 9cea42ed9..c777d3ab7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4223,6 +4223,23 @@ export class Lowerer { if (!symbol) return false; return noteFieldSymbol(symbol, name); }; + /** An instance field of a TypeScript class whose declared storage is + * exactly `T | undefined`: an assignment from a missing read stores the + * undefined arm while the checker narrows later reads to `T`. */ + const optionalInstanceField = (symbol: ts.Symbol): boolean => { + const decl = this.checker.valueDeclarationOf(symbol); + if (!decl || !ts.isPropertyDeclaration(decl) || !ts.isIdentifier(decl.name)) return false; + if (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Static) return false; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return false; + const declared = typeOrNull(decl.name); + const mapped = declared ? this.mapTypeOf(declared) : null; + if (mapped?.kind !== "union" || this.armTag(mapped.unionId, UNDEFINED_T) < 0) return false; + const arms = this.unions.get(mapped.unionId)?.arms ?? []; + return ( + arms.length === 2 && arms.every((arm) => arm.kind === "undefinedT" || !isUnitType(arm)) + ); + }; const noteFieldSymbol = (symbol: ts.Symbol, name: string): boolean => { const set = optionalFields.get(symbol) ?? new Set(); const before = set.size; @@ -5049,6 +5066,13 @@ export class Lowerer { optionalSymbols.add(field); provenance?.note("static-field", node.left, node.right); changed = true; + } else if (field && !optionalSymbols.has(field) && optionalInstanceField(field)) { + // The checker narrows the field to the assigned read's bare + // type, but the stored value can be undefined: reads keep the + // stored union (runtimeOptionalClassField). + optionalSymbols.add(field); + provenance?.note("assigned-field", node.left, node.right); + changed = true; } else if (ts.isIdentifier(node.left.expression)) { const symbol = symbolOf(node.left.expression); if (symbol && noteFieldSymbol(symbol, node.left.name.text)) { @@ -9591,14 +9615,32 @@ export class Lowerer { /* ── scoping and captures ─────────────────────────────────────────── */ declareLocal(nameNode: ts.Node, name: string, type: IrType, mutable: boolean): IrLocal { - return declareContextLocal( + const symbol = this.checker.getSymbolAtLocation(nameNode); + const local = declareContextLocal( this.ctx, name, type, mutable, - this.checker.getSymbolAtLocation(nameNode), + symbol, bindingSource(nameNode), ); + // A binding declared `T | undefined` that receives a missing read: the + // checker narrows it to `T` after the assignment, but the stored value + // can be undefined. Track it like widened storage so bare reads keep + // the union until a guard proves presence. + if ( + symbol && + this.runtimeOptionalAssignedSymbols.has(symbol) && + type.kind === "union" && + this.armTag(type.unionId, UNDEFINED_T) >= 0 && + this.unions.get(type.unionId)?.arms.length === 2 && + !isUnitType(this.stripUndefinedArm(type)) + ) { + const root = this.runtimeOptionalRootOf(local); + this.runtimeOptionalStorageLocals.add(root); + this.runtimeOptionalLocals.add(root); + } + return local; } /** A never-returning call in a value position of type `expected`: the diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index c46f5d49e..b95c5567e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,6 +10493,12 @@ ], "diags": [] }, + "/tests/corpus/missing-read-narrowed-storage.ts": { + "order": [ + "/tests/corpus/missing-read-narrowed-storage.ts" + ], + "diags": [] + }, "/tests/corpus/missing-read-number-conversions.ts": { "order": [ "/tests/corpus/missing-read-number-conversions.ts" diff --git a/tests/corpus/missing-read-narrowed-storage.ts b/tests/corpus/missing-read-narrowed-storage.ts new file mode 100644 index 000000000..97be7214a --- /dev/null +++ b/tests/corpus/missing-read-narrowed-storage.ts @@ -0,0 +1,88 @@ +// A `T | undefined` slot assigned a missing read (an out-of-range +// typed-array or array read the checker types as `T`) holds undefined, +// even though TypeScript narrows later reads to `T`. Reads keep the stored +// value: printing shows undefined, arithmetic gives NaN, and a member read +// throws Node's TypeError. Class fields (through `obj.f` and `this.f`) and +// locals both follow it; a presence guard still narrows. +const base: number = process.argv.length; +const chars = new Uint16Array([5, 6]); +const nums: number[] = [7]; +const far = base + 100; + +function twice(n: number): number { + return n * 2; +} + +class Cursor { + token: number | undefined = 0; + peek(i: number): number { + return chars[i]; + } + advance(i: number): string { + this.token = chars[i]; + return `${this.token} ${this.token === undefined} ${this.token + 1}`; + } +} + +function fields(i: number, label: string): void { + const cursor = new Cursor(); + cursor.token = chars[i]; + console.log(label, "direct", cursor.token, cursor.token === undefined); + console.log(label, "ops", cursor.token + 1, -cursor.token, cursor.token < 3, `${cursor.token}`); + console.log(label, "defaults", cursor.token ?? "none", cursor.token || "falsy"); + console.log(label, "call", twice(cursor.token), [cursor.token]); + cursor.token = cursor.peek(i); + console.log(label, "method", cursor.token, typeof cursor.token); + cursor.token = nums[i]; + console.log(label, "array", cursor.token, String(cursor.token)); + console.log(label, "this", cursor.advance(i)); + if (cursor.token !== undefined) console.log(label, "guarded", cursor.token * 2); + else console.log(label, "guarded absent"); +} +fields(far, "missing"); +fields(0, "present"); + +function locals(i: number, label: string): void { + let code: number | undefined = 0; + code = chars[i]; + console.log(label, "local", code, code + 1, `${code}`, [code], twice(code)); + let late: number | undefined; + late = nums[i]; + console.log(label, "late", late, late === undefined, late ?? "none"); + let branch: number | undefined = 1; + if (base > 0) branch = chars[i]; + console.log(label, "branch", branch, typeof branch); + if (code !== undefined) console.log(label, "local guarded", code * 3); + const viaExpr = (code = chars[i]); + console.log(label, "assign expr", viaExpr, code); +} +locals(far, "missing"); +locals(1, "present"); + +class Node2 { + v: number; + constructor(v: number) { + this.v = v; + } +} +const pool: Node2[] = [new Node2(4)]; + +class Holder { + node: Node2 | undefined = undefined; +} + +function objects(i: number, label: string): void { + const holder = new Holder(); + holder.node = pool[i]; + console.log(label, "object field", holder.node === undefined); + try { + console.log(label, "object member", holder.node.v); + } catch (e) { + console.log(label, "object member threw", (e as Error).name, (e as Error).message); + } + let current: Node2 | undefined = new Node2(1); + current = pool[i]; + console.log(label, "object local", current === undefined); +} +objects(far, "missing"); +objects(0, "present"); From bf17c23af99f461444c15c1231f9876d3241f4bb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:47:17 -0700 Subject: [PATCH 3/4] Return defaulted and missing reads from TypeScript lambdas TypeScript types `read ?? "text"` as number when the read is typed number, and `() => xs[i]` as returning number. Declared functions already widened their returns for both (the primitive result union and the optional read), but arrow functions and function expressions kept the checker's return, so the closure narrowed its result and threw "a 'string' value is not representable" or "undefined is not representable" (the scanbox `[string, () => unknown][]` probe table). Unannotated TypeScript lambdas now get the same widened return as declared functions. A const or global binding of such a lambda keeps the checker's parameters and takes the widened result, and calls through the binding count as optional or primitive-union results for the enclosing function's return. --- .../src/frontend/lowering/lower-stmts.ts | 12 +++ .../compiler/src/frontend/lowering/lowerer.ts | 73 +++++++++++++++++- .../test/ts7/baselines/order-parity.json | 6 ++ tests/corpus/missing-read-lambda-results.ts | 74 +++++++++++++++++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/corpus/missing-read-lambda-results.ts diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ab34a1eb5..266a79d81 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4901,6 +4901,18 @@ export function lowerVarDecl( init.type.kind === "func" ) type = init.type; + // A TypeScript lambda whose closure returns the runtime result (a missing + // read, a `??` default of another type) instead of the checker's bare + // return type: the binding keeps the checker's parameters and takes the + // closure's result. + if ( + !decl.type && + type?.kind === "func" && + init.type.kind === "func" && + (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) && + lowerer.runtimeOptionalFunctionReturns.has(decl.initializer) + ) + type = { ...type, ret: init.type.ret }; if ( lowerer.implicitParamTypes !== null && init.type.kind === "dyn" && diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c777d3ab7..184ffc8a4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4370,12 +4370,29 @@ export class Lowerer { return found; }; const jsLambdas: ts.FunctionLikeDeclaration[] = []; + // TypeScript lambdas keep the checker's inferred return type, which + // spells a missing read as its bare element type and `read ?? "text"` + // as a number. Their closures return the runtime result instead. + const tsLambdas: ts.FunctionLikeDeclaration[] = []; for (const sf of sourceFiles) { - if (!isJsSourceFile(sf)) continue; + const js = isJsSourceFile(sf); + if (!js && sf.isDeclarationFile) continue; ts.walkPreorder(sf, (node) => { - if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) jsLambdas.push(node); + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) + (js ? jsLambdas : tsLambdas).push(node); }); } + /** The primitive result union of a lambda's returns (see + * optionalPrimitiveResultType), as declared functions record it. */ + const lambdaArithmeticReturn = (fn: ts.FunctionLikeDeclaration): IrType | null => { + if (fn.body && !ts.isBlock(fn.body)) + return optionalPrimitiveResultType(fn.body as ts.Expression); + for (const expression of returnsOf(fn)) { + const result = optionalPrimitiveResultType(expression); + if (result) return result; + } + return null; + }; for (const [symbol, signature] of signatureBySymbol) { const declaration = functionDeclBySymbol.get(symbol); if (!declaration || !isJsSourceFile(declaration.getSourceFile())) continue; @@ -5407,6 +5424,54 @@ export class Lowerer { for (const sf of sourceFiles) if (scanFile(sf)) changed = true; for (const [symbol, decl] of functionDeclBySymbol) if (scanReturns(symbol, decl)) changed = true; + for (const fn of tsLambdas) { + // An annotated return is the author's contract, as for parameters. + if (fn.type) continue; + const mapped = this.mapTypeOf(this.typeOf(fn)); + if (mapped?.kind !== "func") continue; + const previous = this.runtimeOptionalFunctionReturnType(fn, mapped.ret); + // Async, generator and void bodies keep their own result shapes. + if ( + previous.kind === "promise" || + previous.kind === "generator" || + previous.kind === "void" + ) + continue; + const arithmetic = lambdaArithmeticReturn(fn); + const next = + arithmetic && + (previous.kind === "f64" || previous.kind === "string" || previous.kind === "bool") + ? arithmetic + : bodyReturnsOptional(fn) + ? this.runtimeOptionalType(previous) + : previous; + if (!typeEquals(previous, next)) { + this.runtimeOptionalFunctionReturns.set(fn, next); + provenance?.note("lambda-return", fn, fn.body); + changed = true; + } + // Calls through the lambda's own binding (`const get = () => xs[i]; + // get()`) produce the same result as calls of a declared function. + const binding = fn.parent; + if ( + next !== previous && + ts.isVariableDeclaration(binding) && + binding.initializer === fn && + !binding.type && + ts.isIdentifier(binding.name) + ) { + const symbol = symbolOf(binding.name); + if (symbol && next.kind === "union" && this.armTag(next.unionId, UNDEFINED_T) >= 0) { + if (!optionalReturns.has(symbol)) { + optionalReturns.add(symbol); + changed = true; + } + } else if (symbol && !arithmeticReturns.has(symbol)) { + arithmeticReturns.set(symbol, next); + changed = true; + } + } + } for (const fn of jsLambdas) { if (!bodyReturnsOptional(fn)) continue; const mapped = this.mapTypeOf(this.typeOf(fn)); @@ -5517,11 +5582,11 @@ export class Lowerer { const symbol = symbolOf(node.name); if (!symbol) return; if ( - isJsSourceFile(sf) && !node.type && !hasJsTypeAnnotation(node) && node.initializer && - (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) && + (isJsSourceFile(sf) || this.runtimeOptionalFunctionReturns.has(node.initializer)) ) { const global = this.globalsBySymbol.get(symbol); if (global?.type.kind === "func") diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b95c5567e..9f0b46a52 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,6 +10493,12 @@ ], "diags": [] }, + "/tests/corpus/missing-read-lambda-results.ts": { + "order": [ + "/tests/corpus/missing-read-lambda-results.ts" + ], + "diags": [] + }, "/tests/corpus/missing-read-narrowed-storage.ts": { "order": [ "/tests/corpus/missing-read-narrowed-storage.ts" diff --git a/tests/corpus/missing-read-lambda-results.ts b/tests/corpus/missing-read-lambda-results.ts new file mode 100644 index 000000000..0d9f047d2 --- /dev/null +++ b/tests/corpus/missing-read-lambda-results.ts @@ -0,0 +1,74 @@ +// A missing read (an out-of-range typed-array or array read the checker +// types as `number`) defaulted with `??` or `||` to a string, or returned +// as is, gives a lambda a result its inferred return type does not spell: +// TypeScript types `read ?? "text"` and `() => xs[i]` as number. The +// closure returns the runtime value, through bindings, callbacks taking +// `unknown`, calls of the binding, and the enclosing function's return. +const base: number = process.argv.length; +const chars = new Uint16Array(4); +chars[0] = 108; +const nums: number[] = [7]; +const far = base + 100; +const a = chars[far]; +const c0 = chars[0]; + +// The scanbox probe table: every arrow is typed `() => number` by +// inference and stored in a `() => unknown` slot. +const probes: [string, () => unknown][] = [ + ["plus", () => a + 1], + ["or", () => a | 0], + ["max", () => Math.max(a, 1)], + ["fcc", () => JSON.stringify(String.fromCharCode(c0, a))], + ["and", () => c0 && 5], + ["or2", () => chars[far] || 7], + ["nullish", () => a ?? "dflt"], + ["nullish present", () => c0 ?? "dflt"], + ["tern", () => (a ? "yes" : "no")], + ["bang", () => !a], + ["concat", () => a + "s"], + ["missing", () => nums[far]], +]; +for (const [name, f] of probes) { + try { + console.log(name, f()); + } catch (e) { + console.log(name, "threw", (e as Error).message); + } +} + +function take(f: () => unknown): unknown { + return f(); +} +console.log("take nullish", take(() => a ?? "d1")); +console.log("take missing", take(() => nums[far])); + +const bound = () => a ?? "d2"; +console.log("bound", bound()); +const block = () => { + return a ?? "d3"; +}; +console.log("block", block()); +const expression = function () { + return a || "d4"; +}; +console.log("expression", expression()); +const read = () => nums[far]; +console.log("read", read(), read() === undefined); + +function viaBinding(i: number): number { + const get = () => nums[i]; + return get(); +} +console.log("via binding", viaBinding(far), viaBinding(0)); + +function declared() { + return a ?? "d5"; +} +console.log("declared", declared()); + +class Lexer { + peek(i: number) { + return chars[i] ?? "eof"; + } +} +console.log("method", new Lexer().peek(far), new Lexer().peek(0)); From cc7c6cb7302aefaf4cdf6d0304ada9b01421551c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:11:19 -0700 Subject: [PATCH 4/4] Decide inline truncation for the host when no triple is given The Node-hosted CLI emits with an empty target triple (the host), while the native CLI passes its toolchain's triple. Inline truncation keyed on the triple text alone, so on x86-64 Linux the Node seed emitted llvm.trunc and the native compiler emitted the inline sequence, and the native bootstrap's LLVM comparison with the Node seed failed (self-hosting-native-driver, rebuild-emit). An empty triple now means the host architecture, as it already does for executable TLS; WASI keeps the intrinsic. The DataView emission test accepts either truncation form, since it runs on hosts of both kinds. --- packages/compiler/src/backend/llvm/emitter.ts | 9 ++++++++- packages/compiler/test/byte-number-emission.test.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8ce49a03d..a15782b31 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -811,7 +811,14 @@ export class LlEmitter { this.ffiExtendNarrowIntegers = options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); this.wasi = options.wasi === true; - this.inlineTrunc = /^(x86_64|amd64)\b/i.test(options.targetTriple ?? ""); + // An empty triple targets the host, as for executableTls below: the + // Node-hosted CLI passes none, the native CLI passes its toolchain's, and + // both must emit the same module for the same target. + this.inlineTrunc = + !this.wasi && + (options.targetTriple + ? /^(x86_64|amd64)\b/i.test(options.targetTriple) + : process.arch === "x64"); this.executableTls = mod.workers === true && mod.lib === undefined && diff --git a/packages/compiler/test/byte-number-emission.test.ts b/packages/compiler/test/byte-number-emission.test.ts index 18c41814e..fc36fd23d 100644 --- a/packages/compiler/test/byte-number-emission.test.ts +++ b/packages/compiler/test/byte-number-emission.test.ts @@ -45,7 +45,8 @@ test("numeric byte pipelines retain checked fallbacks and use field widths on bo expect(wide).toContain("phi i64"); expect(wide).toContain("@llvm.bswap.i64"); const offsets = body(ll, "offsets"); - expect(offsets).toContain("@llvm.trunc.f64"); + // Truncation is the intrinsic, or inline on an x86-64 host (trunc.ts). + expect(offsets).toMatch(/@llvm\.trunc\.f64|@llvm\.copysign\.f64/); expect(offsets).toContain("@scr_dataview_set"); expect(offsets).toContain("@scr_dataview_get"); expect(offsets).toMatch(/icmp ule i(?:32|64)/);