From 6d39c05478f85bef0962531f490fb23a869fe691 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 29 Sep 2026 21:19:06 -0500 Subject: [PATCH] Compile three.js spatial queries and vertex attributes - Support published three.js rotations, bounds, ray intersections, culling, and vertex attributes on native and WASM targets without a JavaScript engine. - Preserve JavaScript class defaults, data descriptors, and captured loop bindings in static code. - Handle typed-array input unions and constructor identities while keeping unsupported method overrides explicit. --- packages/compiler/src/backend/llvm/emitter.ts | 49 ++++++++------- .../src/backend/llvm/expr-stream-bridges.ts | 8 +-- .../frontend/lowering/class-descriptors.ts | 53 +++++++++++++++++ .../lowering/class-dynamic-dispatch.ts | 23 +++++--- .../frontend/lowering/class-static-data.ts | 54 +++++++++++++++++ .../src/frontend/lowering/containers/bytes.ts | 25 +++----- .../src/frontend/lowering/lower-calls.ts | 32 +++++++++- .../src/frontend/lowering/lower-classes.ts | 23 +++++--- .../src/frontend/lowering/lower-exprs.ts | 13 ++++ .../src/frontend/lowering/lower-stmts.ts | 54 ++++++++--------- .../compiler/src/frontend/lowering/lowerer.ts | 7 ++- .../compiler/test/js-override-returns.test.ts | 42 ++++++++++++- .../test/native-class-descriptors.test.ts | 47 +++++++++++++++ .../test/ts7/baselines/order-parity.json | 48 +++++++++++++++ packages/runtime/src/scr_json.c | 27 +++++++++ packages/runtime/src/scr_runtime.h | 1 + tests/corpus/class-data-descriptors.js | 56 ++++++++++++++++++ tests/corpus/class-static-data.js | 20 +++++++ tests/corpus/for-multiple-bindings.ts | 24 ++++++++ tests/corpus/js-dormant-overrides.js | 11 ++++ tests/corpus/js-primitive-union-addition.js | 9 +++ tests/corpus/js-specialized-field-method.js | 8 +++ .../typed-array-constructor-identity.js | 19 ++++++ tests/corpus/typed-array-constructor-union.ts | 19 ++++++ tests/corpus/typed-rest-filtered-return.ts | 30 ++++++++++ tests/fixtures/three/attributes.mjs | 36 +++++++++++ tests/fixtures/three/spatial.mjs | 59 +++++++++++++++++++ tests/harness/__snapshots__/statements.ts.txt | 7 --- tests/harness/three.test.ts | 8 ++- 29 files changed, 714 insertions(+), 98 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-descriptors.ts create mode 100644 packages/compiler/src/frontend/lowering/class-static-data.ts create mode 100644 packages/compiler/test/native-class-descriptors.test.ts create mode 100644 tests/corpus/class-data-descriptors.js create mode 100644 tests/corpus/class-static-data.js create mode 100644 tests/corpus/for-multiple-bindings.ts create mode 100644 tests/corpus/js-dormant-overrides.js create mode 100644 tests/corpus/js-primitive-union-addition.js create mode 100644 tests/corpus/js-specialized-field-method.js create mode 100644 tests/corpus/typed-array-constructor-identity.js create mode 100644 tests/corpus/typed-array-constructor-union.ts create mode 100644 tests/fixtures/three/attributes.mjs create mode 100644 tests/fixtures/three/spatial.mjs diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9d98289b4..52ee5cf8b 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3772,7 +3772,11 @@ export class LlEmitter { B.line(`store ${this.sizeType} 0, ptr ${integerSlot}`); this.integerLoopBindings.set(integerLoop.localId, integerSlot); } else if (s.init) { - this.emitStmt(s.init); + // A multi-declarator head shares the loop's scope. Emitting its + // IR block as an ordinary block would release captured/ref locals + // before the first condition. + const initializers = s.init.kind === "block" ? s.init.body : [s.init]; + for (const initializer of initializers) this.emitStmt(initializer); } const lc = B.newLabel("loop.c"); const lb = B.newLabel("loop.b"); @@ -3780,11 +3784,29 @@ export class LlEmitter { // JS `for (let i ...)`: each iteration gets a FRESH binding holding // a copy of the previous one (closures made in iteration k keep // seeing iteration k's value) — only observable, and only emitted, - // when the init variable is captured (boxed). The freshening (and - // the update) live in the continue-target block. - const initLocal = s.init?.kind === "varDecl" ? this.currentLocals.get(s.init.localId) : undefined; - const freshens = initLocal?.boxed === true; - const lu = s.update || freshens ? B.newLabel("loop.u") : lc; + // when a let variable is captured (boxed). Freshen before the first + // condition and again in the continue-target block before updating. + const initializers = s.init?.kind === "block" ? s.init.body : s.init ? [s.init] : []; + const capturedLets = initializers.flatMap((init) => { + const local = init.kind === "varDecl" ? this.currentLocals.get(init.localId) : undefined; + return local?.boxed && local.mutable ? [local] : []; + }); + const freshenBindings = (): void => { + for (const local of capturedLets) { + const slot = `%${mangleLocal(local.id)}`; + const fresh = B.tmp(); + const old = B.tmp(); + B.line(`${fresh} = ${boxNewCall(this.shapeHost, local.type)} ; per-iteration ${local.name}`); + B.line(`${old} = load ptr, ptr ${slot}`); + const val = this.boxGet(old, local.type); + this.boxSet(fresh, local.type, val); + this.declare(`declare void @scr_box_release(ptr)`); + B.line(`call void @scr_box_release(ptr ${old})`); + B.line(`store ptr ${fresh}, ptr ${slot}`); + } + }; + const lu = s.update || capturedLets.length > 0 ? B.newLabel("loop.u") : lc; + freshenBindings(); B.br(lc); B.startBlock(lc); if (integerLoop && integerSlot) { @@ -3815,20 +3837,7 @@ export class LlEmitter { B.br(lu); if (lu !== lc) { B.startBlock(lu); - if (freshens && initLocal) { - const slot = `%${mangleLocal(initLocal.id)}`; - const fresh = B.tmp(); - const old = B.tmp(); - B.line(`${fresh} = ${boxNewCall(this.shapeHost, initLocal.type)} ; per-iteration ${initLocal.name}`); - B.line(`${old} = load ptr, ptr ${slot}`); - const val = this.boxGet(old, initLocal.type); // ref: +1 out - this.boxSet(fresh, initLocal.type, val); // takes ownership - this.declare(`declare void @scr_box_release(ptr)`); - B.line(`call void @scr_box_release(ptr ${old})`); - B.line(`store ptr ${fresh}, ptr ${slot}`); - // The wrapper scope's entry releases whatever the slot points - // to at loop exit — now the freshest binding. Nothing to fix. - } + freshenBindings(); if (integerLoop && integerSlot) { const old = B.tmp(); const next = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index ba5f3db1d..6baa05f92 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -188,14 +188,14 @@ export function streamTypedRefCommitAdapter(host: LlvmEmitterContext, } if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) { host.declare(`declare ptr @scr_dyn_new_obj()`); - host.declare(`declare ptr @scr_dyn_copy_data_properties(ptr, ptr)`); + host.declare(`declare ptr @scr_dyn_copy_property_descriptors(ptr, ptr)`); host.declare(`declare void @scr_dyn_release_v(ptr)`); host.declare(`declare ptr @scr_str_new(ptr, ${host.sizeType})`); host.declare(`declare void @scr_str_release(ptr)`); host.declare(`declare void @scr_dyn_key_delete(ptr, ptr, i1 zeroext)`); lines.push( ` %bag = call ptr @scr_dyn_new_obj()`, - ` %bag_copy = call ptr @scr_dyn_copy_data_properties(ptr %bag, ptr %d)`, + ` %bag_copy = call ptr @scr_dyn_copy_property_descriptors(ptr %bag, ptr %d)`, ` call void @scr_dyn_release_v(ptr %bag_copy)`, ` %bag_pending = call zeroext i1 @scr_exc_pending()`, ` br i1 %bag_pending, label %bag_fail, label %bag_keys`, @@ -597,13 +597,13 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, B.line(`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`); } if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) { - host.declare(`declare ptr @scr_dyn_copy_data_properties(ptr, ptr)`); + host.declare(`declare ptr @scr_dyn_copy_property_descriptors(ptr, ptr)`); host.declare(`declare void @scr_dyn_release_v(ptr)`); const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); const slot = B.tmp(), bag = B.tmp(), copied = B.tmp(); B.line(`${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`); B.line(`${bag} = load ptr, ptr ${slot}`); - B.line(`${copied} = call ptr @scr_dyn_copy_data_properties(ptr ${out}, ptr ${bag})`); + B.line(`${copied} = call ptr @scr_dyn_copy_property_descriptors(ptr ${out}, ptr ${bag})`); B.line(`call void @scr_dyn_release_v(ptr ${copied})`); } B.terminate(`ret ptr ${out}`); diff --git a/packages/compiler/src/frontend/lowering/class-descriptors.ts b/packages/compiler/src/frontend/lowering/class-descriptors.ts new file mode 100644 index 000000000..28043f885 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-descriptors.ts @@ -0,0 +1,53 @@ +import * as ts from "../ts7/adapter.js"; +import { DYN, type IrExpr, type IrStmt, isDynTypedRefType } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import type { ClassInfo } from "./lower-classes.js"; +import { isCompiledPrototypeMember } from "./class-prototypes.js"; +import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; + +function literalName(name: ts.PropertyName): string | null { + return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null; +} + +/** 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 descriptor = (node: ts.Expression): boolean => ts.isObjectLiteralExpression(node) && + node.properties.every((p) => ts.isPropertyAssignment(p) && + ["value", "writable", "enumerable", "configurable"].includes(literalName(p.name) ?? "")); + const descriptors = call.arguments[member === "defineProperty" ? 2 : 1]!; + if (member === "defineProperty") { + const key = call.arguments[1]!; + if (!ts.isStringLiteral(key) || !safeName(info, key.text) || !descriptor(descriptors)) return null; + } else { + if (!ts.isObjectLiteralExpression(descriptors) || !descriptors.properties.every((p) => { + if (!ts.isPropertyAssignment(p)) return false; + const name = literalName(p.name); + return name !== null && name !== "__proto__" && safeName(info, name) && descriptor(p.initializer); + })) return null; + } + const loc = locOf(call); + const receiver = lowerer.declareHiddenLocal("%descriptorReceiver", target.type); + const desc = lowerer.declareHiddenLocal("%descriptors", DYN); + const value = varRef(receiver.id, target.type, loc); + const boxed = lowerer.coerceToExpected(value, DYN); + const helper = classPropertiesHelper(lowerer, loc); + const bag: IrExpr = { kind: "call", callee: helper.name, args: [boxed], type: DYN, loc }; + const args = member === "defineProperty" + ? [bag, lowerer.lowerExprExpecting(call.arguments[1]!, DYN), varRef(desc.id, DYN, loc)] + : [bag, varRef(desc.id, DYN, loc)]; + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: receiver.id, init: target, loc }, + { kind: "varDecl", localId: desc.id, init: lowerer.lowerExprExpecting(descriptors, DYN), loc }, + { kind: "exprStmt", expr: { kind: "libCall", fn: member === "defineProperty" ? "dyn.defineProperty" : "dyn.defineProps", args, type: DYN, loc }, loc }, + ]; + return { kind: "seqExpr", stmts, result: value, type: value.type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 24492c6e3..95a2a7648 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,4 +1,4 @@ -import { BOOL, DYN, DYN_CLASS_PROPERTIES as PROPERTY_BAG, STRING, VOID, canConvertToDyn, canDynCheckTo, isClassOwnEnumerableFieldName, isDynTypedRefType, isUnitType, typeEquals, typeKey, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { BOOL, DYN, DYN_CLASS_PROPERTIES as PROPERTY_BAG, STRING, VOID, canConvertToDyn, canDynCheckTo, isClassOwnEnumerableFieldName, isDynTypedRefType, isUnitType, typeEquals, typeKey, type IrExpr, type IrFunction, type IrStmt, type IrType, type SrcLoc } from "../../ir/ir.js"; import { streamTypedRefEligible } from "../../ir/analysis.js"; import { varRef } from "../../ir/build.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; @@ -21,6 +21,19 @@ interface PropertyDispatch { classes: Set; } +export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc): IrFunction { + const name = "%dyn.class.properties"; + const existing = lowerer.liftedFns.find((fn) => fn.name === name); + if (existing) return existing; + const helper: IrFunction = { + name, params: [{ localId: "p.0", name: "value", type: DYN }], returnType: DYN, + locals: [{ id: "p.0", name: "value", type: DYN, mutable: false }], + body: [{ kind: "return", value: varRef("p.0", DYN, loc), loc }], loc, + }; + lowerer.liftedFns.push(helper); + return helper; +} + /** Calls on native class capsules keep the instance's compiled methods. The * reachable-body fixed point discovers both the boxed classes and method * names before generating checked native dispatch; ordinary dyn receivers @@ -73,14 +86,8 @@ export class ClassDynamicDispatch { if (this.boxed.size === 0) return false; let changed = false; if (!this.propertyBag) { - const loc = functions[0]!.loc; - this.propertyBag = { - name: "%dyn.class.properties", params: [{ localId: "p.0", name: "value", type: DYN }], returnType: DYN, - locals: [{ id: "p.0", name: "value", type: DYN, mutable: false }], - body: [{ kind: "return", value: varRef("p.0", DYN, loc), loc }], loc, - }; + this.propertyBag = classPropertiesHelper(lowerer, functions[0]!.loc); this.generated.add(this.propertyBag); - lowerer.liftedFns.push(this.propertyBag); changed = true; } for (const className of this.boxed) { diff --git a/packages/compiler/src/frontend/lowering/class-static-data.ts b/packages/compiler/src/frontend/lowering/class-static-data.ts new file mode 100644 index 000000000..08c885750 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-static-data.ts @@ -0,0 +1,54 @@ +import * as ts from "../ts7/adapter.js"; +import { BOOL, DYN, type IrExpr, type IrFunction, type SrcLoc } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { isJsSourceFile } from "../program.js"; +import { findStaticOn, findGenericStaticOn, type ClassInfo } from "./lower-classes.js"; +import type { Lowerer } from "./lowerer.js"; + +const constructorMembers = new Set([ + "name", "length", "prototype", "__proto__", "constructor", "call", "apply", "bind", + "arguments", "caller", "toString", "toLocaleString", "valueOf", "hasOwnProperty", + "isPrototypeOf", "propertyIsEnumerable", "__defineGetter__", "__defineSetter__", + "__lookupGetter__", "__lookupSetter__", +]); + +/** Expando data on an exact JS constructor. Declared statics and Function + * members retain their existing lowering; this bag cannot replace them. */ +export function classStaticDataFor(lowerer: Lowerer, info: ClassInfo, name: string, loc: SrcLoc): IrExpr | null { + if (!info.decl || !isJsSourceFile(info.decl.getSourceFile()) || name.startsWith("#") || constructorMembers.has(name)) return null; + if (findStaticOn(lowerer, info, name) || findGenericStaticOn(lowerer, info, name)) return null; + for (let c: ClassInfo | null = info; c; c = c.base) { + if (c.decl?.members.some((m) => m.name && + ts.canHaveModifiers(m) && ts.getModifiers(m)?.some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword) && + (ts.isComputedPropertyName(m.name) || ((ts.isIdentifier(m.name) || ts.isStringLiteral(m.name)) && m.name.text === name)))) return null; + } + return classStaticData(lowerer, info, loc); +} + +function classStaticData(lowerer: Lowerer, info: ClassInfo, loc: SrcLoc): IrExpr | null { + if (info.localClass || info.mixinInstance || info.generic || info.genericInstance || info.classDecorators || info.def.runtime || info.builtinEmitter || info.builtinStream || info.builtinError) return null; + if (info.staticDataHelper === undefined) { + const base = info.base ? classStaticData(lowerer, info.base, loc) : null; + if (info.base && base === null) return null; + const name = `%static.data.${info.def.name}`; + const globalId = `%g.${name}`; + const readyId = `${globalId}.ready`; + info.staticDataHelper = name; + lowerer.globalsList.push({ id: globalId, name: `${info.def.name}.staticData`, type: DYN, mutable: true }); + lowerer.globalsList.push({ id: readyId, name: `${info.def.name}.staticDataReady`, type: BOOL, mutable: true }); + const helper: IrFunction = { + name, params: [], returnType: DYN, locals: [], loc, + body: [ + { kind: "if", cond: { kind: "unary", op: "!", operand: varRef(readyId, BOOL, loc), type: BOOL, loc }, then: [ + { kind: "assign", localId: globalId, value: base + ? { kind: "libCall", fn: "dyn.objCreate", args: [base], type: DYN, loc } + : { kind: "dynObjLit", fields: [], type: DYN, loc }, loc }, + { kind: "assign", localId: readyId, value: { kind: "boolLit", value: true, type: BOOL, loc }, loc }, + ], else_: null, loc }, + { kind: "return", value: varRef(globalId, DYN, loc), loc }, + ], + }; + lowerer.liftedFns.push(helper); + } + return { kind: "call", callee: info.staticDataHelper, args: [], type: DYN, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index c3ef7c94e..065e0a508 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -193,23 +193,14 @@ export function lowerBytesNew(lowerer: Lowerer, expr: ts.NewExpression, symbol: return { kind: "bytesNew", source: count, type, loc }; } const src = lowerer.lowerExpr(argNode); - if (src.type.kind === "union" && lowerer.armTag(src.type.unionId, UNDEFINED_T) >= 0) { - const present = lowerer.stripUndefinedArm(src.type); - if (present.kind === "array" && present.elem.kind === "f64") { - const undefTag = lowerer.armTag(src.type.unionId, UNDEFINED_T); - const presentTag = lowerer.armTag(src.type.unionId, present); - if (undefTag >= 0 && presentTag >= 0) { - const empty: IrExpr = { kind: "arrayLit", elems: [], type: present, loc }; - const source: IrExpr = { - kind: "ternary", - cond: { kind: "unionIsTag", unionId: src.type.unionId, tag: undefTag, negated: false, value: src, type: BOOL, loc }, - then: empty, - else_: { kind: "unionNarrow", unionId: src.type.unionId, tag: presentTag, value: src, type: present, loc }, - type: present, - loc, - }; - return { kind: "bytesNew", source, type, loc }; - } + if (src.type.kind === "union") { + const arms = lowerer.unions.get(src.type.unionId)?.arms; + if (arms?.every((arm) => arm.kind === "f64" || arm.kind === "bytes" || + typeEquals(arm, UNDEFINED_T) || (arm.kind === "array" && arm.elem.kind === "f64"))) { + // The checked constructor dispatch preserves each arm's copy or + // length semantics, including undefined -> empty, and evaluates + // an effectful source expression exactly once. + return { kind: "bytesNew", source: lowerer.coerceInto(argNode, src, DYN), type, loc }; } } if ( diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e1198338c..375e0232b 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -39,6 +39,8 @@ 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 { classStaticDataFor } from "./class-static-data.js"; export { bodyReadsArguments }; @@ -6537,6 +6539,12 @@ const inliningPredicates = new Set(); if (ts.isIdentifier(m.name) || ts.isStringLiteralLike(m.name)) own.add(m.name.text); else return null; // computed static names — the answer isn't static } + if (!own.has(key)) { + const loc = locOf(call); + const data = classStaticDataFor(lowerer, info, key, loc); + if (data) return { kind: "libCall", fn: "dyn.hasOwn", args: [data, + { kind: "strLit", value: key, type: STRING, loc }], type: BOOL, loc }; + } return { kind: "boolLit", value: own.has(key), type: BOOL, loc: locOf(call) }; } @@ -9266,6 +9274,13 @@ export function lowerPromiseMethodCall(lowerer: Lowerer, call: ts.CallExpression (member === "getOwnPropertyDescriptor" && call.arguments.length === 2)) { if (call.arguments.some((a) => ts.isSpreadElement(a))) return null; let target = tryLowerExpression(lowerer, call.arguments[0]!); + if (target && member === "defineProperty") { + const native = lowerClassDataDescriptor(lowerer, call, member, target); + if (native) return native; + } + if (target && member === "getOwnPropertyDescriptor" && isDynTypedRefType(target.type)) { + target = lowerer.coerceToExpected(target, DYN); + } // Error.cause owns a live data-property slot shared with its checked // view. Other native fields do not yet synchronize descriptor edits. const keyNode = call.arguments[1]!; @@ -9300,6 +9315,10 @@ export function lowerPromiseMethodCall(lowerer: Lowerer, call: ts.CallExpression if (member === "defineProperties" && call.arguments.length === 2 && !call.arguments.some((a) => ts.isSpreadElement(a))) { let target = tryLowerExpression(lowerer, call.arguments[0]!); + if (target) { + const native = lowerClassDataDescriptor(lowerer, call, member, target); + if (native) return native; + } // A FUNCTION-typed target boxes through the dyn boundary: the // property table lives on the CLOSURE (shared by every box of this // function value), so defining through a fresh box sticks — the @@ -10845,7 +10864,14 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I export function lowerObjectMethodCall(lowerer: Lowerer, call: ts.CallExpression, access: ts.PropertyAccessExpression,): IrExpr | null { if (lowerer.chainBlocked(access, call)) return null; - const mappedReceiver = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + let mappedReceiver = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + // 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 + ? tryLowerExpression(lowerer, access.expression) : null; + const specializedReceiver = probe?.type.kind === "object" ? probe : null; + if (specializedReceiver) mappedReceiver = specializedReceiver.type; if (mappedReceiver?.kind === "union") { const dispatched = lowerUnionObjectMethodCall(lowerer, call, access, mappedReceiver) ?? @@ -10866,7 +10892,7 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I : null; if (receiverIr === null) return null; const lowerReceiver = (): IrExpr => { - const receiver = lowerer.lowerExpr(access.expression); + const receiver = specializedReceiver ?? lowerer.lowerExpr(access.expression); const optional = lowerer.runtimeOptionalPropertyReceiver( access.expression, receiver, @@ -10902,7 +10928,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); + if (gfound) return lowerClassGenericMethodCall(lowerer, call, access, info, gfound, specializedReceiver ?? undefined); } // A FUNC-, nullable-FUNC-, or DYN-typed FIELD in call position: // `this.cb()` — the ctor-assigned callback field (countdown.js's diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index aee47c25f..6b712e5af 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -27,6 +27,7 @@ import { mixinResultBindingClassOf, type MixinInstanceInfo } from "./lower-mixin 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"; function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { if (!ts.isClassExpression(decl)) return false; @@ -89,7 +90,7 @@ export interface ClassInfo { * no-dynamic-dispatch semantics by construction). A `gen` entry has a * generator body whose direct calls enter through its spawn wrapper. */ methods: Map; - /** Own JS overrides whose return cannot use the inherited ABI. The slot + /** Own JS overrides whose signature cannot use the inherited ABI. The slot * retains that ABI, but its implementation throws before executing. */ methodEntryFences?: Map; /** OWN GENERIC instance methods (own type parameters — `m(x: T)`), @@ -174,6 +175,8 @@ export interface ClassInfo { * same dynamic story: both are named fences). Accessors and * initializer-less fields keep the fence. */ staticFields: { name: string; type: IrType; initializer: ts.Expression; globalId: string; readonly: boolean }[]; + /** Shared expando data added after a JS class declaration. */ + staticDataHelper?: string; /** STATIC methods — ordinary module functions named `%C.static:m` (the * accessor-colon trick: no user identifier can spell it, and statics * never join vtables, so IrClassDef doesn't know them). `C.m(args)` is @@ -1925,23 +1928,26 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla !overridden.sig.params.every((p, i) => typeEquals(p.type, shapes[i]!.type)) || !typeEquals(overridden.sig.ret, ft.ret)) ) { - const returnOnlyJsOverride = + const fencedJsOverride = isJsSourceFile(member.getSourceFile()) && overridden.declarer.decl !== null && isJsSourceFile(overridden.declarer.decl.getSourceFile()) && - overridden.sig.params.length === shapes.length && - overridden.sig.params.every((p, i) => typeEquals(p.type, shapes[i]!.type)) && member.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) !== true && overridden.sig.async !== true && member.asteriskToken === undefined && overridden.sig.gen === undefined; - if (returnOnlyJsOverride) { + if (fencedJsOverride) { // A dormant JS method must not poison the whole class. Keep // the inherited call/dispatch ABI and refuse at method entry; - // lowering this body with the base return would change JS. + // lowering this body with the base signature would change JS. + const returnOnly = overridden.sig.params.length === shapes.length && + overridden.sig.params.every((p, i) => typeEquals(p.type, shapes[i]!.type)); methodEntryFences.set(mName, unsupportedDiag( "SC1090", locOf(member.name), - `overriding method '${mName}' with a different return type (the native return type must match the base declaration exactly)`, + returnOnly + ? `overriding method '${mName}' with a different return type (the native return type must match the base declaration exactly)` + : `overriding method '${mName}' with a different signature (the native parameter and return types must match the base declaration exactly)`, )); + shapes.splice(0, shapes.length, ...overridden.sig.params); ft.ret = overridden.sig.ret; } else { lowerer.unsupported( @@ -3127,6 +3133,7 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce // or `C.name` below would fall through to paths that answer for // stdlib globals instead of this class. propertyAssignedClassInfoOf(lowerer, symbol) ?? + exactClassOfReceiver(lowerer, expr.expression) ?? undefined; if (!info) return null; // A decorated name that can REBIND (a replacing decorator): the @@ -3170,6 +3177,8 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce if (expr.name.text === "name" && info.def.jsName !== undefined) { return { kind: "strLit", value: info.def.jsName, type: STRING, loc }; } + const data = classStaticDataFor(lowerer, info, expr.name.text, loc); + if (data) return { kind: "dynKeyGet", value: data, key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, type: DYN, loc }; return null; } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 5f3c73743..e9acc0898 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1512,6 +1512,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isConditionalExpression(expr)) return lowerTernary(lowerer, expr); if (ts.isPropertyAccessExpression(expr)) { + // Expando statics may still be absent even when JS inference sees + // the eventual assignment's scalar type. Preserve the checked value. + const staticField = lowerStaticFieldRead(lowerer, expr); + if (staticField) return staticField; const prototypeData = lowerClassPrototypeData(lowerer, expr); if (prototypeData) return prototypeData; // `super.x`: the base chain's GETTER, called directly (super @@ -7075,6 +7079,15 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr } const optionalStringPlus = lowerOptionalStringPlus(); if (optionalStringPlus) return optionalStringPlus; + if (isJsSourceFile(expr.getSourceFile())) { + const primitive = (type: IrType): boolean => + type.kind === "f64" || type.kind === "string" || type.kind === "bool" || isUnitType(type) || + (type.kind === "union" && (lowerer.unions.get(type.unionId)?.arms.every(primitive) ?? false)); + if ((left.type.kind === "union" || right.type.kind === "union") && primitive(left.type) && primitive(right.type)) { + lowerer.runtimeOptionalArithmeticTypes.set(expr, DYN); + return { kind: "libCall", fn: "dyn.add", args: [lowerer.coerceToExpected(left, DYN), lowerer.coerceToExpected(right, DYN)], type: DYN, loc }; + } + } lowerer.unsupported("SC1043", expr); break; case ts.SyntaxKind.MinusToken: diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index a562c4595..cde49ae57 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -23,6 +23,7 @@ import { bindingContextualGenericFnNodeOf, bindingGenericFnAliasInfoOf, bindingG 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 { genericIfaceBindingKeepsClass, staticFieldWriteTarget } from "./lower-classes.js"; import { lowerStreamUnderscoreAssign, streamClassAliasDecl } from "./lower-stream.js"; import { lowerHttpResPropertyAssignment, lowerHttpServerTimeoutAssignment, lowerServerCloseOverrideAssignment } from "./lower-server.js"; @@ -3326,13 +3327,9 @@ export function isParseArgsDynCheckerType(lowerer: Lowerer, type: ts.Type): bool ); } -/** For-loop initializers. let/const stay restricted to ONE declarator (JS - * per-iteration binding copies for several captured loop variables are - * not modeled yet); `var` initializers take any number — a `var` is ONE - * function-scoped binding with no per-iteration copies to model, so - * `for (var i = 0, n = xs.length; ...)` is just two hoisted-slot - * assignments (wrapped in a block when several). Statement position goes - * through lowerVarStatement. */ +/** For-loop initializers evaluate declarators from left to right. The LLVM + * loop emitter keeps their scope alive and freshens every captured let + * binding before the first condition and each subsequent update. */ export function lowerVarDeclList(lowerer: Lowerer, list: ts.VariableDeclarationList): IrStmt | null { if ((list.flags & ts.NodeFlags.Using) !== 0) { lowerer.unsupported("SC1090", list, "'using' declarations (dispose-at-scope-exit semantics)"); @@ -3353,26 +3350,20 @@ export function isParseArgsDynCheckerType(lowerer: Lowerer, type: ts.Type): bool if (out.length === 1) return out[0]!; return { kind: "block", body: out, loc: locOf(list) }; } - if (list.declarations.length !== 1) { - lowerer.unsupported("SC1090", list, "multi-declaration for-loop initializers"); - } - const decl = list.declarations[0]!; - if (ts.isArrayBindingPattern(decl.name) || ts.isObjectBindingPattern(decl.name)) { - // `for (let [x] = init; ...)`: the desugar is a multi-statement - // block, and the backend's per-iteration fresh-binding copy (what - // makes closures in iteration k see iteration k's let) keys off a - // single varDecl init — a captured destructured head would silently - // share one binding. `var` heads above have no per-iteration story - // and lower; let/const keep an honest fence. - lowerer.unsupported( - "SC1031", - decl.name, - "let/const destructuring in for-loop initializers (declare the pattern before the loop, or use var)", - ); + const out: IrStmt[] = []; + for (const decl of list.declarations) { + if (ts.isArrayBindingPattern(decl.name) || ts.isObjectBindingPattern(decl.name)) { + lowerer.unsupported( + "SC1031", + decl.name, + "let/const destructuring in for-loop initializers (declare the pattern before the loop, or use var)", + ); + } + const lowered = lowerer.lowerVarDecl(decl, isLet); + if (!lowered) throw new InternalCompilerError("lowerer bug: for-init declarator resolved to a global"); + out.push(lowered); } - const lowered = lowerer.lowerVarDecl(decl, isLet); - if (!lowered) throw new InternalCompilerError("lowerer bug: for-init declarator resolved to a global"); - return lowered; + return out.length === 1 ? out[0]! : { kind: "block", body: out, loc: locOf(list) }; } /** A single static type for an initializer-less JavaScript `let` whose @@ -5181,6 +5172,13 @@ function isStrictDelete(node: ts.DeleteExpression): boolean { // property instead of writing this storage. const classInfo = lowerer.exactClassOfReceiver(expr.left.expression); if (classInfo) { + const data = classStaticDataFor(lowerer, classInfo, expr.left.name.text, locOf(expr.left)); + if (data) { + const loc = locOf(expr); + const key: IrExpr = { kind: "strLit", value: expr.left.name.text, type: STRING, loc }; + const value = lowerer.lowerExprExpecting(expr.right, DYN); + return { kind: "exprStmt", expr: { kind: "libCall", fn: "dyn.keySet", args: [data, key, value], type: VOID, loc }, loc }; + } const found = lowerer.findStaticOn(classInfo, expr.left.name.text); if (found?.field !== undefined) { if (found.declarer !== classInfo) { @@ -7171,7 +7169,9 @@ function isStrictDelete(node: ts.DeleteExpression): boolean { return forValues(tmp.id, [write, ...body]); } const local = lowerer.declareLocal(decl.name, decl.name.text, elemValueT, isLet); - if (!typeEquals(elemValueT, sourceT.elem)) { + const declaredElement = lowerer.mapTypeOf(lowerer.typeOf(decl.name)); + if (!typeEquals(elemValueT, sourceT.elem) || + (declaredElement !== null && lowerer.runtimeOptionalWidening(elemValueT, declaredElement) !== null)) { const root = lowerer.runtimeOptionalRootOf(local); lowerer.runtimeOptionalLocals.add(root); lowerer.runtimeOptionalStorageLocals.add(root); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cc400ec92..bbf0af2ab 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -8142,7 +8142,12 @@ export class Lowerer { } } } - const e = this.lowerExpr(node); + let e = this.lowerExpr(node); + if (expected?.kind === "union" && this.armTag(expected.unionId, UNDEFINED_T) >= 0) { + // A destination that accepts undefined must retain an unchecked + // read's storage value even when the checker still calls it present. + e = this.runtimeOptionalSourceValue(node, e) ?? e; + } return expected ? this.coerceInto(node, e, expected) : e; } diff --git a/packages/compiler/test/js-override-returns.test.ts b/packages/compiler/test/js-override-returns.test.ts index c4c2a4a25..181e6a3e4 100644 --- a/packages/compiler/test/js-override-returns.test.ts +++ b/packages/compiler/test/js-override-returns.test.ts @@ -58,8 +58,48 @@ console.log("after"); } }); +test("incompatible JS parameter overrides refuse at method entry", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-override-parameters-")); + try { + const entry = join(dir, "main.cjs"); + writeFileSync(entry, ` +// @ts-nocheck +class Base { + method(value = 1) { return 1; } + dispatch(value) { return this.method(value); } +} +class Derived extends Base { + method(value = "x") { console.log("must not execute", value); return "x"; } +} +class Leaf extends Derived { callSuper(value) { super.method(value); } } +function argument(label) { console.log("argument", label); return 2; } +const derived = new Derived(); +try { derived.method(argument("direct")); } +catch (error) { console.log("direct", String(error).includes("SC1090"), String(error).includes("different signature")); } +try { derived.dispatch(argument("virtual")); } +catch (error) { console.log("virtual", String(error).includes("SC1090")); } +try { new Leaf().callSuper(argument("super")); } +catch (error) { console.log("super", String(error).includes("SC1090")); } +console.log("after"); +`); + const { coverage } = analyze(entry, { dynamic: false }); + expect(coverage.diagnostics).toEqual([]); + expect(coverage.runtimeFences?.map((d) => [d.code, d.message])).toEqual([ + ["SC1090", expect.stringContaining("overriding method 'method' with a different signature")], + ]); + const result = await compile(entry, { backend: "llvm", dynamic: false, sanitize, outDir: dir, outPath: join(dir, "program") }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const child = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect(child.status).toBe(0); + expect(child.stderr).toBe(""); + expect(child.stdout).toBe("argument direct\ndirect true true\nargument virtual\nvirtual true\nargument super\nsuper true\nafter\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test.each([ - ["parameter types", `class Base { method(value = 1) { return 1; } } class Derived extends Base { method(value = "x") { return "x"; } }`], ["async methods", `class Base { method() { return 1; } } class Derived extends Base { async method() { return "x"; } }`], ["generators", `class Base { method() { return 1; } } class Derived extends Base { *method() { yield "x"; } }`], ["accessors", `class Base { get value() { return 1; } } class Derived extends Base { get value() { return "x"; } }`], diff --git a/packages/compiler/test/native-class-descriptors.test.ts b/packages/compiler/test/native-class-descriptors.test.ts new file mode 100644 index 000000000..6a309fc54 --- /dev/null +++ b/packages/compiler/test/native-class-descriptors.test.ts @@ -0,0 +1,47 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { analyze, compile } from "../src/index.js"; + +function fixture(source: string) { + const dir = mkdtempSync("/tmp/scriptc-class-descriptors-"); + const entry = join(dir, "main.js"); + writeFileSync(entry, source); + return { dir, entry }; +} + +test.each([ + "Object.defineProperty(value, 'x', {value: 2, writable: false});", + "Object.defineProperties(value, {x: {value: 2}});", + "Object.defineProperty(value, 'data', {get() { return this.x; }});", + "Object.defineProperties(value, {data: {get() { return this.x; }}});", + "Object.defineProperty(value, String('data'), {value: 2});", +])("keeps unsafe native descriptor changes fenced: %s", (operation) => { + const { dir, entry } = fixture(`class Value { constructor() { this.x = 1; } } const value = new Value(); ${operation}`); + try { + const { coverage } = analyze(entry, { dynamic: false }); + expect([...coverage.diagnostics, ...(coverage.runtimeFences ?? [])].some((d) => d.code === "SC2020" && d.message.includes("Object.define"))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("incompatible JS overrides throw at entry without executing the body", async () => { + const { dir, entry } = fixture(` +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")); } +console.log(new Base().copy()); +`); + 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\n1\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 c097dab03..7c613f07d 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11739,6 +11739,54 @@ "/tests/corpus/rest-optional-array-arguments.ts" ], "diags": [] + }, + "/tests/corpus/class-data-descriptors.js": { + "order": [ + "/tests/corpus/class-data-descriptors.js" + ], + "diags": [] + }, + "/tests/corpus/class-static-data.js": { + "order": [ + "/tests/corpus/class-static-data.js" + ], + "diags": [] + }, + "/tests/corpus/for-multiple-bindings.ts": { + "order": [ + "/tests/corpus/for-multiple-bindings.ts" + ], + "diags": [] + }, + "/tests/corpus/js-dormant-overrides.js": { + "order": [ + "/tests/corpus/js-dormant-overrides.js" + ], + "diags": [] + }, + "/tests/corpus/js-primitive-union-addition.js": { + "order": [ + "/tests/corpus/js-primitive-union-addition.js" + ], + "diags": [] + }, + "/tests/corpus/js-specialized-field-method.js": { + "order": [ + "/tests/corpus/js-specialized-field-method.js" + ], + "diags": [] + }, + "/tests/corpus/typed-array-constructor-identity.js": { + "order": [ + "/tests/corpus/typed-array-constructor-identity.js" + ], + "diags": [] + }, + "/tests/corpus/typed-array-constructor-union.ts": { + "order": [ + "/tests/corpus/typed-array-constructor-union.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 6db33fc2f..98a1922a0 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1518,6 +1518,15 @@ ScrDyn *scr_dyn_bytes_key_get(const ScrDyn *value, const ScrStr *key) { if (key->len == 10 && memcmp(key->data, "byteLength", 10) == 0) return scr_dyn_new_num(scr_bytes_byte_len(bytes)); if (key->len == 10 && memcmp(key->data, "byteOffset", 10) == 0) return scr_dyn_new_num(scr_bytes_byte_offset(bytes)); if (key->len == 6 && memcmp(key->data, "buffer", 6) == 0) return scr_array_buffer_from_bytes(bytes); + if (key->len == 11 && memcmp(key->data, "constructor", 11) == 0) { + /* Match the compiler's opaque JS builtin identity values. */ + char token[64]; + int length = snprintf(token, sizeof token, "[builtin %s]", value->buffer ? "Buffer" : scr_bytes_elem_name(bytes->elem)); + ScrStr *name = scr_str_new(token, (size_t)length); + ScrDyn *result = scr_dyn_new_str(name); + scr_str_release(name); + return result; + } if (key->len && !(key->len > 1 && key->data[0] == '0')) { size_t index = 0; bool digits = true; @@ -5356,6 +5365,24 @@ ScrDyn *scr_dyn_copy_data_properties(ScrDyn *target, const ScrDyn *src) { return scr_exc_pending() ? NULL : scr_dyn_retain(target); } +/* Compiler-owned native class views retain every property and its flags, + * including nonenumerable data, without invoking accessors. */ +ScrDyn *scr_dyn_copy_property_descriptors(ScrDyn *target, const ScrDyn *src) { + if (!src || src->kind == SCR_DYN_UNDEF) return scr_dyn_retain(target); + ScrDyn *keys = scr_dyn_obj_own_keys(src); + if (!keys) return NULL; + for (size_t i = 0; i < keys->v.arr.len; i++) { + ScrDyn *key = keys->v.arr.items[i]; + ScrDyn *descriptor = scr_dyn_own_descriptor(src, key->v.str); + ScrDyn *result = descriptor ? scr_dyn_define_property(target, key, descriptor) : NULL; + scr_dyn_release(descriptor); + scr_dyn_release(result); + if (scr_exc_pending()) break; + } + scr_dyn_release(keys); + return scr_exc_pending() ? NULL : scr_dyn_retain(target); +} + /* Variadic Object.assign's argument pack (the `Object.assign({}, * ...arr.map(f), tail)` shape): the compiler builds one fresh dyn array * of sources — plain arguments push borrowed (+1 in), spread arguments diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 42a0d19a0..41c3a2296 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -3585,6 +3585,7 @@ bool scr_dyn_property_is_enumerable(const ScrDyn *value, const ScrStr *key); * and scalar/function/handle sources copy nothing. */ ScrDyn *scr_dyn_assign(ScrDyn *target, const ScrDyn *src); ScrDyn *scr_dyn_copy_data_properties(ScrDyn *target, const ScrDyn *src); +ScrDyn *scr_dyn_copy_property_descriptors(ScrDyn *target, const ScrDyn *src); /* Variadic Object.assign (the spread-source form): the compiler packs * every source into one fresh dyn array — pack_push retains a plain * source in (BORROWED), pack_push_spread flattens a spread source through diff --git a/tests/corpus/class-data-descriptors.js b/tests/corpus/class-data-descriptors.js new file mode 100644 index 000000000..5e7053e58 --- /dev/null +++ b/tests/corpus/class-data-descriptors.js @@ -0,0 +1,56 @@ +class Point { + constructor(x) { + this.x = x; + const result = Object.defineProperty(this, 'id', { value: x + 10 }); + console.log(result === this); + Object.defineProperties(this, { + label: { value: 'point', enumerable: true, configurable: true, writable: true }, + meta: { value: { count: 1 }, configurable: true }, + }); + } +} +class Child extends Point {} +const point = new Point(2); +const child = new Child(3); +console.log(point.id, child.id, point.label, point.meta.count); +console.log(Object.keys(point).join(','), Object.hasOwn(point, 'id')); +const id = Object.getOwnPropertyDescriptor(point, 'id'); +console.log(id.value, id.writable, id.enumerable, id.configurable); +point.meta.count = 4; +point.label = 'changed'; +console.log(point.meta.count, child.meta.count, point.label); +try { point.id = 7; } catch (error) { console.log(error instanceof TypeError); } +try { Object.defineProperty(point, 'id', { value: 9 }); } catch (error) { console.log(error instanceof TypeError); } +const returned = Object.defineProperties(point, { label: { value: 'final', writable: false } }); +console.log(returned === point, point.label); +const label = Object.getOwnPropertyDescriptor(point, 'label'); +console.log(label.value, label.writable, label.enumerable, label.configurable); +/** @param {unknown} value */ +function stringify(value) { return JSON.stringify(value); } +console.log(stringify(point)); +function read(value) { return value.id + ':' + value.label; } +console.log(read(point), read(child)); +const empty = Object.getOwnPropertyDescriptor(point, 'missing'); +console.log(empty === undefined); +const events = []; +function target() { events.push('target'); return point; } +function description() { events.push('value'); return 5; } +Object.defineProperty(target(), 'extra', { value: description(), enumerable: true }); +console.log(events.join(','), point.extra); +// Snapshot/commit operations must retain nonenumerable descriptors too. +/** @param {unknown} value */ +function merge(value) { Object.assign(value, { label: 'blocked' }); } +try { merge(point); } catch (error) { console.log(error instanceof TypeError); } +console.log(point.id, Object.getOwnPropertyDescriptor(point, 'id').writable); +/** @param {unknown} value */ +function extend(value) { Object.assign(value, { extra2: 7 }); } +extend(point); +console.log(point.extra2, point.id, Object.getOwnPropertyDescriptor(point, 'id').enumerable); +class Cycle { + constructor() { this.x = 1; Object.defineProperty(this, 'self', { value: this }); } +} +function cycle() { + const value = new Cycle(); + console.log(value.self === value, Object.keys(value).join(',')); +} +cycle(); diff --git a/tests/corpus/class-static-data.js b/tests/corpus/class-static-data.js new file mode 100644 index 000000000..16ef87f7f --- /dev/null +++ b/tests/corpus/class-static-data.js @@ -0,0 +1,20 @@ +// @ts-nocheck +class Defaults { constructor(value = Defaults.value) { this.value = value; } } +class Child extends Defaults {} +const Alias = Defaults; +console.log(Defaults.value, Child.value, Defaults.hasOwnProperty('value')); +let effects = 0; +function initial() { effects++; return 'first'; } +Defaults.value = initial(); +console.log(effects, new Defaults().value, Child.value, Child.hasOwnProperty('value')); +Alias.value = 'second'; +console.log(Defaults.value, new Child().value); +Child.value = 'child'; +Defaults.value = 'third'; +console.log(Defaults.value, Child.value, Child.hasOwnProperty('value')); +Child.value = undefined; +console.log(Child.value, Defaults.value, Child.hasOwnProperty('value')); +Defaults.options = { scale: 2 }; +console.log(Child.options.scale); +Child.options.scale = 3; +console.log(Defaults.options.scale); diff --git a/tests/corpus/for-multiple-bindings.ts b/tests/corpus/for-multiple-bindings.ts new file mode 100644 index 000000000..c9185a748 --- /dev/null +++ b/tests/corpus/for-multiple-bindings.ts @@ -0,0 +1,24 @@ +const callbacks: (() => string)[] = []; +for (let i = 0, label = 'a'; i < 3; i++) { + callbacks.push(() => `${i}:${label}`); + label += 'b'; + if (i === 1) continue; +} +console.log(callbacks.map((fn) => fn()).join(',')); +let initial: () => number = () => -1; +const body: (() => number)[] = []; +for (let i = 0, save = (initial = () => i); i < 2; i++) { + body.push(() => i); +} +console.log(initial(), body.map((fn) => fn()).join(',')); +let sum = 0; +for (let i = 1, end = i + 3; i < end; i++) sum += i; +console.log(sum); +let escaped: () => number = () => -1; +outer: for (let i = 0, n = 3; i < n; i++) { + for (let j = 0, m = 2; j < m; j++) { + escaped = () => i + j + n + m; + if (j === 1) break outer; + } +} +console.log(escaped()); diff --git a/tests/corpus/js-dormant-overrides.js b/tests/corpus/js-dormant-overrides.js new file mode 100644 index 000000000..b87398513 --- /dev/null +++ b/tests/corpus/js-dormant-overrides.js @@ -0,0 +1,11 @@ +class Shape { + constructor(size = 1) { this.size = size; } + copy(source, recursive = true) { return source.size + (recursive ? 1 : 0); } + area() { return this.size * this.size; } +} +class ColoredShape extends Shape { + constructor(size = 1) { super(size); this.color = 'red'; } + copy(source, recursive) { return source.size + (recursive ? 1 : 0); } +} +const shape = new ColoredShape(3); +console.log(shape.area(), shape.color, new Shape(2).copy(shape)); diff --git a/tests/corpus/js-primitive-union-addition.js b/tests/corpus/js-primitive-union-addition.js new file mode 100644 index 000000000..69106639f --- /dev/null +++ b/tests/corpus/js-primitive-union-addition.js @@ -0,0 +1,9 @@ +const table = ['a', 'b']; +function pair(i = 0, j = 0) { return table[i] + table[j]; } +function chain(i = 0, j = 0, k = 0) { return table[i] + table[j] + table[k]; } +console.log(pair(0, 1), pair(0, 4), pair(4, 0), pair(4, 5)); +console.log(chain(0, 1, 0), chain(4, 5, 0), chain(4, 5, 6)); +console.log(chain(0, 4, 1)); +let calls = 0; +function take(i = 0) { calls++; return table[i]; } +console.log(take(4) + take(5) + take(0), calls); diff --git a/tests/corpus/js-specialized-field-method.js b/tests/corpus/js-specialized-field-method.js new file mode 100644 index 000000000..4a3a3a32f --- /dev/null +++ b/tests/corpus/js-specialized-field-method.js @@ -0,0 +1,8 @@ +class Vector { constructor(x = 0) { this.x = x; } dot(other) { return this.x * other.x; } } +class Plane { constructor() { this.normal = new Vector(2); } } +class Ray { + constructor() { this.direction = new Vector(3); } + distance(plane) { return plane.normal.dot(this.direction); } +} +const ray = new Ray(); +console.log(ray.distance(new Plane())); diff --git a/tests/corpus/typed-array-constructor-identity.js b/tests/corpus/typed-array-constructor-identity.js new file mode 100644 index 000000000..64161452b --- /dev/null +++ b/tests/corpus/typed-array-constructor-identity.js @@ -0,0 +1,19 @@ +// @ts-nocheck +/** @param {unknown} value */ +function identify(value) { + const ctor = value.constructor; + if (ctor === Uint8Array) return 'u8'; + if (ctor === Uint8ClampedArray) return 'u8c'; + if (ctor === Int8Array) return 'i8'; + if (ctor === Uint16Array) return 'u16'; + if (ctor === Int16Array) return 'i16'; + if (ctor === Uint32Array) return 'u32'; + if (ctor === Int32Array) return 'i32'; + if (ctor === Float32Array) return 'f32'; + if (ctor === Float64Array) return 'f64'; + if (ctor === Buffer) return 'buffer'; + return 'other'; +} +console.log(identify(new Uint8Array()), identify(new Uint8ClampedArray()), identify(new Int8Array())); +console.log(identify(new Uint16Array()), identify(new Int16Array()), identify(new Uint32Array()), identify(new Int32Array())); +console.log(identify(new Float32Array()), identify(new Float64Array()), identify(Buffer.from([1, 2]))); diff --git a/tests/corpus/typed-array-constructor-union.ts b/tests/corpus/typed-array-constructor-union.ts new file mode 100644 index 000000000..11782cc1b --- /dev/null +++ b/tests/corpus/typed-array-constructor-union.ts @@ -0,0 +1,19 @@ +let evaluations = 0; +function source(kind: number): number[] | Float32Array | number | undefined { + evaluations++; + if (kind === 0) return [1.5, 2.5]; + if (kind === 1) return new Float32Array([3.5, 4.5]); + if (kind === 2) return 3; + return undefined; +} +for (let i = 0; i < 4; i++) { + // @ts-expect-error JavaScript accepts every arm, although no single TS overload does. + const values = new Float32Array(source(i)); + console.log(values.length, values.join(','), evaluations); +} +function copy(values: number[] | Float32Array) { return new Uint16Array(values); } +const original = new Float32Array([10.5, 20.5]); +const cloned = copy(original); +original[0] = 100; +console.log(cloned.join(','), copy([65537, -1]).join(',')); +try { new Float32Array(source(2) === 3 ? -1 : 0); } catch (e) { console.log(e instanceof RangeError); } diff --git a/tests/corpus/typed-rest-filtered-return.ts b/tests/corpus/typed-rest-filtered-return.ts index f53fa6117..468801943 100644 --- a/tests/corpus/typed-rest-filtered-return.ts +++ b/tests/corpus/typed-rest-filtered-return.ts @@ -12,3 +12,33 @@ const second = new Value(2); const result = collect(undefined, first, null, second); console.log(result.map((value) => value.value).join(","), result[0] === first, result[1] === second); console.log(collect().length); + +// Unchecked inputs widen the elements of a rest pack, never the pack itself. +const inputs: Value[] = [first]; +const missing = inputs[10]; +const fromReads = collect(inputs[0], missing, second); +console.log(fromReads.map((value) => value.value).join(","), fromReads.length); + +class Collector { + report(label: string, ...values: Value[]): void { + console.log(label, values.length, values[0] === undefined, values[1] === undefined); + for (const value of values) console.log(value === undefined ? "missing" : value.value); + } +} +const collector = new Collector(); +collector.report("first", missing, second); +collector.report("later", first, missing); +collector.report("empty"); + +class DerivedCollector extends Collector { + report(label: string, ...values: Value[]): void { + super.report(label, ...values); + } +} +function throughBase(value: Collector): void { value.report("virtual", inputs[10], first); } +throughBase(new DerivedCollector()); + +function reportValues(...values: Value[]): void { + for (const value of values) console.log("function", value === undefined ? "missing" : value.value); +} +reportValues(inputs[10], first); diff --git a/tests/fixtures/three/attributes.mjs b/tests/fixtures/three/attributes.mjs new file mode 100644 index 000000000..9da5890b7 --- /dev/null +++ b/tests/fixtures/three/attributes.mjs @@ -0,0 +1,36 @@ +import { Float32BufferAttribute, Uint8BufferAttribute, Uint16BufferAttribute } from 'three/src/core/BufferAttribute.js'; +import { Matrix3 } from 'three/src/math/Matrix3.js'; +import { Matrix4 } from 'three/src/math/Matrix4.js'; +import { Box3 } from 'three/src/math/Box3.js'; +import { Vector3 } from 'three/src/math/Vector3.js'; +const positions = new Float32BufferAttribute([0, 0, 0, 1, 2, 3], 3); +console.log(positions.count, positions.itemSize, positions.getX(1), positions.getY(1), positions.getZ(1)); +positions.applyMatrix4(new Matrix4().makeTranslation(2, 3, 4)); +console.log(positions.getX(0), positions.getY(0), positions.getZ(0), positions.getX(1), positions.getY(1), positions.getZ(1)); +const bounds = new Box3().setFromBufferAttribute(positions); +console.log(bounds.min.x, bounds.min.y, bounds.min.z, bounds.max.x, bounds.max.y, bounds.max.z); +const indices = new Uint16BufferAttribute([0, 1, 2], 1); +console.log(indices.count, indices.getX(1)); +console.log(Object.hasOwn(positions, 'id'), Object.keys(positions).includes('id')); +const descriptor = Object.getOwnPropertyDescriptor(positions, 'id'); +console.log(descriptor.writable, descriptor.enumerable, descriptor.configurable); +const source = new Float32Array([2, 4, 6]); +const copied = new Float32BufferAttribute(source, 3); +source[0] = 100; +console.log('copy', copied.getX(0), copied.getY(0), copied.getZ(0)); +copied.setXYZ(0, 3, 2, 1); +copied.applyMatrix3(new Matrix3().set(2, 0, 0, 0, 3, 0, 0, 0, 4)); +console.log('matrix3', copied.getX(0), copied.getY(0), copied.getZ(0)); +const vector = new Vector3().fromBufferAttribute(copied, 0); +console.log('vector', vector.x, vector.y, vector.z); +const colors = new Uint8BufferAttribute([0, 255, 0, 255], 4, true); +console.log('normalized', colors.getX(0), colors.getY(0), colors.getW(0)); +colors.setX(0, 1); +console.log('stored', colors.array[0]); +copied.addUpdateRange(0, 3); +console.log('ranges', copied.updateRanges.length, copied.updateRanges[0].start, copied.updateRanges[0].count); +copied.clearUpdateRanges(); +console.log('cleared', copied.updateRanges.length); +const before = copied.version; +copied.needsUpdate = true; +console.log('updated', copied.version === before + 1); diff --git a/tests/fixtures/three/spatial.mjs b/tests/fixtures/three/spatial.mjs new file mode 100644 index 000000000..d312c5347 --- /dev/null +++ b/tests/fixtures/three/spatial.mjs @@ -0,0 +1,59 @@ +import { Vector2 } from 'three/src/math/Vector2.js'; +import { Vector3 } from 'three/src/math/Vector3.js'; +import { Euler } from 'three/src/math/Euler.js'; +import { Quaternion } from 'three/src/math/Quaternion.js'; +import { Matrix4 } from 'three/src/math/Matrix4.js'; +import { Box2 } from 'three/src/math/Box2.js'; +import { Box3 } from 'three/src/math/Box3.js'; +import { Sphere } from 'three/src/math/Sphere.js'; +import { Ray } from 'three/src/math/Ray.js'; +import { Plane } from 'three/src/math/Plane.js'; +import { Triangle } from 'three/src/math/Triangle.js'; +import { Frustum } from 'three/src/math/Frustum.js'; +import { Spherical } from 'three/src/math/Spherical.js'; +import { Cylindrical } from 'three/src/math/Cylindrical.js'; +import { generateUUID, seededRandom } from 'three/src/math/MathUtils.js'; + +const euler = new Euler(0, 0, 0); +const quaternion = new Quaternion().setFromEuler(euler); +console.log('rotation', quaternion.x, quaternion.y, quaternion.z, quaternion.w, euler.order); +Euler.DEFAULT_ORDER = 'ZYX'; +console.log('default', new Euler().order, euler.order); +Euler.DEFAULT_ORDER = 'XYZ'; +euler.set(0, Math.PI / 2, 0); +const rotated = new Vector3(1, 0, 0).applyEuler(euler); +console.log('rotated', Math.round(rotated.x), Math.round(rotated.y), Math.round(rotated.z)); + +const box = new Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1)); +console.log('box', box.containsPoint(new Vector3()), box.containsPoint(new Vector3(2, 0, 0))); +box.expandByPoint(new Vector3(2, 3, 4)); +const size = box.getSize(new Vector3()); +console.log('size', size.x, size.y, size.z); +const sphere = new Sphere(new Vector3(), 1); +console.log('sphere', sphere.containsPoint(new Vector3(0, 0.5, 0)), sphere.intersectsBox(box)); +console.log('separated', sphere.intersectsSphere(new Sphere(new Vector3(5, 0, 0), 1))); +const ray = new Ray(new Vector3(0, 0, 8), new Vector3(0, 0, -1)); +const hit = ray.intersectBox(box, new Vector3()); +console.log('box hit', hit.x, hit.y, hit.z); +const sphereHit = ray.intersectSphere(sphere, new Vector3()); +console.log('sphere hit', sphereHit.x, sphereHit.y, sphereHit.z); +const miss = new Ray(new Vector3(10, 10, 10), new Vector3(1, 0, 0)); +console.log('miss', miss.intersectBox(box, new Vector3()) === null, miss.intersectSphere(sphere, new Vector3()) === null); +const plane = new Plane(new Vector3(0, 0, 1), -2); +const planeHit = ray.intersectPlane(plane, new Vector3()); +console.log('plane', planeHit.z, plane.distanceToPoint(new Vector3(0, 0, 5))); +const triangle = new Triangle(new Vector3(0, 0, 0), new Vector3(2, 0, 0), new Vector3(0, 2, 0)); +console.log('triangle', triangle.getArea(), triangle.getNormal(new Vector3()).z); +const barycentric = triangle.getBarycoord(new Vector3(0.5, 0.5, 0), new Vector3()); +console.log('barycentric', barycentric.x, barycentric.y, barycentric.z); +const projection = new Matrix4().makePerspective(-1, 1, 1, -1, 1, 10); +const frustum = new Frustum().setFromProjectionMatrix(projection); +console.log('frustum', frustum.containsPoint(new Vector3(0, 0, -2)), frustum.containsPoint(new Vector3(0, 0, 2))); +console.log('visible', frustum.intersectsSphere(new Sphere(new Vector3(0, 0, -3), 0.5)), frustum.intersectsSphere(new Sphere(new Vector3(20, 0, -3), 0.5))); +const box2 = new Box2().setFromPoints([new Vector2(-1, -2), new Vector2(3, 4)]); +console.log('box2', box2.min.x, box2.max.y, box2.containsPoint(new Vector2(0, 0))); +const spherical = new Spherical().setFromVector3(new Vector3(0, 0, 2)); +const cylindrical = new Cylindrical().setFromVector3(new Vector3(0, 3, 2)); +console.log('coordinates', spherical.radius, cylindrical.radius, cylindrical.y); +console.log('uuid', /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(generateUUID())); +console.log('seed', seededRandom(42).toFixed(6), seededRandom().toFixed(6)); diff --git a/tests/harness/__snapshots__/statements.ts.txt b/tests/harness/__snapshots__/statements.ts.txt index cfde1d529..cbc63869c 100644 --- a/tests/harness/__snapshots__/statements.ts.txt +++ b/tests/harness/__snapshots__/statements.ts.txt @@ -5,13 +5,6 @@ statements.ts:6:19 - error SC1052: for-in over class instances (which keys exist | ^ 7 | console.log(key); -statements.ts:9:6 - error SC1090: multi-declaration for-loop initializers are not supported yet - - 8 | } - 9 | for (let i = 0, j = n; i < j; i = i + 1) { - | ^~~~~~~~~~~~~~~~ - 10 | console.log(i); - statements.ts:12:1 - error SC1090: debugger statements are not supported yet 11 | } diff --git a/tests/harness/three.test.ts b/tests/harness/three.test.ts index c6badd955..048e59a92 100644 --- a/tests/harness/three.test.ts +++ b/tests/harness/three.test.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import { expect, test } from "vitest"; import { compile } from "@scriptc/compiler"; -const entry = join(import.meta.dirname, "../fixtures/three/math.mjs"); const hasZig = spawnSync("zig", ["version"], { stdio: "ignore" }).status === 0; const wasiRunner = ` const {readFileSync} = require('node:fs'); @@ -22,7 +21,10 @@ function run(command: string, args: string[]) { return { stdout: result.stdout, stderr: result.stderr, status: result.status, signal: result.signal }; } -test.for(["native", "wasm32-wasi"])("published three.js math runs statically through LLVM on %s", async (target, context) => { +const cases = ["math", "spatial", "attributes"].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`); if (target === "wasm32-wasi" && !hasZig) context.skip(); const dir = await mkdtemp("/tmp/scriptc-three-"); const previousTarget = process.env["SCRIPTC_TARGET"]; @@ -34,7 +36,7 @@ test.for(["native", "wasm32-wasi"])("published three.js math runs statically thr process.env["SCRIPTC_CC"] = "zigcc"; } const result = await compile(entry, { - outDir: dir, outPath: join(dir, wasm ? "math.wasm" : "math"), + outDir: dir, outPath: join(dir, wasm ? `${fixture}.wasm` : fixture), backend: "llvm", dynamic: false, npmStatic: ["three"], sanitize: !wasm && process.env["SCRIPTC_SAN"] === "1", });