diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 6a6a252928..c636cbef9e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -388,6 +388,15 @@ export function lowerTernary( } } +/** Whether `expr` is the checked-dynamic Number/Boolean prototype value a + * JavaScript source read (jsPrimitivePrototypeValue), or one of its + * members: the value is the prototype OBJECT, not the wrapper's primitive, + * so the checker's wrapper type must not extract a scalar from it. */ +function isPrimitivePrototypeValue(expr: IrExpr): boolean { + if (expr.kind === "libCall") return expr.fn === "dyn.builtinPrototype"; + return expr.kind === "dynKeyGet" && isPrimitivePrototypeValue(expr.value); +} + /** Checker-driven union narrowing. tsc's control-flow analysis narrows a * union-typed reference at use sites (`if (r.kind === "ok") { ...r... }` * types `r` as the ok-arm inside the branch); the IR value is still the @@ -429,6 +438,10 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx // instead of misreading the payload (the dyn boundary's usual stance). // Object/array narrowings stay dyn-typed and keep their fences. if (expr.type.kind === "dyn") { + // Number.prototype and Boolean.prototype (and their members) are the + // prototype OBJECTS, though @types/node declares them with the wrapper + // interface: extracting the wrapper's numeric value would throw. + if (isPrimitivePrototypeValue(expr)) return expr; // A process.versions entry declared as a string can be absent here. if (isOptionalProcessVersionsRead(lowerer, node)) return lowerer.coerceToExpected(expr, lowerer.withUndefinedArm(STRING)); diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3bcaf10133..e56e7e9615 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1914,6 +1914,8 @@ export function lowerIntrinsicProperty( lowerer.stdlibGlobalMember(expr.expression, "Object") === "prototype" ) return builtinGlobalValue("Object", locOf(expr)); + const primitivePrototype = jsPrimitivePrototypeValue(lowerer, expr); + if (primitivePrototype) return primitivePrototype; const builtinPrototype = builtinPrototypeMethod(lowerer, expr); if (builtinPrototype) return { @@ -5011,3 +5013,56 @@ function globalThisWrittenNames(lowerer: Lowerer, globalType: ts.Type): Set ({ + kind: "libCall", + fn: "dyn.builtinPrototype", + args: [{ kind: "strLit", value: kind, type: STRING, loc }], + type: DYN, + loc, + }); + const receiver = expr.expression; + if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "prototype") { + const kind = primitivePrototypeKind(lowerer, receiver); + if (!kind) return null; + return { + kind: "dynKeyGet", + value: value(kind), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + type: DYN, + loc, + }; + } + const kind = primitivePrototypeKind(lowerer, expr); + return kind ? value(kind) : null; +} + +/** Whether `expr` names `Number.prototype` or `Boolean.prototype` in a + * JavaScript source, which lowers to the prototype object above rather than + * to the wrapper interface's numeric/boolean value. */ +export function isPrimitivePrototypeRead(lowerer: Lowerer, expr: ts.Expression): boolean { + return primitivePrototypeKind(lowerer, expr) !== null; +} + +/** The kind whose prototype `expr` names (`Number.prototype`) in a + * JavaScript source, if any. */ +function primitivePrototypeKind( + lowerer: Lowerer, + expr: ts.Expression, +): "Number" | "Boolean" | null { + if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== "prototype") return null; + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + if (lowerer.stdlibGlobalMember(expr, "Number") === "prototype") return "Number"; + if (lowerer.stdlibGlobalMember(expr, "Boolean") === "prototype") return "Boolean"; + return null; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2df4e1b4eb..0a9f691f92 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -135,6 +135,7 @@ import { } from "./containers/indexed-objects.js"; import { lowerTupleReadMethodCall } from "./containers/array-methods.js"; import { tryFuseIndexRead } from "./array-values.js"; +import { isPrimitivePrototypeRead } from "./expressions/property-access.js"; import { lowerBufferConstruct, lowerBytesPrototypeSetCall, @@ -3954,6 +3955,12 @@ function lowerDynReceiverMethodCall( ) recv = lowerer.coerceToExpected(recv, DYN); if (recv.type.kind !== "dyn") return null; + // `Number.prototype.m(...)` reads the prototype OBJECT, whose `m` is its + // own member, so every primitive specialization below — which assumes a + // primitive receiver and coerces the value it is given — would be wrong. + // The shared dispatch reads the member and calls it with the prototype as + // `this`, which is Node's answer for the wrapper around +0 / false. + const prototypeReceiver = isPrimitivePrototypeRead(lowerer, access.expression); if ( access.name.text === "normalize" && call.arguments.nodes.length <= 1 && @@ -4064,7 +4071,7 @@ function lowerDynReceiverMethodCall( // prototype declares (Array carries includes/indexOf/slice too), so no // other kind inherits them. Shared names would need a receiver-kind // dispatch — they keep the fence. - if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable) { + if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable && !prototypeReceiver) { // A checked-dynamic object can still carry its OWN member of that // name: zod's schemas assign `trim`, `toLowerCase` and `startsWith` // in their constructors (`z.string().trim().min(1)`), an object's @@ -4094,6 +4101,7 @@ function lowerDynReceiverMethodCall( // runtime dispatch. if ( !ownCallable && + !prototypeReceiver && !call.questionDotToken && !access.questionDotToken && isDynPrimitiveKindMethod(access.name.text) && @@ -4116,6 +4124,7 @@ function lowerDynReceiverMethodCall( access.name.text === "toString" && call.arguments.nodes.length <= 1 && !ownCallable && + !prototypeReceiver && !(checkerUntyped && jsAssignsOwnToString(lowerer, access.expression)) ) { const argument = call.arguments.nodes[0]; @@ -4134,7 +4143,7 @@ function lowerDynReceiverMethodCall( // Every dyn kind's prototype chain declares valueOf, and the runtime // answers each kind's own: primitives and plain objects themselves, a // Date its time value, an own or inherited valueOf member its result. - if (access.name.text === "valueOf" && call.arguments.nodes.length === 0) { + if (access.name.text === "valueOf" && call.arguments.nodes.length === 0 && !prototypeReceiver) { const webAssembly = lowerWebAssemblyValueOf(lowerer, access.expression, recv, locOf(call)); if (webAssembly !== null) return webAssembly; return { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-prototype-values.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-prototype-values.js.json new file mode 100644 index 0000000000..f8b16dac22 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-prototype-values.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-primitive-prototype-values.js" + ], + "diags": [] +} diff --git a/tests/corpus/js-primitive-prototype-values.js b/tests/corpus/js-primitive-prototype-values.js new file mode 100644 index 0000000000..cd21f855b9 --- /dev/null +++ b/tests/corpus/js-primitive-prototype-values.js @@ -0,0 +1,37 @@ +// Number.prototype and Boolean.prototype are objects in JavaScript: the +// wrappers around +0 and false, carrying their methods as non-enumerable +// data properties. Reading one used to answer undefined. +console.log(typeof Number.prototype, typeof Boolean.prototype); +console.log(Number.prototype === Number.prototype, Boolean.prototype === Boolean.prototype); +console.log(Number.prototype.toString(), Number.prototype.toString(2), Number.prototype.toFixed(2)); +console.log(Number.prototype.valueOf(), Boolean.prototype.valueOf(), Boolean.prototype.toString()); +console.log(Object.getPrototypeOf(Number.prototype) === Object.prototype); +console.log(Object.getPrototypeOf(Boolean.prototype) === Object.prototype); +console.log(Number.prototype.hasOwnProperty('toString'), Number.prototype.hasOwnProperty('nope')); +console.log(Object.keys(Number.prototype).length, Object.keys(Boolean.prototype).length); +console.log(typeof Number.prototype.toString, Number.prototype.toString.name, Number.prototype.toString.length); +console.log(Boolean.prototype.valueOf.name, Boolean.prototype.toString.name, Boolean.prototype.toString.length); + +// The methods are not generic: only a primitive of the kind, or the +// prototype object itself (the wrapper around +0 / false), is a `this`. +const attempt = (label, run) => { + try { + console.log(label, 'no-throw', JSON.stringify(run())); + } catch (error) { + console.log(label, error.constructor.name, error.message); + } +}; +attempt('num-null', () => Number.prototype.toString.call(null)); +attempt('num-object', () => Number.prototype.toString.call({})); +attempt('num-string', () => Number.prototype.valueOf.call('5')); +attempt('num-bool', () => Number.prototype.toFixed.call(true)); +attempt('num-number', () => Number.prototype.toString.call(255, 16)); +attempt('bool-object', () => Boolean.prototype.valueOf.call({})); +attempt('bool-number', () => Boolean.prototype.toString.call(1)); +attempt('bool-boolean', () => Boolean.prototype.valueOf.call(true)); +const holder = new Object(); +holder.toString = Number.prototype.toString; +attempt('holder', () => holder.toString()); +const other = new Object(); +other.myValueOf = Number.prototype.valueOf; +attempt('holder-valueOf', () => other.myValueOf()); diff --git a/tests/harness/corpus-costs/js-primitive-prototype-values.js.json b/tests/harness/corpus-costs/js-primitive-prototype-values.js.json new file mode 100644 index 0000000000..c428eb8f11 --- /dev/null +++ b/tests/harness/corpus-costs/js-primitive-prototype-values.js.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4600 }