From 11c97074e2f5436acdcfe175372b2677fda4abe5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:30 -0700 Subject: [PATCH 1/9] Answer the object a constructor returns from new A class constructor's `return expr` was evaluated and dropped, so `new C()` answered the constructed instance even where Node answers the returned object (`if (C.instance) return C.instance;` built a second instance). A return that yields an instance of the class itself stores it in a per-class result slot, read and cleared by one helper at every construction site (direct new, class values, the checked dynamic construction dispatchers, record-to-class coercion). A return of any other object refuses with SC1090 (compile time in TypeScript, a deferred fence in JavaScript); a JavaScript value typed only at run time is tested when it returns. super() into a class that can return an existing instance refuses, since Node would rebind the subclass's this. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/class-construction.ts | 5 +- .../lowering/class-dynamic-dispatch.ts | 5 +- .../frontend/lowering/coercions/structural.ts | 9 +- .../frontend/lowering/constructor-returns.ts | 377 ++++++++++++++++++ .../src/frontend/lowering/lower-classes.ts | 52 ++- .../src/frontend/lowering/lower-stmts.ts | 6 + .../compiler/src/frontend/lowering/lowerer.ts | 4 + .../constructor-returns-instance.js.json | 6 + .../constructor-returns-instance.ts.json | 6 + ...constructor-returns-instance-super.ts.json | 6 + .../constructor-returns-object.ts.json | 6 + tests/corpus/constructor-returns-instance.js | 45 +++ tests/corpus/constructor-returns-instance.ts | 112 ++++++ .../constructor-returns-instance-super.ts | 15 + .../diagnostics/constructor-returns-object.ts | 10 + .../constructor-returns-instance-super.ts.txt | 6 + .../constructor-returns-object.ts.txt | 6 + tests/harness/errors.test.ts | 52 +++ 18 files changed, 705 insertions(+), 23 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/constructor-returns.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json create mode 100644 tests/corpus/constructor-returns-instance.js create mode 100644 tests/corpus/constructor-returns-instance.ts create mode 100644 tests/diagnostics/constructor-returns-instance-super.ts create mode 100644 tests/diagnostics/constructor-returns-object.ts create mode 100644 tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt create mode 100644 tests/harness/__snapshots__/constructor-returns-object.ts.txt diff --git a/packages/compiler/src/frontend/lowering/class-construction.ts b/packages/compiler/src/frontend/lowering/class-construction.ts index 4dae7ecd09..7f4a003c9f 100644 --- a/packages/compiler/src/frontend/lowering/class-construction.ts +++ b/packages/compiler/src/frontend/lowering/class-construction.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { constructedValue } from "./constructor-returns.js"; import { BOOL, DYN, @@ -389,13 +390,13 @@ export class ClassConstructionDispatch { if (!args) continue; const type: IrType = { kind: "classval", className: name }; const callee = varRef("p.0", DYN, loc); - const value: IrExpr = { + const value = constructedValue(lowerer, info, { kind: "newValue", callee: { kind: "dynCheck", value: callee, type, loc }, args, type: { kind: "object", className: name }, loc, - }; + }); const body: IrStmt[] = [ { kind: "return", value: lowerer.coerceToExpected(value, DYN), loc }, ]; diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 120032bf5a..16c83e9e9d 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { constructedValue } from "./constructor-returns.js"; import { eventTargetApiMembers, eventTargetInheritedMember } from "./lower-event-target.js"; import { BOOL, @@ -3158,13 +3159,13 @@ export class ClassDynamicDispatch { } if (!supported || !info.decl) continue; const cls = classValueRef(lowerer, info, info.decl); - const result: IrExpr = { + const result = constructedValue(lowerer, info, { kind: "new", className: name, args, type: { kind: "object", className: name }, loc, - }; + }); this.constructDispatch.body.unshift({ kind: "if", cond: { diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index 925f59d9b7..6a5e0058f6 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -1,3 +1,4 @@ +import { constructedValue } from "../constructor-returns.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import type { FieldLift } from "./structural-plans.js"; import { buildArrayConversion } from "./builders.js"; @@ -524,7 +525,13 @@ export function recordClassWidthHelper( params: [{ localId: "r.0", name: "r", type: fromT }], returnType: toT, locals: [{ id: "r.0", name: "r", type: fromT, mutable: true }], - body: [{ kind: "return", value: { kind: "new", className, args, type: toT, loc }, loc }], + body: [ + { + kind: "return", + value: constructedValue(lowerer, info, { kind: "new", className, args, type: toT, loc }), + loc, + }, + ], loc, }); return name; diff --git a/packages/compiler/src/frontend/lowering/constructor-returns.ts b/packages/compiler/src/frontend/lowering/constructor-returns.ts new file mode 100644 index 0000000000..d054a6b490 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/constructor-returns.ts @@ -0,0 +1,377 @@ +/** Constructors that `return` a value. + * + * JavaScript's `new C()` answers the constructor's returned value when it + * is an object (functions included) and the constructed `this` otherwise. + * A primitive or bare return therefore keeps the ordinary lowering, which + * evaluates the operand and drops it. + * + * A return whose value is an instance of the constructor's own class keeps + * the class representation (`if (Single.instance) return + * Single.instance;`). The return stores that instance in a per-class + * result slot and every direct `new C()` takes it from there. Any other + * object has no representation as a `C` instance and refuses at the return + * statement, deferred to run time in JavaScript like other statement + * fences. A JavaScript value that is an object only at run time (`any`) + * is tested when it returns: a primitive answers the constructed + * instance, an instance of the class takes the result slot, and any other + * object refuses. + * + * `super()` from a subclass refuses: Node would rebind the subclass's + * `this` to the returned instance. */ +import * as ts from "../ts7/adapter.js"; +import { varRef } from "../../ir/build.js"; +import { + BOOL, + DYN, + type IrExpr, + type IrGlobal, + type IrStmt, + type IrType, + STRING, + typeKey, +} from "../../ir/ir.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import type { ClassInfo } from "./lower-classes.js"; +import type { Lowerer } from "./lowerer.js"; + +type ReturnKind = "instance" | "object" | "mixed"; + +export interface ConstructorReturnPlan { + /** Returns whose value may be an object, by kind. Primitive and `this` + * returns are absent: they keep the ordinary lowering. */ + returns: Map; + /** The result slot (`C | undefined`), present when some return yields an + * instance of the class itself. */ + slot: IrGlobal | null; +} + +const PRIMITIVE_FLAGS = + ts.TypeFlags.Primitive | ts.TypeFlags.EnumLike | ts.TypeFlags.Void | ts.TypeFlags.Never; + +/** Types whose values may be primitives or objects: open types, type + * variables and intersections (a branded `string & { tag }` is a string). */ +const OPEN_FLAGS = + ts.TypeFlags.AnyOrUnknown | ts.TypeFlags.InstantiableNonPrimitive | ts.TypeFlags.Intersection; + +function unparenthesized(node: ts.Expression): ts.Expression { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + +/** Whether a value of this checker type can be an object: "never", + * "always", or "maybe" (open types and primitive/object unions). */ +function objectness(type: ts.Type): "never" | "always" | "maybe" { + const parts = type.isUnionType() ? ts.constituentTypes(type) : [type]; + let primitive = false; + let object = false; + for (const part of parts) { + if ((part.flags & OPEN_FLAGS) !== 0) return "maybe"; + if ((part.flags & PRIMITIVE_FLAGS) !== 0) primitive = true; + else object = true; + } + if (!object) return "never"; + return primitive ? "maybe" : "always"; +} + +/** The constructor's own `return ` statements: nested functions, + * arrows and classes have their own returns. */ +function ownValueReturns(ctor: ts.ConstructorDeclaration): ts.ReturnStatement[] { + const out: ts.ReturnStatement[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) out.push(node); + node.forEachChild(visit); + }; + ctor.body?.forEachChild(visit); + return out; +} + +/** A return inside a `try` with a `finally` block: the finally block runs + * after the result is stored and may throw past it. */ +function insideFinallyTry(node: ts.Node, ctor: ts.ConstructorDeclaration): boolean { + for (let p = node.parent; p !== undefined && p !== ctor; p = p.parent) { + if (ts.isTryStatement(p) && p.finallyBlock !== undefined && !nodeWithin(node, p.finallyBlock)) + return true; + } + return false; +} + +function nodeWithin(node: ts.Node, ancestor: ts.Node): boolean { + return node.pos >= ancestor.pos && node.end <= ancestor.end; +} + +function instanceType(info: ClassInfo): IrType { + return { kind: "object", className: info.def.name }; +} + +function isInstanceOf(lowerer: Lowerer, info: ClassInfo, type: IrType | null): boolean { + if (type === null) return false; + if (type.kind === "object") return type.className === info.def.name; + if (type.kind !== "union") return false; + const arms = lowerer.unions.get(type.unionId)?.arms ?? []; + return ( + arms.length === 2 && + arms.some((arm) => arm.kind === "object" && arm.className === info.def.name) && + arms.some((arm) => arm.kind === "undefinedT") + ); +} + +export function constructorReturnPlan( + lowerer: Lowerer, + info: ClassInfo, +): ConstructorReturnPlan | null { + const byClass = lowerer.constructorReturnPlans; + const known = byClass.get(info.def.name); + if (known !== undefined) return known; + const ctor = info.ctor; + let plan: ConstructorReturnPlan | null = null; + if (ctor?.body !== undefined && info.decl !== null) { + const returns = new Map(); + for (const stmt of ownValueReturns(ctor)) { + const value = unparenthesized(stmt.expression!); + if (value.kind === ts.SyntaxKind.ThisKeyword) continue; + const type = lowerer.typeOf(value); + const shape = objectness(type); + if (shape === "never") continue; + // Local classes construct through their own environment, which has + // no result slot. + const instance = + info.localClass === undefined && + !insideFinallyTry(stmt, ctor) && + isInstanceOf(lowerer, info, lowerer.mapTypeOf(type)); + returns.set(stmt, instance ? "instance" : shape === "always" ? "object" : "mixed"); + } + if (returns.size > 0) { + let slot: IrGlobal | null = null; + // A JavaScript value typed only at run time may hold an instance of + // the class too (`if (C.instance) return C.instance;`). + const runtimeInstance = + info.localClass === undefined && + isJsSourceFile(ctor.getSourceFile()) && + [...returns].some(([stmt, kind]) => kind === "mixed" && !insideFinallyTry(stmt, ctor)); + if ([...returns.values()].includes("instance") || runtimeInstance) { + slot = { + id: `%g.${info.def.name}.%constructorResult`, + name: `${info.def.name}.%constructorResult`, + type: lowerer.withUndefinedArm(instanceType(info)), + mutable: true, + }; + lowerer.globalsList.push(slot); + } + plan = { returns, slot }; + } + } + byClass.set(info.def.name, plan); + return plan; +} + +function foreignObjectFeature(info: ClassInfo): string { + const display = info.def.name.replace(/^%|^%m\d+\./, ""); + return `returning an object other than an instance of the class from a constructor (Node's 'new ${display}()' answers the returned object, which has no representation as a '${display}' instance)`; +} + +/** `return ` inside a class constructor whose value may be an + * object. Null when the statement keeps the ordinary lowering. */ +export function lowerConstructorReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { + const info = lowerer.currentClass; + if (info === null || info.ctor === null || stmt.expression === undefined) return null; + let fn: ts.Node | undefined = stmt.parent; + while (fn !== undefined && !ts.isFunctionLike(fn) && !ts.isClassLikeDeclaration(fn)) + fn = fn.parent; + if (fn !== info.ctor) return null; + const plan = constructorReturnPlan(lowerer, info); + const kind = plan?.returns.get(stmt); + if (plan === null || kind === undefined) return null; + const loc = locOf(stmt); + if (kind === "instance") { + const value = lowerer.coerceInto( + stmt.expression, + lowerer.lowerExpr(stmt.expression), + plan.slot!.type, + ); + return { + kind: "block", + body: [ + { kind: "assign", localId: plan.slot!.id, value, loc }, + { kind: "return", value: null, loc }, + ], + loc, + }; + } + const feature = foreignObjectFeature(info); + if (kind === "object" || !isJsSourceFile(stmt.getSourceFile())) + lowerer.unsupported("SC1090", stmt, feature); + // A JavaScript value that is an object only sometimes: a primitive + // still answers the constructed instance, an object refuses. + const value = lowerer.coerceToExpected(lowerer.lowerExpr(stmt.expression), DYN); + if (value.type.kind !== "dyn") lowerer.unsupported("SC1090", stmt, feature); + const held = lowerer.declareHiddenLocal("%returned", DYN); + const read = varRef(held.id, DYN, loc); + const typeOf: IrExpr = { kind: "libCall", fn: "dyn.typeof", args: [read], type: STRING, loc }; + const typeIs = (name: string): IrExpr => ({ + kind: "strEq", + negated: false, + left: typeOf, + right: { kind: "strLit", value: name, type: STRING, loc }, + type: BOOL, + loc, + }); + const diag = unsupportedDiag("SC1090", loc, feature); + lowerer.runtimeFences.push(diag); + const sf = stmt.getSourceFile(); + const line = ts.getLineAndCharacterOfPosition(sf, loc.start).line + 1; + return { + kind: "block", + body: [ + { kind: "varDecl", localId: held.id, init: value, loc }, + { + kind: "if", + // null answers "object" and is falsy; every object is truthy. + cond: { + kind: "logical", + op: "||", + left: typeIs("function"), + right: { + kind: "logical", + op: "&&", + left: typeIs("object"), + right: lowerer.ensureBool(read, stmt.expression), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + then: [ + ...(plan.slot !== null && !insideFinallyTry(stmt, info.ctor) + ? [ + { + kind: "if" as const, + cond: { + kind: "libCall" as const, + fn: "dyn.typedRefIs" as const, + args: [ + read, + { + kind: "strLit" as const, + value: typeKey(instanceType(info)), + type: STRING, + loc, + }, + ], + type: BOOL, + loc, + }, + then: [ + { + kind: "assign" as const, + localId: plan.slot.id, + value: lowerer.coerceToExpected( + lowerer.coerceToExpected(read, instanceType(info)), + plan.slot.type, + ), + loc, + }, + { kind: "return" as const, value: null, loc }, + ], + else_: null, + loc, + }, + ] + : []), + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${loc.file}:${line}]`, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "return", value: null, loc }, + ], + loc, + }; +} + +/** The value of a construction of exactly class `C`: the constructed + * instance, or the instance the constructor returned. Unchanged for + * classes without a result slot. The slot is read and cleared by a + * per-class helper, so every construction site (direct `new`, class + * values, the checked dynamic dispatchers) shares one protocol. */ +export function constructedValue(lowerer: Lowerer, info: ClassInfo, constructed: IrExpr): IrExpr { + const slot = constructorReturnPlan(lowerer, info)?.slot; + if (slot === null || slot === undefined) return constructed; + const loc = constructed.loc; + const type = instanceType(info); + const name = `%${info.def.name}.%constructed`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const instance = { id: "instance.0", name: "instance", type, mutable: false }; + const returned = { id: "returned.0", name: "returned", type: slot.type, mutable: false }; + lowerer.liftedFns.push({ + name, + params: [{ localId: instance.id, name: instance.name, type }], + returnType: type, + locals: [instance, returned], + body: [ + { kind: "varDecl", localId: returned.id, init: varRef(slot.id, slot.type, loc), loc }, + { kind: "assign", localId: slot.id, value: lowerer.wrappedUndefined(slot.type, loc)!, loc }, + { + kind: "return", + value: { + kind: "orDefault", + left: varRef(returned.id, slot.type, loc), + right: varRef(instance.id, type, loc), + type, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [constructed], type, loc }; +} + +/** A construction through a class value whose static class is `info`: the + * value holds `info` itself or a descendant. Descendants of a class with a + * result slot refuse at their `super()`, so only `info`'s own slot can + * apply; a descendant with its own slot has no static home here. */ +export function constructedFromValue( + lowerer: Lowerer, + info: ClassInfo, + constructed: IrExpr, + blame: ts.Node, +): IrExpr { + const pending = [...info.subclasses]; + while (pending.length > 0) { + const sub = pending.pop()!; + pending.push(...sub.subclasses); + const plan = constructorReturnPlan(lowerer, sub); + if (plan === null || plan.slot === null) continue; + const display = sub.def.name.replace(/^%|^%m\d+\./, ""); + lowerer.unsupported( + "SC1090", + blame, + `constructing through a class value that may hold '${display}', whose constructor can return an existing instance`, + ); + } + return constructedValue(lowerer, info, constructed); +} + +/** `super()` into a class with a result slot: Node rebinds the subclass's + * `this` to the returned instance, which has no representation. */ +export function refuseSuperIntoResultSlot(lowerer: Lowerer, base: ClassInfo, blame: ts.Node): void { + const plan = constructorReturnPlan(lowerer, base); + if (plan === null || plan.slot === null) return; + const display = base.def.name.replace(/^%|^%m\d+\./, ""); + lowerer.unsupported( + "SC1090", + blame, + `extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance '${display}' returns)`, + ); +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f3a4830c36..8bb3fd8cb6 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1,5 +1,10 @@ import { lowerValueReturningConstructorNew } from "./js-dynamic-objects.js"; import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { + constructedFromValue, + constructedValue, + refuseSuperIntoResultSlot, +} from "./constructor-returns.js"; import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { lowerReadlineUiNew } from "./builtins/readline.js"; import { collectClassMethodMutations } from "./class-method-mutations.js"; @@ -8020,6 +8025,7 @@ export function superCallStmt( info.decl ?? info.ctor!, )[0]!; } + refuseSuperIntoResultSlot(lowerer, base, info.ctor ?? info.decl!); lowerer.noteSuperConstructorEdge(`%${base.def.name}.constructor`); return { kind: "exprStmt", @@ -8429,21 +8435,21 @@ function lowerProgramClassNew( : lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); for (let current: ClassInfo | null = info; current; current = current.base) { if (current.def.baseValueGlobal) - return { + return constructedValue(lowerer, info, { kind: "newValue", callee: classValueRef(lowerer, info, expr), args, type: { kind: "object", className: info.def.name }, loc, - }; + }); } - return { + return constructedValue(lowerer, info, { kind: "new", className: info.def.name, args, type: { kind: "object", className: info.def.name }, loc, - }; + }); } function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { @@ -8731,13 +8737,18 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { below(info); const callee: IrExpr = { kind: "varRef", localId: g.id, type: g.type, loc }; const args = lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); - return { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }; + return constructedFromValue( + lowerer, + info, + { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, + expr, + ); } } } @@ -9638,13 +9649,18 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { }; below(info); const args = lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); - return { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }; + return constructedFromValue( + lowerer, + info, + { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, + expr, + ); } if (calleeT?.kind === "union") { const def = lowerer.unions.get(calleeT.unionId); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 12544979c6..9ede6182bb 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -14,6 +14,7 @@ import { processDataMemberValue } from "./builtins/process.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; import { bindingSource } from "../binding-source.js"; +import { lowerConstructorReturn } from "./constructor-returns.js"; import type { Lowerer } from "./lowerer.js"; import { arrayValueRead, @@ -1370,6 +1371,11 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt // incompatible boundary keeps the ordinary per-statement refusal. let value: IrExpr | null = null; if (stmt.expression) { + // A constructor's returned object replaces the constructed `this`. + if (lowerer.currentClass?.ctor) { + const constructorReturn = lowerConstructorReturn(lowerer, stmt); + if (constructorReturn !== null) return constructorReturn; + } return lowerer.lowerReturnStmt(stmt.expression, locOf(stmt)); } else if (lowerer.ctx.returnType.kind === "union") { value = lowerer.wrappedUndefined(lowerer.ctx.returnType, locOf(stmt)); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 145fb3ae6b..cdc1703645 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4,6 +4,7 @@ import { lowerLifetimeRegionCall, lowerLifetimeRegionNew, } from "./lifetime-regions.js"; +import type { ConstructorReturnPlan } from "./constructor-returns.js"; import { internTableType, lowerInternTableCall, lowerInternTableNew } from "./intern-tables.js"; import { isExperimentalDeclarationFile } from "../experimental-import.js"; import { @@ -2706,6 +2707,9 @@ export class Lowerer { * by class name (cycle-initialization.ts). */ readonly classInitFlags = new Map(); readonly cycleInitDeclarations = new Map(); + /** Each class's constructor return plan by class name + * (constructor-returns.ts); null when no return can yield an object. */ + readonly constructorReturnPlans = new Map(); /** CommonJS require declarations whose bindings code running before the * require can read, and those references (require-initialization.ts). */ readonly requireInitFlags = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json new file mode 100644 index 0000000000..a85c33c1a1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-instance.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json new file mode 100644 index 0000000000..e01b6a4266 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-instance.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json new file mode 100644 index 0000000000..86b15f6804 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/constructor-returns-instance-super.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json new file mode 100644 index 0000000000..f3415a9373 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/constructor-returns-object.ts" + ], + "diags": [] +} diff --git a/tests/corpus/constructor-returns-instance.js b/tests/corpus/constructor-returns-instance.js new file mode 100644 index 0000000000..ab7af8b2c9 --- /dev/null +++ b/tests/corpus/constructor-returns-instance.js @@ -0,0 +1,45 @@ +// JavaScript constructors whose returned value is typed only at run time: +// an instance of the class replaces `this`, a primitive keeps it. +class Connection { + constructor(url) { + if (Connection.open && Connection.open.url === url) return Connection.open; + this.url = url; + this.uses = 0; + Connection.open = this; + } + use() { + this.uses++; + return this; + } +} +const c1 = new Connection("db://a").use(); +const c2 = new Connection("db://a").use(); +const c3 = new Connection("db://b").use(); +console.log(c1 === c2, c2.uses, c3 === c1, c3.uses, c3.url); + +class Counter { + constructor(start, answer) { + this.count = start; + return answer; + } +} +console.log(new Counter(1, 5).count, new Counter(2, "x").count, new Counter(3, null).count); +console.log(new Counter(4, undefined).count, new Counter(5, true).count); +const kept = new Counter(6, 0); +console.log(new Counter(7, kept) === kept, kept.count); + +const memoCache = new Map(); +let memoCreated = 0; +class Memo { + constructor(key) { + const hit = memoCache.get(key); + if (hit !== undefined) return hit; + this.key = key; + this.created = ++memoCreated; + memoCache.set(key, this); + } +} +const m1 = new Memo("k"); +const m2 = new Memo("k"); +const m3 = new Memo("j"); +console.log(m1 === m2, m3 === m1, m2.created, m3.created, memoCreated); diff --git a/tests/corpus/constructor-returns-instance.ts b/tests/corpus/constructor-returns-instance.ts new file mode 100644 index 0000000000..81e25a12de --- /dev/null +++ b/tests/corpus/constructor-returns-instance.ts @@ -0,0 +1,112 @@ +// A constructor's returned object replaces the constructed instance when it +// is an object; primitive, bare and `this` returns answer the instance. +class Registry { + static current: Registry | undefined = undefined; + readonly id: number = 0; + entries: string[] = []; + constructor(id: number) { + if (Registry.current) return Registry.current; + this.id = id; + Registry.current = this; + } + add(entry: string): this { + this.entries.push(entry); + return this; + } +} +const first = new Registry(1).add("a"); +const second = new Registry(2).add("b"); +console.log(first === second, second.id, second.entries.join(",")); + +// An instance returned only on some paths. +class Slot { + static previous: Slot | undefined = undefined; + label: string; + constructor(label: string, reuse: boolean) { + this.label = label; + const previous = Slot.previous; + Slot.previous = this; + if (reuse && previous) return previous; + } +} +const s1 = new Slot("one", true); +const s2 = new Slot("two", true); +const s3 = new Slot("three", false); +console.log(s1.label, s2.label, s3.label, s2 === s1, s3 === s2); + +// Constructing the class again inside its own constructor. +class Node2 { + static made = 0; + value: number; + child: Node2 | null = null; + constructor(depth: number, share: Node2 | null) { + Node2.made++; + this.value = depth; + if (share !== null) return share; + if (depth > 0) this.child = new Node2(depth - 1, null); + } +} +const root = new Node2(2, null); +const alias = new Node2(9, root); +console.log(alias === root, alias.value, root.child?.value, root.child?.child?.value, Node2.made); + +// A constructor that throws leaves later constructions unaffected. +class Guarded { + static kept: Guarded | undefined = undefined; + n: number; + constructor(n: number) { + if (n < 0) throw new RangeError(`negative: ${n}`); + this.n = n; + if (n === 0 && Guarded.kept) return Guarded.kept; + Guarded.kept = this; + } +} +try { + new Guarded(-1); +} catch (e) { + console.log((e as Error).message); +} +const g1 = new Guarded(5); +const g2 = new Guarded(0); +const g3 = new Guarded(7); +console.log(g1.n, g2.n, g3.n, g1 === g2, g3 === g1); + +// Primitive, bare and `this` returns keep the constructed instance. +class Plain { + v = 3; + constructor(mode: number) { + if (mode === 1) return; + if (mode === 2) return this; + } +} +const plain = new Plain(2); +console.log(new Plain(0).v, new Plain(1).v, plain.v, plain instanceof Plain); + +// The derived class's own constructor may return an instance of itself. +class Animal { + sound = "..."; +} +class Dog extends Animal { + static pack: Dog[] = []; + name: string; + constructor(name: string) { + super(); + this.name = name; + this.sound = "woof"; + const known = Dog.pack.find((d) => d.name === name); + if (known) return known; + Dog.pack.push(this); + } +} +const rex = new Dog("rex"); +const again = new Dog("rex"); +const fido = new Dog("fido"); +console.log(rex === again, fido === rex, Dog.pack.length, again.sound, again instanceof Animal); + +// Constructions through class values take the returned instance too. +const Ctor: typeof Registry = Registry; +console.log(new Ctor(3) === first); +function build(make: new (id: number) => T, id: number): T { + return new make(id); +} +console.log(build(Registry, 4) === first, build(Registry, 5).entries.length); diff --git a/tests/diagnostics/constructor-returns-instance-super.ts b/tests/diagnostics/constructor-returns-instance-super.ts new file mode 100644 index 0000000000..c5e34c7a1a --- /dev/null +++ b/tests/diagnostics/constructor-returns-instance-super.ts @@ -0,0 +1,15 @@ +// `super()` into a constructor that can return an existing instance would +// rebind the subclass's `this` to that instance. +class Shared { + static one: Shared | undefined = undefined; + constructor() { + if (Shared.one) return Shared.one; + Shared.one = this; + } +} +class Derived extends Shared { + constructor() { + super(); + } +} +console.log(new Derived() instanceof Derived); diff --git a/tests/diagnostics/constructor-returns-object.ts b/tests/diagnostics/constructor-returns-object.ts new file mode 100644 index 0000000000..63abf4c4d8 --- /dev/null +++ b/tests/diagnostics/constructor-returns-object.ts @@ -0,0 +1,10 @@ +// Node's `new Point()` answers the object a constructor returns. A plain +// object has no representation as a Point instance, so the return refuses +// instead of answering the constructed instance. +class Point { + x = 1; + constructor() { + return { x: 2 }; + } +} +console.log(new Point().x); diff --git a/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt b/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt new file mode 100644 index 0000000000..53a01d4318 --- /dev/null +++ b/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt @@ -0,0 +1,6 @@ +constructor-returns-instance-super.ts:11:3 - error SC1090: extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance 'Shared' returns) is not supported yet + + 10 | class Derived extends Shared { + 11 | constructor() { + | ^~~~~~~~~~~~~~~ + 12 | super(); \ No newline at end of file diff --git a/tests/harness/__snapshots__/constructor-returns-object.ts.txt b/tests/harness/__snapshots__/constructor-returns-object.ts.txt new file mode 100644 index 0000000000..fe2220d563 --- /dev/null +++ b/tests/harness/__snapshots__/constructor-returns-object.ts.txt @@ -0,0 +1,6 @@ +constructor-returns-object.ts:7:5 - error SC1090: returning an object other than an instance of the class from a constructor (Node's 'new Point()' answers the returned object, which has no representation as a 'Point' instance) is not supported yet + + 6 | constructor() { + 7 | return { x: 2 }; + | ^~~~~~~~~~~~~~~~ + 8 | } \ No newline at end of file diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 51f04d3108..856c1ad7de 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1013,3 +1013,55 @@ console.log(typeof Derived); ); }); }); + +describe("constructors returning objects (JS lane)", () => { + test("a returned plain object is a named deferred fence", async () => { + // Node's `new Box(2)` answers `{ size: 99 }`; a Box instance cannot + // hold that object, so the return refuses instead of answering `this`. + const r = await compileAndRun( + "constructor-returns-object", + `'use strict'; +class Box { + constructor(size) { + this.size = size; + if (size > 1) return { size: 99 }; + } +} +console.log(new Box(1).size); +console.log(new Box(2)); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("1\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: returning an object other than an instance of the class from a constructor \(Node's 'new Box\(\)' answers the returned object, .*\) is not supported yet \[SC1090 at .*constructor-returns-object\.cjs:5\]\n$/, + ); + }); + + test("an untyped returned value fences only when it holds a foreign object", async () => { + // Primitive results answer the constructed instance, as in Node; an + // object that is not a Wrap instance refuses. + const r = await compileAndRun( + "constructor-returns-untyped", + `'use strict'; +class Wrap { + constructor(value) { + this.value = value; + return value; + } +} +console.log(new Wrap(1).value, new Wrap('s').value, new Wrap(null).value); +const w = new Wrap(0); +console.log(new Wrap(w) === w); +console.log(new Wrap({ a: 1 })); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("1 s null\ntrue\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: returning an object other than an instance of the class from a constructor .* is not supported yet \[SC1090 at .*constructor-returns-untyped\.cjs:5\]\n$/, + ); + }); +}); From af0c7f6a8c4d2c66b510131d17aae6ff843aa4da Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:30 -0700 Subject: [PATCH 2/9] Answer the right operand of && over an always-truthy left `list && list[0]` with an array, object or function on the left and a union result (the element read may be undefined) coerced the left operand into that union, which traps at run time ("a 'number[]' value is not representable in the target union") where Node answers the element. Every object is truthy: evaluate the left for its effects and answer the right. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 19 ++++++++ .../corpus/logical-and-truthy-index.ts.json | 6 +++ tests/corpus/logical-and-truthy-index.ts | 44 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json create mode 100644 tests/corpus/logical-and-truthy-index.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 09296bdfed..d3deaa7196 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -11399,6 +11399,25 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE } } } + // `&&` over an always-truthy plain left operand (an array, object or + // function value) whose result union has no arm for it: every JS + // object is truthy, so `list && list[0]` evaluates the left for its + // effects and answers the right. Coercing the left into that union + // would trap at run time where Node answers the right operand. + if ( + op === ts.SyntaxKind.AmpersandAmpersandToken && + target?.kind === "union" && + REF_TRUTHY_KINDS.has(left.type.kind) && + lowerer.armTag(target.unionId, left.type) < 0 + ) { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: left, loc }], + result: lowerer.coerceInto(expr.right, right, target), + type: target, + loc, + }; + } if (target?.kind === "union") { // `&&` whose left does NOT fit the result union: the checker // built that result by DROPPING left arms which are always diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json new file mode 100644 index 0000000000..2e73889846 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/logical-and-truthy-index.ts" + ], + "diags": [] +} diff --git a/tests/corpus/logical-and-truthy-index.ts b/tests/corpus/logical-and-truthy-index.ts new file mode 100644 index 0000000000..b89e4d78da --- /dev/null +++ b/tests/corpus/logical-and-truthy-index.ts @@ -0,0 +1,44 @@ +// `&&` whose left operand is an array, object or function value: every +// object is truthy, so the result is the right operand, here an element +// read that may be undefined at run time. +const list = [3, 1, 2]; +const head = list && list[0]; +console.log(head); +const words: string[] = ["x"]; +console.log(words && words[0], words && words[5]); +const empty: number[] = []; +console.log(empty && empty[0]); +function first(xs: (string | number)[]): string | number { + return xs && xs[0]; +} +console.log(first([1]), first(["s"])); +type Item = { n: number } | string; +const items: Item[] = [{ n: 1 }, "z"]; +console.log(items && items[0], items && items[1]); +const nested: number[][] = [[1]]; +console.log(nested && nested[0]); +const flags: boolean[] = [false]; +console.log(flags && flags[0]); +const maybe: (number | null)[] = [null]; +console.log(maybe && maybe[0]); +const holes: (number | undefined)[] = [undefined, 4]; +console.log(holes && holes[0], holes && holes[1]); +const box = { k: [1, 2] }; +console.log(box.k && box.k[1]); +// The left operand still evaluates exactly once. +let reads = 0; +const source = (): number[] => { + reads++; + return [7, 8]; +}; +console.log(source() && source()[1], reads); +const table: Record = { a: [5] }; +const pick = (key: string): number | undefined => table[key] && table[key][0]; +console.log(pick("a"), reads); +class Holder { + xs = [1, 2]; + second(): number | undefined { + return this.xs && this.xs[1]; + } +} +console.log(new Holder().second()); From e250214e0309c6ab2a6c3f0612563ad7830b6e4a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:40:50 -0700 Subject: [PATCH 3/9] Name objects built by plain constructor functions in util.inspect console.log(new F(2)) printed `{ a: 2 }` where Node prints `F { a: 2 }`. A construction through a plain function now stamps the first prototype on the receiver's chain whose own `constructor` data property holds a named function with that name, as Node's getConstructorName finds it. The dyn printer already reads such runtime class names, so its code is unchanged; stamped prototypes stay alive and are cleared before release at exit. `new F()` boxes F under its name so the prototype's constructor carries it. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 12 +++++- .../es5-constructor-inspect-names.cjs.json | 6 +++ packages/runtime/src/scr_json.c | 42 +++++++++++++++++++ .../corpus/es5-constructor-inspect-names.cjs | 41 ++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json create mode 100644 tests/corpus/es5-constructor-inspect-names.cjs diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 8bb3fd8cb6..0baaeb0edf 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -85,7 +85,7 @@ import { isNodeTypesPath, locOf, } from "../program.js"; -import { PoisonError, dynFallbackType, newFnCtx, own } from "./lowerer.js"; +import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./lowerer.js"; import { isAssertClassNew } from "./assert-instances.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; @@ -9715,7 +9715,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { ((callee.type.kind === "func" || callee.type.kind === "classval") && lowerer.dynConvertible(callee.type))) ) { - return lowerCheckedConstructor(lowerer, expr, lowerer.coerceToExpected(callee, DYN)); + let boxed = lowerer.coerceToExpected(callee, DYN); + // A plain function's box carries its name: the construction's + // prototype records it as `constructor`, which util.inspect prints + // (`F { a: 2 }`). + if (boxed.kind === "dynFrom" && boxed.fnName === undefined && callee.type.kind === "func") { + const name = jsFuncNameOf(expr.expression, lowerer); + if (name !== null) boxed = { ...boxed, fnName: name }; + } + return lowerCheckedConstructor(lowerer, expr, boxed); } lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json new file mode 100644 index 0000000000..b5e8ba0875 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/es5-constructor-inspect-names.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b23e782414..453cd6ac6a 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4336,6 +4336,47 @@ bool scr_dyn_data_view_is(const ScrDyn *value) { return value->kind == SCR_DYN_BYTES && value->v.bytes->is_data_view; } +/* util.inspect names an object after the function held by the first own + * `constructor` data property on its prototype chain (Node's + * getConstructorName). The dyn printer reads runtime class names + * (v.obj.class_name, set only on prototypes that stay alive), so a + * construction through a plain function stamps that prototype with the + * function's static name and keeps it alive; the stamps are cleared before + * release at exit, so freelist reuse never sees them. */ +static SCR_TL ScrDyn **scr_dyn_named_prototypes; +static SCR_TL size_t scr_dyn_named_count, scr_dyn_named_cap; + +static void scr_dyn_named_prototypes_cleanup(void) { + for (size_t i = 0; i < scr_dyn_named_count; i++) { + scr_dyn_named_prototypes[i]->v.obj.class_name = NULL; + scr_dyn_release(scr_dyn_named_prototypes[i]); + } + free(scr_dyn_named_prototypes); + scr_dyn_named_prototypes = NULL; + scr_dyn_named_count = scr_dyn_named_cap = 0; +} + +static void scr_dyn_name_prototype_chain(const ScrDyn *object) { + for (ScrDyn *p = object->prototype; p; p = p->prototype) { + if (p->kind != SCR_DYN_OBJ || p->v.obj.class_name) return; + ScrDynEntry *entry = scr_dyn_find_entry(p, "constructor", 11); + if (!entry || entry->accessor || !entry->value || entry->value->kind != SCR_DYN_FUNC) continue; + const char *name = entry->value->v.fn.name; + if (!name || !name[0]) continue; + if (scr_dyn_named_count == scr_dyn_named_cap) { + if (scr_dyn_named_cap == 0) scr_atexit(scr_dyn_named_prototypes_cleanup); + size_t cap = scr_dyn_named_cap ? scr_dyn_named_cap * 2 : 8; + ScrDyn **grown = realloc(scr_dyn_named_prototypes, cap * sizeof *grown); + if (!grown) scr_json_oom(); + scr_dyn_named_prototypes = grown; + scr_dyn_named_cap = cap; + } + p->v.obj.class_name = name; + scr_dyn_named_prototypes[scr_dyn_named_count++] = scr_dyn_retain(p); + return; + } +} + /* Ordinary native function constructors retain their live prototype table. * A constructor may replace the fresh receiver with another object. */ ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) { @@ -4346,6 +4387,7 @@ ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr ScrDyn *receiver = scr_iterator_object(prototype) ? scr_dyn_obj_create(prototype) : scr_dyn_new_obj(); scr_dyn_release(prototype); if (!receiver) return NULL; + scr_dyn_name_prototype_chain(receiver); scr_dyn_this_push_dyn(receiver); ScrDyn *result = scr_dyn_call(callee, args->v.arr.items, args->v.arr.len, what->data); scr_dyn_this_pop(); diff --git a/tests/corpus/es5-constructor-inspect-names.cjs b/tests/corpus/es5-constructor-inspect-names.cjs new file mode 100644 index 0000000000..b7cc843491 --- /dev/null +++ b/tests/corpus/es5-constructor-inspect-names.cjs @@ -0,0 +1,41 @@ +// util.inspect names objects built by plain constructor functions after +// the constructor their prototype chain records, as Node does. +'use strict'; +const util = require('util'); +function Point(x, y) { + this.x = x; + this.y = y; +} +Point.prototype.norm = function () { + return Math.hypot(this.x, this.y); +}; +const p = new Point(3, 4); +console.log(p, p.norm()); +p.z = 0; +console.log(p); +function Empty() {} +console.log(new Empty(), [new Empty()], { inner: new Empty() }); +console.log(util.inspect(new Point(1, 2))); +console.log(`${util.inspect({ list: [new Point(0, 1)] })}`); +// A subclass whose prototype was replaced without a constructor reports +// the nearest constructor on the chain. +function Base(n) { + this.n = n; +} +function Derived(n) { + Base.call(this, n); + this.extra = true; +} +Derived.prototype = Object.create(Base.prototype); +console.log(new Derived(1)); +Derived.prototype.constructor = Derived; +console.log(new Derived(2)); +// A replaced prototype object without a constructor prints plainly. +function Plain() { + this.v = 1; +} +Plain.prototype = { get() { return this.v; } }; +console.log(new Plain()); +const made = Object.create(Point.prototype); +made.x = 9; +console.log(made); From 8514c45b5351342a54a0a578dbbc1eee854d5d63 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:31 -0700 Subject: [PATCH 4/9] Make builtin module functions real function values in JavaScript uuid 11 threw `TypeError: native.randomUUID is not a function` where Node works. Root cause: a builtin module function used as a JavaScript value without a function-value adapter lowered to a string identity token ("[builtin crypto.randomUUID]"); uuid's native.js stores it in an exported record (`export default { randomUUID }`), so the call hit a string and typeof answered "string". crypto.randomUUID and randomUUIDv7 get value adapters (randomUUID's through its options-validating entry point, the new valueFn surface field), boxed adapters carry the builtin's name, and every other builtin module function value is one function per builtin that throws the named SC1090 fence when called, keeping identity, typeof and inspection Node's. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtin-function-fences.ts | 52 +++++++++++++++++++ .../src/frontend/lowering/lower-exprs.ts | 4 ++ .../compiler/src/frontend/lowering/lowerer.ts | 14 +++-- .../src/frontend/lowering/surfaces.ts | 21 +++++++- .../main.ts.json | 7 +++ .../corpus/builtin-function-values.mjs.json | 6 +++ .../builtin-function-record-values/main.ts | 10 ++++ .../builtin-function-record-values/native.js | 3 ++ tests/corpus/builtin-function-values.mjs | 17 ++++++ 9 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/builtin-function-fences.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json create mode 100644 tests/corpus/builtin-function-record-values/main.ts create mode 100644 tests/corpus/builtin-function-record-values/native.js create mode 100644 tests/corpus/builtin-function-values.mjs diff --git a/packages/compiler/src/frontend/lowering/builtin-function-fences.ts b/packages/compiler/src/frontend/lowering/builtin-function-fences.ts new file mode 100644 index 0000000000..e6221384b8 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtin-function-fences.ts @@ -0,0 +1,52 @@ +/** A builtin module function used as a JavaScript value when it has no + * value adapter (lowerNativeCallableValue). Node hands the program a real + * function; the compiled value is one function per builtin that throws the + * named fence when called. Identity flows (`fn === fs.readFileSync`, Set + * membership) still hold, `typeof` answers "function" and inspection + * prints `[Function: name]`, where a string token would answer "string" + * and turn a call into an ordinary "is not a function" TypeError. */ +import * as ts from "../ts7/adapter.js"; +import { DYN, type IrExpr, type SrcLoc, funcOf } from "../../ir/ir.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import type { Lowerer } from "./lowerer.js"; + +export function builtinFunctionFenceValue( + lowerer: Lowerer, + bi: { module: string; member: string }, + node: ts.Identifier, + loc: SrcLoc, +): IrExpr { + const display = `${bi.module}.${bi.member}`; + const name = `%builtin.fence.${display}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const diag = unsupportedDiag( + "SC1090", + loc, + `library functions as values ('${display}' has no function-value lowering; call it directly)`, + ); + lowerer.runtimeFences.push(diag); + const line = ts.getLineAndCharacterOfPosition(node.getSourceFile(), loc.start).line + 1; + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: DYN, + body: [ + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${loc.file}:${line}]`, + loc, + }, + ], + loc, + }); + } + return { + kind: "dynFrom", + value: { kind: "closure", fnName: name, captures: [], type: funcOf([], DYN), loc }, + fnName: bi.member, + type: DYN, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d3deaa7196..10b09acb12 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -31,6 +31,7 @@ import { literalUnionArm } from "../union-discriminants.js"; * (FieldTarget). */ import { isFsStreamMember, lowerFsStreamProperty } from "./builtins/fs-streams.js"; import * as ts from "../ts7/adapter.js"; +import { builtinFunctionFenceValue } from "./builtin-function-fences.js"; import { isZlibStreamMember, lowerZlibInstanceOf, @@ -1271,6 +1272,9 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // the same identity-token story as stdlib globals above (the // harness adds worker_threads.Worker to its identity Set). if (isJsSourceFile(expr.getSourceFile())) { + // A function member is a function value that fences when called. + if (builtinModuleFnOf(lowerer, bi.module, bi.member)) + return builtinFunctionFenceValue(lowerer, bi, expr, loc); return { kind: "strLit", value: `[builtin ${bi.module}.${bi.member}]`, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cdc1703645..b0efdd048a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2434,6 +2434,8 @@ export class Lowerer { * so named imports, namespace reads, CommonJS bindings, and alias chains * all reuse one immortal zero-capture closure. */ readonly builtinCallableValueFns = new Map(); + /** Each builtin function-value adapter's Node name, for boxed values. */ + readonly builtinCallableValueNames = new Map(); /** Optional-chain lowering state. While a chain body lowers, the guarded * receiver NODE reads as a chainRecv (typed by the narrowed arm) instead * of re-lowering, its checker type reads non-nullish (typeOf), and the @@ -8217,12 +8219,17 @@ export class Lowerer { liveReference(expr.type) || (expr.type.kind === "union" && (this.unions.get(expr.type.unionId)?.arms.some(liveReference) ?? false)); + // A builtin function's adapter boxes under the builtin's name, the + // name Node's function carries (`[Function: randomUUID]`). + const builtinName = + expr.kind === "closure" ? this.builtinCallableValueNames.get(expr.fnName) : undefined; return { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc, ...(liveRef ? { liveRef: true as const } : {}), + ...(builtinName !== undefined ? { fnName: builtinName } : {}), }; } return expr; @@ -11923,11 +11930,12 @@ export class Lowerer { param.mode === "optional" ? `${param.mode}:${param.defaultValue}` : param.mode, ) .join(","); - const key = `${fn.fn}:${typeKey(funcType)}:${adapterShape}`; + const key = `${fn.valueFn ?? fn.fn}:${typeKey(funcType)}:${adapterShape}`; let fnName = this.builtinCallableValueFns.get(key); if (!fnName) { - fnName = `%builtin.value.${fn.fn}.${this.builtinCallableValueFns.size}`; + fnName = `%builtin.value.${fn.valueFn ?? fn.fn}.${this.builtinCallableValueFns.size}`; this.builtinCallableValueFns.set(key, fnName); + this.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1)); const params = funcType.params.map((type, index) => ({ localId: `arg.${index}`, name: `arg${index}`, @@ -11992,7 +12000,7 @@ export class Lowerer { loc, }; }); - const call: IrExpr = { kind: "libCall", fn: fn.fn, args, type: fn.result, loc }; + const call: IrExpr = { kind: "libCall", fn: fn.valueFn ?? fn.fn, args, type: fn.result, loc }; const body: IrStmt[] = fn.result.kind === "void" ? [ diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 73cc58c45f..2952ee5870 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -793,6 +793,10 @@ export interface BuiltinModuleFn { * omission or explicit undefined; rest parameters pack into one typed * array slot. Entries with call-site-specific validation remain absent. */ valueParams?: BuiltinValueParam[]; + /** The libCall a function value's adapter makes when it differs from the + * direct-call row's `fn` (a direct call picks an arity-specific entry + * point; the value accepts every arity through one). */ + valueFn?: IrLibFn; } export type BuiltinValueParam = @@ -1502,7 +1506,15 @@ export const BUILTIN_MODULE_FNS: Record< userInfo: { fn: "os.userName", params: [], result: VOID }, }, crypto: { - randomUUID: { fn: "crypto.randomUUID", params: [], result: STRING }, + randomUUID: { + fn: "crypto.randomUUID", + params: [], + result: STRING, + // As a value (uuid's `export default { randomUUID }`), the optional + // options argument goes through the validating entry point. + valueFn: "crypto.randomUUIDOpts", + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + }, // A real Buffer result. The composed randomBytes(n).toString(enc) // form KEEPS its one-libCall lowering (lowerCryptoComposedCall runs // first and the Buffer never materializes there); this entry covers @@ -1523,7 +1535,12 @@ export const BUILTIN_MODULE_FNS: Record< randomFillSync: { fn: "crypto.randomFill", params: [BYTES_U8, F64, F64], result: BYTES_U8 }, randomInt: { fn: "crypto.randomInt", params: [F64, F64], result: F64 }, randomFill: { fn: "crypto.randomFillCb", params: [], result: VOID }, - randomUUIDv7: { fn: "crypto.randomUUIDv7", params: [DYN], result: STRING }, + randomUUIDv7: { + fn: "crypto.randomUUIDv7", + params: [DYN], + result: STRING, + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + }, createSecretKey: { fn: "crypto.createSecretKey", params: [DYN, DYN], result: DYN }, generateKeySync: { fn: "crypto.generateKeySync", params: [DYN, DYN], result: DYN }, generateKey: { fn: "crypto.generateKeyCb", params: [], result: VOID }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json new file mode 100644 index 0000000000..eb79fc63a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/builtin-function-record-values/native.js", + "/tests/corpus/builtin-function-record-values/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json new file mode 100644 index 0000000000..f13bd6642b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/builtin-function-values.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/builtin-function-record-values/main.ts b/tests/corpus/builtin-function-record-values/main.ts new file mode 100644 index 0000000000..c742acc161 --- /dev/null +++ b/tests/corpus/builtin-function-record-values/main.ts @@ -0,0 +1,10 @@ +// A builtin function stored in a JavaScript record is a real function: +// TypeScript consumers call it through the record. +import native from "./native.js"; +const shape = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const id = native.randomUUID(); +console.log(typeof id, id.length, shape.test(id)); +const ids = new Set(); +for (let i = 0; i < 50; i++) ids.add(native.randomUUID()); +console.log(ids.size); +console.log(shape.test(native.randomUUID({ disableEntropyCache: true }))); diff --git a/tests/corpus/builtin-function-record-values/native.js b/tests/corpus/builtin-function-record-values/native.js new file mode 100644 index 0000000000..7009a2cd95 --- /dev/null +++ b/tests/corpus/builtin-function-record-values/native.js @@ -0,0 +1,3 @@ +// uuid's native.js layout: a record exporting a builtin function value. +import { randomUUID } from "crypto"; +export default { randomUUID }; diff --git a/tests/corpus/builtin-function-values.mjs b/tests/corpus/builtin-function-values.mjs new file mode 100644 index 0000000000..ad03312919 --- /dev/null +++ b/tests/corpus/builtin-function-values.mjs @@ -0,0 +1,17 @@ +// Builtin functions used as JavaScript values are functions: typeof, +// identity, their name, and calls through the value. +import { randomUUID, scryptSync } from "crypto"; +const table = { randomUUID, scryptSync }; +console.log(typeof table.randomUUID, table.randomUUID === randomUUID); +console.log(/^[0-9a-f-]{36}$/.test(table.randomUUID())); +console.log(/^[0-9a-f-]{36}$/.test(table.randomUUID(undefined))); +try { + table.randomUUID(5); +} catch (e) { + console.log(e.code, e.name); +} +console.log(table.randomUUID); +console.log(typeof table.scryptSync, table.scryptSync === scryptSync); +console.log(new Set([scryptSync]).has(table.scryptSync), table.scryptSync.name); +const pick = (useNative) => (useNative && table.randomUUID ? table.randomUUID() : "fallback"); +console.log(pick(true).length, pick(false)); From 815ce664e59b45d03b51590fea5a0c65da339e17 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:31 -0700 Subject: [PATCH 5/9] Let writes initialize a module let declared without an initializer `let f: T; f = (n) => ... f(n - 1)` at module scope threw "Cannot access 'f' before initialization": a function-typed (pointer-guarded) binding kept the null-pointer TDZ test on its writes, but the declaration already initialized it (to undefined) when it ran. Such bindings now carry the existing initialization flag: the declaration sets it, writes that can run earlier check it, and writes no longer test the empty pointer. A write that really precedes the declaration still throws. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/cycle-initialization.ts | 45 ++++++++++++++++++ .../src/frontend/lowering/lower-modules.ts | 5 ++ .../compiler/src/frontend/lowering/lowerer.ts | 7 +++ ...let-uninitialized-function-binding.ts.json | 6 +++ .../let-uninitialized-function-binding.ts | 46 +++++++++++++++++++ 5 files changed, 109 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json create mode 100644 tests/corpus/let-uninitialized-function-binding.ts diff --git a/packages/compiler/src/frontend/lowering/cycle-initialization.ts b/packages/compiler/src/frontend/lowering/cycle-initialization.ts index 1e8b9e5a58..fb62fb998f 100644 --- a/packages/compiler/src/frontend/lowering/cycle-initialization.ts +++ b/packages/compiler/src/frontend/lowering/cycle-initialization.ts @@ -41,6 +41,10 @@ export interface CycleInitFlag { * own module's early calls is checked opportunistically: whatever cannot * be checked keeps the unchecked behavior instead of refusing. */ cycle: boolean; + /** A pointer-guarded binding declared without an initializer: its + * declaration initializes it (to undefined, which the pointer cannot + * hold), so the flag alone decides its writes (markUninitializedWrites). */ + uninitialized?: true; /** A class binding: its references are the class's own IR forms and its * static field storage (`global` is the flag itself). */ className?: string; @@ -107,6 +111,30 @@ export function markCycleEarlyBindings(lowerer: Lowerer): void { registerInitFlag(lowerer, binding, global, false); } } + // `let f: T;` with a pointer-guarded slot: once the declaration ran the + // binding is initialized while the slot is still empty, so a write + // checks the flag (where code can run before the declaration) instead of + // the empty pointer. `f = () => f(...)` is the shape. + for (const [id, entry] of lowerer.uninitializedLexicalGlobals) { + const global = lowerer.globalsList.find((g) => g.id === id); + if (global === undefined) continue; + if (!lowerer.cycleInitFlags.has(id)) { + registerInitFlag( + lowerer, + { + symbol: entry.symbol, + name: entry.name, + kind: "lexical", + module: entry.module, + reference: entry.name, + settled: new Set(), + }, + global, + false, + ); + } + lowerer.cycleInitFlags.get(id)!.uninitialized = true; + } } /** A class that its module's early-running code can reach before the class @@ -304,6 +332,22 @@ export function checkCycleBindingAccesses(lowerer: Lowerer, mod: IrModule): void const fileOfInit = new Map(); for (const [sf, name] of lowerer.initNameOf) fileOfInit.set(name, sf); for (const fn of mod.functions) rewriteFunction(lowerer, fn, fileOfInit.get(fn.name)); + markUninitializedWrites(lowerer, mod); +} + +/** Writes to a binding declared without an initializer that run after its + * declaration store into an initialized binding whose slot may still be + * empty: the pointer guard must not refuse them. Writes that can run + * earlier carry the flag check rewriteFunction inserted. */ +function markUninitializedWrites(lowerer: Lowerer, mod: IrModule): void { + const ids = new Set(); + for (const [id, flag] of lowerer.cycleInitFlags) if (flag.uninitialized) ids.add(id); + if (ids.size === 0) return; + const mark = (s: IrStmt): IrStmt => + s.kind === "assign" && !s.initializes && ids.has(s.localId) ? { ...s, initializes: true } : s; + const rewriteExpr = (e: IrExpr): IrExpr => mapExprChildren(e, rewriteExpr, rewriteStmt); + const rewriteStmt = (s: IrStmt): IrStmt => mark(mapStmtChildren(s, rewriteExpr, rewriteStmt)); + for (const fn of mod.functions) fn.body = fn.body.map(rewriteStmt); } function rewriteFunction( @@ -444,6 +488,7 @@ function rewriteFunction( kind: "assign", localId: s.localId, value: { kind: "varRef", localId: t, type: s.value.type, loc: s.loc }, + ...(flag.uninitialized ? { initializes: true as const } : {}), loc: s.loc, }; return { diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index da70cd53ae..96fd170f88 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2426,6 +2426,11 @@ export function collectGlobals( }; lowerer.globalsBySymbol.set(symbol, g); lowerer.globalsList.push(g); + // `let f: T;` initializes the binding (to undefined) where the + // declaration runs: later writes are ordinary stores, which the + // pointer guard alone would refuse (markCycleEarlyBindings). + if (g.tdz && !decl.initializer) + lowerer.uninitializedLexicalGlobals.set(g.id, { name: nameNode, module: sf, symbol }); // Mutable checked-dynamic LET globals ride the same entry // init: a closure called above the declaration statement // reads the dyn undefined instead of faulting on NULL — the diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b0efdd048a..7eaf92f36e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2712,6 +2712,13 @@ export class Lowerer { /** Each class's constructor return plan by class name * (constructor-returns.ts); null when no return can yield an object. */ readonly constructorReturnPlans = new Map(); + /** Pointer-guarded (`tdz`) module bindings declared without an + * initializer (`let f: T;`): an initialization flag tracks their + * declaration (markCycleEarlyBindings). */ + readonly uninitializedLexicalGlobals = new Map< + string, + { name: ts.Identifier; module: ts.SourceFile; symbol: ts.Symbol } + >(); /** CommonJS require declarations whose bindings code running before the * require can read, and those references (require-initialization.ts). */ readonly requireInitFlags = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json new file mode 100644 index 0000000000..f5706acaa4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/let-uninitialized-function-binding.ts" + ], + "diags": [] +} diff --git a/tests/corpus/let-uninitialized-function-binding.ts b/tests/corpus/let-uninitialized-function-binding.ts new file mode 100644 index 0000000000..ade6748d30 --- /dev/null +++ b/tests/corpus/let-uninitialized-function-binding.ts @@ -0,0 +1,46 @@ +// A module-scope `let` declared without an initializer is initialized (to +// undefined) where the declaration runs, so a later assignment of a closure +// that calls the binding itself is an ordinary write. +type Step = (n: number) => number; + +// A write that runs before the declaration is still in the dead zone. +function early(): void { + late = () => 1; +} +try { + early(); +} catch (e) { + console.log((e as Error).name, (e as Error).message); +} +let late: () => number; +late = () => 2; +early(); +console.log(late()); + +let countdown: Step; +countdown = (n) => (n <= 0 ? 0 : 1 + countdown(n - 1)); +console.log(countdown(5)); + +let log: (s: string) => void; +log = (s) => { + if (s.length < 3) log(s + "!"); + else console.log(s); +}; +log("a"); + +// Reassignment and assignment from a function body after the declaration. +let pick: (xs: number[]) => number; +function install(largest: boolean): void { + pick = largest ? (xs) => Math.max(...xs) : (xs) => Math.min(...xs); +} +install(true); +console.log(pick!([3, 9, 4])); +install(false); +console.log(pick!([3, 9, 4])); + +// Mutual recursion through two such bindings. +let isEven: (n: number) => boolean; +let isOdd: (n: number) => boolean; +isEven = (n) => (n === 0 ? true : isOdd(n - 1)); +isOdd = (n) => (n === 0 ? false : isEven(n - 1)); +console.log(isEven(10), isOdd(7), isEven(3)); From 8a89024e7b8ef8e11c3e64cbf9552a7b1ad1858b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:34:31 -0700 Subject: [PATCH 6/9] Refuse module exports that read this when called through the module binding `m.f()` where `m` is `const m = require("./x")` or `import * as m from "./x"` lowers to a direct call of the exported function, so a function that reads `this` saw no receiver (or another frame's) where Node passes the module object: `exports.hello = function () { return this.name; }` threw a TypeError natively. Such calls now refuse with SC1090 (deferred in JavaScript); exports that do not read this compile as before. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 3 + .../frontend/lowering/module-member-this.ts | 64 +++++++++++++++++++ tests/harness/errors.test.ts | 49 ++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 packages/compiler/src/frontend/lowering/module-member-this.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c1..76240a7b06 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,4 +1,5 @@ import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js"; +import { refuseModuleMemberThis } from "./module-member-this.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { lowerHashStreamCall } from "./builtins/hash-stream.js"; import { lowerArrayCallbackCall } from "./containers/array-callback-call.js"; @@ -1188,6 +1189,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (facadeServed) return facadeServed; const memberSym = lowerer.checker.getSymbolAtLocation(nsMember); if (memberSym) fenceEarlyNsMemberRef(lowerer, expr.expression, memberSym); + refuseModuleMemberThis(lowerer, expr, nsMember); const generic = lowerer.genericFnOf(nsMember); if (generic) return lowerer.lowerGenericCall(expr, generic); const sig = lowerer.fnSigOf(nsMember); @@ -1251,6 +1253,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (!ts.isIdentifier(nameId)) { lowerer.unsupported("SC1090", nameId, "private-named module members"); } + refuseModuleMemberThis(lowerer, expr, nameId); const generic = lowerer.genericFnOf(nameId); if (generic) return lowerer.lowerGenericCall(expr, generic); const sig = lowerer.fnSigOf(nameId); diff --git a/packages/compiler/src/frontend/lowering/module-member-this.ts b/packages/compiler/src/frontend/lowering/module-member-this.ts new file mode 100644 index 0000000000..a29e361d9c --- /dev/null +++ b/packages/compiler/src/frontend/lowering/module-member-this.ts @@ -0,0 +1,64 @@ +/* A module export called through its module binding (`m.f()` where `m` is + * `const m = require("./x")` or `import * as m from "./x"`): Node passes + * the module object (the exports object, or the namespace) as `this`. The + * member call lowers to a direct call of the exported function, which has + * no module object to pass, so an export that reads `this` refuses instead + * of seeing another receiver. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +/** The function an export binding holds, when its declaration shows it. */ +function exportedFunction(decl: ts.Node): ts.Node | null { + let node: ts.Node | undefined = decl; + if (ts.isPropertyAccessExpression(node) && node.parent && ts.isBinaryExpression(node.parent)) + node = node.parent; + if (ts.isBinaryExpression(node)) node = node.right; + else if (ts.isVariableDeclaration(node) || ts.isPropertyAssignment(node)) node = node.initializer; + while (node !== undefined && ts.isParenthesizedExpression(node)) node = node.expression; + if (node === undefined) return null; + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isMethodDeclaration(node) + ) + return node; + return null; +} + +/** Whether a function's own body reads `this` (arrows share it; other + * functions and classes have their own). */ +function readsOwnThis(fn: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if (node.kind === ts.SyntaxKind.ThisKeyword) { + found = true; + return; + } + if ((ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLikeDeclaration(node)) + return; + node.forEachChild(visit); + }; + fn.forEachChild(visit); + return found; +} + +export function refuseModuleMemberThis( + lowerer: Lowerer, + call: ts.CallExpression, + member: ts.Identifier, +): void { + let symbol = lowerer.checker.getSymbolAtLocation(member); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + if (symbol === undefined) return; + for (const decl of lowerer.checker.declarationsOf(symbol)) { + const fn = exportedFunction(decl); + if (fn === null || !readsOwnThis(fn)) continue; + lowerer.unsupported( + "SC1090", + call, + `calling a module export that reads 'this' through its module binding ('${member.text}': Node passes the module object as 'this', which this call has no value for)`, + ); + } +} diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 856c1ad7de..d99192d207 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1065,3 +1065,52 @@ console.log(new Wrap({ a: 1 })); ); }); }); + +describe("module exports that read this, called through the module binding (JS lane)", () => { + test("a require binding's member call is a named deferred fence", async () => { + // Node passes the exports object as `this` to `m.hello()`; the direct + // call has none to pass, so it refuses instead of reading another one. + const r = await compileAndRun( + "require-member-this", + `'use strict'; +const m = require('./lib.cjs'); +console.log(m.plain(21)); +console.log(m.hello()); +`, + "cjs", + { + "lib.cjs": `'use strict'; +exports.name = 'lib'; +exports.hello = function () { return 'hi from ' + this.name; }; +exports.plain = function (x) { return x * 2; }; +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("42\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: calling a module export that reads 'this' through its module binding \('hello': .*\) is not supported yet \[SC1090 at .*require-member-this\.cjs:4\]\n$/, + ); + }); + + test("a namespace import's member call is a named deferred fence", async () => { + const r = await compileAndRun( + "namespace-member-this", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.who()); +`, + "mjs", + { + "lib.mjs": `export function who() { return typeof this; } +export function plain(x) { return x + 1; } +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("2\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: calling a module export that reads 'this' through its module binding \('who': .*\) is not supported yet \[SC1090 at .*namespace-member-this\.mjs:3\]\n$/, + ); + }); +}); From 4e72477c55918a5976a58d0181895c9473cdefa0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 16:12:12 -0700 Subject: [PATCH 7/9] =?UTF-8?q?Answer=20NaN=20for=20**=20with=20a=20base?= =?UTF-8?q?=20of=20=C2=B11=20and=20a=20non-finite=20exponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `1 ** x` with x = Infinity printed 1 where Node prints NaN: the operator lowered to libm's pow, which answers 1 for pow(±1, ±Infinity) and pow(1, NaN) (and LLVM folds pow(1.0, x) to 1.0). Such operands now keep the direct pow call behind an inline guard that selects NaN when the base is ±1 and the exponent is not finite. A finite literal exponent (x ** 2) or a literal base other than ±1 (2 ** k) cannot meet those cases and keeps the bare call. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/llvm/expr-primitives.ts | 40 ++++++++++++++++++- .../tests/corpus/exponent-unit-base.ts.json | 6 +++ tests/corpus/exponent-unit-base.ts | 16 ++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json create mode 100644 tests/corpus/exponent-unit-base.ts diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index e237f8f13c..dc2ba0112f 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -379,7 +379,29 @@ export function emitOperatorExpr( const fn = libm[e.op]; if (fn === undefined) throw new LlvmUnsupportedError(`bin:${e.op}`, e.loc); host.declare(`declare double @${fn}(double, double)`); - B.line(`${t} = call double @${fn}(double ${l.name}, double ${r.name})`); + // libm's pow answers 1 for pow(±1, ±Infinity) and pow(1, NaN), where + // `**` answers NaN (and LLVM folds pow(1.0, x) to 1.0): an inline + // guard selects NaN for a base of ±1 with a non-finite exponent and + // keeps the direct pow call. A finite literal exponent, or a literal + // base other than ±1, cannot meet those cases and needs no guard. + if (fn === "pow" && !libmPowAgrees(e.left, e.right)) { + host.declare(`declare double @llvm.fabs.f64(double)`); + const power = B.tmp(); + B.line(`${power} = call double @pow(double ${l.name}, double ${r.name})`); + const base = B.tmp(); + const unit = B.tmp(); + const magnitude = B.tmp(); + const finite = B.tmp(); + const reject = B.tmp(); + B.line(`${base} = call double @llvm.fabs.f64(double ${l.name})`); + B.line(`${unit} = fcmp oeq double ${base}, 1.0`); + B.line(`${magnitude} = call double @llvm.fabs.f64(double ${r.name})`); + B.line(`${finite} = fcmp olt double ${magnitude}, 0x7FF0000000000000`); + B.line(`${reject} = select i1 ${finite}, i1 false, i1 ${unit}`); + B.line(`${t} = select i1 ${reject}, double 0x7FF8000000000000, double ${power}`); + } else { + B.line(`${t} = call double @${fn}(double ${l.name}, double ${r.name})`); + } } return { name: t, type: e.type }; } @@ -1103,3 +1125,19 @@ export function emitRecordLitFields( host.initField(ptr, type, inline, v); } } + +/** Whether libm pow matches `**` for these operands: every case except a + * base of ±1 with a NaN or infinite exponent agrees. */ +function libmPowAgrees(base: IrExpr, exponent: IrExpr): boolean { + const power = numericLiteral(exponent); + if (power !== null && Number.isFinite(power)) return true; + const value = numericLiteral(base); + return value !== null && !Number.isNaN(value) && Math.abs(value) !== 1; +} + +function numericLiteral(expr: IrExpr): number | null { + if (expr.kind === "numLit") return expr.value; + if (expr.kind === "unary" && expr.op === "-" && expr.operand.kind === "numLit") + return -expr.operand.value; + return null; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json new file mode 100644 index 0000000000..b8de10f6a1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/exponent-unit-base.ts" + ], + "diags": [] +} diff --git a/tests/corpus/exponent-unit-base.ts b/tests/corpus/exponent-unit-base.ts new file mode 100644 index 0000000000..d8d4cd1ac9 --- /dev/null +++ b/tests/corpus/exponent-unit-base.ts @@ -0,0 +1,16 @@ +// `**` answers NaN for a base of ±1 with a NaN or infinite exponent, where +// libm's pow (and LLVM's folding of pow(1, x)) answers 1. +const values = [Infinity, -Infinity, NaN, 0, -0, 2, 0.5]; +const read = (i: number): number => values[i % values.length]!; +for (let i = 0; i < values.length; i++) { + const x = read(i); + console.log(x, 1 ** x, (-1) ** x, x ** 0, x ** 1, 2 ** x, x ** -1); +} +let base = 1; +let exponent = Infinity; +console.log(base ** exponent, (-base) ** exponent, base ** NaN); +exponent = 3; +console.log(base ** exponent, (-1) ** exponent); +let acc = 1; +acc **= Infinity; +console.log(acc, Math.pow(1, Infinity), NaN ** 0, (-0) ** -1, 0 ** -1); From 907d481a1d55380ea1099f162f4b5f767e629728 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 21:31:43 -0500 Subject: [PATCH 8/9] Refuse super() into a constructor that returns an instance only when it returns another one A base constructor that returns an instance of its class gets a result slot, and super() from a subclass refused. three.js's Color constructor ends in `return this.set(r, g, b)`, where set returns this, so every construction of Color4 (`super(r, g, b)` during three.webgpu.js's module initialization) threw the refusal, while Node constructs the subclass's own instance. In JavaScript, super() into such a class now calls a per-subclass helper after the base constructor: it reads and clears the base's result slot, and refuses at run time only when the base returned an instance other than the subclass's this, where Node would rebind it. TypeScript subclasses keep the compile-time refusal. --- .../frontend/lowering/constructor-returns.ts | 93 +++++++++++++++++-- .../src/frontend/lowering/lower-classes.ts | 13 ++- .../constructor-returns-this-super.js.json | 6 ++ .../corpus/constructor-returns-this-super.js | 33 +++++++ tests/harness/corpus-costs.json | 2 + 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json create mode 100644 tests/corpus/constructor-returns-this-super.js diff --git a/packages/compiler/src/frontend/lowering/constructor-returns.ts b/packages/compiler/src/frontend/lowering/constructor-returns.ts index d054a6b490..950ace57cb 100644 --- a/packages/compiler/src/frontend/lowering/constructor-returns.ts +++ b/packages/compiler/src/frontend/lowering/constructor-returns.ts @@ -17,7 +17,10 @@ * object refuses. * * `super()` from a subclass refuses: Node would rebind the subclass's - * `this` to the returned instance. */ + * `this` to the returned instance. In JavaScript the refusal is a run-time + * test after the call: an instance other than the subclass's own `this` + * refuses, and the constructed instance itself (`return this.set(r, g, + * b)`, where `set` answers `this`) continues. */ import * as ts from "../ts7/adapter.js"; import { varRef } from "../../ir/build.js"; import { @@ -29,6 +32,7 @@ import { type IrType, STRING, typeKey, + VOID, } from "../../ir/ir.js"; import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; import { isJsSourceFile, locOf } from "../program.js"; @@ -363,15 +367,84 @@ export function constructedFromValue( return constructedValue(lowerer, info, constructed); } -/** `super()` into a class with a result slot: Node rebinds the subclass's - * `this` to the returned instance, which has no representation. */ -export function refuseSuperIntoResultSlot(lowerer: Lowerer, base: ClassInfo, blame: ts.Node): void { +/** `super()` into a class with a result slot, from the constructor of + * class `sub`: `call` runs the base constructor over `self` (the + * subclass's `this`, upcast to the base). Node rebinds the subclass's + * `this` to an instance the base constructor returns, which has no + * representation. A TypeScript subclass refuses. In JavaScript the base + * stores the instance it returns in its slot, and a per-subclass helper + * reads and clears the slot after the call: an instance other than `self` + * refuses at run time. Unchanged for bases without a result slot. */ +export function superIntoResultSlot( + lowerer: Lowerer, + base: ClassInfo, + sub: ClassInfo, + call: IrStmt, + self: IrExpr, + blame: ts.Node, +): IrStmt { const plan = constructorReturnPlan(lowerer, base); - if (plan === null || plan.slot === null) return; + if (plan === null || plan.slot === null) return call; + const slot = plan.slot; const display = base.def.name.replace(/^%|^%m\d+\./, ""); - lowerer.unsupported( - "SC1090", - blame, - `extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance '${display}' returns)`, - ); + const feature = `extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance '${display}' returns)`; + if (!isJsSourceFile(blame.getSourceFile())) { + lowerer.unsupported("SC1090", blame, feature); + return call; + } + const loc = locOf(blame); + const type = instanceType(base); + const name = `%${sub.def.name}.%superResult`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const diag = unsupportedDiag("SC1090", loc, feature); + lowerer.runtimeFences.push(diag); + const line = ts.getLineAndCharacterOfPosition(blame.getSourceFile(), loc.start).line + 1; + const instance = { id: "instance.0", name: "instance", type, mutable: false }; + const returned = { id: "returned.0", name: "returned", type: slot.type, mutable: false }; + lowerer.liftedFns.push({ + name, + params: [{ localId: instance.id, name: instance.name, type }], + returnType: VOID, + locals: [instance, returned], + body: [ + { kind: "varDecl", localId: returned.id, init: varRef(slot.id, slot.type, loc), loc }, + { kind: "assign", localId: slot.id, value: lowerer.wrappedUndefined(slot.type, loc)!, loc }, + { + kind: "if", + cond: { + kind: "bin", + op: "!==", + left: { + kind: "orDefault", + left: varRef(returned.id, slot.type, loc), + right: varRef(instance.id, type, loc), + type, + loc, + }, + right: varRef(instance.id, type, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${loc.file}:${line}]`, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "return", value: null, loc }, + ], + loc, + }); + } + const check: IrStmt = { + kind: "exprStmt", + expr: { kind: "call", callee: name, args: [self], type: VOID, loc }, + loc, + }; + return { kind: "block", body: [call, check], loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 568308b47a..4f33bdc699 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -3,7 +3,7 @@ import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; import { constructedFromValue, constructedValue, - refuseSuperIntoResultSlot, + superIntoResultSlot, } from "./constructor-returns.js"; import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { lowerReadlineUiNew } from "./builtins/readline.js"; @@ -8032,9 +8032,8 @@ export function superCallStmt( info.decl ?? info.ctor!, )[0]!; } - refuseSuperIntoResultSlot(lowerer, base, info.ctor ?? info.decl!); lowerer.noteSuperConstructorEdge(`%${base.def.name}.constructor`); - return { + const call: IrStmt = { kind: "exprStmt", expr: { kind: "call", @@ -8045,6 +8044,14 @@ export function superCallStmt( }, loc, }; + return superIntoResultSlot( + lowerer, + base, + info, + call, + lowerer.upcastTo({ ...thisRef }, base.def.name), + info.ctor ?? info.decl!, + ); } /** `super.method(args)`: the base chain's implementation, called diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json new file mode 100644 index 0000000000..fa81da713c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-this-super.js" + ], + "diags": [] +} diff --git a/tests/corpus/constructor-returns-this-super.js b/tests/corpus/constructor-returns-this-super.js new file mode 100644 index 0000000000..3901c55536 --- /dev/null +++ b/tests/corpus/constructor-returns-this-super.js @@ -0,0 +1,33 @@ +// A JavaScript base constructor that returns `this` through a method, as +// three.js's Color does: `super()` from a subclass keeps the subclass's own +// instance. +class Color { + constructor(r, g, b) { + this.r = 1; + this.g = 1; + this.b = 1; + return this.set(r, g, b); + } + set(r, g, b) { + if (g === undefined) { + this.r = this.g = this.b = r; + } else { + this.r = r; + this.g = g; + this.b = b; + } + return this; + } +} +class Color4 extends Color { + constructor(r, g, b, a = 1) { + super(r, g, b); + this.a = a; + } +} +const c = new Color(0.5); +const c4 = new Color4(0.1, 0.2, 0.3); +const c4b = new Color4(1, 0, 0, 0.5); +console.log(c.r, c.g, c.b, c instanceof Color); +console.log(c4.r, c4.g, c4.b, c4.a, c4 instanceof Color4, c4 instanceof Color); +console.log(c4b.a, c4b !== c4, Object.keys(c4b).join(",")); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 1ba71d05fa..aa9621ecc7 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2485,6 +2485,7 @@ "constant-module-globals/main.ts": 2455, "constant-number-remainders.ts": 2350, "constructed-class-dispatch.ts": 3014, + "constructor-returns-this-super.js": 2431, "contextual-array-union-write.ts": 5379, "contextual-return-layouts.ts": 2919, "counted-loop-numbers.ts": 2515, @@ -5541,6 +5542,7 @@ "constant-module-globals/main.ts": 2911, "constant-number-remainders.ts": 2372, "constructed-class-dispatch.ts": 3382, + "constructor-returns-this-super.js": 4629, "contextual-array-union-write.ts": 6417, "contextual-return-layouts.ts": 3746, "counted-loop-numbers.ts": 2724, From 9290f32e0d3d216be56535ed10413ba5c6be4ad9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 20:37:35 -0700 Subject: [PATCH 9/9] Treat constructor returns of chainable this-calls as returning this three.js's Color constructor ends with `return this.set(r, g, b)`, and every reachable `set` (Color's, and Color4's override returning `super.set(...)`) returns `this`. The return analysis classified it as returning another instance and refused `class Color4 extends Color`. A return that is `this`, or a `this.m(...)`/`super.m(...)` call whose every reachable implementation returns only `this`, a primitive or nothing, now keeps the ordinary lowering, so such classes need no result slot and subclasses construct through super() as before. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/constructor-returns.ts | 74 ++++++++++++++++++- .../constructor-returns-chained-this.js.json | 6 ++ .../constructor-returns-chained-this.ts.json | 6 ++ .../constructor-returns-chained-this.js | 40 ++++++++++ .../constructor-returns-chained-this.ts | 38 ++++++++++ 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.ts.json create mode 100644 tests/corpus/constructor-returns-chained-this.js create mode 100644 tests/corpus/constructor-returns-chained-this.ts diff --git a/packages/compiler/src/frontend/lowering/constructor-returns.ts b/packages/compiler/src/frontend/lowering/constructor-returns.ts index d054a6b490..806f1824eb 100644 --- a/packages/compiler/src/frontend/lowering/constructor-returns.ts +++ b/packages/compiler/src/frontend/lowering/constructor-returns.ts @@ -118,6 +118,78 @@ function isInstanceOf(lowerer: Lowerer, info: ClassInfo, type: IrType | null): b ); } +/** Whether an expression always yields the receiver itself: `this`, or a + * call `this.m(...)` / `super.m(...)` whose every implementation the + * receiver can reach (the statically resolved method and, for `this` + * calls, each subclass override) returns only `this`, a primitive, or + * nothing (`return this.set(r, g, b)` with a chainable setter). Such a + * return answers the constructed instance. Methods already being checked + * count as returning `this` (their other returns decide). */ +function returnsThis( + lowerer: Lowerer, + info: ClassInfo, + node: ts.Expression, + visiting: Set = new Set(), +): boolean { + let e = node; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e)) + e = e.expression; + if (e.kind === ts.SyntaxKind.ThisKeyword) return true; + if (!ts.isCallExpression(e)) return false; + const callee = unparenthesized(e.expression); + if ( + !ts.isPropertyAccessExpression(callee) || + callee.questionDotToken !== undefined || + !ts.isIdentifier(callee.name) + ) + return false; + const receiver = unparenthesized(callee.expression).kind; + if (receiver !== ts.SyntaxKind.ThisKeyword && receiver !== ts.SyntaxKind.SuperKeyword) + return false; + const name = callee.name.text; + const symbol = lowerer.checker.getSymbolAtLocation(callee.name); + if (symbol === undefined) return false; + const methods: ts.Node[] = [...lowerer.checker.declarationsOf(symbol)]; + if (receiver === ts.SyntaxKind.ThisKeyword) { + const pending = [...info.subclasses]; + while (pending.length > 0) { + const sub = pending.pop()!; + pending.push(...sub.subclasses); + for (const member of sub.decl?.members ?? []) { + if (member.name === undefined || !ts.isIdentifier(member.name) || member.name.text !== name) + continue; + if (!ts.isMethodDeclaration(member)) return false; + methods.push(member); + } + } + } + if (methods.length === 0) return false; + for (const method of methods) { + if (!ts.isMethodDeclaration(method) || method.body === undefined) return false; + if (visiting.has(method)) continue; + visiting.add(method); + for (const ret of ownReturns(method.body)) { + if (ret.expression === undefined) continue; + if (objectness(lowerer.typeOf(ret.expression)) === "never") continue; + if (!returnsThis(lowerer, info, ret.expression, visiting)) return false; + } + } + return true; +} + +/** A function body's own return statements (nested functions, arrows and + * classes have their own). */ +function ownReturns(body: ts.Node): ts.ReturnStatement[] { + const out: ts.ReturnStatement[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node)) out.push(node); + node.forEachChild(visit); + }; + body.forEachChild(visit); + return out; +} + export function constructorReturnPlan( lowerer: Lowerer, info: ClassInfo, @@ -131,7 +203,7 @@ export function constructorReturnPlan( const returns = new Map(); for (const stmt of ownValueReturns(ctor)) { const value = unparenthesized(stmt.expression!); - if (value.kind === ts.SyntaxKind.ThisKeyword) continue; + if (returnsThis(lowerer, info, value)) continue; const type = lowerer.typeOf(value); const shape = objectness(type); if (shape === "never") continue; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.js.json new file mode 100644 index 0000000000..ad1317aa62 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-chained-this.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.ts.json new file mode 100644 index 0000000000..9fb2595c4f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-chained-this.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-chained-this.ts" + ], + "diags": [] +} diff --git a/tests/corpus/constructor-returns-chained-this.js b/tests/corpus/constructor-returns-chained-this.js new file mode 100644 index 0000000000..c3a2a04696 --- /dev/null +++ b/tests/corpus/constructor-returns-chained-this.js @@ -0,0 +1,40 @@ +// The JavaScript shape three.js ships: the constructor returns a chainable +// setter's result and other classes extend it. +class Color { + constructor(r, g, b) { + this.isColor = true; + this.r = 1; + this.g = 1; + this.b = 1; + return this.set(r, g, b); + } + set(r, g, b) { + if (g === undefined && b === undefined) { + if (r !== undefined) this.setScalar(r); + } else { + this.r = r; + this.g = g; + this.b = b; + } + return this; + } + setScalar(s) { + this.r = this.g = this.b = s; + return this; + } +} +class Tinted extends Color { + constructor(r, g, b, tint) { + super(r, g, b); + this.tint = tint; + } + set(r, g, b, a = 1) { + this.a = a; + return super.set(r, g, b); + } +} +const c = new Color(0.5); +console.log(c.r, c.g, c.b, c instanceof Color); +const t = new Tinted(0.1, 0.2, 0.3, 'warm'); +console.log(t.r, t.g, t.b, t.a, t.tint, t instanceof Tinted, t instanceof Color); +console.log(new Color().r); diff --git a/tests/corpus/constructor-returns-chained-this.ts b/tests/corpus/constructor-returns-chained-this.ts new file mode 100644 index 0000000000..d2a9f39558 --- /dev/null +++ b/tests/corpus/constructor-returns-chained-this.ts @@ -0,0 +1,38 @@ +// A constructor that ends with `return this.set(...)`, where every +// reachable `set` returns `this`, answers the constructed instance, so +// subclasses still construct through super() (three.js's Color). +class Color { + r = 0; + g = 0; + b = 0; + constructor(r?: number, g?: number, b?: number) { + return this.set(r ?? 0, g ?? 0, b ?? 0); + } + set(r: number, g: number, b: number): this { + if (r < 0) return this.clear(); + this.r = r; + this.g = g; + this.b = b; + return this; + } + clear(): this { + this.r = this.g = this.b = 0; + return this; + } +} +class NamedColor extends Color { + name: string; + constructor(name: string, r: number, g: number, b: number) { + super(r, g, b); + this.name = name; + } + override set(r: number, g: number, b: number): this { + super.set(r * 2, g * 2, b * 2); + return this; + } +} +const plain = new Color(1, 2, 3); +console.log(plain instanceof Color, plain.r, plain.g, plain.b); +console.log(new Color(-1, 5, 5).r, new Color().b); +const named = new NamedColor("teal", 0, 1, 2); +console.log(named instanceof NamedColor, named instanceof Color, named.name, named.r, named.g, named.b);