From 66183f93bb957852f644d30b2ef76bec5ef76f5a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 29 Sep 2026 22:45:39 -0500 Subject: [PATCH] Compile three.js scenes, cameras, and geometry - Compile scene hierarchies, synchronized rotations, and perspective camera transforms. - Support box and buffer geometry, bounds, normals, and index selection. - Preserve JavaScript constructor and callback behavior in native LLVM and WASM builds. --- .../src/frontend/lowering/class-callbacks.ts | 62 ++++++++ .../frontend/lowering/class-descriptors.ts | 25 +++- .../lowering/class-dynamic-dispatch.ts | 72 +++++++++- .../lowering/class-instance-constructor.ts | 59 ++++++++ .../frontend/lowering/class-method-values.ts | 14 +- .../lowering/expressions/object-literals.ts | 4 +- .../src/frontend/lowering/lower-calls.ts | 24 +++- .../src/frontend/lowering/lower-classes.ts | 132 ++++++++++++------ .../src/frontend/lowering/lower-exprs.ts | 21 ++- .../src/frontend/lowering/lower-stmts.ts | 3 + .../compiler/src/frontend/lowering/lowerer.ts | 1 + .../test/native-class-descriptors.test.ts | 25 +++- .../test/ts7/baselines/order-parity.json | 36 +++++ tests/corpus/class-callback-replacement.js | 46 ++++++ tests/corpus/class-instance-constructor.js | 24 ++++ tests/corpus/js-class-computed-fields.js | 10 ++ .../corpus/js-derived-constructor-hoisting.js | 14 ++ tests/corpus/js-inherited-parameter-abi.js | 17 +++ tests/corpus/js-nested-native-descriptors.js | 18 +++ tests/fixtures/three/geometry.mjs | 39 ++++++ tests/fixtures/three/scene.mjs | 40 ++++++ tests/harness/three.test.ts | 2 +- 22 files changed, 619 insertions(+), 69 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-callbacks.ts create mode 100644 packages/compiler/src/frontend/lowering/class-instance-constructor.ts create mode 100644 tests/corpus/class-callback-replacement.js create mode 100644 tests/corpus/class-instance-constructor.js create mode 100644 tests/corpus/js-class-computed-fields.js create mode 100644 tests/corpus/js-derived-constructor-hoisting.js create mode 100644 tests/corpus/js-inherited-parameter-abi.js create mode 100644 tests/corpus/js-nested-native-descriptors.js create mode 100644 tests/fixtures/three/geometry.mjs create mode 100644 tests/fixtures/three/scene.mjs diff --git a/packages/compiler/src/frontend/lowering/class-callbacks.ts b/packages/compiler/src/frontend/lowering/class-callbacks.ts new file mode 100644 index 00000000..a7bc525b --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-callbacks.ts @@ -0,0 +1,62 @@ +import * as ts from "../ts7/adapter.js"; +import { BOOL, DYN, STRING, VOID, isClassOwnEnumerableFieldName, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { locOf } from "../program.js"; +import { dynUndefinedExpr, type Lowerer } from "./lowerer.js"; +import { findMethodOn, type ClassInfo } from "./lower-classes.js"; +import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; +import { tryLowerExpression } from "./expressions/try-lower-expression.js"; + +/** Zero-argument notification methods can be replaced by own callbacks. + * Other compiled signatures retain their method-replacement fence. */ +export function isClassCallback(lowerer: Lowerer, info: ClassInfo, name: string): boolean { + if (!isClassOwnEnumerableFieldName(name) || name.startsWith("get:") || name.startsWith("set:")) return false; + if (info.def.runtime || info.builtinError || info.builtinEmitter || info.builtinStream || info.fields.has(name)) return false; + const found = findMethodOn(lowerer, info, name); + return !!found && found.sig.params.length === 0 && found.sig.ret.kind === "void" && !found.sig.abstract; +} + +function callbackBag(lowerer: Lowerer, receiver: IrExpr, loc: SrcLoc): IrExpr { + return { kind: "call", callee: classPropertiesHelper(lowerer, loc).name, + args: [lowerer.coerceToExpected(receiver, DYN)], type: DYN, loc }; +} + +export function lowerClassCallbackAssign(lowerer: Lowerer, expr: ts.BinaryExpression): IrStmt | null { + const access = expr.left; + if (!ts.isPropertyAccessExpression(access) || access.questionDotToken) return null; + const receiver = tryLowerExpression(lowerer, access.expression); + if (!receiver) return null; + const info = receiver.type.kind === "object" ? lowerer.classes.get(receiver.type.className) : undefined; + if (!info || !isClassCallback(lowerer, info, access.name.text)) return null; + const loc = locOf(expr); + return { kind: "exprStmt", expr: { kind: "libCall", fn: "dyn.keySet", args: [callbackBag(lowerer, receiver, loc), + { kind: "strLit", value: access.name.text, type: STRING, loc }, lowerer.lowerExprExpecting(expr.right, DYN)], type: VOID, loc }, loc }; +} + +/** Read the own property before arguments run, retaining callback identity + * and the original native receiver for JavaScript's `this` binding. */ +export function classCallbackValue(lowerer: Lowerer, receiver: IrExpr, name: string, fallback: IrExpr, loc: SrcLoc): IrExpr { + const bag = lowerer.declareHiddenLocal("%callbackBag", DYN); + const value = varRef(bag.id, DYN, loc); + const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; + const result: IrExpr = { kind: "ternary", cond: { kind: "libCall", fn: "dyn.hasOwn", args: [value, key], type: BOOL, loc }, + then: { kind: "dynKeyGet", value, key, type: DYN, loc }, else_: lowerer.coerceToExpected(fallback, DYN), type: DYN, loc }; + return { kind: "seqExpr", stmts: [{ kind: "varDecl", localId: bag.id, init: callbackBag(lowerer, receiver, loc), loc }], result, type: DYN, loc }; +} + +export function classCallbackCall(lowerer: Lowerer, receiver: IrExpr, name: string, args: IrExpr[], fallback: IrExpr, loc: SrcLoc): IrExpr { + const bag = lowerer.declareHiddenLocal("%callbackBag", DYN); + const own = lowerer.declareHiddenLocal("%callbackOwn", BOOL); + const callback = lowerer.declareHiddenLocal("%callback", DYN); + const value = varRef(bag.id, DYN, loc); + const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; + const call: IrExpr = { kind: "dynCall", callee: varRef(callback.id, DYN, loc), receiver: lowerer.coerceToExpected(receiver, DYN), + calleeName: name, args, type: DYN, loc }; + const result: IrExpr = { kind: "ternary", cond: varRef(own.id, BOOL, loc), then: call, + else_: { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: fallback, loc }], result: dynUndefinedExpr(loc), type: DYN, loc }, type: DYN, loc }; + return { kind: "seqExpr", stmts: [ + { kind: "varDecl", localId: bag.id, init: callbackBag(lowerer, receiver, loc), loc }, + { kind: "varDecl", localId: own.id, init: { kind: "libCall", fn: "dyn.hasOwn", args: [value, key], type: BOOL, loc }, loc }, + { kind: "varDecl", localId: callback.id, init: { kind: "dynKeyGet", value, key, type: DYN, loc }, loc }, + ], result, type: DYN, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/class-descriptors.ts b/packages/compiler/src/frontend/lowering/class-descriptors.ts index 28043f88..4ed75c85 100644 --- a/packages/compiler/src/frontend/lowering/class-descriptors.ts +++ b/packages/compiler/src/frontend/lowering/class-descriptors.ts @@ -11,17 +11,34 @@ function literalName(name: ts.PropertyName): string | null { return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null; } +function bagOnlyProperty(lowerer: Lowerer, owner: ClassInfo, name: string): boolean { + return !owner.fields.has(name) && !isCompiledPrototypeMember(lowerer, owner, name) && + owner.subclasses.every((child) => bagOnlyProperty(lowerer, child, name)); +} + +/** A named bag property needs no snapshot of unrelated native fields, + * which may contain recursive or otherwise opaque values. */ +export function lowerClassDescriptorRead(lowerer: Lowerer, call: ts.CallExpression, target: IrExpr): IrExpr | null { + if (!isDynTypedRefType(target.type)) return null; + const info = lowerer.classes.get(target.type.className); + const key = call.arguments[1]!; + if (!info || info.def.runtime || info.builtinError || info.builtinEmitter || info.builtinStream || + !ts.isStringLiteral(key) || !bagOnlyProperty(lowerer, info, key.text)) return null; + const loc = locOf(call); + const bag: IrExpr = { kind: "call", callee: classPropertiesHelper(lowerer, loc).name, + args: [lowerer.coerceToExpected(target, DYN)], type: DYN, loc }; + return { kind: "libCall", fn: "dyn.getOwnPropertyDescriptor", args: [bag, lowerer.lowerExprExpecting(key, DYN)], type: DYN, loc }; +} + /** Native layout fields cannot change descriptors. New named data properties * live in the instance's shared bag, preserving attributes and identity. */ export function lowerClassDataDescriptor(lowerer: Lowerer, call: ts.CallExpression, member: string, target: IrExpr): IrExpr | null { if (!isDynTypedRefType(target.type)) return null; const info = lowerer.classes.get(target.type.className); if (!info || info.def.runtime || info.builtinError || info.builtinEmitter || info.builtinStream) return null; - const safeName = (owner: ClassInfo, name: string): boolean => - !owner.fields.has(name) && !isCompiledPrototypeMember(lowerer, owner, name) && - owner.subclasses.every((child) => safeName(child, name)); + const safeName = (owner: ClassInfo, name: string): boolean => bagOnlyProperty(lowerer, owner, name); const descriptor = (node: ts.Expression): boolean => ts.isObjectLiteralExpression(node) && - node.properties.every((p) => ts.isPropertyAssignment(p) && + node.properties.every((p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && ["value", "writable", "enumerable", "configurable"].includes(literalName(p.name) ?? "")); const descriptors = call.arguments[member === "defineProperty" ? 2 : 1]!; if (member === "defineProperty") { diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 95a2a764..223844e6 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -4,13 +4,15 @@ import { varRef } from "../../ir/build.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; import { dynUndefinedExpr, PoisonError, type Lowerer } from "./lowerer.js"; import { implicitDefaultInstance, type ParamShape } from "./lower-calls.js"; -import { accessorCall, findGenericMethodOn, findMethodOn, upcastTo, type ClassInfo } from "./lower-classes.js"; +import { accessorCall, findGenericMethodOn, findMethodOn, genericOverrideBelow, upcastTo, type ClassInfo } from "./lower-classes.js"; import { classPrototypeData, hasClassPrototypeData } from "./class-prototypes.js"; +import { isClassCallback } from "./class-callbacks.js"; type Invoke = Extract; interface Dispatch { source: Invoke; fn: IrFunction; + callback: IrFunction; classes: Set; } @@ -21,6 +23,16 @@ interface PropertyDispatch { classes: Set; } +export function classInstanceOf(lowerer: Lowerer, value: IrExpr, info: ClassInfo, loc: SrcLoc): IrExpr { + const name = `%dyn.class.instanceof:${info.def.name}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) lowerer.liftedFns.push({ + name, params: [{ localId: "value", name: "value", type: DYN }], returnType: BOOL, + locals: [{ id: "value", name: "value", type: DYN, mutable: false }], + body: [{ kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc }], loc, + }); + return { kind: "call", callee: name, args: [value], type: BOOL, loc }; +} + export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc): IrFunction { const name = "%dyn.class.properties"; const existing = lowerer.liftedFns.find((fn) => fn.name === name); @@ -47,6 +59,7 @@ export class ClassDynamicDispatch { private readonly bagClasses = new Set(); private readonly bagInitializers = new Map>(); private readonly generated = new Set(); + private readonly instanceTests = new Map>(); process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { const seenTypes = new Set(); @@ -85,6 +98,26 @@ export class ClassDynamicDispatch { }); if (this.boxed.size === 0) return false; let changed = false; + for (const fn of functions) { + if (!fn.name.startsWith("%dyn.class.instanceof:")) continue; + const target = fn.name.slice("%dyn.class.instanceof:".length); + let checked = this.instanceTests.get(fn.name); + if (!checked) { checked = new Set(); this.instanceTests.set(fn.name, checked); } + for (const className of this.boxed) { + if (checked.has(className)) continue; + checked.add(className); + const subtype = className === target || lowerer.isSubclassOf(className, target); + if (!subtype && !lowerer.isSubclassOf(target, className)) continue; + const loc = fn.loc; + const value = varRef("value", DYN, loc); + const type: IrType = { kind: "object", className }; + const result: IrExpr = subtype ? { kind: "boolLit", value: true, type: BOOL, loc } + : { kind: "instanceOf", value: { kind: "dynCheck", value, type, loc }, className: target, type: BOOL, loc }; + fn.body.unshift({ kind: "if", cond: { kind: "libCall", fn: "dyn.typedRefIs", args: [value, + { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc }, then: [{ kind: "return", value: result, loc }], else_: null, loc }); + changed = true; + } + } if (!this.propertyBag) { this.propertyBag = classPropertiesHelper(lowerer, functions[0]!.loc); this.generated.add(this.propertyBag); @@ -220,19 +253,33 @@ export class ClassDynamicDispatch { let dispatch = this.dispatches.get(key); if (!dispatch) { const params = [expr.recv, ...expr.args].map((_, i) => ({ localId: `p.${i}`, name: `p${i}`, type: DYN })); + params.splice(1, 0, { localId: "p.callback", name: "callback", type: DYN }); + const callback: IrFunction = { + name: `%dyn.class.callback.${this.dispatches.size}`, params: [params[0]!], returnType: DYN, + locals: [{ id: "p.0", name: "receiver", type: DYN, mutable: false }], + body: [{ kind: "return", value: dynUndefinedExpr(expr.loc), loc: expr.loc }], loc: expr.loc, + }; const helper: IrFunction = { name: `%dyn.class.call.${this.dispatches.size}`, params, returnType: DYN, locals: params.map((p) => ({ id: p.localId, name: p.name, type: DYN, mutable: false })), body: [{ kind: "return", value: { ...expr, recv: varRef("p.0", DYN, expr.loc), args: expr.args.map((_, i) => varRef(`p.${i + 1}`, DYN, expr.loc)) }, loc: expr.loc }], loc: expr.loc, }; - dispatch = { source: expr, fn: helper, classes: new Set() }; + dispatch = { source: expr, fn: helper, callback, classes: new Set() }; this.dispatches.set(key, dispatch); this.generated.add(helper); - lowerer.liftedFns.push(helper); + this.generated.add(callback); + lowerer.liftedFns.push(helper, callback); changed = true; } - return { kind: "call", callee: dispatch.fn.name, args: [expr.recv, ...expr.args], type: DYN, loc: expr.loc }; + // Resolve an own callback before argument effects can replace it. + const local = { id: `%dispatch.receiver.${fn.locals.length}`, name: "receiver", type: DYN, mutable: false }; + fn.locals.push(local); + const receiver = varRef(local.id, DYN, expr.loc); + return { kind: "seqExpr", stmts: [{ kind: "varDecl", localId: local.id, init: expr.recv, loc: expr.loc }], result: { + kind: "call", callee: dispatch.fn.name, args: [receiver, + { kind: "call", callee: dispatch.callback.name, args: [receiver], type: DYN, loc: expr.loc }, ...expr.args], type: DYN, loc: expr.loc, + }, type: DYN, loc: expr.loc }; }, }); } @@ -243,6 +290,13 @@ export class ClassDynamicDispatch { const loc = dispatch.source.loc; const type: IrType = { kind: "object", className: info.def.name }; const receiver = varRef("p.0", DYN, loc); + if (isClassCallback(lowerer, info, dispatch.source.method)) { + const bag: IrExpr = { kind: "call", callee: this.propertyBag!.name, args: [receiver], type: DYN, loc }; + dispatch.callback.body.unshift({ kind: "if", cond: { + kind: "libCall", fn: "dyn.typedRefIs", args: [receiver, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc, + }, then: [{ kind: "return", value: { kind: "libCall", fn: "dyn.getOwnPropertyDescriptor", args: [bag, + lowerer.coerceToExpected({ kind: "strLit", value: dispatch.source.method, type: STRING, loc }, DYN)], type: DYN, loc }, loc }], else_: null, loc }); + } const before = lowerer.diags.length; let branch: IrStmt[]; try { @@ -410,7 +464,7 @@ export class ClassDynamicDispatch { result = methodInfo.sig.ret; callee = `%${owner.def.name}.${method}`; virtual = lowerer.overrideBelow(owner, method) || methodInfo.sig.abstract === true; - } else if (generic?.info.implicitParams && generic.declarer.decl && !lowerer.inHierarchy(info)) { + } else if (generic?.info.implicitParams && generic.declarer.decl && !genericOverrideBelow(lowerer, info, method)) { owner = generic.declarer; // A failed eager specialization leaves a signature in the instance // cache, but no body. Later dispatch arities must retain the fence @@ -461,6 +515,14 @@ export class ClassDynamicDispatch { if (virtual) lowerer.noteVirtualEdge(owner, method); else lowerer.noteEdge(callee); } + if (isClassCallback(lowerer, info, method)) { + const descriptor = varRef("p.callback", DYN, loc); + body.unshift({ kind: "if", cond: { kind: "dynTest", test: "undefined", negated: true, value: descriptor, type: BOOL, loc }, then: [ + { kind: "return", value: { kind: "dynCall", callee: { kind: "dynKeyGet", value: descriptor, + key: { kind: "strLit", value: "value", type: STRING, loc }, type: DYN, loc }, receiver: varRef("p.0", DYN, loc), + args: incoming, calleeName: dispatch.source.calleeName, type: DYN, loc }, loc }, + ], else_: null, loc }); + } return body; } } diff --git a/packages/compiler/src/frontend/lowering/class-instance-constructor.ts b/packages/compiler/src/frontend/lowering/class-instance-constructor.ts new file mode 100644 index 00000000..84ea137e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-instance-constructor.ts @@ -0,0 +1,59 @@ +import * as ts from "../ts7/adapter.js"; +import { BOOL, DYN, STRING, VOID, type IrExpr, type IrStmt } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import { classValueRef, upcastTo, type ClassInfo } from "./lower-classes.js"; +import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; + +/** Select the instance's concrete class before evaluating constructor + * arguments. Each branch completes arguments against its own constructor, + * including subclasses with different defaults or parameter counts. */ +export function lowerInstanceConstructorNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + let access = expr.expression; + while (ts.isParenthesizedExpression(access)) access = access.expression; + if (!ts.isPropertyAccessExpression(access) || access.name.text !== "constructor" || access.questionDotToken) return null; + const receiver = lowerer.lowerExpr(access.expression); + if (receiver.type.kind !== "object") return null; + const info = lowerer.classes.get(receiver.type.className); + if (!info || info.def.runtime) return null; + const loc = locOf(expr); + const local = lowerer.declareHiddenLocal("%constructorReceiver", receiver.type); + const value = varRef(local.id, receiver.type, loc); + const construct = (current: ClassInfo): IrExpr => { + if (current.fields.has("constructor") || current.generic || current.localClass || current.classDecorators) { + lowerer.unsupported("SC1090", expr, "instance constructors on generic, local, decorated, or constructor-shadowing classes"); + } + classValueRef(lowerer, current, expr); + const result: IrExpr = { + kind: "new", className: current.def.name, + args: lowerer.completeArgs(expr.arguments ?? [], current.ctorParams, loc, expr), + type: { kind: "object", className: current.def.name }, loc, + }; + return upcastTo(lowerer, result, info.def.name); + }; + const visit = (current: ClassInfo): IrExpr => { + let result = construct(current); + for (const child of current.subclasses) result = { + kind: "ternary", cond: { kind: "instanceOf", value, className: child.def.name, type: BOOL, loc }, + then: visit(child), else_: result, type: receiver.type, loc, + }; + return result; + }; + const bag: IrExpr = { kind: "call", callee: classPropertiesHelper(lowerer, loc).name, + args: [lowerer.coerceToExpected(value, DYN)], type: DYN, loc }; + const check = "%class.constructor.check"; + if (!lowerer.liftedFns.some((fn) => fn.name === check)) lowerer.liftedFns.push({ + name: check, params: [{ localId: "bag", name: "bag", type: DYN }], returnType: VOID, + locals: [{ id: "bag", name: "bag", type: DYN, mutable: false }], loc, + body: [{ kind: "if", cond: { kind: "libCall", fn: "dyn.hasOwn", args: [varRef("bag", DYN, loc), + { kind: "strLit", value: "constructor", type: STRING, loc }], type: BOOL, loc }, then: [ + { kind: "runtimeFence", code: "SC1090", message: "construction through a replaced instance constructor is not supported yet", loc }, + ], else_: null, loc }, { kind: "return", value: null, loc }], + }); + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: local.id, init: receiver, loc }, + { kind: "exprStmt", expr: { kind: "call", callee: check, args: [bag], type: VOID, loc }, loc }, + ]; + return { kind: "seqExpr", stmts, result: visit(info), type: receiver.type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index ee0c1879..e399d4c7 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -6,6 +6,7 @@ import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { findGenericMethodOn, findMethodOn, type ClassInfo } from "./lower-classes.js"; import { funcTypeFromParamShapes, implicitDefaultInstance, type ParamShape } from "./lower-calls.js"; +import { classCallbackValue, isClassCallback } from "./class-callbacks.js"; /** A method value retains its declaration's identity, not the receiver from * extraction. Its native thunk validates the receiver supplied at call time. */ @@ -14,10 +15,19 @@ export function lowerClassMethodValue(lowerer: Lowerer, expr: ts.PropertyAccessE const value = methodValue(lowerer, expr, info); if (!value) return null; const loc = locOf(expr); + const callback = isClassCallback(lowerer, info, method); + const receiver = lowerer.lowerExpr(expr.expression); + const local = callback ? lowerer.declareHiddenLocal("%callbackReceiver", receiver.type) : null; + const reference = local ? varRef(local.id, receiver.type, loc) : receiver; + const finish = (result: IrExpr): IrExpr => { + if (!local) return result; + const selected = classCallbackValue(lowerer, reference, method, result, loc); + return { kind: "seqExpr", stmts: [{ kind: "varDecl", localId: local.id, init: receiver, loc }], result: selected, type: selected.type, loc }; + }; const overrides = [...lowerer.classes.values()].filter((candidate) => candidate !== info && lowerer.isSubclassOf(candidate.def.name, info.def.name) && candidate.methods.has(method)); if (overrides.length === 0) { - return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(expr.expression), loc }], result: value, type: value.type, loc }; + return finish({ kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: reference, loc }], result: value, type: value.type, loc }); } // Select the declaration when extracting the value. Calling the value // later must not redispatch the method name on a different receiver. @@ -38,7 +48,7 @@ export function lowerClassMethodValue(lowerer: Lowerer, expr: ts.PropertyAccessE lowerer.liftedFns.push({ name, params: [{ localId: "this.0", name: "this", type: receiverType }], returnType: value.type, locals: [{ id: "this.0", name: "this", type: receiverType, mutable: false }], body, loc }); } - return { kind: "call", callee: name, args: [lowerer.lowerExpr(expr.expression)], type: value.type, loc }; + return finish({ kind: "call", callee: name, args: [reference], type: value.type, loc }); } function methodValue(lowerer: Lowerer, expr: ts.PropertyAccessExpression, info: ClassInfo): IrExpr | null { diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 19d22a26..134d0b1f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -300,7 +300,9 @@ export function lowerDynObjectLiteral( const lowerValue = (): IrExpr => ts.isMethodDeclaration(prop) ? (lowerer.rejectThisInObjectMethod(prop.body ?? prop), lowerer.lowerLambda(prop)) - : lowerer.lowerExpr(valueExpr as ts.Expression); + : !boxValue && ts.isObjectLiteralExpression(valueExpr) + ? lowerer.lowerExprExpecting(valueExpr, DYN) + : lowerer.lowerExpr(valueExpr as ts.Expression); raw = fenceClosureProbe(lowerer, valueExpr, undefined, lowerValue) ?? lowerValue(); diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 375e0232..1fd9c756 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -27,6 +27,7 @@ import { isSafeToDiscard } from "./expressions/evaluation-safety.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { httpClientFnBindingOf, isStreamUndefCallExpr, lowerCompatReqStreamOptionalCall, lowerHttpClientFnCall } from "./lower-server.js"; import { EMITTER_API_MEMBERS, exactInstanceClassOf, findGenericMethodOn, lowerClassGenericMethodCall, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js"; +import { classCallbackCall, isClassCallback } from "./class-callbacks.js"; import { emitterRooted, lowerEmitterMethodCall } from "./lower-event-emitter.js"; import { lowerConsoleInspectArg, lowerFormatCall } from "./lower-inspect.js"; import { STREAM_API_MEMBERS, lowerStreamMethodCall, lowerStreamModuleCall, lowerStreamStaticCall, streamSidesOf } from "./lower-stream.js"; @@ -39,7 +40,7 @@ import { rejectStaticThis } from "./static-this.js"; import { fenceNodeModuleMutationCall, lowerRequireCacheKeys } from "./lower-node-module.js"; import { defaultAfterUndefined, lowerOptionalArgument, lowerStaticallyUndefinedArgument, positionNumber } from "./optional-arguments.js"; import { fenceSymbolFieldCopy } from "./symbol-fields.js"; -import { lowerClassDataDescriptor } from "./class-descriptors.js"; +import { lowerClassDataDescriptor, lowerClassDescriptorRead } from "./class-descriptors.js"; import { classStaticDataFor } from "./class-static-data.js"; export { bodyReadsArguments }; @@ -9279,6 +9280,8 @@ export function lowerPromiseMethodCall(lowerer: Lowerer, call: ts.CallExpression if (native) return native; } if (target && member === "getOwnPropertyDescriptor" && isDynTypedRefType(target.type)) { + const descriptor = lowerClassDescriptorRead(lowerer, call, target); + if (descriptor) return descriptor; target = lowerer.coerceToExpected(target, DYN); } // Error.cause owns a live data-property slot shared with its checked @@ -10868,7 +10871,7 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I // A specialized JS parameter can expose a native field whose checker // type still belongs to the unspecialized body (plane.normal.dot()). // Use the lowered receiver's representation and retain its evaluation. - const probe = mappedReceiver?.kind !== "object" && lowerer.implicitParamTypes !== null + const probe = mappedReceiver?.kind !== "object" && (lowerer.implicitParamTypes !== null || ts.isNewExpression(access.expression)) ? tryLowerExpression(lowerer, access.expression) : null; const specializedReceiver = probe?.type.kind === "object" ? probe : null; if (specializedReceiver) mappedReceiver = specializedReceiver.type; @@ -10928,7 +10931,7 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I // (lowerClassGenericMethodCall has the exactness rules). if (info && !found) { const gfound = findGenericMethodOn(lowerer, info, access.name.text); - if (gfound) return lowerClassGenericMethodCall(lowerer, call, access, info, gfound, specializedReceiver ?? undefined); + if (gfound) return lowerClassGenericMethodCall(lowerer, call, access, info, gfound, lowerReceiver()); } // A FUNC-, nullable-FUNC-, or DYN-typed FIELD in call position: // `this.cb()` — the ctor-assigned callback field (countdown.js's @@ -10990,6 +10993,21 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I } if (!info || !found) return null; const method = access.name.text; + if (isClassCallback(lowerer, info, method)) { + const loc = locOf(call); + const receiver = lowerReceiver(); + const local = lowerer.declareHiddenLocal("%callbackReceiver", receiver.type); + const value: IrExpr = { kind: "varRef", localId: local.id, type: receiver.type, loc }; + const args = lowerer.completeArgs(call.arguments, found.sig.params, loc, call); + const virtual = lowerer.overrideBelow(info, method); + if (virtual) lowerer.noteVirtualEdge(info, method); + else lowerer.noteEdge(`%${found.declarer.def.name}.${method}`); + const fallback: IrExpr = virtual + ? { kind: "virtualCall", className: info.def.name, method, args: [lowerer.upcastTo(value, info.def.name), ...args], type: found.sig.ret, loc } + : { kind: "call", callee: `%${found.declarer.def.name}.${method}`, args: [lowerer.upcastTo(value, found.declarer.def.name), ...args], type: found.sig.ret, loc }; + const result = classCallbackCall(lowerer, value, method, call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), fallback, loc); + return { kind: "seqExpr", stmts: [{ kind: "varDecl", localId: local.id, init: receiver, loc }], result, type: result.type, loc }; + } if (found.declarer.builtinError) { // The one builtin method: Error.prototype.toString, a runtime // implementation called directly (overriding it is fenced, so no diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 6b712e5a..68e99fb7 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -28,6 +28,7 @@ import { rejectStaticThis } from "./static-this.js"; import { lowerUrlNew } from "./lower-url.js"; import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native-proxy.js"; import { classStaticDataFor } from "./class-static-data.js"; +import { lowerInstanceConstructorNew } from "./class-instance-constructor.js"; function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { if (!ts.isClassExpression(decl)) return false; @@ -1909,6 +1910,20 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla `overriding the builtin Error method '${mName}'`, ); } + if (overridden && isJsSourceFile(member.getSourceFile())) { + // An unannotated JS parameter can use the base's typed ABI. + // Defaults stay local to each declaration, so only inherited + // required/optional slots participate in this refinement. + for (let i = 0; i < shapes.length; i++) { + const parameter = member.parameters[i]; + const inherited = overridden.sig.params[i]; + if (parameter && inherited && !parameter.type && !parameter.initializer && !parameter.dotDotDotToken && + shapes[i]!.type.kind === "dyn" && inherited.type.kind !== "dyn" && + (inherited.mode === "required" || inherited.mode === "omittable")) { + shapes[i] = { ...inherited }; + } + } + } // JS overrides may ignore trailing arguments. Retain the base's // checked-value ABI slots as unused parameters, and let shorter // calls fill them with undefined. Typed tails and rest/arguments @@ -3776,7 +3791,7 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce * method `name` — overrideBelow's twin: generic methods have no vtable * slot, so a call that could reach an override compiles only when the * receiver's runtime class is statically exact. */ - function genericOverrideBelow(lowerer: Lowerer, info: ClassInfo, name: string): boolean { + export function genericOverrideBelow(lowerer: Lowerer, info: ClassInfo, name: string): boolean { return info.subclasses.some( (s) => s.genericMethods?.has(name) === true || genericOverrideBelow(lowerer, s, name), ); @@ -4675,56 +4690,65 @@ export function lowerClassMembers(lowerer: Lowerer, info: ClassInfo): IrFunction forward?: IrExpr[],): IrStmt[] { const out: IrStmt[] = []; let superSeen = false; - for (const stmt of info.ctor!.body!.statements) { - const superCall = - ts.isExpressionStatement(stmt) && - ts.isCallExpression(stmt.expression) && - stmt.expression.expression.kind === ts.SyntaxKind.SuperKeyword - ? stmt.expression - : null; - if (!superCall) { - out.push(...lowerer.lowerStmts([stmt])); - continue; - } - if (!lowerer.suppressStats) { - lowerer.stats.statementsTotal++; - lowerer.bumpFileStat(locOf(stmt).file, "total"); - } - try { - if (superSeen) lowerer.unsupported("SC1090", stmt, "multiple super() calls"); - superSeen = true; - const base = superBaseOf(info)!; - if (base.builtinEmitter && superCall.arguments.length > 0) { - // @types/node admits super({ captureRejections }) — no lowering. - lowerer.unsupported("SC1090", superCall, "EventEmitter constructor options ('captureRejections')"); - } - if (base.builtinStream) { - // super(options?) into a runtime stream base: the stream spoke - // parses the options and binds overridden underscore methods. - out.push(...lowerStreamSuperCall(lowerer, info, base, superCall.arguments, thisLocal, locOf(stmt), stmt)); - out.push(...lowerer.fieldInitStmts(info, thisLocal)); - out.push(...paramPropInitStmts(lowerer, info, thisLocal)); + const statements = info.ctor!.body!.statements; + const entry = { stmts: statements, index: 0, ctx: lowerer.ctx, frame: lowerer.scopes[lowerer.scopes.length - 1]!, out }; + lowerer.activeStmtLists.push(entry); + try { + for (let index = 0; index < statements.length; index++) { + entry.index = index; + const stmt = statements[index]!; + const superCall = + ts.isExpressionStatement(stmt) && + ts.isCallExpression(stmt.expression) && + stmt.expression.expression.kind === ts.SyntaxKind.SuperKeyword + ? stmt.expression + : null; + if (!superCall) { + out.push(...lowerer.lowerStmts([stmt])); continue; } - const args = forward !== undefined - ? forward - : base.builtinError - ? lowerer.errorConstructorArgs(superCall.arguments, locOf(stmt), stmt) - : base.builtinEmitter - ? [] - : lowerer.completeArgs(superCall.arguments, base.ctorParams, locOf(stmt), stmt); - out.push(lowerer.superCallStmt(info, thisLocal, args, locOf(stmt))); - // super() returns → field initializers → parameter-property - // assignments (Node's order, probed) → the rest of the body. - out.push(...lowerer.fieldInitStmts(info, thisLocal)); - out.push(...paramPropInitStmts(lowerer, info, thisLocal)); - } catch (e) { - if (!(e instanceof PoisonError)) throw e; if (!lowerer.suppressStats) { - lowerer.stats.statementsFailed++; - lowerer.bumpFileStat(locOf(stmt).file, "failed"); + lowerer.stats.statementsTotal++; + lowerer.bumpFileStat(locOf(stmt).file, "total"); + } + try { + if (superSeen) lowerer.unsupported("SC1090", stmt, "multiple super() calls"); + superSeen = true; + const base = superBaseOf(info)!; + if (base.builtinEmitter && superCall.arguments.length > 0) { + // @types/node admits super({ captureRejections }) — no lowering. + lowerer.unsupported("SC1090", superCall, "EventEmitter constructor options ('captureRejections')"); + } + if (base.builtinStream) { + // super(options?) into a runtime stream base: the stream spoke + // parses the options and binds overridden underscore methods. + out.push(...lowerStreamSuperCall(lowerer, info, base, superCall.arguments, thisLocal, locOf(stmt), stmt)); + out.push(...lowerer.fieldInitStmts(info, thisLocal)); + out.push(...paramPropInitStmts(lowerer, info, thisLocal)); + continue; + } + const args = forward !== undefined + ? forward + : base.builtinError + ? lowerer.errorConstructorArgs(superCall.arguments, locOf(stmt), stmt) + : base.builtinEmitter + ? [] + : lowerer.completeArgs(superCall.arguments, base.ctorParams, locOf(stmt), stmt); + out.push(lowerer.superCallStmt(info, thisLocal, args, locOf(stmt))); + // super() returns → field initializers → parameter-property + // assignments (Node's order, probed) → the rest of the body. + out.push(...lowerer.fieldInitStmts(info, thisLocal)); + out.push(...paramPropInitStmts(lowerer, info, thisLocal)); + } catch (e) { + if (!(e instanceof PoisonError)) throw e; + if (!lowerer.suppressStats) { + lowerer.stats.statementsFailed++; + lowerer.bumpFileStat(locOf(stmt).file, "failed"); + } } } + } finally { + lowerer.activeStmtLists.pop(); } if (!superSeen) { // tsc guarantees the call exists somewhere; if it wasn't a top-level @@ -5087,6 +5111,22 @@ function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const loc = locOf(expr); + const instanceConstructor = lowerInstanceConstructorNew(lowerer, expr); + if (instanceConstructor) return instanceConstructor; + let selected: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(selected)) selected = selected.expression; + if (ts.isConditionalExpression(selected)) { + const yes = exactClassOfReceiver(lowerer, selected.whenTrue); + const no = exactClassOfReceiver(lowerer, selected.whenFalse); + if (yes && no && !yes.classDecorators && !no.classDecorators) { + const yesType: IrType = { kind: "object", className: yes.def.name }; + const noType: IrType = { kind: "object", className: no.def.name }; + const type: IrType = yes === no ? yesType : { kind: "union", unionId: lowerer.unions.intern([yesType, noType].sort((a, b) => typeKey(a) < typeKey(b) ? -1 : 1)) }; + return { kind: "ternary", cond: lowerer.lowerCondition(selected.condition), + then: lowerer.coerceInto(expr, lowerProgramClassNew(lowerer, expr, yes, loc), type), + else_: lowerer.coerceInto(expr, lowerProgramClassNew(lowerer, expr, no, loc), type), type, loc }; + } + } if (ts.isPropertyAccessExpression(expr.expression) && expr.expression.name.text === "Segmenter" && lowerer.isStdlibGlobal(expr.expression.expression, "Intl")) { const args = expr.arguments ?? []; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index e9acc089..112b11a6 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -49,6 +49,7 @@ import { coerceStringSearchValue, defaultAfterUndefined, lowerStaticallyUndefine import { recordTextCodecClass } from "../../ir/ir.js"; import { classSymbolKeyOf } from "./symbol-fields.js"; import { lowerClassMethodValue } from "./class-method-values.js"; +import { classInstanceOf } from "./class-dynamic-dispatch.js"; import { lowerGlobalValue } from "./lower-global-value.js"; import { lowerClassPrototypeData } from "./class-prototypes.js"; @@ -667,8 +668,13 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // every function and constructor global is "function" (the harness's // `typeof queueMicrotask === 'function'` probes). Shadowing locals // have non-stdlib symbols and keep the ordinary path. - if (ts.isIdentifier(expr.expression)) { - const sym = lowerer.checker.getSymbolAtLocation(expr.expression); + let tested = expr.expression; + while (ts.isParenthesizedExpression(tested)) tested = tested.expression; + if (ts.isIdentifier(tested)) { + const sym = lowerer.checker.getSymbolAtLocation(tested); + if (!sym && !lowerer.dynamic) { + return { kind: "strLit", value: "undefined", type: STRING, loc }; + } if (lowerer.isStdlibSymbol(sym)) { const t = lowerer.typeOf(expr.expression); if ( @@ -5723,9 +5729,9 @@ export function lowerElementCompound(lowerer: Lowerer, expr: ts.BinaryExpression // type (static `any` — mapTypeOf answers null without --dynamic) // probes the receiver's own lowered world: a dyn value takes the same // write, anything else falls through to the fences. - if (receiverIr?.kind === "dyn" || receiverIr === null) { + if (receiverIr?.kind === "dyn" || receiverIr?.kind === "object" || receiverIr === null) { const obj = receiverIr !== null ? lowerer.lowerExpr(target.expression) : tryLowerExpression(lowerer, target.expression); - if (obj !== null && obj.type.kind === "dyn") { + if (obj !== null && (obj.type.kind === "dyn" || isDynTypedRefType(obj.type))) { const loc = locOf(expr); const litKey = recordKeyLiteralText(target.argumentExpression); let key: IrExpr = @@ -5744,7 +5750,7 @@ export function lowerElementCompound(lowerer: Lowerer, expr: ts.BinaryExpression `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`, ); } - return { kind: "exprStmt", expr: { kind: "libCall", fn: "dyn.keySet", args: [obj, key, value], type: VOID, loc }, loc }; + return { kind: "exprStmt", expr: { kind: "libCall", fn: "dyn.keySet", args: [lowerer.coerceToExpected(obj, DYN), key, value], type: VOID, loc }, loc }; } } if (receiverIr?.kind !== "array") { @@ -6778,7 +6784,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr const reference = scalarSide.type; if ((isDynTypedRefType(reference) || reference.kind === "record" || reference.kind === "array" || reference.kind === "bytes" || reference.kind === "func" || reference.kind === "regex" || reference.kind === "url" || - reference.kind === "bigint" || reference.kind === "set" || DYN_HANDLE_KINDS.has(reference.kind)) && + reference.kind === "bigint" || reference.kind === "set" || reference.kind === "union" || DYN_HANDLE_KINDS.has(reference.kind)) && lowerer.dynConvertible(reference)) { const boxed: IrExpr = { kind: "dynFrom", value: scalarSide, type: DYN, loc: scalarSide.loc, @@ -8424,6 +8430,9 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE // (name strings are user-writable), so `u instanceof TypeError` keeps // the fence. Reads past the narrow bridge through maybeNarrow's // validated %Error extraction. SEMANTICS.md 67. + if (left.type.kind === "dyn" && !target.def.runtime && !target.builtinError && !target.builtinEmitter && !target.builtinStream && !target.localClass) { + return classInstanceOf(lowerer, left, target, loc); + } if (left.type.kind === "dyn" && target.def.name === "%Error") { return { kind: "dynTest", test: "error", value: left, type: BOOL, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index cde49ae5..580aad8b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -24,6 +24,7 @@ import { isMixinFnBinding, mixinResultBindingClassOf } from "./lower-mixins.js"; import type { ClassInfo, ClassIteratorInfo } from "./lower-classes.js"; import { isCompiledPrototypeMember } from "./class-prototypes.js"; import { classStaticDataFor } from "./class-static-data.js"; +import { lowerClassCallbackAssign } from "./class-callbacks.js"; import { genericIfaceBindingKeepsClass, staticFieldWriteTarget } from "./lower-classes.js"; import { lowerStreamUnderscoreAssign, streamClassAliasDecl } from "./lower-stream.js"; import { lowerHttpResPropertyAssignment, lowerHttpServerTimeoutAssignment, lowerServerCloseOverrideAssignment } from "./lower-server.js"; @@ -5303,6 +5304,8 @@ function isStrictDelete(node: ts.DeleteExpression): boolean { const value = lowerer.lowerExprExpecting(expr.right, target.fieldType); return lowerer.fieldSetStmt(target, value, locOf(expr), expr.left); } + const callback = lowerClassCallbackAssign(lowerer, expr); + if (callback) return callback; // A write to an ABSTRACT property through an abstract-typed // receiver: the read fence's write twin (the declaration is // erased at runtime — no shared slot exists to write). diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index bbf0af2a..e5f5358c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5773,6 +5773,7 @@ export class Lowerer { const sameFamily = (src.kind === "record" && arm.kind === "record") || (src.kind === "array" && arm.kind === "array") || + (src.kind === "object" && arm.kind === "object") || // Tuples already lift into ordinary array slots. Consider that // same conversion when the array is an arm of a union too. (src.kind === "record" && this.shapes.get(src.shapeId)?.tuple === true && arm.kind === "array") || diff --git a/packages/compiler/test/native-class-descriptors.test.ts b/packages/compiler/test/native-class-descriptors.test.ts index 6a309fc5..4e6a3306 100644 --- a/packages/compiler/test/native-class-descriptors.test.ts +++ b/packages/compiler/test/native-class-descriptors.test.ts @@ -32,7 +32,7 @@ test("incompatible JS overrides throw at entry without executing the body", asyn class Base { copy(value = 1) { return value; } } class Child extends Base { copy(value) { console.log('must not execute'); return value; } } const child = new Child(); -try { child.copy(2); } catch (error) { console.log(error.message.includes("overriding method 'copy' with a different signature")); } +try { child.copy(2); } catch (error) { console.log(error.message.includes("overriding method 'copy' with a different return type")); } console.log(new Base().copy()); `); try { @@ -45,3 +45,26 @@ console.log(new Base().copy()); rmSync(dir, { recursive: true, force: true }); } }); + +test("replaced instance constructors refuse before evaluating constructor arguments", async () => { + const { dir, entry } = fixture(` +function argument() { console.log('must not execute'); return 2; } +class Value { + constructor(x = 1) { this.x = x; } + clone() { return new this.constructor(argument()); } +} +function replace(value) { value.constructor = Value; } +const value = new Value(); +replace(value); +try { value.clone(); } catch (error) { console.log(error.message.includes('replaced instance constructor')); } +`); + try { + const result = await compile(entry, { dynamic: false, outDir: dir, outPath: join(dir, "program"), sanitize: process.env["SCRIPTC_SAN"] === "1" }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const run = spawnSync(result.binaryPath, { encoding: "utf8", timeout: 30_000 }); + expect(run.error).toBeUndefined(); + expect({ status: run.status, signal: run.signal, stdout: run.stdout, stderr: run.stderr }).toEqual({ status: 0, signal: null, stdout: "true\n", stderr: "" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7c613f07..7ca1cd6e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11787,6 +11787,42 @@ "/tests/corpus/typed-array-constructor-union.ts" ], "diags": [] + }, + "/tests/corpus/class-callback-replacement.js": { + "order": [ + "/tests/corpus/class-callback-replacement.js" + ], + "diags": [] + }, + "/tests/corpus/class-instance-constructor.js": { + "order": [ + "/tests/corpus/class-instance-constructor.js" + ], + "diags": [] + }, + "/tests/corpus/js-class-computed-fields.js": { + "order": [ + "/tests/corpus/js-class-computed-fields.js" + ], + "diags": [] + }, + "/tests/corpus/js-derived-constructor-hoisting.js": { + "order": [ + "/tests/corpus/js-derived-constructor-hoisting.js" + ], + "diags": [] + }, + "/tests/corpus/js-inherited-parameter-abi.js": { + "order": [ + "/tests/corpus/js-inherited-parameter-abi.js" + ], + "diags": [] + }, + "/tests/corpus/js-nested-native-descriptors.js": { + "order": [ + "/tests/corpus/js-nested-native-descriptors.js" + ], + "diags": [] } } } diff --git a/tests/corpus/class-callback-replacement.js b/tests/corpus/class-callback-replacement.js new file mode 100644 index 00000000..a2866976 --- /dev/null +++ b/tests/corpus/class-callback-replacement.js @@ -0,0 +1,46 @@ +class Signal { + constructor(label) { this.label = label; } + changed() { console.log('default', this.label); } + emit() { this.changed(); } + replace(callback) { this.changed = callback; } +} +class Child extends Signal { + changed() { console.log('child', this.label); } + parent() { super.changed(); } +} +const first = new Signal('a'), second = new Signal('b'), child = new Child('c'); +const original = first.changed; +first.emit(); second.emit(); child.emit(); +let calls = 0; +function callback() { calls++; console.log('callback', this.label, calls); } +first.replace(callback); child.replace(callback); +console.log('identity', first.changed === callback, child.changed === callback); +first.emit(); second.emit(); child.emit(); child.parent(); +original.call(second); +console.log('own', Object.hasOwn(first, 'changed'), Object.hasOwn(second, 'changed')); +const saved = first.changed; +first.replace(() => console.log('replacement')); +saved.call(second); +first.emit(); +function dynamic(value) { value.changed(); } +dynamic(first); dynamic(child); +first.replace(undefined); +try { first.emit(); } catch (error) { console.log('invalid', error instanceof TypeError); } +function next() { first.replace(() => console.log('next')); return 'argument'; } +first.replace((value) => console.log('selected', value)); +function invoke(value) { value.changed(next()); } +invoke(first); +first.emit(); +const fresh = new Signal('fresh'); +function install() { fresh.replace(() => console.log('installed')); return 1; } +function invokeFresh(value) { value.changed(install()); } +invokeFresh(fresh); +fresh.emit(); +class PrivateSignal { + #changed() { console.log('private'); } + emit() { this.#changed(); } +} +const hidden = new PrivateSignal(); +function shadowPrivate(value) { value['#changed'] = () => console.log('public'); } +shadowPrivate(hidden); +hidden.emit(); diff --git a/tests/corpus/class-instance-constructor.js b/tests/corpus/class-instance-constructor.js new file mode 100644 index 00000000..41a66b8b --- /dev/null +++ b/tests/corpus/class-instance-constructor.js @@ -0,0 +1,24 @@ +class Value { + constructor(x = 1) { this.x = x; } + clone() { return new this.constructor(this.x); } + reset() { return new this.constructor(); } +} +class Child extends Value { + constructor(x = 2, extra = 3) { super(x); this.extra = extra; } +} +class Leaf extends Child {} +const a = new Value(4), b = new Child(5), c = new Leaf(6); +for (const source of [a, b, c]) { + const copy = source.clone(); + console.log(copy !== source, copy.x, copy instanceof Value, copy instanceof Child, copy instanceof Leaf); + console.log(source.reset().x); +} +let order = ''; +function receiver() { order += 'r'; return b; } +function argument() { order += 'a'; return 7; } +const selected = new (receiver().constructor)(argument()); +console.log(order, selected.x, selected instanceof Child); +class Other { constructor(x = 9) { this.x = x; } } +/** @returns {Value | Other} */ +function choose(flag) { return new (flag ? Value : Other)(argument()); } +console.log(choose(true).x, choose(false).x, order); diff --git a/tests/corpus/js-class-computed-fields.js b/tests/corpus/js-class-computed-fields.js new file mode 100644 index 00000000..20764751 --- /dev/null +++ b/tests/corpus/js-class-computed-fields.js @@ -0,0 +1,10 @@ +class Vector { constructor() { this.x = 1; this.y = 2; this.z = 3; } } +const value = new Vector(); +let order = ''; +function receiver() { order += 'r'; return value; } +function key() { order += 'k'; return 'y'; } +function number() { order += 'v'; return 8; } +receiver()[key()] = number(); +console.log(order, value.x, value.y, value.z); +for (const name of ['x', 'z', 'extra']) value[name] = 4; +console.log(value.x, value.z, value.extra); diff --git a/tests/corpus/js-derived-constructor-hoisting.js b/tests/corpus/js-derived-constructor-hoisting.js new file mode 100644 index 00000000..3e1a89b3 --- /dev/null +++ b/tests/corpus/js-derived-constructor-hoisting.js @@ -0,0 +1,14 @@ +class Base { constructor() { this.x = 1; } } +class Child extends Base { + constructor() { + before(); + super(); + let count = 2; + after(); + after(); + this.x = count; + function before() { console.log('before super'); } + function after() { count++; console.log('after', count); } + } +} +console.log(new Child().x); diff --git a/tests/corpus/js-inherited-parameter-abi.js b/tests/corpus/js-inherited-parameter-abi.js new file mode 100644 index 00000000..89a4c399 --- /dev/null +++ b/tests/corpus/js-inherited-parameter-abi.js @@ -0,0 +1,17 @@ +class Base { + /** @param {boolean} [force] */ + update(force) { console.log('base', force === undefined, force === true); } + forward(force) { this.update(force); } +} +class Child extends Base { + update(force) { console.log('child', force === undefined, force === false); super.update(force); } +} +const base = new Base(), child = new Child(); +base.update(); child.update(); child.update(false); child.forward(true); +class Defaults { + run(value = 5) { console.log('default', value); } +} +class NoDefault extends Defaults { + run(value) { console.log('no default', value === undefined); } +} +new Defaults().run(); new NoDefault().run(); new NoDefault().run(3); diff --git a/tests/corpus/js-nested-native-descriptors.js b/tests/corpus/js-nested-native-descriptors.js new file mode 100644 index 00000000..750133f9 --- /dev/null +++ b/tests/corpus/js-nested-native-descriptors.js @@ -0,0 +1,18 @@ +class Position { constructor(x) { this.x = x; } } +class Scene { + constructor(value) { + Object.defineProperties(this, { + position: { value, enumerable: true, configurable: true }, + hidden: { value: new Position(3) }, + }); + } +} +const position = new Position(1); +const scene = new Scene(position); +console.log(scene.position === position, scene.hidden.x, Object.keys(scene).join(',')); +position.x = 2; +console.log(scene.position.x); +const descriptor = Object.getOwnPropertyDescriptor(scene, 'position'); +console.log(descriptor.value === position, descriptor.writable, descriptor.enumerable, descriptor.configurable); +// @ts-expect-error JavaScript typeof permits an unresolvable reference. +console.log(typeof scriptcMissingHostGlobal, typeof (scriptcMissingHostGlobal)); diff --git a/tests/fixtures/three/geometry.mjs b/tests/fixtures/three/geometry.mjs new file mode 100644 index 00000000..99204826 --- /dev/null +++ b/tests/fixtures/three/geometry.mjs @@ -0,0 +1,39 @@ +import { BoxGeometry } from 'three/src/geometries/BoxGeometry.js'; +import { BufferGeometry } from 'three/src/core/BufferGeometry.js'; +import { Float32BufferAttribute } from 'three/src/core/BufferAttribute.js'; +import { Matrix4 } from 'three/src/math/Matrix4.js'; +import { Vector3 } from 'three/src/math/Vector3.js'; + +const box = new BoxGeometry(2, 4, 6); +console.log('box', box.type, box.attributes.position.count, box.index.count, box.groups.length); +box.computeBoundingBox(); +box.computeBoundingSphere(); +console.log('bounds', box.boundingBox.min.x, box.boundingBox.min.y, box.boundingBox.min.z, + box.boundingBox.max.x, box.boundingBox.max.y, box.boundingBox.max.z, box.boundingSphere.radius.toFixed(4)); +box.translate(1, 2, 3); +console.log('translated', box.boundingBox.min.x, box.boundingBox.min.y, box.boundingBox.min.z); +const dense = new BoxGeometry(1, 1, 1, 2, 3, 4); +console.log('segments', dense.attributes.position.count, dense.index.count, dense.groups[5].materialIndex); + +const geometry = new BufferGeometry(); +geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 2, 0, 0, 0, 2, 0], 3)); +geometry.computeBoundingBox(); +geometry.computeBoundingSphere(); +geometry.computeVertexNormals(); +console.log('triangle', geometry.attributes.position.count, geometry.attributes.normal.getZ(0)); +console.log('sphere', geometry.boundingSphere.center.x, geometry.boundingSphere.center.y, geometry.boundingSphere.radius.toFixed(4)); +geometry.applyMatrix4(new Matrix4().makeScale(2, 3, 4)); +console.log('scaled', geometry.boundingBox.max.x, geometry.boundingBox.max.y, geometry.attributes.normal.getZ(1)); +geometry.setIndex([0, 1, 2]); +console.log('index16', geometry.index.array instanceof Uint16Array, geometry.index.getX(2)); +geometry.setIndex([0, 65536, 2]); +console.log('index32', geometry.index.array instanceof Uint32Array, geometry.index.getX(1)); +geometry.addGroup(0, 3, 2); +geometry.setDrawRange(1, 2); +console.log('draw', geometry.groups[0].materialIndex, geometry.drawRange.start, geometry.drawRange.count); +geometry.clearGroups(); +geometry.deleteAttribute('normal'); +console.log('removed', geometry.groups.length, geometry.hasAttribute('normal')); +const point = new Vector3(1, 2, 3), copy = point.clone(); +copy.x = 4; +console.log('clone', copy !== point, point.x, copy.x, copy.y, copy.z); diff --git a/tests/fixtures/three/scene.mjs b/tests/fixtures/three/scene.mjs new file mode 100644 index 00000000..05afb1ac --- /dev/null +++ b/tests/fixtures/three/scene.mjs @@ -0,0 +1,40 @@ +import { Scene } from 'three/src/scenes/Scene.js'; +import { Object3D } from 'three/src/core/Object3D.js'; +import { PerspectiveCamera } from 'three/src/cameras/PerspectiveCamera.js'; +import { Vector3 } from 'three/src/math/Vector3.js'; + +const scene = new Scene(); +scene.position.set(1, 2, 3); +const parent = new Object3D(), child = new Object3D(); +parent.position.set(2, 3, 4); +child.position.set(3, 4, 5); +parent.add(child); +scene.add(parent); +scene.updateMatrixWorld(); +console.log('scene', scene.isScene, scene.children.length, child.parent === parent); +console.log('world', child.matrixWorld.elements[12], child.matrixWorld.elements[13], child.matrixWorld.elements[14]); +child.rotation.z = Math.PI / 2; +console.log('rotation', Number(child.quaternion.z).toFixed(4), Number(child.quaternion.w).toFixed(4)); +child.quaternion.setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 4); +console.log('quaternion', Number(child.rotation.y).toFixed(4)); +const descriptor = Object.getOwnPropertyDescriptor(child, 'position'); +console.log('position', descriptor.value === child.position, descriptor.writable, descriptor.enumerable, descriptor.configurable); +const names = []; +scene.name = 'scene'; parent.name = 'parent'; child.name = 'child'; +scene.traverse((object) => names.push(object.name)); +console.log('traverse', names.join(',')); +parent.remove(child); +console.log('removed', parent.children.length, child.parent === null); +scene.add(child); +scene.updateMatrixWorld(); +console.log('reparent', child.matrixWorld.elements[12], child.matrixWorld.elements[13], child.matrixWorld.elements[14]); + +const camera = new PerspectiveCamera(70, 1.5, 0.01, 10); +camera.position.z = 1; +scene.add(camera); +scene.updateMatrixWorld(true); +console.log('camera', camera.isPerspectiveCamera, camera.position.z, camera.projectionMatrix.elements[11]); +console.log('view', camera.matrixWorldInverse.elements[12], camera.matrixWorldInverse.elements[13], camera.matrixWorldInverse.elements[14]); +camera.aspect = 2; +camera.updateProjectionMatrix(); +console.log('projection', Number(camera.projectionMatrix.elements[0]).toFixed(4)); diff --git a/tests/harness/three.test.ts b/tests/harness/three.test.ts index 048e59a9..2ee73914 100644 --- a/tests/harness/three.test.ts +++ b/tests/harness/three.test.ts @@ -21,7 +21,7 @@ function run(command: string, args: string[]) { return { stdout: result.stdout, stderr: result.stderr, status: result.status, signal: result.signal }; } -const cases = ["math", "spatial", "attributes"].flatMap((fixture) => +const cases = ["math", "spatial", "attributes", "geometry", "scene"].flatMap((fixture) => ["native", "wasm32-wasi"].map((target) => ({ fixture, target }))); test.for(cases)("published three.js $fixture runs statically through LLVM on $target", async ({ fixture, target }, context) => { const entry = join(import.meta.dirname, `../fixtures/three/${fixture}.mjs`);