diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 52ee5cf8..e1abc132 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4679,14 +4679,12 @@ export class LlEmitter { throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${key}`); } const prefix = `sc_ldr_${this.liveDynRefAdapters.size}`; - const adapter = streamTypedRefMaterializeAdapter( + return streamTypedRefMaterializeAdapter( this, t, - { prefix, adapters: new Map() }, + { prefix, adapters: this.liveDynRefAdapters }, `${prefix}_materialize`, ); - this.liveDynRefAdapters.set(key, adapter); - return adapter; } liveDynUnionRefAdapter( diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 24ac5683..a32deaa5 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -48,16 +48,7 @@ export function emitDynamicExpr(host: LlvmEmitterContext, e: ExprOf<"dynFrom" | throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${typeKey(v.type)}`); } const key = typeKey(v.type); - let adapter = host.liveDynRefAdapters.get(key); - if (!adapter) { - const prefix = `sc_ldr_${host.liveDynRefAdapters.size}`; - adapter = host.streamTypedRefMaterializeAdapter( - v.type, - { prefix, adapters: new Map() }, - `${prefix}_materialize`, - ); - host.liveDynRefAdapters.set(key, adapter); - } + const adapter = host.liveDynRefAdapter(v.type); const rc = vAdapters(host.shapeHost, v.type); const boxed = 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 6baa05f9..5ffd0d4a 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -559,6 +559,9 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, return adapter; } } + // Publish both symbols before their bodies: class-array and callback + // converters can request this same adapter while emitting the commit. + adapter.commit = `@${snapshot}_commit`; adapter.commit = host.streamTypedRefCommitAdapter(t, snapshot); const B = new BlockBuilder(); diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 223844e6..0f8bdec1 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -16,6 +16,12 @@ interface Dispatch { classes: Set; } +interface PropertyReceiver { + info: ClassInfo; + capsule: ClassInfo; + key: string; +} + interface PropertyDispatch { name: string; write: boolean; @@ -316,16 +322,18 @@ export class ClassDynamicDispatch { } } for (const dispatch of this.properties.values()) { - for (const info of this.propertyCandidates(lowerer, dispatch.name)) { - if (dispatch.classes.has(info.def.name)) continue; - dispatch.classes.add(info.def.name); + for (const plan of this.propertyReceivers(lowerer)) { + const { info } = plan; + if (!isClassOwnEnumerableFieldName(dispatch.name) || + !info.fields.has(dispatch.name) && !findMethodOn(lowerer, info, `get:${dispatch.name}`) && !findMethodOn(lowerer, info, `set:${dispatch.name}`)) continue; + if (dispatch.classes.has(plan.key)) continue; + dispatch.classes.add(plan.key); const loc = dispatch.fn.loc; - const type: IrType = { kind: "object", className: info.def.name }; - const receiver = varRef("p.0", DYN, loc); + const { receiver, condition } = this.propertyReceiver(plan, loc); const before = lowerer.diags.length; let branch: IrStmt[]; try { - branch = this.propertyBody(lowerer, dispatch, info, { kind: "dynCheck", value: receiver, type, loc }); + branch = this.propertyBody(lowerer, dispatch, info, receiver); } catch (error) { if (!(error instanceof PoisonError) || !info.decl) throw error; const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" }); @@ -333,23 +341,18 @@ export class ClassDynamicDispatch { branch = [fence]; } dispatch.fn.body.unshift({ - kind: "if", cond: { - kind: "libCall", fn: "dyn.typedRefIs", args: [receiver, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc, - }, then: branch, else_: null, loc, + kind: "if", cond: condition, then: branch, else_: null, loc, }); changed = true; } } for (const dispatch of this.computed.values()) { - for (const className of this.boxed) { - if (dispatch.classes.has(className)) continue; - const info = lowerer.classes.get(className); - if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) continue; - dispatch.classes.add(className); + for (const plan of this.propertyReceivers(lowerer)) { + if (dispatch.classes.has(plan.key)) continue; + const { info } = plan; + dispatch.classes.add(plan.key); const loc = dispatch.fn.loc; - const type: IrType = { kind: "object", className }; - const value = varRef("p.0", DYN, loc); - const receiver: IrExpr = { kind: "dynCheck", value, type, loc }; + const { receiver, condition } = this.propertyReceiver(plan, loc); const names = new Set([...info.fields.keys()].filter(isClassOwnEnumerableFieldName)); for (let owner: ClassInfo | null = info; owner; owner = owner.base) { for (const method of owner.methods.keys()) { @@ -372,9 +375,7 @@ export class ClassDynamicDispatch { kind: "strEq", left: varRef(dispatch.keyLocal, STRING, loc), right: { kind: "strLit", value: name, type: STRING, loc }, negated: false, type: BOOL, loc, }, then: body, else_: null, loc }); } - dispatch.fn.body.splice(dispatch.branchIndex, 0, { kind: "if", cond: { - kind: "libCall", fn: "dyn.typedRefIs", args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc, - }, then: branch, else_: null, loc }); + dispatch.fn.body.splice(dispatch.branchIndex, 0, { kind: "if", cond: condition, then: branch, else_: null, loc }); changed = true; } } @@ -400,13 +401,34 @@ export class ClassDynamicDispatch { add(root); } - private propertyCandidates(lowerer: Lowerer, name: string): ClassInfo[] { - if (!isClassOwnEnumerableFieldName(name)) return []; - return [...this.boxed].flatMap((className) => { - const info = lowerer.classes.get(className); - if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) return []; - return info.fields.has(name) || findMethodOn(lowerer, info, `get:${name}`) || findMethodOn(lowerer, info, `set:${name}`) ? [info] : []; - }); + /** A capsule retains its static type, while its object may be a subclass. + * Preorder insertion lets later branches test the most derived layout first. */ + private propertyReceivers(lowerer: Lowerer): PropertyReceiver[] { + const plans: PropertyReceiver[] = []; + for (const name of this.boxed) { + const capsule = lowerer.classes.get(name); + if (!capsule || capsule.builtinEmitter || capsule.builtinStream || capsule.builtinError) continue; + const visit = (info: ClassInfo): void => { + plans.push({ info, capsule, key: JSON.stringify([name, info.def.name]) }); + for (const child of info.subclasses) visit(child); + }; + visit(capsule); + } + return plans; + } + + private propertyReceiver(plan: PropertyReceiver, loc: SrcLoc): { receiver: IrExpr; condition: IrExpr } { + const value = varRef("p.0", DYN, loc); + const type: IrType = { kind: "object", className: plan.capsule.def.name }; + const checked: IrExpr = { kind: "dynCheck", value, type, loc }; + const matches: IrExpr = { kind: "libCall", fn: "dyn.typedRefIs", args: [value, + { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc }; + if (plan.info === plan.capsule) return { receiver: checked, condition: matches }; + return { + receiver: { kind: "downcast", value: checked, type: { kind: "object", className: plan.info.def.name }, loc }, + condition: { kind: "ternary", cond: matches, then: { kind: "instanceOf", value: checked, + className: plan.info.def.name, type: BOOL, loc }, else_: { kind: "boolLit", value: false, type: BOOL, loc }, type: BOOL, loc }, + }; } private propertyBody(lowerer: Lowerer, dispatch: PropertyDispatch, info: ClassInfo, receiver: IrExpr): IrStmt[] { diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 134d0b1f..ef3e4e27 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -325,6 +325,11 @@ export function lowerDynObjectLiteral( let v = boxValue ? boxValue(valueExpr as ts.Expression, raw) : lowerer.coerceToExpected(raw, DYN); + // A JS object's array-valued properties retain the same source array. + // Repeated references must compare equal and mutations remain shared. + if (!boxValue && isJsSourceFile(prop.getSourceFile()) && v.kind === "dynFrom" && v.value.type.kind === "array") { + v = { ...v, liveRef: true }; + } if (v.type.kind !== "dyn") { const convDiagsBefore = lowerer.diags.length; try { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 1fd9c756..e942e981 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -26,7 +26,7 @@ import { lowerAbsenceProbe, lowerPromiseAllTupleCall, lowerPromiseRejectCall, st 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 { EMITTER_API_MEMBERS, exactInstanceClassOf, findGenericMethodOn, lowerClassGenericMethodCall, lowerStaticMethodCall, returnsOnlyThis, 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"; @@ -6768,6 +6768,12 @@ const inliningPredicates = new Set(); function reconcileOverloadReturn(lowerer: Lowerer, expr: ts.CallExpression | ts.TaggedTemplateExpression, call: IrExpr): IrExpr { const rsig = lowerer.checker.getResolvedSignature(expr); const rdecl = rsig ? lowerer.checker.signatureDeclaration(rsig) : undefined; + // Fluent JS overrides share the base's return ABI, but their result + // still has the receiver's subclass layout at the call site. + if (call.type.kind === "object" && rdecl && ts.isMethodDeclaration(rdecl) && + isJsSourceFile(rdecl.getSourceFile()) && returnsOnlyThis(rdecl)) { + call = lowerer.maybeNarrow(call, expr); + } const resolvedOverload = rsig && rdecl && (ts.isFunctionDeclaration(rdecl) || ts.isMethodDeclaration(rdecl)) && diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 68e99fb7..bf5f666e 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -30,6 +30,24 @@ import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native import { classStaticDataFor } from "./class-static-data.js"; import { lowerInstanceConstructorNew } from "./class-instance-constructor.js"; +export function returnsOnlyThis(member: ts.MethodDeclaration): boolean { + const body = member.body; + const last = body?.statements.at(-1); + if (!body || !last || !ts.isReturnStatement(last)) return false; + let valid = true; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node)) return; + if (ts.isReturnStatement(node)) { + let value = node.expression; + while (value && ts.isParenthesizedExpression(value)) value = value.expression; + if (!value || value.kind !== ts.SyntaxKind.ThisKeyword) valid = false; + } + node.forEachChild(visit); + }; + body.forEachChild(visit); + return valid; +} + function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { if (!ts.isClassExpression(decl)) return false; for (let parent: ts.Node | undefined = decl.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) { @@ -545,13 +563,16 @@ export interface GenericClassInfo { /** The undefined-armed union of a JS class property's inferred type — the * honest slot for a field first assigned outside the constructor's top * level (undefined until the write runs, Node-exact). Null when the - * inference is unmappable, checked-dynamic (dyn stays out of class - * fields — KEEP NARROW), or an arm-less kind that cannot join a union + * inference is unmappable or an arm-less kind that cannot join a union * (genResultRecord's list, including scalar-backed Date values). */ function undefArmedFieldType(lowerer: Lowerer, p: ts.Symbol): IrType | null { const t = lowerer.checker.getTypeOfSymbol(p); - const mapped = lowerer.mapTypeOf(t); - if (!mapped || mapped.kind === "void" || mapped.kind === "dyn") return null; + const site = lowerer.checker.declarationsOf(p)[0]; + let mapped = lowerer.mapTypeOf(t) ?? (site ? dynFallbackType(lowerer, site, t) : null); + if (mapped && site) mapped = jsOpenObjectType(site, mapped, lowerer.shapes, lowerer.unions); + if (!mapped || mapped.kind === "void") return null; + // An implicit JS value already carries undefined in its native box. + if (mapped.kind === "dyn") return mapped; const byKey = new Map(); const arms = mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped]; for (const a of arms) { @@ -1911,6 +1932,11 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla ); } if (overridden && isJsSourceFile(member.getSourceFile())) { + // Fluent overrides return the same native receiver. Keep the + // inherited return ABI and upcast `this` when lowering returns. + if (overridden.sig.ret.kind === "object" && base && + (base.def.name === overridden.sig.ret.className || lowerer.isSubclassOf(base.def.name, overridden.sig.ret.className)) && + returnsOnlyThis(member)) ft.ret = overridden.sig.ret; // 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. @@ -2386,6 +2412,10 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla if (isJsSourceFile(decl.getSourceFile())) { const armed = undefArmedFieldType(lowerer, p); if (armed !== null) { + // Unknown JS properties use the instance bag: they do not + // exist until assigned, and a subclass may declare the same + // name as an ordinary layout field. + if (armed.kind === "dyn") continue; fields.set(p.name, armed); fieldOrder.push({ name: p.name, type: armed, initializer: undefined }); continue; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 112b11a6..250995cd 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2163,8 +2163,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { `reading the abstract property '${expr.name.text}' through a '${lowerer.checker.typeToString(lowerer.typeOf(expr.expression))}'-typed receiver (abstract property declarations are erased at runtime, so no shared slot exists — type the receiver as the concrete class, or declare an abstract getter instead)`, ); } - if (isDynTypedRefType(recvLowered.type)) { - const info = lowerer.classes.get(recvLowered.type.className); + // Array reads may add an undefined arm even when the checker still + // reports a class. Its property bag must remain reachable, while + // an absent element takes the normal checked-value TypeError. + const nativeReceiver = lowerer.stripUndefinedArm(recvLowered.type); + if (isDynTypedRefType(nativeReceiver)) { + const info = lowerer.classes.get(nativeReceiver.className); if (info && !info.def.runtime && !info.builtinError && !info.builtinEmitter && !info.builtinStream) { return lowerer.maybeNarrow({ kind: "dynKeyGet", value: lowerer.coerceToExpected(recvLowered, DYN), @@ -4679,7 +4683,7 @@ export function lowerOptionalNumber( // (`pkg.workspaces.packages["0"]` — the lowering world types the // unknown-rooted chain `any`); a non-dyn lowering falls through to // the fences below (re-lowering is pure IR construction). - if (receiverIr?.kind === "dyn" || receiverIr === null) { + if (receiverIr?.kind === "dyn" || receiverIr?.kind === "object" || receiverIr?.kind === "union" || receiverIr === null) { const obj = lowerer.lowerExpr(expr.expression); // A generic mapped type can stay unresolved at this body use even // though its instantiated parameter has a concrete record ABI. @@ -4690,7 +4694,7 @@ export function lowerOptionalNumber( return lowerer.lowerRecordKeyRead(expr, obj.type.shapeId, shape); } } - if (obj.type.kind === "dyn") { + if (obj.type.kind === "dyn" || isDynTypedRefType(obj.type)) { const rawKey = lowerer.lowerExpr(expr.argumentExpression); // Number, bool, and DYN keys stringify (ToPropertyKey) — the // dyn-keyed read `catchWarning[warning.name]` where the property @@ -4699,7 +4703,7 @@ export function lowerOptionalNumber( if (key.type.kind === "string") { const opt = hasOptionalChainGuard(expr.expression); return lowerer.maybeNarrow( - { kind: "dynKeyGet", key, ...(opt ? { optional: true as const } : {}), value: obj, type: DYN, loc: locOf(expr) }, + { kind: "dynKeyGet", key, ...(opt ? { optional: true as const } : {}), value: lowerer.coerceToExpected(obj, DYN), type: DYN, loc: locOf(expr) }, expr, ); } @@ -9913,6 +9917,9 @@ function representedClassFieldTarget( if (receiverIr?.kind === "object") { return classFieldTarget(lowerer, access.expression, receiverIr, access.name.text); } + if (receiverIr?.kind === "union" && hasClassPayload(lowerer, receiverIr)) { + return representedClassFieldTarget(lowerer, access.expression, access.name.text, lowerer.lowerExpr(access.expression)); + } if (receiverIr?.kind === "record") { const shape = lowerer.shapes.get(receiverIr.shapeId); const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 580aad8b..225ba666 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -39,7 +39,7 @@ import { isNativeProxyInitializer } from "./expressions/native-proxy.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerUnionFieldWrite } from "./expressions/union-field-write.js"; import { UNSUPPORTED, checkerPanicDiag, isCheckerPanic, requiresDynamicDiag } from "../../diagnostics/diagnostic.js"; -import { isParseArgsDynTypeName, isUnitOnlyTsType, unitOnlyUnion } from "../type-mapper.js"; +import { isParseArgsDynTypeName, isUnitOnlyTsType, jsOpenObjectType, unitOnlyUnion } from "../type-mapper.js"; import { canonicalBuiltinModule } from "../builtin-modules.js"; import { isRelativeSpecifier } from "../workspace-registry.js"; import { probeNodeRequireRefusal } from "../npm.js"; @@ -3830,7 +3830,8 @@ export function lowerVarDecl(lowerer: Lowerer, decl: ts.VariableDeclaration, isL } const preservesObjectIdentity = init.type.kind === "dyn" && (inferredObjectType?.kind === "record" || - (isJsSourceFile(decl.getSourceFile()) && inferredObjectType?.kind === "func") || + (isJsSourceFile(decl.getSourceFile()) && inferredObjectType !== null && + (inferredObjectType.kind === "func" || jsOpenObjectType(decl, inferredObjectType, lowerer.shapes, lowerer.unions).kind === "dyn")) || (isJsSourceFile(decl.getSourceFile()) && inferredObjectType !== null && (isUnitType(inferredObjectType) || inferredObjectType.kind === "union" && lowerer.unions.get(inferredObjectType.unionId)?.arms.every(isUnitType)))) && !decl.type && !hasJsTypeAnnotation(decl); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 3b4320ec..6ae11b61 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -6333,6 +6333,8 @@ function canBoxDynComposite( case "regex": case "url": return true; + case "object": + return isDynTypedRefType(t); case "func": return canBoxFuncIntoDyn(t, getRecord, getUnion); case "set": @@ -6386,6 +6388,10 @@ export function canDynCheckTo( if (t.kind === "bytes") return true; if (t.kind === "classval") return true; if (t.kind === "object" && t.className === "%Error") return true; + // Native class arrays validate each branded reference, never fabricate + // class instances from plain object data. + if (isDynTypedRefType(t)) return true; + if (t.kind === "array" && isDynTypedRefType(t.elem)) return true; if (t.kind === "func") return canAdaptDynFuncTo(t, getRecord, getUnion); if (DYN_HANDLE_KINDS.has(t.kind)) return true; if (t.kind === "union") { diff --git a/packages/compiler/test/native-class-array-boundaries.test.ts b/packages/compiler/test/native-class-array-boundaries.test.ts new file mode 100644 index 00000000..30e3e52c --- /dev/null +++ b/packages/compiler/test/native-class-array-boundaries.test.ts @@ -0,0 +1,42 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +test("checked class arrays reject unbranded and differently branded elements", async () => { + const dir = mkdtempSync("/tmp/scriptc-class-arrays-"); + try { + const entry = join(dir, "main.ts"); + writeFileSync(entry, ` +class Item { value = 1; } +class Other { value = 1; } +function check(input: unknown): void { + try { const items = input as Item[]; console.log('accepted', items.length); } + catch (error) { console.log('rejected', error instanceof TypeError); } +} +check([new Item()]); +check([new Other()]); +check([{value: 1}]); +const mixed: unknown[] = []; +mixed.push(new Item()); mixed.push({value: 1}); +check(mixed); +check(null); +check([1]); +check([]); +`); + 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 child = spawnSync(result.binaryPath, [], {encoding: "utf8", timeout: 30_000}); + expect(child.error).toBeUndefined(); + expect({status: child.status, signal: child.signal, stdout: child.stdout, stderr: child.stderr}).toEqual({ + status: 0, signal: null, stderr: "", + stdout: "accepted 1\nrejected true\nrejected true\nrejected true\nrejected true\nrejected true\naccepted 0\n", + }); + } 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 7ca1cd6e..c366ad0e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -11823,6 +11823,42 @@ "/tests/corpus/js-nested-native-descriptors.js" ], "diags": [] + }, + "/tests/corpus/js-class-fluent-overrides.js": { + "order": [ + "/tests/corpus/js-class-fluent-overrides.js" + ], + "diags": [] + }, + "/tests/corpus/js-class-subclass-properties.js": { + "order": [ + "/tests/corpus/js-class-subclass-properties.js" + ], + "diags": [] + }, + "/tests/corpus/js-object-shared-arrays.js": { + "order": [ + "/tests/corpus/js-object-shared-arrays.js" + ], + "diags": [] + }, + "/tests/corpus/js-optional-class-fields.js": { + "order": [ + "/tests/corpus/js-optional-class-fields.js" + ], + "diags": [] + }, + "/tests/corpus/native-class-array-boundaries.ts": { + "order": [ + "/tests/corpus/native-class-array-boundaries.ts" + ], + "diags": [] + }, + "/tests/corpus/native-class-type-predicates.ts": { + "order": [ + "/tests/corpus/native-class-type-predicates.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 98a1922a..273a879e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1951,6 +1951,9 @@ const ScrDynJsvalOps *scr_dyn_jsval_ops(void) { bool scr_dyn_isl_typeof_is(const ScrDyn *d, const char *name) { if (d->kind == SCR_DYN_TYPED_REF) { + if (d->v.typed_ref.type_key_len >= 7 && + memcmp(d->v.typed_ref.type_key, "object:", 7) == 0) + return strcmp(name, "object") == 0; ScrDyn *materialized = scr_dyn_typed_ref_materialize(d); bool out = scr_dyn_isl_typeof_is(materialized, name); if (materialized->kind != SCR_DYN_JSVAL) { @@ -1978,6 +1981,8 @@ bool scr_dyn_isl_typeof_is(const ScrDyn *d, const char *name) { bool scr_dyn_isl_is_array(const ScrDyn *d) { if (d->kind == SCR_DYN_TYPED_REF) { + if (d->v.typed_ref.type_key_len >= 7 && + memcmp(d->v.typed_ref.type_key, "object:", 7) == 0) return false; ScrDyn *materialized = scr_dyn_typed_ref_materialize(d); bool out = materialized->kind == SCR_DYN_ARR || scr_dyn_isl_is_array(materialized); @@ -2425,6 +2430,9 @@ bool scr_dyn_truthy(const ScrDyn *d) { ScrStr *scr_dyn_typeof(const ScrDyn *d) { const char *s; if (d->kind == SCR_DYN_TYPED_REF) { + if (d->v.typed_ref.type_key_len >= 7 && + memcmp(d->v.typed_ref.type_key, "object:", 7) == 0) + return scr_str_new("object", 6); ScrDyn *materialized = scr_dyn_typed_ref_materialize(d); ScrStr *out = scr_dyn_typeof(materialized); scr_dyn_release(materialized); @@ -5963,11 +5971,18 @@ static bool scr_ffi_signature_equal(const ScrDyn *entry, const ScrDyn *definitio if (!definition || definition->kind != SCR_DYN_OBJ) return false; ScrDyn *expected = scr_dyn_obj_get(entry, "arguments", 9); ScrDyn *actual = scr_dyn_obj_get(definition, "arguments", 9); - if (!actual || actual->kind != SCR_DYN_ARR || actual->v.arr.len != expected->v.arr.len || - !scr_ffi_abi_equal(scr_dyn_obj_get(entry, "return", 6), scr_dyn_obj_get(definition, "return", 6))) return false; - for (size_t i = 0; i < actual->v.arr.len; i++) - if (!scr_ffi_abi_equal(actual->v.arr.items[i], expected->v.arr.items[i])) return false; - return true; + if (!actual) return false; + // JS object properties can retain a native array by reference. Read its + // current contents when checking the requested ABI, just like a dyn array. + ScrDyn *view = actual->kind == SCR_DYN_TYPED_REF + ? scr_dyn_typed_ref_materialize(actual) : scr_dyn_retain(actual); + bool matches = view && view->kind == SCR_DYN_ARR && view->v.arr.len == expected->v.arr.len && + scr_ffi_abi_equal(scr_dyn_obj_get(entry, "return", 6), scr_dyn_obj_get(definition, "return", 6)); + if (matches) for (size_t i = 0; i < view->v.arr.len; i++) { + if (!scr_ffi_abi_equal(view->v.arr.items[i], expected->v.arr.items[i])) { matches = false; break; } + } + scr_dyn_release(view); + return matches; } static void scr_ffi_callback_remove(ScrDyn *entry, ScrDyn *catalog) { diff --git a/tests/corpus/js-class-fluent-overrides.js b/tests/corpus/js-class-fluent-overrides.js new file mode 100644 index 00000000..c3ffbf4e --- /dev/null +++ b/tests/corpus/js-class-fluent-overrides.js @@ -0,0 +1,28 @@ +class Base { + constructor() { this.value = 1; } + copy(source) { this.value = source.value; return this; } + dispatch(source) { return this.copy(source); } +} +class Child extends Base { + constructor() { super(); this.extra = 2; } + copy(source) { super.copy(source); this.extra = source.extra; return this; } + childOnly() { return this.extra; } +} +class Leaf extends Child { + constructor() { super(); this.leaf = 3; } + copy(source) { + super.copy(source); + if (source.leaf === 0) return (this); + this.leaf = source.leaf; + return this; + } +} +const source = new Leaf(); +source.value = 4; source.extra = 5; source.leaf = 6; +const target = new Leaf(); +console.log('direct', target.copy(source) === target, target.value, target.extra, target.leaf); +console.log('chain', target.copy(source).childOnly(), target.copy(source).leaf); +source.extra = 8; +console.log('virtual', target.dispatch(source) === target, target.extra); +source.leaf = 0; +console.log('early', target.copy(source) === target, target.leaf); diff --git a/tests/corpus/js-class-subclass-properties.js b/tests/corpus/js-class-subclass-properties.js new file mode 100644 index 00000000..19e76030 --- /dev/null +++ b/tests/corpus/js-class-subclass-properties.js @@ -0,0 +1,43 @@ +class Base { + constructor() { this.base = 1; } + read(key) { return this[key]; } + write(key, value) { this[key] = value; } + named() { return this.extra; } + attach(value) { this.extra = value; } + remember(key, value) { + if (this.cache === undefined) this.cache = {}; + this.cache[key] = value; + } + recall(key) { return this.cache[key]; } + saved() { + const cache = this.cache; + if (cache === undefined) return 'empty'; + return Object.keys(cache).join(','); + } +} +class Child extends Base { + constructor() { super(); this.extra = 2; this.storage = 3; } + get doubled() { return this.storage * 2; } + set doubled(value) { this.storage = value / 2; } +} +class Leaf extends Child { + constructor() { super(); this.leaf = 4; } + get doubled() { return this.storage * 3; } + set doubled(value) { this.storage = value / 3; } +} +const base = new Base(), child = new Child(), leaf = new Leaf(); +console.log('absent', base.read('extra'), Object.keys(base).join(',')); +base.attach({answer: 42}); +console.log('added', base.read('extra').answer, Object.keys(base).join(',')); +console.log('inherited', child.read('extra'), child.named(), leaf.read('leaf')); +child.write('extra', 9); leaf.write('leaf', 10); +child.write('doubled', 12); leaf.write('doubled', 12); +console.log('writes', child.extra, leaf.leaf, child.storage, leaf.storage); +console.log('accessors', child.read('doubled'), leaf.read('doubled')); +let order = ''; +function receiver() { order += 'r'; return leaf; } +function key() { order += 'k'; return 'leaf'; } +console.log('read', receiver()[key()], order); +base.remember('first', 1); base.remember('second', {value: 2}); +console.log('cache', base.recall('first'), base.recall('second').value, Object.keys(base.cache).join(',')); +console.log('saved', base.saved(), child.saved()); diff --git a/tests/corpus/js-object-shared-arrays.js b/tests/corpus/js-object-shared-arrays.js new file mode 100644 index 00000000..1fb79aa3 --- /dev/null +++ b/tests/corpus/js-object-shared-arrays.js @@ -0,0 +1,11 @@ +const shared = [1, 2, 3]; +const a = {values: shared}, b = {nested: {values: shared}}; +console.log('identity', a.values === b.nested.values, a.values === shared); +a.values[1] = 8; +console.log('write', shared[1], b.nested.values[1]); +shared.push(4); +console.log('push', a.values.length, b.nested.values.join(',')); +b.nested.values.pop(); +console.log('pop', shared.length, a.values.join(',')); +const separate = {values: [1, 8, 3]}; +console.log('distinct', separate.values === a.values); diff --git a/tests/corpus/js-optional-class-fields.js b/tests/corpus/js-optional-class-fields.js new file mode 100644 index 00000000..f51890b4 --- /dev/null +++ b/tests/corpus/js-optional-class-fields.js @@ -0,0 +1,26 @@ +class Vector { + constructor() { this.x = 1; this.y = 2; } + get doubled() { return this.x * 2; } + set doubled(value) { this.x = value / 2; } +} +class Holder { + initialize() { this.vector = new Vector(); } +} +const holder = new Holder(); +holder.initialize(); +let calls = 0; +function receiver() { calls++; return holder; } +receiver().vector.x = 7; +holder.vector.y += 3; +holder.vector.doubled = 18; +console.log('nested', holder.vector.x, holder.vector.y, calls); + +class Positioned { + constructor() { Object.defineProperty(this, 'position', {value: new Vector(), enumerable: true}); } +} +const objects = [new Positioned()]; +console.log('array', objects[0].position.x); +objects[0].position.x = 4; +console.log('write', objects[0].position.x); +try { console.log(objects[1].position.x); } +catch (error) { console.log('missing', error instanceof TypeError, error.message); } diff --git a/tests/corpus/native-class-array-boundaries.ts b/tests/corpus/native-class-array-boundaries.ts new file mode 100644 index 00000000..452528a0 --- /dev/null +++ b/tests/corpus/native-class-array-boundaries.ts @@ -0,0 +1,29 @@ +class Plane { + constant: number; + constructor(value: number) { this.constant = value; } +} +function roundtrip(value: unknown): Plane[] { return value as Plane[]; } +function optional(value: unknown): Plane[] | null { return value as Plane[] | null; } +const plane = new Plane(2); +const planes = [plane]; +const copy = roundtrip(planes); +console.log('array', copy.length, copy[0] === plane, copy[0].constant); +copy[0].constant = 4; +console.log('mutation', plane.constant, optional(planes)![0] === plane, optional(null) === null); +function boxedRecord(value: unknown) { return value; } +const boxed = boxedRecord({plane, planes}); +const view = boxed as {plane: unknown; planes: unknown[]}; +console.log('record', view.plane === plane, view.planes[0] === plane); +const callback: unknown = (value: Plane): number => value.constant; +console.log('callback', (callback as (value: Plane) => number)(plane)); + +class Tree { + children: Tree[] = []; + select: (node: Tree) => Tree[] = (node) => node.children; +} +const root = new Tree(), child = new Tree(); +root.children.push(child); +const tree: unknown = root; +const restored = tree as Tree; +console.log('recursive', restored === root, restored.select(root)[0] === child); +console.log('view', Object.keys(tree as object).sort().join(',')); diff --git a/tests/corpus/native-class-type-predicates.ts b/tests/corpus/native-class-type-predicates.ts new file mode 100644 index 00000000..80c08c7b --- /dev/null +++ b/tests/corpus/native-class-type-predicates.ts @@ -0,0 +1,15 @@ +class Opaque { + entries: Map = new Map(); + self: Opaque | null = null; +} +function inspect(value: unknown): void { + console.log(typeof value, typeof value === 'object', typeof value === 'function', Array.isArray(value)); +} +const value = new Opaque(); +value.self = value; +inspect(value); +inspect([value]); +inspect(null); +inspect(1); +inspect('text'); +value.self = null; diff --git a/tests/ffi/catalog.mjs b/tests/ffi/catalog.mjs index 521b03fa..7c0e16ae 100644 --- a/tests/ffi/catalog.mjs +++ b/tests/ffi/catalog.mjs @@ -2,8 +2,9 @@ import { createRequire } from "node:module"; const requireModule = createRequire(import.meta.url); const ffi = requireModule("node:ffi"); console.log(ffi === requireModule("node:ffi")); +const u64Arguments = ["u64"]; const definitions = { - nativeU64: { arguments: ["u64"], return: "u64" }, + nativeU64: { arguments: u64Arguments, return: "u64" }, nativePointer: { arguments: ["pointer"], return: "pointer" }, nativeI64: { arguments: ["int64"], return: "int64" }, nativeByte: { arguments: ["bool"], return: "uint8" }, @@ -44,4 +45,9 @@ second.lib.close(); if (process.env.FFI_STATIC === "1") { try { ffi.dlopen(process.env.FFI_LIBRARY, { nativeU64: { arguments: ["i64"], return: "u64" } }); } catch { console.log("signature mismatch"); } + u64Arguments[0] = "i64"; + try { ffi.dlopen(process.env.FFI_LIBRARY, definitions); } + catch { console.log("mutated signature"); } + try { ffi.dlopen(process.env.FFI_LIBRARY, { nativeU64: { return: "u64" } }); } + catch (error) { console.log("missing arguments", error.name, error.code); } } diff --git a/tests/fixtures/three/materials.mjs b/tests/fixtures/three/materials.mjs new file mode 100644 index 00000000..3bbe66d9 --- /dev/null +++ b/tests/fixtures/three/materials.mjs @@ -0,0 +1,45 @@ +import { MeshNormalMaterial } from 'three/src/materials/MeshNormalMaterial.js'; +import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js'; +import { MeshStandardMaterial } from 'three/src/materials/MeshStandardMaterial.js'; +import { Plane } from 'three/src/math/Plane.js'; +import { Vector3 } from 'three/src/math/Vector3.js'; + +const normal = new MeshNormalMaterial({wireframe: true, opacity: 0.5, transparent: true}); +console.log('normal', normal.type, normal.wireframe, normal.opacity, normal.transparent); +normal.setValues({wireframe: false, opacity: 0.8}); +normal.needsUpdate = true; +console.log('updated', normal.wireframe, normal.opacity, normal.version); +const copy = normal.clone(); +console.log('clone', copy !== normal, copy.type, copy.opacity, copy.normalScale.x); +copy.normalScale.x = 2; +console.log('independent', normal.normalScale.x, copy.normalScale.x); + +const plane = new Plane(new Vector3(1, 0, 0), 2); +normal.setValues({clippingPlanes: [plane], clipIntersection: true}); +if (normal.clippingPlanes !== null) { + console.log('clip', normal.clippingPlanes.length, normal.clippingPlanes[0] === plane, normal.clipIntersection); + normal.clippingPlanes[0].constant = 3; +} +console.log('plane', plane.constant); +normal.setValues({clippingPlanes: null}); +console.log('unclipped', normal.clippingPlanes === null); + +const basic = new MeshBasicMaterial({color: 0xff0000}); +console.log('red', basic.color.r, basic.color.g, basic.color.b); +basic.setValues({color: 0x0000ff}); +console.log('blue', basic.color.r, basic.color.g, basic.color.b); +console.log('basic-copy', basic.clone().color.b); +const standard = new MeshStandardMaterial({roughness: 0.25, metalness: 0.8, color: 0x00ff00}); +console.log('standard', standard.type, standard.roughness, standard.metalness, standard.color.g); +console.log('standard-copy', standard.clone().roughness, standard.clone().metalness); + +let count = 0; +function disposed(event) { + count++; + console.log('event', event.type, event.target === normal, this === normal); +} +normal.addEventListener('dispose', disposed); +normal.dispose(); +normal.removeEventListener('dispose', disposed); +normal.dispose(); +console.log('events', count); diff --git a/tests/fixtures/three/mesh.mjs b/tests/fixtures/three/mesh.mjs new file mode 100644 index 00000000..ccfc6f79 --- /dev/null +++ b/tests/fixtures/three/mesh.mjs @@ -0,0 +1,33 @@ +import { Mesh } from 'three/src/objects/Mesh.js'; +import { BoxGeometry } from 'three/src/geometries/BoxGeometry.js'; +import { MeshNormalMaterial } from 'three/src/materials/MeshNormalMaterial.js'; +import { Scene } from 'three/src/scenes/Scene.js'; +import { Vector3 } from 'three/src/math/Vector3.js'; + +const geometry = new BoxGeometry(2, 4, 6); +const material = new MeshNormalMaterial(); +const mesh = new Mesh(geometry, material); +console.log('mesh', mesh.type, mesh.isMesh, mesh.geometry === geometry, mesh.material === material); +const scene = new Scene(); +scene.add(mesh); +mesh.position.set(1, 2, 3); +scene.updateMatrixWorld(); +const point = mesh.getVertexPosition(0, new Vector3()); +console.log('vertex', point.x, point.y, point.z); +console.log('world', mesh.matrixWorld.elements[12], mesh.matrixWorld.elements[13], mesh.matrixWorld.elements[14]); +const copy = mesh.clone(); +console.log('copy', copy !== mesh, copy.geometry === geometry, copy.material === material, copy.position.x); +copy.position.x = 7; +console.log('independent', mesh.position.x, copy.position.x); +const other = new Mesh(geometry, material); +console.log('fluent', other.copy(mesh) === other, other.copy(mesh).geometry === geometry); +const child = new Mesh(geometry, material); +child.position.y = 3; +mesh.add(child); +const recursive = mesh.clone(); +console.log('children', recursive.children.length, recursive.children[0] !== child, recursive.children[0].position.y); +console.log('shallow', mesh.clone(false).children.length); +const materials = [material, new MeshNormalMaterial({wireframe: true})]; +const multi = new Mesh(geometry, materials); +const multiCopy = multi.clone(); +console.log('materials', multiCopy.material !== multi.material, multiCopy.material.length, multiCopy.material[0] === material, multiCopy.material[1].wireframe); diff --git a/tests/harness/ffi-catalog.test.ts b/tests/harness/ffi-catalog.test.ts index ccbdf484..8a88c886 100644 --- a/tests/harness/ffi-catalog.test.ts +++ b/tests/harness/ffi-catalog.test.ts @@ -31,7 +31,7 @@ describe.each(["llvm"] as const)("static node:ffi catalog, %s", backend => { expect(native.error).toBeUndefined(); expect(native.stderr).toBe(""); expect(native.status).toBe(0); - expect(native.stdout).toBe("true\n9007199254740993n\ntrue\n0n 0n\n65 255\n-9223372036854775808n 18446744073709551615n\n" + "TypeError ERR_INVALID_ARG_VALUE\n".repeat(12) + "5\narity\narity\nsignature\nclosed\n42n\nsignature mismatch\n"); + expect(native.stdout).toBe("true\n9007199254740993n\ntrue\n0n 0n\n65 255\n-9223372036854775808n 18446744073709551615n\n" + "TypeError ERR_INVALID_ARG_VALUE\n".repeat(12) + "5\narity\narity\nsignature\nclosed\n42n\nsignature mismatch\nmutated signature\nmissing arguments Error SC2020\n"); // A separately linked Node library is only the oracle. scriptc links // the object file and must work before this dynamic library exists. if (oracle) { @@ -42,7 +42,7 @@ describe.each(["llvm"] as const)("static node:ffi catalog, %s", backend => { expect(node.error).toBeUndefined(); expect(node.stderr).toBe(native.stderr); expect(node.status).toBe(native.status); - expect(node.stdout + "signature mismatch\n").toBe(native.stdout); + expect(node.stdout + "signature mismatch\nmutated signature\nmissing arguments Error SC2020\n").toBe(native.stdout); } }); }); diff --git a/tests/harness/three.test.ts b/tests/harness/three.test.ts index 2ee73914..617bb8ac 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", "geometry", "scene"].flatMap((fixture) => +const cases = ["math", "spatial", "attributes", "geometry", "scene", "materials", "mesh"].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`);