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/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..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 @@ -6487,6 +6491,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. */ @@ -7595,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 { @@ -14380,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/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/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 9cea42ed9..184ffc8a4 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; @@ -4353,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; @@ -5049,6 +5083,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)) { @@ -5383,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)); @@ -5493,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") @@ -9591,14 +9680,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/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)/); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b99f04249..7fdd6d5c5 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -10493,6 +10493,24 @@ ], "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" + ], + "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-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)); 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"); 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));