Convert global isFinite's argument with ToNumber (#1662)

This commit is contained in:
Chris Tate
2026-10-21 16:37:03 -05:00
committed by GitHub
9 changed files with 337 additions and 75 deletions
@@ -524,8 +524,15 @@ export function emitOperatorExpr(
case "seqExpr": {
// Statements mid-expression: each emits in place (its own frame,
// exactly statement position); the result is an ordinary temp of
// the current frame. No jump can leave the expression's region.
for (const s of e.stmts) host.emitStmt(s);
// the current frame. No jump can leave the expression's region. A
// statement can still end the block (a call that always throws):
// what follows then emits in a block with no predecessors, so every
// value it reads is defined there, and LLVM deletes it.
for (const s of e.stmts) {
if (host.B.isTerminated()) host.B.startBlock(host.B.newLabel("seq.dead"));
host.emitStmt(s);
}
if (host.B.isTerminated()) host.B.startBlock(host.B.newLabel("seq.dead"));
return host.emitExpr(e.result);
}
default: {
@@ -3,7 +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 { lowerToNumberArguments } from "../lower-exprs.js";
import {
BOOL,
DYN,
@@ -115,11 +115,19 @@ export function lowerStringStaticCall(
const packed = lowerer.lowerExprExpecting(spread.expression, arrayOf(F64));
return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc };
}
// 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.nodes.map((a) => lowerToNumberArgument(lowerer, a));
// Each code converts with ToNumber after every argument is evaluated: a
// missing value is NaN (code unit 0 for fromCharCode, a RangeError for
// fromCodePoint), exactly Node.
const { stmts, values: elems } = lowerToNumberArguments(lowerer, call.arguments.nodes);
const packed: IrExpr = { kind: "arrayLit", elems, type: arrayOf(F64), loc };
return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc };
const result: IrExpr = {
kind: "libCall",
fn: `string.${member}`,
args: [packed],
type: STRING,
loc,
};
return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: STRING, loc };
}
/** `String.fromCharCode.apply(thisArg, codes)`, the btoa chunking idiom
@@ -166,7 +166,6 @@ import { lowerReflectCall } from "./builtins/reflection.js";
import {
lowerAbsenceProbe,
v8CalleeText,
lowerOptionalNumber,
lowerPromiseAllTupleCall,
lowerPromiseRejectCall,
lowerToNumberArgument,
@@ -1008,10 +1007,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
// excludes shadowed names. parseInt's omitted radix completes
// to 0 — the spec's "undefined" (base 10 with the 0x hex escape);
// parseFloat lowers the STRING form only (Node would ToString other
// values — no static story); isNaN/isFinite's arguments are
// checker-pinned (or checked) to number, where the global's ToNumber
// coercion is the identity and the tests are Number.isNaN /
// Number.isFinite exactly (ms's `isFinite(val)` guard).
// values — no static story); isNaN/isFinite apply the global's
// ToNumber to any argument, then test like Number.isNaN /
// Number.isFinite.
if (
(expr.expression.text === "parseInt" ||
expr.expression.text === "isNaN" ||
@@ -1130,18 +1128,14 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
}
if (islFn && expr.arguments.nodes.length === islFn.args.length) {
// The static forms above already took the exactly-typed argument
// (a string for parseFloat, a number for isFinite): name the
// argument type that refused the call, so the message states the
// condition rather than reading as if the global itself were
// unsupported.
// (a string for parseFloat): name the argument type that refused
// the call, so the message states the condition rather than
// reading as if the global itself were unsupported.
const argText = loweredArgumentText(lowerer, expr.arguments.nodes[0]!);
const exact = expr.expression.text === "isFinite" ? "number" : "string";
lowerer.refuseUnsupportedApi(
`'${expr.expression.text}' with a '${argText}' argument`,
expr,
exact === "string"
? "only a string argument compiles statically: convert the argument explicitly first (for example String(x), or narrow the union)"
: "only a number argument compiles statically: narrow the argument to a number first",
"only a string argument compiles statically: convert the argument explicitly first (for example String(x), or narrow the union)",
);
}
}
@@ -10314,7 +10308,7 @@ export function lowerStaticNumberGlobal(
args: readonly ts.Expression[],
loc: SrcLoc,
): IrExpr | null {
if (name === "parseInt" || name === "isNaN") {
if (name === "parseInt" || name === "isNaN" || name === "isFinite") {
const maxArgs = name === "parseInt" ? 2 : 1;
if (args.length < 1 || args.length > maxArgs) {
lowerer.noLowering(
@@ -10322,10 +10316,12 @@ export function lowerStaticNumberGlobal(
call,
);
}
if (name === "isNaN") {
// The global's ToNumber turns a missing value into NaN.
if (name === "isNaN" || name === "isFinite") {
// The global's ToNumber turns a missing value into NaN, converts
// strings, booleans and null, and calls valueOf/toString on objects.
const x = lowerToNumberArgument(lowerer, args[0]!);
return { kind: "libCall", fn: "num.isNaN", args: [x], type: BOOL, loc };
const fn = name === "isNaN" ? "num.isNaN" : "number.isFinite";
return { kind: "libCall", fn, args: [x], type: BOOL, loc };
}
const radix: IrExpr = args[1]
? lowerer.lowerExprExpecting(args[1], F64)
@@ -10461,32 +10457,28 @@ export function lowerStaticNumberGlobal(
: lowerer.lowerExprExpecting(args[0]!, STRING);
return { kind: "libCall", fn: "num.parseInt", args: [s, radix], type: F64, loc };
}
// STATIC parseFloat/isFinite over exactly-typed arguments —
// parseInt's siblings (num.parseFloat is ECMA 19.2.4's decimal-
// literal prefix parse in scr_string.c; a number-typed isFinite IS
// Number.isFinite — the global's ToNumber coercion is the identity
// there, ms's `isFinite(val)` guard). Other argument types retain
// their native coercion checks or the SC2012 refusal. The probe never
// emits — lowering is IR construction.
if ((name === "parseFloat" || name === "isFinite") && args.length === 1) {
if (name === "parseFloat") {
const optional = optionalCallValue(lowerer, args[0]!);
if (optional) {
const parsed = lowerOptionalStringNumber(
lowerer,
optional,
loc,
"num.parseFloat",
"parseFloat",
);
if (parsed) return parsed;
}
// STATIC parseFloat over string arguments — parseInt's sibling
// (num.parseFloat is ECMA 19.2.4's decimal-literal prefix parse in
// scr_string.c). Other argument types retain their native coercion
// checks or the SC2012 refusal. The probe never emits — lowering is IR
// construction.
if (name === "parseFloat" && args.length === 1) {
const optional = optionalCallValue(lowerer, args[0]!);
if (optional) {
const parsed = lowerOptionalStringNumber(
lowerer,
optional,
loc,
"num.parseFloat",
"parseFloat",
);
if (parsed) return parsed;
}
const probed = tryLowerExpression(lowerer, args[0]!);
if (name === "parseFloat" && probed?.type.kind === "string") {
if (probed?.type.kind === "string") {
return { kind: "libCall", fn: "num.parseFloat", args: [probed], type: F64, loc };
}
if (name === "parseFloat" && probed?.type.kind === "dyn") {
if (probed?.type.kind === "dyn") {
return {
kind: "libCall",
fn: "num.parseFloat",
@@ -10495,20 +10487,6 @@ export function lowerStaticNumberGlobal(
loc,
};
}
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;
}
@@ -1420,9 +1420,83 @@ export function lowerOptionalNumber(
* 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 {
const operand = numberOperand(lowerer, node);
return operand.convert ? operand.convert(operand.value) : operand.value;
}
/** The ToNumber arguments of one call (the Math functions,
* String.fromCharCode): Node evaluates every argument before it converts
* any. When an argument's conversion can run program code (valueOf or
* toString on a dynamic value) and a later argument follows, the arguments
* evaluate into temporaries first and convert in order after. `lowerOther`
* lowers an argument that takes no conversion (a spread's array), or returns
* null for a ToNumber argument; with temporaries, such an array is copied
* where it is evaluated, since JavaScript reads a spread's elements then,
* before a later conversion's valueOf could change them. Returns the
* temporaries' statements (empty when none are needed) and the converted
* values. */
export function lowerToNumberArguments(
lowerer: Lowerer,
nodes: readonly ts.Expression[],
lowerOther?: (node: ts.Expression) => IrExpr | null,
): { stmts: IrStmt[]; values: IrExpr[] } {
const operands = nodes.map((node): NumberOperand => {
const other = lowerOther?.(node);
return other
? { value: other, convert: null, runsCode: false, spread: true }
: numberOperand(lowerer, node);
});
const late = operands.slice(0, -1).some((operand) => operand.runsCode);
if (!late) {
const values = operands.map((o) => (o.convert ? o.convert(o.value) : o.value));
return { stmts: [], values };
}
const stmts: IrStmt[] = [];
const values = operands.map((operand) => {
if (operand.value.kind === "numLit") return operand.value;
const temp = lowerer.declareHiddenLocal("%numberArgument", operand.value.type);
const loc = operand.value.loc;
const init: IrExpr =
operand.spread && operand.value.type.kind === "array"
? {
kind: "arrIntrinsic",
method: "slice",
receiver: operand.value,
args: [numLit(0, loc), numLit(Infinity, loc)],
type: operand.value.type,
loc,
}
: operand.value;
stmts.push({ kind: "varDecl", localId: temp.id, init, loc });
const read = varRef(temp.id, operand.value.type, loc);
return operand.convert ? operand.convert(read) : read;
});
return { stmts, values };
}
/** An argument a ToNumber site lowers: its evaluated value and the
* conversion that makes it a number (null when the value already is one).
* `runsCode`: the conversion can call program code. `spread`: the value is
* a spread's array (lowerToNumberArguments' `lowerOther`). */
interface NumberOperand {
value: IrExpr;
convert: ((value: IrExpr) => IrExpr) | null;
runsCode: boolean;
spread?: boolean;
}
function numberOperand(lowerer: Lowerer, node: ts.Expression): NumberOperand {
let inner = node;
while (ts.isParenthesizedExpression(inner)) inner = inner.expression;
const loc = locOf(inner);
const number = (value: IrExpr): NumberOperand => ({ value, convert: null, runsCode: false });
const dynToNumber = (value: IrExpr): IrExpr => ({
kind: "libCall",
fn: "dyn.toNumberCoerce",
args: [value],
type: F64,
loc,
});
const isOptionalNumber = (value: IrExpr): boolean =>
value.type.kind === "union" &&
lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 &&
@@ -1433,12 +1507,54 @@ export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): Ir
? 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);
return number(lowerOptionalNumber(lowerer, optional, loc, inner));
// A conditional with number branches builds each branch as a number;
// other branches box, and the value converts like any dynamic value.
if (ts.isConditionalExpression(inner)) {
const branch = (b: ts.Expression) => lowerer.mapTypeOf(lowerer.typeOf(b))?.kind;
if (branch(inner.whenTrue) === "f64" && branch(inner.whenFalse) === "f64")
return number(lowerer.lowerExprExpecting(node, F64));
return { value: lowerer.lowerExprExpecting(node, DYN), convert: dynToNumber, runsCode: true };
}
const value = lowerer.lowerExpr(node);
if (isOptionalNumber(value)) return lowerOptionalNumber(lowerer, value, loc, inner);
return lowerer.coerceInto(node, value, F64);
if (isOptionalNumber(value)) return number(lowerOptionalNumber(lowerer, value, loc, inner));
// Other values convert with ToNumber: strings parse as numeric literals,
// booleans and null are numbers, and checked-dynamic values call
// valueOf/toString or throw Node's TypeError for a Symbol or BigInt.
const num = (n: number): IrExpr => ({ kind: "numLit", value: n, type: F64, loc });
const pure = (convert: (value: IrExpr) => IrExpr): NumberOperand => ({
value,
convert,
runsCode: false,
});
switch (value.type.kind) {
case "string":
return pure((v) => ({ kind: "libCall", fn: "num.fromString", args: [v], type: F64, loc }));
case "bool":
return pure((v) => ({
kind: "ternary",
cond: v,
then: num(1),
else_: num(0),
type: F64,
loc,
}));
case "nullT":
return pure((v) => defaultAfterUndefined(v, num(0)));
case "undefinedT":
return pure((v) => defaultAfterUndefined(v, num(NaN)));
case "dyn":
return { value, convert: dynToNumber, runsCode: true };
case "union":
if (lowerer.dynConvertible(value.type))
return {
value: lowerer.coerceToExpected(value, DYN),
convert: dynToNumber,
runsCode: true,
};
break;
}
return number(lowerer.coerceInto(node, value, F64));
}
/** The stored value of an identifier whose storage holds an undefined arm
@@ -2,7 +2,7 @@ import { dynUndefinedExpr, nodeThrowExpr } from "../../ir/build.js";
import { InternalCompilerError } from "../../errors.js";
import * as ts from "../ts7/adapter.js";
import { lowerToNumberArgument } from "./lower-exprs.js";
import { lowerToNumberArguments } from "./lower-exprs.js";
import type { Lowerer } from "./lowerer.js";
import { isDeclaredInAmbientNamespace } from "../type-mapper.js";
import {
@@ -2415,8 +2415,14 @@ export function lowerStdlibMethodCall(
const staticMath = isMath ? own(STATIC_MATH_FNS, name) : undefined;
// 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);
// for undefined, instead of trusting the checker's number type. The
// arguments are all evaluated before any converts (lowerToNumberArguments).
const mathArgs = (
nodes: readonly ts.Expression[],
lowerOther?: (node: ts.Expression) => IrExpr | null,
) => lowerToNumberArguments(lowerer, nodes, lowerOther);
const afterArguments = (stmts: IrStmt[], result: IrExpr): IrExpr =>
stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: result.type, loc };
// Math.hypot, and Math.max/Math.min with a spread among other arguments
// (`Math.max(...xs, 5)`): the arguments in order, spreads flattened, as
// one number[] the runtime folds.
@@ -2427,8 +2433,8 @@ export function lowerStdlibMethodCall(
call.arguments.nodes.length > 1 &&
call.arguments.nodes.some(ts.isSpreadElement)))
) {
const elems = call.arguments.nodes.map((a) =>
ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : mathArg(a),
const { stmts, values: elems } = mathArgs(call.arguments.nodes, (a) =>
ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : null,
);
const spreads = call.arguments.nodes.flatMap((a, i) => (ts.isSpreadElement(a) ? [i] : []));
const packed: IrExpr = {
@@ -2439,7 +2445,7 @@ export function lowerStdlibMethodCall(
loc,
};
const fn = name === "max" ? "math.maxArr" : name === "min" ? "math.minArr" : staticMath.fn;
return { kind: "libCall", fn, args: [packed], type: F64, loc };
return afterArguments(stmts, { kind: "libCall", fn, args: [packed], type: F64, loc });
}
if (staticMath && call.arguments.nodes.every((a) => !ts.isSpreadElement(a))) {
// Math.max/Math.min at ANY plain arity — Node's are variadic. The
@@ -2455,16 +2461,16 @@ export function lowerStdlibMethodCall(
if (call.arguments.nodes.length === 0) {
return { kind: "numLit", value: name === "max" ? -Infinity : Infinity, type: F64, loc };
}
const args = call.arguments.nodes.map(mathArg);
const { stmts, values: args } = mathArgs(call.arguments.nodes);
let acc = args[0]!;
for (let i = 1; i < args.length; i++) {
acc = { kind: "libCall", fn: staticMath.fn, args: [acc, args[i]!], type: F64, loc };
}
return acc;
return afterArguments(stmts, acc);
}
if (call.arguments.nodes.length === staticMath.arity) {
const args = call.arguments.nodes.map(mathArg);
return { kind: "libCall", fn: staticMath.fn, args, type: F64, loc };
const { stmts, values: args } = mathArgs(call.arguments.nodes);
return afterArguments(stmts, { kind: "libCall", fn: staticMath.fn, args, type: F64, loc });
}
}
// `Math.max(...xs)` / `Math.min(...xs)` over a number[]: a STATIC
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6905-isfinite-coercion.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6906-tonumber-arguments.mjs"
],
"diags": []
}
+38
View File
@@ -0,0 +1,38 @@
// Global isFinite applies ToNumber to its argument before the test, like
// global isNaN: strings parse as numeric literals, booleans and null are 0
// and 1, undefined is NaN, and objects convert through valueOf/toString.
// Number.isFinite stays strict and answers false for anything not a number.
const values: unknown[] = ["12", "", " 0x10 ", "abc", "1e400", true, false, null, undefined, {}, [], [7], [1, 2]];
for (const value of values) {
console.log(JSON.stringify(value) ?? "undefined", isFinite(value as any), Number.isFinite(value));
}
const u: unknown = 12;
console.log(isFinite(u as any));
let calls = "";
const boxed: unknown = { valueOf: () => ((calls += "v"), 5) };
console.log(isFinite(boxed as any), calls);
const text: unknown = { toString: () => "Infinity" };
console.log(isFinite(text as any));
class Cache {
#maxAge: unknown;
constructor(maxAge?: number) {
this.#maxAge = maxAge;
}
expires(): boolean {
return isFinite(this.#maxAge as any);
}
}
console.log(new Cache(1000).expires(), new Cache().expires());
const throwing: unknown[] = [Symbol("s"), 10n];
for (const value of throwing) {
try {
isFinite(value as any);
} catch (e) {
console.log(e instanceof TypeError);
}
}
console.log(isFinite(1 / 0), isFinite(-1 / 0), isFinite(0 / 0), isFinite(-0));
+97
View File
@@ -0,0 +1,97 @@
// Arguments that the global number functions convert with ToNumber, in a
// JavaScript file where they arrive as strings, booleans, null, undefined
// or dynamic values: isNaN, isFinite, Math functions and
// String.fromCharCode match Node for each.
console.log(isNaN("12"), isNaN("abc"), isNaN(true), isNaN(null), isNaN(undefined));
console.log(isFinite("12"), isFinite("abc"), isFinite(true), isFinite(null), isFinite(undefined));
console.log(Math.floor("3.7"), Math.max("4", 2), Math.abs(false), Math.sqrt(null), String.fromCharCode("65", true));
const parsed = JSON.parse('["12", " 0x10 ", "", "1e400", true, null, {}, [], [7]]');
for (const value of parsed) console.log(JSON.stringify(value), isNaN(value), isFinite(value), Math.trunc(value));
let calls = "";
const boxed = { valueOf: () => ((calls += "v"), 4.5), toString: () => ((calls += "s"), "9") };
console.log(isFinite(boxed), isNaN(boxed), Math.round(boxed), calls);
class LruCache {
constructor(config) {
this.maxAge = config.maxAge;
}
hasMaxAge() {
return isFinite(this.maxAge);
}
}
console.log(new LruCache({ maxAge: 1000 }).hasMaxAge(), new LruCache({}).hasMaxAge());
for (const value of [Symbol("s"), 10n]) {
try {
isFinite(value);
} catch (e) {
console.log(e instanceof TypeError, e.message);
}
}
// A conditional argument converts whichever branch it takes.
for (const flag of [true, false]) {
console.log(isFinite(flag ? "12" : "abc"), isNaN(flag ? null : "x"), Math.floor(flag ? "3.7" : true));
}
// Every argument is evaluated before any converts, as in Node: valueOf runs
// after the last argument, and an argument that throws stops the call
// before any conversion.
let order = "";
const valued = (name, n) => ({ valueOf: () => ((order += "v" + name), n) });
const arg = (name, n) => ((order += "e" + name), valued(name, n));
console.log(Math.max(arg("a", 1), arg("b", 5), arg("c", 3)), order);
order = "";
console.log(Math.pow(arg("a", 2), arg("b", 10)), order);
order = "";
console.log(Math.hypot(arg("a", 3), ...[4], arg("c", 12)), order);
order = "";
console.log(String.fromCharCode(arg("a", 72), arg("b", 105)), order);
order = "";
try {
Math.max(
arg("a", 1),
(() => {
order += "throw";
throw new Error("boom");
})(),
);
} catch (e) {
console.log(e.message, order);
}
order = "";
try {
Math.min(
{
valueOf() {
order += "x";
throw new TypeError("first");
},
},
arg("b", 2),
);
} catch (e) {
console.log(e.message, order);
}
// A spread reads its elements where it is evaluated, before an earlier
// argument's valueOf runs.
const values = [1, 2];
const mutator = {
valueOf() {
values[0] = 100;
values.push(50);
return 3;
},
};
console.log(Math.max(mutator, ...values), values.join(","));
const more = [4];
const resetter = {
valueOf() {
more[0] = -1;
return 0;
},
};
console.log(Math.hypot(resetter, ...more, 3), more.join(","));