diff --git a/package.json b/package.json index 91c1845c..2208a4b6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "effect": "3.22.2", "eslint": "^9.20.0", "js-yaml": "4.3.0", + "rollup": "4.62.2", "three": "0.186.1", "tsx": "^4.19.0", "typescript": "5.9.3", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 46638f6a..ead8b243 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -184,9 +184,12 @@ export interface GenericFnInfo { * Instance methods take `this` (object:) as param 0 and lower * under the declarer's instantiation bindings (generic-class receivers) * MERGED with the method instantiation's own; statics lower as plain - * module functions with the static-method this/super fence. Absent for + * module functions with an exact receiver or the this/super fence. Absent for * top-level functions and object-literal methods. */ member?: { cls: ClassInfo; kind: "method" | "static" }; + /** Direct static calls specialize lexical this for the proven receiver. */ + staticReceiver?: ClassInfo; + receiverSpecializations?: Map; /** Object-literal generic methods (`{ m(x: T) {...} }` and generic * arrow/function-expression properties): lowered as plain module * functions — `this` inside is fenced (rejectThisInObjectMethod) and the @@ -1728,19 +1731,24 @@ export function genericFnOf(lowerer: Lowerer, ident: ts.Identifier): GenericFnIn lowerer.fnStack.push(fnCtx); lowerer.localClassInstantiations.push({ owner: decl, name: inst.name }); try { - // STATIC generic methods: `this`/`super` name the RECEIVER class (a - // dynamic value) — the lowerStaticMethod fence, applied here because - // generic statics have no non-generic lowering pass. Arrow functions - // are transparent (they inherit the method's `this`); this-binding - // function forms are opaque. + // Static specializations bind lexical this only when the caller + // proved its exact receiver. Other instances retain the fence; + // super remains unsupported. Arrows inherit the lexical receiver. if (info.member?.kind === "static" && decl.body) { rejectStaticThis( lowerer, decl.body, (keyword) => `'${keyword}' in static methods (it names the RECEIVER class — a dynamic value; reference the class by name instead)`, + false, info.staticReceiver !== undefined, ); } const params: IrParam[] = []; + const receiverInit: IrStmt[] = []; + if (info.staticReceiver) { + const value = lowerer.classValueRef(info.staticReceiver, decl); + const local = lowerer.declareThis(value.type); + receiverInit.push({ kind: "varDecl", localId: local.id, init: value, loc: locOf(decl) }); + } if (cls && info.member!.kind === "method") { // Instance methods take `this` as param 0, exactly like plain // `%C.method` functions (lowerClassMethodMemberInner). @@ -1752,7 +1760,7 @@ export function genericFnOf(lowerer: Lowerer, ident: ts.Identifier): GenericFnIn // threaded — a default mentioning T resolves like any body expression. const declared = lowerer.declareParams(decl.parameters, inst.params); params.push(...declared.params); - const body = [...declared.prologue]; + const body = [...receiverInit, ...declared.prologue]; const bodyBlock = blockBodyOf(decl); if (bodyBlock) { body.push(...lowerer.lowerStmts(bodyBlock.statements)); @@ -2173,7 +2181,8 @@ function runtimeOptionalHofGenericBinding( const broadFunction = lowerer.checker.typeToString(t) === "Function"; const broadArray = lowerer.checkerAnyArrayType(t); const broadPromise = broadPromiseParam(lowerer, t); - const classParam = classParams && lowerer.mapTypeOf(t)?.kind === "object"; + const declaredKind = classParams ? lowerer.mapTypeOf(t)?.kind : undefined; + const classParam = classParams && (declaredKind === "object" || declaredKind === "array"); if ((t.flags & ts.TypeFlags.Any) === 0 && !broadFunction && !broadArray && !broadPromise && !classParam) return null; const sym = lowerer.checker.getSymbolAtLocation(param.name); if (!sym) return null; @@ -2233,6 +2242,8 @@ function runtimeOptionalHofGenericBinding( if (retTs.flags & ts.TypeFlags.Any) return null; if (retTs.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) return DYN; if (lowerer.checker.isArrayType(retTs)) { + if (info.decl.parameters.some((param, index) => info.implicitParams?.[index] && + lowerer.mapTypeOf(lowerer.typeOf(param.name))?.kind === "array")) return null; const elem = lowerer.checker.getTypeArguments(retTs as ts.TypeReference)[0]; // An implicit-any parameter poisons an inferred array result to // any[] even when this instance's body produces one concrete @@ -2375,7 +2386,8 @@ function storedImplicitArgumentType(lowerer: Lowerer, arg: ts.Expression): IrTyp // receiver type in the checked fallback. Keep existing broad JS inference. for (let i = 0; i < info.decl.parameters.length; i++) { const symbol = info.implicitParams?.[i]; - if (symbol && lowerer.mapTypeOf(lowerer.typeOf(info.decl.parameters[i]!.name))?.kind === "object") { + const kind = lowerer.mapTypeOf(lowerer.typeOf(info.decl.parameters[i]!.name))?.kind; + if (symbol && (kind === "object" || kind === "array")) { argTypes.set(symbol, lowerer.checker.getUnknownType()); } } @@ -9914,7 +9926,9 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I export function lowerRecordFieldCall(lowerer: Lowerer, call: ts.CallExpression, access: ts.PropertyAccessExpression,): IrExpr | null { if (lowerer.chainBlocked(call)) return null; - const receiverType = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + const stored = ts.isIdentifier(access.expression) + ? lowerer.peekLocal(access.expression)?.type ?? lowerer.globalOf(access.expression)?.type : undefined; + const receiverType = stored?.kind === "record" ? stored : lowerer.mapTypeOf(lowerer.typeOf(access.expression)); if (receiverType?.kind !== "record" && receiverType?.kind !== "union") return null; // A union of records can share a closure-valued field. Its normal // property read performs the tag dispatch before arguments evaluate. @@ -10896,7 +10910,8 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I // A specialized JS parameter can expose a native field whose checker // type still belongs to the unspecialized body (plane.normal.dot()). // Use the lowered receiver's representation and retain its evaluation. - const probe = mappedReceiver?.kind !== "object" && (lowerer.implicitParamTypes !== null || ts.isNewExpression(access.expression)) + const probe = mappedReceiver?.kind !== "object" && (lowerer.implicitParamTypes !== null || ts.isNewExpression(access.expression) || + isJsSourceFile(call.getSourceFile()) && ts.isCallExpression(access.expression)) ? tryLowerExpression(lowerer, access.expression) : null; const specializedReceiver = probe?.type.kind === "object" ? probe : null; if (specializedReceiver) mappedReceiver = specializedReceiver.type; diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 40957239..c3bfc8f8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -8,7 +8,7 @@ import * as ts from "../ts7/adapter.js"; import type { FnCtx, Lowerer } from "./lowerer.js"; import { BOOL, DATE_T, DYN, F64, bytesOf, type IrClassDef, type IrExpr, type IrFunction, type IrLocal, type IrParam, type IrStmt, type IrType, JSVAL, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, typeEquals } from "../../ir/ir.js"; import { MAX_GENERIC_INSTANCES, appendImplicitUndefinedReturn, bodyReadsArguments, generatorMeta, genericCallInstance, implicitAnyParamSymbolsOf, implicitCallInstance, implicitMonoFile, omittedArgFor, type GenericFnInfo, type ParamShape } from "./lower-calls.js"; -import { isGenericCallableMemberType, jsOpenObjectType, typeKey } from "../type-mapper.js"; +import { isGenericCallableMemberType, jsOpenObjectType, typeKey, withUnitArm } from "../type-mapper.js"; import { cjsClassExprWholeExportOf, isCjsJsFile, isJsSourceFile, isModuleExportsAccess, isNodeTypesPath, locOf } from "../program.js"; import { PoisonError, dynFallbackType, dynUndefinedExpr, newFnCtx, own } from "./lowerer.js"; import { lowerArrayConstructor, lowerMapSeedNew, lowerSetSeedNew } from "./lower-containers.js"; @@ -24,10 +24,11 @@ import { builtinFenceHintOf } from "./surfaces.js"; import { lowerHttpAgentNew, lowerHttpServerNew } from "./lower-server.js"; import { ambientNsRootOf, ambientUndefReadType, ambientUndefVarRootOf, ambientUndefinedFnSymbolOf, fenceEarlyAliasUse, fenceEarlyNsMemberRef, nsMemberIdentOf, nsUndefRead } from "./lower-namespaces.js"; import { mixinResultBindingClassOf, type MixinInstanceInfo } from "./lower-mixins.js"; -import { rejectStaticThis } from "./static-this.js"; +import { hasLexicalPrivateReference, hasStaticThis, rejectStaticThis } from "./static-this.js"; import { lowerUrlNew } from "./lower-url.js"; import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native-proxy.js"; import { classStaticDataFor } from "./class-static-data.js"; +import { lowerObjectFactoryNew } from "./object-factory-new.js"; import { lowerInstanceConstructorNew } from "./class-instance-constructor.js"; export function returnsOnlyThis(member: ts.MethodDeclaration): boolean { @@ -57,6 +58,10 @@ function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { } export function storedClassValueType(lowerer: Lowerer, expression: ts.Expression): IrType | null { + if (expression.kind === ts.SyntaxKind.ThisKeyword) { + const stored = lowerer.resolveThis(); + if (stored?.type.kind === "classval") return stored.type; + } if (ts.isIdentifier(expression)) { const stored = lowerer.peekLocal(expression) ?? lowerer.globalOf(expression); if (stored?.type.kind === "classval") return stored.type; @@ -201,9 +206,9 @@ export interface ClassInfo { * never join vtables, so IrClassDef doesn't know them). `C.m(args)` is * a direct call; `const f = C.m` a zero-capture closure; calls through * class VALUES devirtualize when no strict descendant redeclares the - * member. `this`/`super` inside fence at lowering (JS binds `this` to - * the RECEIVER class — dynamic). Absent on builtin classes. */ - staticMethods?: Map; + * member. Receiver-dependent methods specialize for exact receivers; + * detached methods and `super` retain their fences. Absent on builtins. */ + staticMethods?: Map; /** `static { ... }` blocks, in declaration order. They are DECLARATION-TIME * CODE, not shape: JS runs each block once when the class statement * evaluates, whether or not anything ever references the class — so their @@ -560,6 +565,29 @@ export interface GenericClassInfo { return declared ? (lowerer.classes.get(irName) ?? null) : null; } +/** A dictionary lookup may be absent even when its checker type is a + * required reference. Keep literal nullish fallbacks in inferred JS storage. */ +function jsFieldInitializerType(lowerer: Lowerer, type: IrType, expression: ts.Expression): IrType { + let node = expression; + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (node.kind === ts.SyntaxKind.NullKeyword) return withUnitArm(type, "nullT", lowerer.unions) ?? type; + const inferred = lowerer.typeOf(node); + for (const arm of inferred.isUnionType() ? ts.constituentTypes(inferred) : [inferred]) { + if ((arm.flags & ts.TypeFlags.Undefined) !== 0) type = lowerer.withUndefinedArmOf(type) ?? type; + if ((arm.flags & ts.TypeFlags.Null) !== 0) type = withUnitArm(type, "nullT", lowerer.unions) ?? type; + } + if (ts.isConditionalExpression(node)) { + return jsFieldInitializerType(lowerer, jsFieldInitializerType(lowerer, type, node.whenTrue), node.whenFalse); + } + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) type = jsFieldInitializerType(lowerer, type, node.left); + if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) return jsFieldInitializerType(lowerer, type, node.right); + } + return type; +} + /** 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 @@ -571,6 +599,12 @@ export interface GenericClassInfo { 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; + for (const declaration of lowerer.checker.declarationsOf(p)) { + const assignment = ts.isBinaryExpression(declaration) ? declaration : declaration.parent; + if (assignment && ts.isBinaryExpression(assignment) && assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + mapped = jsFieldInitializerType(lowerer, mapped, assignment.right); + } + } // An implicit JS value already carries undefined in its native box. if (mapped.kind === "dyn") return mapped; const byKey = new Map(); @@ -1269,7 +1303,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla const paramProps: NonNullable = []; const staticFields: ClassInfo["staticFields"] = []; - const staticMethods = new Map(); + const staticMethods: NonNullable = new Map(); const staticBlocks: ts.ClassStaticBlockDeclaration[] = []; // GENERIC methods (own type parameters), instance and static: only // the SYNTAX is checked here — parameter/return types mention the @@ -2309,6 +2343,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla type = inferredEmptyCollectionFieldType(lowerer, decl, name, rhs) ?? type; } if (!type || type.kind === "void") lowerer.badType(assign, t ?? lowerer.typeOf(assign)); + type = jsFieldInitializerType(lowerer, type, rhs); // A JSDoc claim the BODY contradicts (`@type {Command}` // assigned `undefined` — the lazy-init idiom): the // representation follows the body — the field widens to the @@ -3045,7 +3080,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla * with its OWN storage, exactly JS). */ export function findStaticOn(lowerer: Lowerer, info: ClassInfo | null, name: string): | { declarer: ClassInfo; field: ClassInfo["staticFields"][number]; method?: undefined } - | { declarer: ClassInfo; method: { params: ParamShape[]; ret: IrType; member: ts.MethodDeclaration }; field?: undefined } + | { declarer: ClassInfo; method: { params: ParamShape[]; ret: IrType; member: ts.MethodDeclaration; receiverInfo?: GenericFnInfo }; field?: undefined } | null { for (let c = info; c; c = c.base) { const field = c.staticFields.find((s) => s.name === name); @@ -3164,7 +3199,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla * Inherited writes create a new subclass property in JS, so they must not * update the declaring class's global. */ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAccessExpression): { id: string; type: IrType } | null { - if (access.questionDotToken || !ts.isIdentifier(access.expression)) return null; + if (access.questionDotToken || (!ts.isIdentifier(access.expression) && access.expression.kind !== ts.SyntaxKind.ThisKeyword)) return null; const info = lowerer.exactClassOfReceiver(access.expression); if (!info) return null; const found = lowerer.findStaticOn(info, access.name.text); @@ -3186,8 +3221,8 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce * generic member rejection names the site. */ export function lowerStaticFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { if (expr.questionDotToken) return null; - if (!ts.isIdentifier(expr.expression)) return null; - const symbol = lowerer.resolveValueSymbol(expr.expression); + if (!ts.isIdentifier(expr.expression) && expr.expression.kind !== ts.SyntaxKind.ThisKeyword) return null; + const symbol = ts.isIdentifier(expr.expression) ? lowerer.resolveValueSymbol(expr.expression) : null; const info = (symbol ? lowerer.classBySymbol.get(symbol) : undefined) ?? // A require binding over `module.exports = class {…}` (the alias @@ -3409,6 +3444,12 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce * hit the declaring class's storage exactly (the shadowing hazards of * general class values don't arise). Null for everything else. */ export function exactClassOfReceiver(lowerer: Lowerer, expr: ts.Expression): ClassInfo | null { + // A class-valued lexical this is introduced only for an exact static + // receiver specialization; ordinary instance this remains an object. + if (expr.kind === ts.SyntaxKind.ThisKeyword) { + const receiver = lowerer.resolveThis(); + return receiver?.type.kind === "classval" ? lowerer.classes.get(receiver.type.className) ?? null : null; + } if (!ts.isIdentifier(expr)) return null; const symbol = lowerer.resolveValueSymbol(expr); if (!symbol) return null; @@ -3550,6 +3591,11 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce export function lowerStaticMethodCall(lowerer: Lowerer, call: ts.CallExpression, access: ts.PropertyAccessExpression,): IrExpr | null { if (access.questionDotToken) return null; + if (access.expression.kind === ts.SyntaxKind.ThisKeyword) { + const receiver = lowerer.resolveThis(); + const info = receiver?.type.kind === "classval" ? lowerer.classes.get(receiver.type.className) : undefined; + if (info) return staticCallOn(lowerer, call, access, info, false); + } // `module.exports.describe()` in a module whose whole export IS a // class expression: the receiver is exactly that class (the kept // export assignment pins it) — the direct-name rules apply. @@ -3649,7 +3695,7 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce `calling the static member '${access.name.text}' through a class value (a subclass of '${info.def.name.replace(/^%|^%m\d+\./, "")}' redeclares it, so the runtime class decides which declaration answers)`, ); } - const instance = genericCallInstance(lowerer, call, gfound.info); + const instance = genericCallInstance(lowerer, call, staticMethodForReceiver(lowerer, access, gfound.info, info, throughValue)); const args = lowerer.completeArgs(call.arguments, instance.params, loc, call); return { kind: "call", callee: instance.name, args, type: instance.returnType, loc }; } @@ -3662,7 +3708,7 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce } const specialized = findGenericStaticOn(lowerer, info, access.name.text); if (specialized?.declarer === found.declarer && specialized.info.implicitParams) { - const instance = implicitCallInstance(lowerer, call, specialized.info); + const instance = implicitCallInstance(lowerer, call, staticMethodForReceiver(lowerer, access, specialized.info, info, throughValue)); const args = lowerer.completeArgs(call.arguments, instance.params, loc, call); return { kind: "call", callee: instance.name, args, type: instance.returnType, loc }; } @@ -3682,12 +3728,44 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce } return { kind: "callValue", callee, args, type: found.field.type.ret, loc }; } + if (found.method.member.body && hasStaticThis(found.method.member)) { + const generic = found.method.receiverInfo ??= { + decl: found.method.member, baseName: access.name.text, + qualifiedName: `%${found.declarer.def.name}.static:${access.name.text}`, + typeParams: [], instances: new Map(), member: { cls: found.declarer, kind: "static" }, + }; + const instance = genericCallInstance(lowerer, call, staticMethodForReceiver(lowerer, access, generic, info, throughValue)); + return { kind: "call", callee: instance.name, + args: lowerer.completeArgs(call.arguments, instance.params, loc, call), type: instance.returnType, loc }; + } const fnName = `%${found.declarer.def.name}.static:${access.name.text}`; lowerer.noteEdge(fnName); const args = lowerer.completeArgs(call.arguments, found.method.params, loc, call); return { kind: "call", callee: fnName, args, type: found.method.ret, loc }; } +function staticMethodForReceiver(lowerer: Lowerer, access: ts.PropertyAccessExpression, method: GenericFnInfo, + receiver: ClassInfo, throughValue: boolean): GenericFnInfo { + if (!hasStaticThis(method.decl)) return method; + // A private name in the inherited body belongs to the declaring class, + // even when the receiver declares a different private with that spelling. + // Do not resolve it by receiver name until the static ABI tracks the brand. + if (method.member && method.member.cls !== receiver && hasLexicalPrivateReference(method.decl)) { + lowerer.unsupported("SC1090", access, "private member references in an inherited receiver-dependent static method"); + } + if (throughValue && !classValueIsExactlyOwn(lowerer, access.expression, receiver)) { + lowerer.unsupported("SC1090", access, "receiver-dependent static methods through a polymorphic class value"); + } + const instances = method.receiverSpecializations ??= new Map(); + let specialized = instances.get(receiver.def.name); + if (!specialized) { + specialized = { ...method, qualifiedName: `${method.qualifiedName}%on.${receiver.def.name}`, + instances: new Map(), staticReceiver: receiver, receiverSpecializations: new Map() }; + instances.set(receiver.def.name, specialized); + } + return specialized; +} + /** Static member access through a class VALUE (`X.m` where X is * classval-typed): devirtualized — the member resolves against the * static class's chain, exact when no strict descendant redeclares it @@ -3711,6 +3789,7 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce } if ( !ts.isIdentifier(expr.expression) && + expr.expression.kind !== ts.SyntaxKind.ThisKeyword && // `module.exports.label` in a class-replaced CJS module: the // receiver is the exact exported class, and the read is // side-effect-free — as bindable as an identifier. @@ -5172,6 +5251,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const loc = locOf(expr); const instanceConstructor = lowerInstanceConstructorNew(lowerer, expr); if (instanceConstructor) return instanceConstructor; + const objectFactory = lowerObjectFactoryNew(lowerer, expr); + if (objectFactory) return objectFactory; let selected: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(selected)) selected = selected.expression; if (ts.isConditionalExpression(selected)) { diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8147acda..cf2c3a2c 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -32,7 +32,7 @@ import { lowerSocketInstanceOf, lowerTlsRootCertificates } from "./lower-server. import { findGenericMethodOn, lowerStaticFieldRead, staticFieldWriteTarget, storedClassValueType } from "./lower-classes.js"; import { bindingNeverReassigned, funcTypeFromParamShapes, implicitMonoFile, lowerTaggedTemplate, nullishGenericBindingUnitOf, objLitGenericFnInfoOf, objLitGenericFnNodeOf, requireObjLitGenericReceiver } from "./lower-calls.js"; import { mixinFnOfCallee } from "./lower-mixins.js"; -import { isConstAssertionTypeNode, isGenericCallableMemberType, isParseArgsDynTypeName, underConstAssertion, unitOnlyUnion } from "../type-mapper.js"; +import { isConstAssertionTypeNode, isGenericCallableMemberType, isParseArgsDynTypeName, underConstAssertion, unitOnlyUnion, withUnitArm } from "../type-mapper.js"; import { lowerYield } from "./lower-generators.js"; import { lowerStreamProperty, lowerStreamStateProperty, streamSidesOf } from "./lower-stream.js"; import { countedFor, numLit, varRef } from "../../ir/build.js"; @@ -5168,6 +5168,10 @@ export function lowerOptionalNumber( } if (ts.isPropertyAccessExpression(expr)) { const target = lowerer.fieldTarget(expr); + if (target?.container === "class" && target.fieldType.kind === "union" && + lowerer.unions.get(target.fieldType.unionId)?.arms.some(isUnitType)) { + return lowerer.fieldGetExpr(target, locOf(expr), expr); + } if (target?.container !== "recordOvf") return null; // A JS file-scope object-literal global is record-shaped to the // checker but stored in the checked-dynamic tree to preserve object @@ -6227,7 +6231,10 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio if (raw.type.kind === "bigint") { return { kind: "libCall", fn: "bigint.neg", args: [raw], type: BIGINT_T, loc }; } - const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); + // Match the checked numeric boundary used by JS binary arithmetic. + const operand = raw.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()) + ? { kind: "dynCheck" as const, value: raw, type: F64, loc } + : lowerOptionalNumber(lowerer, raw, loc, expr.operand); if (operand.type.kind !== "f64") lowerer.unsupported("SC1043", expr); if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc }; return { kind: "unary", op: "-", operand, type: F64, loc }; @@ -7405,15 +7412,17 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE // Test the deciding operand in its own representation. The result // contains only the values that can survive short-circuit evaluation. let target = lowerer.mapTypeOf(lowerer.typeOf(expr)); - // Unchecked array reads carry undefined even when the checker sees - // a required value. The RHS can return it for either operator; the - // LHS can return it only for &&. Preserve it through later chain pairs. - if (target !== null && !isUnitType(target) && ( - (right.type.kind === "union" && lowerer.armTag(right.type.unionId, UNDEFINED_T) >= 0) || - (op === ts.SyntaxKind.AmpersandAmpersandToken && left.type.kind === "union" && - lowerer.armTag(left.type.unionId, UNDEFINED_T) >= 0) - )) { - target = lowerer.runtimeOptionalType(target); + // Index reads can be absent without noUncheckedIndexedAccess. Their + // explicit nullish fallback still runs, even if the checker discarded it. + if (target !== null) { + const returned = [right.type, ...(op === ts.SyntaxKind.AmpersandAmpersandToken ? [left.type] : [])]; + for (const type of returned) { + const arms = type.kind === "union" ? lowerer.unions.get(type.unionId)?.arms ?? [] : [type]; + for (const arm of arms) { + if (arm.kind === "undefinedT" && !isUnitType(target)) target = lowerer.runtimeOptionalType(target); + if (arm.kind === "nullT") target = withUnitArm(target, arm.kind, lowerer.unions) ?? target; + } + } } // A broad-JSDoc npm-static body can leave the CHECKER result `any` // even after declaration-backed specialization has recovered both @@ -9918,7 +9927,9 @@ function representedClassFieldTarget( * fieldSet/recordSet/accessor-call (minus value/kind) or null. */ export function fieldTarget(lowerer: Lowerer, access: ts.PropertyAccessExpression): FieldTarget | null { if (lowerer.chainBlocked(access)) return null; - const receiverIr = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + const stored = ts.isIdentifier(access.expression) + ? lowerer.peekLocal(access.expression)?.type ?? lowerer.globalOf(access.expression)?.type : undefined; + const receiverIr = stored?.kind === "record" ? stored : lowerer.mapTypeOf(lowerer.typeOf(access.expression)); if (receiverIr?.kind === "object") { return classFieldTarget(lowerer, access.expression, receiverIr, access.name.text); } diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 15024cd2..be2a658c 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -3395,7 +3395,15 @@ export function lowerStaticReadableStreamReaderCall( ts.isSpreadElement(call.arguments[0]!) ) { const spread = call.arguments[0]! as ts.SpreadElement; - const src = lowerer.lowerExpr(spread.expression); + let src = lowerer.lowerExpr(spread.expression); + // JavaScript array call results retain checked storage so aliases stay + // shared. This read-only fold can extract the inferred numeric array. + if (src.type.kind === "dyn") { + const inferred = lowerer.mapTypeOf(lowerer.typeOf(spread.expression)); + if (inferred?.kind === "array" && inferred.elem.kind === "f64") { + src = lowerer.coerceInto(spread.expression, src, inferred); + } + } if (src.type.kind !== "array" || src.type.elem.kind !== "f64") { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 6f0af989..724e6c7b 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -19,6 +19,7 @@ import { ENTRY_NAME, PoisonError, boundIdentifiersOf, dynFallbackType, dynUndefi import { isNativeFfiRequire, builtinMemberRequireDecl, builtinNamespaceDestructureModuleOf, createRequireBindingDecl, createRequireNamespaceDecl, createRequireProgramModuleDecl, createRequireSpecOf, isPromisifyCall, registerBuiltinCallableAlias, stripTypeCasts } from "./lower-builtins.js"; import { bindingContextualGenericFnNodeOf, bindingGenericFnAliasInfoOf, bindingGenericFnInfoOf, bindingGenericFnNodeOf, bindingNeverReassigned, deadUnmappableBinding, funcTypeFromParamShapes, implicitLocalFnInfoOf, implicitLocalFnNodeOf, nullishGenericBindingUnitOf, registerOverloadedCallableAlias } from "./lower-calls.js"; import { hasJsTypeAnnotation, isVarDeclared, numericIteratorSourceOf, provenanceElidedConstDecl } from "./lower-stmts.js"; +import { objectFactorySignature } from "./object-factory-new.js"; import { streamClassAliasDecl } from "./lower-stream.js"; import { stdlibGlobalAliasDecl, stdlibGlobalAliasNameOf, stdlibGlobalNameOf } from "./surfaces.js"; import { isNativeBuiltinValueInitializer } from "./lower-builtin-values.js"; @@ -1609,7 +1610,10 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts // and closures created in the init body capture it normally. // References from separately-declared functions cascade to // their own per-site runtime fences. - if (isJsSourceFile(sf) && !lowerer.mapTypeOf(lowerer.typeOf(nameNode))) continue; + const factoryType = !isLet && isJsSourceFile(sf) && !decl.type && !hasJsTypeAnnotation(decl) && + ts.isIdentifier(decl.name) && nameNode === decl.name && decl.initializer && ts.isNewExpression(decl.initializer) + ? objectFactorySignature(lowerer, decl.initializer)?.returnType : undefined; + if (!factoryType && isJsSourceFile(sf) && !lowerer.mapTypeOf(lowerer.typeOf(nameNode))) continue; // `var p1 = import("./m")` at file scope: the global holds the // island promise/handle — the import expression's only // production — whatever the checker's namespace type mapped to @@ -1624,7 +1628,11 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts // exactly the local rule (uncheckedOverloadHandleCall). (uncheckedOverloadHandleCall(lowerer, decl.initializer) ? JSVAL : null) : null) : null; - let type = handleT ?? lowerer.irTypeOf(nameNode); + let type = handleT ?? factoryType ?? lowerer.irTypeOf(nameNode); + // JavaScript call results may retain a checked array supplied by + // the caller. A typed global here would copy it on extraction. + if (isJsSourceFile(sf) && !decl.type && !hasJsTypeAnnotation(decl) && type.kind === "array" && + decl.initializer && ts.isCallExpression(decl.initializer)) type = DYN; // Unannotated JavaScript aliases retain an existing native // checked object instead of copying it into an inferred record. if (isJsSourceFile(sf) && !decl.type && !hasJsTypeAnnotation(decl) && diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 225ba666..7972e557 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -24,6 +24,7 @@ import { isMixinFnBinding, mixinResultBindingClassOf } from "./lower-mixins.js"; import type { ClassInfo, ClassIteratorInfo } from "./lower-classes.js"; import { isCompiledPrototypeMember } from "./class-prototypes.js"; import { classStaticDataFor } from "./class-static-data.js"; +import { objectFactorySignature } from "./object-factory-new.js"; import { lowerClassCallbackAssign } from "./class-callbacks.js"; import { genericIfaceBindingKeepsClass, staticFieldWriteTarget } from "./lower-classes.js"; import { lowerStreamUnderscoreAssign, streamClassAliasDecl } from "./lower-stream.js"; @@ -3829,7 +3830,7 @@ export function lowerVarDecl(lowerer: Lowerer, decl: ts.VariableDeclaration, isL type = DYN; } const preservesObjectIdentity = init.type.kind === "dyn" && - (inferredObjectType?.kind === "record" || + (inferredObjectType?.kind === "record" || inferredObjectType?.kind === "array" || (isJsSourceFile(decl.getSourceFile()) && inferredObjectType !== null && (inferredObjectType.kind === "func" || jsOpenObjectType(decl, inferredObjectType, lowerer.shapes, lowerer.unions).kind === "dyn")) || (isJsSourceFile(decl.getSourceFile()) && inferredObjectType !== null && @@ -3957,6 +3958,9 @@ export function lowerVarDecl(lowerer: Lowerer, decl: ts.VariableDeclaration, isL } if (!type) lowerer.badType(decl.name, lowerer.typeOf(decl.name)); let settledType: IrType = type; + if (!isLet && isJsSourceFile(decl.getSourceFile()) && !hasJsTypeAnnotation(decl) && decl.initializer && ts.isNewExpression(decl.initializer)) { + settledType = objectFactorySignature(lowerer, decl.initializer)?.returnType ?? settledType; + } const arithmeticType = decl.initializer ? lowerer.runtimeOptionalArithmeticTypes.get(decl.initializer) : undefined; const isStringArithmeticUnion = (t: IrType): boolean => { if (t.kind !== "union") return false; @@ -5167,7 +5171,7 @@ function isStrictDelete(node: ts.DeleteExpression): boolean { // SUBCLASS name (`D.x = v` creates an OWN property on D in JS — // different storage) and through class VALUES (the same dynamic // story) are named fences, never a silently-wrong global write. - if (!expr.left.questionDotToken && ts.isIdentifier(expr.left.expression)) { + if (!expr.left.questionDotToken && (ts.isIdentifier(expr.left.expression) || expr.left.expression.kind === ts.SyntaxKind.ThisKeyword)) { // The receiver must BE the class exactly (its name, or a // const binding holding a class expression) — a general class // VALUE could hold a subclass, where JS creates an own diff --git a/packages/compiler/src/frontend/lowering/object-factory-new.ts b/packages/compiler/src/frontend/lowering/object-factory-new.ts new file mode 100644 index 00000000..c0434082 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/object-factory-new.ts @@ -0,0 +1,58 @@ +import * as ts from "../ts7/adapter.js"; +import type { IrExpr } from "../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import { bindingNeverReassigned, type FnSig } from "./lower-calls.js"; + +/** An ordinary constructor's explicit object return replaces its receiver. + * Without observing this or new.target, it can use the function's call ABI. */ +export function objectFactorySignature(lowerer: Lowerer, expr: ts.NewExpression): FnSig | null { + if (!ts.isIdentifier(expr.expression) || lowerer.peekLocal(expr.expression)) return null; + // This probe also sees ordinary classes during global collection. Leave + // their deferred diagnostics for the class declaration's lowering. + const wasCollecting = lowerer.collecting; + let symbol: ts.Symbol | null = null; + lowerer.collecting = true; + try { + if (!lowerer.isTopLevelFnSymbol(expr.expression)) return null; + symbol = lowerer.resolveValueSymbol(expr.expression); + } finally { + lowerer.collecting = wasCollecting; + } + if (!symbol) return null; + const declaration = lowerer.checker.declarationsOf(symbol).find((node): node is ts.FunctionDeclaration => + ts.isFunctionDeclaration(node) && node.body !== undefined, + ); + if (!declaration?.body || !bindingNeverReassigned(lowerer, symbol, declaration) || !isJsSourceFile(declaration.getSourceFile()) || declaration.asteriskToken || + declaration.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return null; + const last = declaration.body.statements.at(-1); + if (!last || !ts.isReturnStatement(last) || !last.expression || !ts.isObjectLiteralExpression(last.expression)) return null; + let safe = true; + const visit = (node: ts.Node, nestedArrow = false): void => { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { safe = false; return; } + if (ts.isFunctionLike(node)) { + if (!ts.isArrowFunction(node)) return; + nestedArrow = true; + } + if (node.kind === ts.SyntaxKind.ThisKeyword || ts.isMetaProperty(node)) safe = false; + if (!nestedArrow && ts.isReturnStatement(node) && (!node.expression || !ts.isObjectLiteralExpression(node.expression))) safe = false; + ts.forEachChild(node, (child) => visit(child, nestedArrow)); + }; + for (const parameter of declaration.parameters) visit(parameter); + visit(declaration.body); + if (!safe) return null; + const signature = lowerer.fnSigOf(expr.expression); + return signature && signature.returnType.kind !== "void" ? signature : null; +} + +export function lowerObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + const signature = objectFactorySignature(lowerer, expr); + if (!signature) return null; + const loc = locOf(expr); + lowerer.noteEdge(signature.name); + return { + kind: "call", callee: signature.name, + args: lowerer.completeArgs(expr.arguments ?? [], signature.params, loc, expr), + type: signature.returnType, loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/static-this.ts b/packages/compiler/src/frontend/lowering/static-this.ts index 8064dfe1..b2610409 100644 --- a/packages/compiler/src/frontend/lowering/static-this.ts +++ b/packages/compiler/src/frontend/lowering/static-this.ts @@ -9,6 +9,7 @@ export function rejectStaticThis( root: ts.Node, message: (keyword: "this" | "super") => string, includeRoot = false, + allowThis = false, ): void { const visit = (node: ts.Node): void => { if ( @@ -19,7 +20,7 @@ export function rejectStaticThis( ) { return; } - if (node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.SuperKeyword) { + if ((node.kind === ts.SyntaxKind.ThisKeyword && !allowThis) || node.kind === ts.SyntaxKind.SuperKeyword) { const keyword = node.kind === ts.SyntaxKind.ThisKeyword ? "this" : "super"; lowerer.unsupported("SC1090", node, message(keyword)); } @@ -28,3 +29,27 @@ export function rejectStaticThis( if (includeRoot) visit(root); else root.forEachChild(visit); } + +export function hasStaticThis(root: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (node.kind === ts.SyntaxKind.ThisKeyword) found = true; + if ((ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassDeclaration(node) || ts.isClassExpression(node)) return; + node.forEachChild(visit); + }; + root.forEachChild(visit); + return found; +} + +/** Private names belong to the lexical class, independently of any + * same-spelled member in a receiver's subclass. */ +export function hasLexicalPrivateReference(root: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) return; + if (ts.isPrivateIdentifier(node)) found = true; + node.forEachChild(visit); + }; + root.forEachChild(visit); + return found; +} diff --git a/packages/compiler/src/frontend/npm-static-subtyping.test.ts b/packages/compiler/src/frontend/npm-static-subtyping.test.ts new file mode 100644 index 00000000..184ded97 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-subtyping.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { checkPreflight, loadProgram } from "./program-node.js"; + +const source = ` +export class Base { run() {} } +export class Derived extends Base { + /** @param {number} value */ + run(value) { console.log(value); } +} +export class Other { + /** @param {number} value */ + run(value) { console.log(value); } +} +/** @template T */ +export class Box { + /** @param {T} value */ + constructor(value) { this.value = value; } + run() {} +} +/** @extends {Box} */ +export class NumberBox extends Box { run(value) { console.log(value); } } +`; + +test.each([ + ["accepts an inherited JavaScript method with extra parameters", "function take(value: Base) {} take(new Derived());", false], + ["accepts a base in a union", "function take(value: Base | null) {} take(new Derived());", false], + ["accepts a constructor parameter", "class Use { constructor(value: Base) {} } new Use(new Derived());", false], + ["retains unrelated structural errors", "function take(value: Base) {} take(new Other());", true], + ["retains extra intersection requirements", "function take(value: Base & { required: string }) {} take(new Derived());", true], + ["retains incompatible generic arguments", "function take(value: Box) {} take(new NumberBox(1));", true], + ["accepts matching generic arguments", "function take(value: Box) {} take(new NumberBox(1));", false], + ["retains TypeScript subclass errors", "class Typed extends Base { run(value: number) {} } function take(value: Base) {} take(new Typed());", true], +] as const)("%s", (_name, consumer, error) => { + const dir = mkdtempSync("/tmp/scriptc-subtyping-"); + try { + const pkg = join(dir, "node_modules", "runtime-classes"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, "package.json"), '{"name":"runtime-classes","type":"module","main":"index.js"}'); + writeFileSync(join(pkg, "index.js"), source); + writeFileSync(join(dir, "package.json"), '{"type":"module"}'); + writeFileSync(join(dir, "main.ts"), 'import { Base, Derived, Other, Box, NumberBox } from "runtime-classes";\n' + consumer); + const load = loadProgram(join(dir, "main.ts"), { npmStatic: ["runtime-classes"] }); + try { + const diagnostics = checkPreflight(load); + if (error) expect(diagnostics.some((d) => d.code === "SC0001" && d.message.includes("not assignable")), JSON.stringify(diagnostics)).toBe(true); + else expect(diagnostics).toEqual([]); + } finally { load.dispose(); } + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/packages/compiler/src/frontend/npm-static-subtyping.ts b/packages/compiler/src/frontend/npm-static-subtyping.ts new file mode 100644 index 00000000..b9de1838 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-subtyping.ts @@ -0,0 +1,47 @@ +import * as ts from "./ts7/adapter.js"; +import { npmStaticPackageOfPath } from "./npm-static.js"; + +/** JS subclasses can extend inferred fields and method arities. The native + * class collector validates their shared layout and dispatch ABI; a consumer + * upcast should not fail solely on the checker's structural override model. */ +export function isNpmStaticSubclassArgument(program: ts.Program, diagnostic: ts.Diagnostic): boolean { + if (diagnostic.code !== 2345 || diagnostic.fileName === undefined) return false; + const source = program.getSourceFile(diagnostic.fileName); + if (!source) return false; + const checker = program.getTypeChecker(); + const runtimeClass = (type: ts.Type): boolean => { + const symbol = type.getSymbol(); + return symbol !== undefined && checker.declarationsOf(symbol).some((declaration) => + (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)) && + /\.[cm]?js$/.test(declaration.getSourceFile().fileName) && + npmStaticPackageOfPath(declaration.getSourceFile().fileName) !== null, + ); + }; + const subtype = (actual: ts.Type, expected: ts.Type): boolean => { + if (!runtimeClass(actual)) return false; + const targets = expected.isUnionType() ? ts.constituentTypes(expected) : [expected]; + const seen = new Set(); + const visit = (type: ts.Type): boolean => { + const target = type.isTypeReference() ? type.getTarget() : type; + if (!target?.isClassOrInterface() || seen.has(target)) return false; + seen.add(target); + for (const base of checker.getBaseTypes(type.isClassOrInterface() ? type : target)) { + if (targets.some((expected) => runtimeClass(expected) && base.getSymbol() === expected.getSymbol() && checker.isTypeAssignableTo(base, expected))) return true; + if (visit(base)) return true; + } + return false; + }; + return visit(actual); + }; + let accepted = false; + ts.walkPreorder(source, (node) => { + if (node.end < diagnostic.pos || node.getStart(source) > diagnostic.end) return "skip"; + if (!ts.isCallExpression(node) && !ts.isNewExpression(node)) return undefined; + const index = node.arguments?.findIndex((argument) => argument.getStart(source) === diagnostic.pos && argument.end === diagnostic.end) ?? -1; + if (index < 0) return undefined; + const parameter = checker.getResolvedSignature(node)?.getParameters()[index]; + if (parameter && node.arguments) accepted = subtype(checker.getTypeAtLocation(node.arguments[index]!), checker.getTypeOfSymbol(parameter)); + return undefined; + }); + return accepted; +} diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 9a78f022..1dacc722 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -54,6 +54,7 @@ import { isNodeModulesPath, nearestInvalidPackageJsonPath, nearestPackageType, n import { probeNodeImportRefusal, probeNodeRequireRefusal } from "./npm.js"; import { isNpmStaticPackage, npmStaticActive, npmStaticFsShadow, npmStaticPackageOfPath, reportNpmStaticOffender, setNpmStaticDeclarationOverloads, setNpmStaticPackages } from "./npm-static.js"; import { isPrunedNpmReexport, planNpmStaticReexports } from "./npm-static-prune.js"; +import { isNpmStaticSubclassArgument } from "./npm-static-subtyping.js"; import { npmStaticDeclarationReexports, npmStaticRuntimeClassTargets, parseNpmStaticDeclarationOverloads, parseNpmStaticDeclarationProperties } from "./npm-static-declaration-syntax.js"; import type { FrontendServices } from "./services.js"; import type { NpmStaticDeclarationOverloads, NpmStaticDeclarationProperties, NpmStaticOverloadSignature } from "./npm-static-declaration-syntax.js"; @@ -2382,6 +2383,7 @@ function preflight7(load: LoadResult): { !npmStaticFileSuppressed(d) && !nodeModulesJsSuppressed(d) && !namespaceCalleeSuppressed(p, d) && + !isNpmStaticSubclassArgument(p, d) && !workspaceImplicitAnySuppressed(p, d) && !jsdocTypeSuppressed(p, d, commentDup), ); diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index eb7964e5..ffaf3791 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -87,6 +87,9 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ 2308, 2614, // 2740's sibling elaboration ("Property X is missing in type Y") 2741, + // JavaScript subclasses may widen fields or add method parameters. + // Class lowering checks native storage and override ABIs at each use. + 2415, // unresolvable modules: Node throws WHEN the require executes — the // binding types as any and reached uses fence (never a silent pass) 2307, 2792, diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index c4a44e72..da888977 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -3551,12 +3551,18 @@ export function isUnitOnlyTsType(t: ts.Type, resolveTypeParam?: TypeParamResolve * admits, `(() => void) | undefined`) so the interned union is IDENTICAL * to what mapping the checker's own `T | undefined` produces. */ export function withUndefinedArm(t: IrType, unions: UnionRegistry): IrType | null { + return withUnitArm(t, "undefinedT", unions); +} + +/** Preserve a nullish runtime value that JavaScript inference omitted. */ +export function withUnitArm(t: IrType, kind: "nullT" | "undefinedT", unions: UnionRegistry): IrType | null { + const unit: IrType = { kind }; if (t.kind === "union") { const def = unions.get(t.unionId); if (!def) return null; if (def.arms.some((a) => a.kind === "date")) return null; - if (def.arms.some((a) => a.kind === "undefinedT")) return t; - const arms = [...def.arms, UNDEFINED_T]; + if (def.arms.some((a) => a.kind === kind)) return t; + const arms = [...def.arms, unit]; arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); return { kind: "union", unionId: unions.transform(def, arms) }; } @@ -3568,7 +3574,7 @@ export function withUndefinedArm(t: IrType, unions: UnionRegistry): IrType | nul ) { return null; } - const arms = [t, UNDEFINED_T]; + const arms = [t, unit]; arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); return { kind: "union", unionId: unions.intern(arms) }; } diff --git a/packages/compiler/test/static-receiver-boundaries.test.ts b/packages/compiler/test/static-receiver-boundaries.test.ts new file mode 100644 index 00000000..1bc3c306 --- /dev/null +++ b/packages/compiler/test/static-receiver-boundaries.test.ts @@ -0,0 +1,40 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { analyze } from "../src/index.js"; + +test.each([ + ["constructor reads this", "function Make() { console.log(this); return { value: 1 }; } new Make();"], + ["constructor arrow captures this", "function Make() { return { read: () => this }; } new Make();"], + ["constructor default reads this", "function Make({value = this} = {}) { return { value }; } new Make();"], + ["constructor reads new.target", "function Make() { console.log(new.target); return { value: 1 }; } new Make();"], + ["constructor returns a primitive on one branch", "function Make(flag) { if (flag) return 1; return { value: 1 }; } new Make(true);"], + ["constructor binding reassigned", "function Make() { return { value: 1 }; } Make = function () { return { value: 2 }; }; console.log(new Make().value);"], +] as const)("object-returning constructor retains the fence when %s", (_name, source) => { + const dir = mkdtempSync("/tmp/scriptc-constructor-boundary-"); + try { + const entry = join(dir, "main.mjs"); + writeFileSync(entry, source); + const { coverage } = analyze(entry); + expect([...coverage.diagnostics, ...(coverage.runtimeFences ?? [])].length).toBeGreaterThan(0); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test.each([ + ["detached method", "class C { static value = 1; static read(): number { return this.value; } } const read = C.read; console.log(read());"], + ["polymorphic receiver", "class C { static read(): string { return this.name; } } class D extends C {} function run(value: typeof C): string { return value.read(); } console.log(run(D));"], + ["inherited private field", "class C { static #value = 1; static read(): number { return this.#value; } } class D extends C {} console.log(D.read());"], + ["shadowed private field", "class C { static #value = 1; static read(): number { return this.#value; } } class D extends C { static #value = 2; } console.log(D.read());"], + ["inherited private method", "class C { static #value(): number { return 1; } static read(): number { return this.#value(); } } class D extends C {} console.log(D.read());"], + ["shadowed private method", "class C { static #value(): number { return 1; } static read(): number { return this.#value(); } } class D extends C { static #value(): number { return 2; } } console.log(D.read());"], + ["inherited static write", "class C { static value = 1; static write(): void { this.value = 2; } } class D extends C {} D.write();"], +] as const)("receiver specialization preserves the %s boundary", (_name, source) => { + const dir = mkdtempSync("/tmp/scriptc-static-boundary-"); + try { + const entry = join(dir, "main.ts"); + writeFileSync(entry, source); + const { coverage } = analyze(entry); + expect(coverage.preflightFailed).toBe(false); + expect(coverage.diagnostics.some(d => d.code === "SC1090"), JSON.stringify(coverage.diagnostics)).toBe(true); + } 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 8bad70a6..34c044f9 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -9122,12 +9122,24 @@ ], "diags": [] }, + "/tests/corpus/js-chained-native-results.mjs": { + "order": [ + "/tests/corpus/js-chained-native-results.mjs" + ], + "diags": [] + }, "/tests/corpus/js-checked-array-reference.js": { "order": [ "/tests/corpus/js-checked-array-reference.js" ], "diags": [] }, + "/tests/corpus/js-checked-number-negation.mjs": { + "order": [ + "/tests/corpus/js-checked-number-negation.mjs" + ], + "diags": [] + }, "/tests/corpus/js-class-added-parameters.js": { "order": [ "/tests/corpus/js-class-added-parameters.js" @@ -9202,6 +9214,18 @@ ], "diags": [] }, + "/tests/corpus/js-nullish-inherited-fields.mjs": { + "order": [ + "/tests/corpus/js-nullish-inherited-fields.mjs" + ], + "diags": [] + }, + "/tests/corpus/js-object-returning-constructors.mjs": { + "order": [ + "/tests/corpus/js-object-returning-constructors.mjs" + ], + "diags": [] + }, "/tests/corpus/js-object-shared-arrays.js": { "order": [ "/tests/corpus/js-object-shared-arrays.js" @@ -9768,6 +9792,12 @@ ], "diags": [] }, + "/tests/corpus/static-method-receivers.ts": { + "order": [ + "/tests/corpus/static-method-receivers.ts" + ], + "diags": [] + }, "/tests/corpus/stdio-values.mjs": { "order": [ "/tests/corpus/stdio-values.mjs" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 690afdc5..f41115fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: js-yaml: specifier: 4.3.0 version: 4.3.0 + rollup: + specifier: 4.62.2 + version: 4.62.2 three: specifier: 0.186.1 version: 0.186.1 diff --git a/tests/corpus/js-chained-native-results.mjs b/tests/corpus/js-chained-native-results.mjs new file mode 100644 index 00000000..4746276d --- /dev/null +++ b/tests/corpus/js-chained-native-results.mjs @@ -0,0 +1,10 @@ +const Vector = class Vector { + constructor(x = 0) { this.x = x; } + copy(value) { this.x = value.x; return this; } + add(value) { this.x += value.x; return this; } +}; +let calls = 0; +function make() { calls++; return new Vector(); } +const result = make().copy(new Vector(2)).add(new Vector(3)); +console.log(result.x, calls); +console.log(make().copy(result).add(result).x, calls); diff --git a/tests/corpus/js-checked-number-negation.mjs b/tests/corpus/js-checked-number-negation.mjs new file mode 100644 index 00000000..8989abd2 --- /dev/null +++ b/tests/corpus/js-checked-number-negation.mjs @@ -0,0 +1,4 @@ +const input = JSON.parse('[3, -4, 0, -0]'); +for (const value of input) console.log(-value, Object.is(-value, -0)); +const object = { get() { return JSON.parse('5'); } }; +console.log(-object.get()); diff --git a/tests/corpus/js-nullish-inherited-fields.mjs b/tests/corpus/js-nullish-inherited-fields.mjs new file mode 100644 index 00000000..11b0dd9a --- /dev/null +++ b/tests/corpus/js-nullish-inherited-fields.mjs @@ -0,0 +1,40 @@ +class Value { + constructor() { this.id = 7; } +} + +class Base { + /** @param {Record} values */ + load(values) { this.value = values.missing || null; } +} + +class Derived extends Base { + constructor() { + super(); + /** @type {?Value} */ + this.value = null; + } +} + +const base = new Base(); +console.log('initial', base.value === undefined); +base.load({}); +console.log('missing', base.value === null); +const value = new Value(); +base.load({ missing: value }); +console.log('present', base.value === value); +const derived = new Derived(); +console.log('derived', derived.value === null); +derived.load({ missing: value }); +console.log('identity', derived.value === value); +derived.load({}); +console.log('reset', derived.value === null); + +class Optional { + /** @param {Value} [value] */ + constructor(value) { + /** @type {Value} */ + this.value = value; + } +} +console.log('omitted', new Optional().value === undefined); +console.log('supplied', new Optional(value).value === value); diff --git a/tests/corpus/js-object-returning-constructors.mjs b/tests/corpus/js-object-returning-constructors.mjs new file mode 100644 index 00000000..16f1a63a --- /dev/null +++ b/tests/corpus/js-object-returning-constructors.mjs @@ -0,0 +1,23 @@ +function Counter(start = 2) { + let value = start; + return { + next() { return ++value; }, + current() { return value; }, + }; +} + +let calls = 0; +function initial() { calls++; return 10; } +const first = new Counter(initial()); +const second = new Counter(); +console.log(calls, first.current(), second.current()); +console.log(first.next(), first.next(), second.next()); +console.log(first === second); +function readFirst() { return first.current(); } +console.log('global', readFirst()); + +function Choice(flag = false) { + if (flag) return { value: 3 }; + return { value: 9 }; +} +console.log(new Choice(true).value, new Choice().value); diff --git a/tests/corpus/static-method-receivers.ts b/tests/corpus/static-method-receivers.ts new file mode 100644 index 00000000..e8d1a9dd --- /dev/null +++ b/tests/corpus/static-method-receivers.ts @@ -0,0 +1,24 @@ +class Base { + static label = "base"; + static scale(value: number): number { return value * 2; } + static run(value: number): number { return this.scale(value) + 1; } + static describe(label: string = this.label): string { return this.name + ":" + label; } + static rename(label: string): void { this.label = label; } + static delayed(value: number): () => number { return () => this.run(value); } + static generic(value: T): T { console.log(this.run(2)); return value; } +} +class Derived extends Base { + static label = "derived"; + static scale(value: number): number { return value * 3; } +} +console.log(Base.run(4), Derived.run(4)); +console.log(Base.delayed(5)(), Derived.delayed(5)()); +console.log(Base.generic('base'), Derived.generic('derived')); +console.log(Base.describe(), Derived.describe(), Derived.describe('custom')); +Base.rename('first'); Derived.rename('second'); +console.log(Base.describe(), Derived.describe()); +class Private { + static #value = 7; + static read(): number { return this.#value; } +} +console.log(Private.read()); diff --git a/tests/diagnostics/class-values.ts b/tests/diagnostics/class-values.ts index d2de5d0f..02c5360f 100644 --- a/tests/diagnostics/class-values.ts +++ b/tests/diagnostics/class-values.ts @@ -69,10 +69,12 @@ let pick: typeof Left | typeof Right = Left; const picked = new pick(); void picked; -// `this` in a static method names the RECEIVER class — a dynamic value. +// A polymorphic class value cannot specialize a receiver-dependent method. class UsesThis { static self(): string { return this.name; } } -console.log(UsesThis.self()); +class OtherThis extends UsesThis {} +function self(value: typeof UsesThis): string { return value.self(); } +console.log(self(OtherThis)); diff --git a/tests/fixtures/npm-static/array-output-parameters.mjs b/tests/fixtures/npm-static/array-output-parameters.mjs new file mode 100644 index 00000000..dc71eb44 --- /dev/null +++ b/tests/fixtures/npm-static/array-output-parameters.mjs @@ -0,0 +1,30 @@ +class Item { + constructor(value = 1) { this.value = value; } +} +class Collector { + /** @param {Array} output */ + collect(output) { output.push(new Item(3)); } + /** @param {Array} output + * @return {Array} */ + append(output) { this.collect(output); return output; } + run() { + const output = []; + this.collect(output); + console.log('local', output.length, output[0].value); + return output; + } +} +const collector = new Collector(); +const values = []; +collector.collect(values); +const alias = collector.append(values); +console.log('shared', values.length, alias === values, values[0].value); +console.log('nested', collector.run().length); +alias.push(new Item(7)); +function readShared() { return alias.length + values[2].value; } +console.log('write-back', readShared()); +/** @returns {Array} */ +function seeded() { return [new Item(4)]; } +const typed = seeded(); +collector.collect(typed); +console.log('typed-result', typed.length, typed[0].value, typed[1].value); diff --git a/tests/fixtures/three/collision.mjs b/tests/fixtures/three/collision.mjs new file mode 100644 index 00000000..342ea88c --- /dev/null +++ b/tests/fixtures/three/collision.mjs @@ -0,0 +1,34 @@ +import { Vector3, Triangle, Sphere, Ray } from 'three'; +import { Capsule } from 'three/addons/math/Capsule.js'; +import { Octree } from 'three/addons/math/Octree.js'; + +const round = n => Math.round(n * 1000000); +const tree = new Octree(); +tree.trianglesPerLeaf = 2; +for (let x = -4; x < 4; x++) { + for (let z = -4; z < 4; z++) { + tree.addTriangle(new Triangle(new Vector3(x,0,z),new Vector3(x,0,z+1),new Vector3(x+1,0,z))); + tree.addTriangle(new Triangle(new Vector3(x+1,0,z),new Vector3(x,0,z+1),new Vector3(x+1,0,z+1))); + } +} +tree.build(); +const body = new Capsule(new Vector3(0.1,0.2,0.1),new Vector3(0.1,1.3,0.1),0.35); +const candidates = []; +tree.getCapsuleTriangles(body,candidates); +console.log('candidates', candidates.length, new Set(candidates).size); +const hit = tree.capsuleIntersect(body); +if (hit) console.log('capsule',round(hit.depth),round(hit.normal.y),round(body.start.y)); +else console.log('capsule miss'); +body.translate(new Vector3(0,3,0)); +console.log('capsule miss',tree.capsuleIntersect(body) === false); +const sphere = tree.sphereIntersect(new Sphere(new Vector3(0.1,0.2,0.1),0.4)); +if(sphere) console.log('sphere',round(sphere.depth),round(sphere.normal.y)); +else console.log('sphere miss'); +const ray = tree.rayIntersect(new Ray(new Vector3(0.2,4,0.3),new Vector3(0,-1,0))); +if(ray) console.log('ray',round(ray.distance),round(ray.position.x),round(ray.position.y)); +else console.log('ray miss'); +console.log('ray miss',tree.rayIntersect(new Ray(new Vector3(8,4,8),new Vector3(0,-1,0))) === false); +const a = new Vector3(0,0,0), b = new Vector3(0,0,1), c = new Vector3(1,0,0); +console.log('inside', Triangle.containsPoint(new Vector3(0.2,0.1,0.2),a,b,c)); +console.log('outside', Triangle.containsPoint(new Vector3(2,0,2),a,b,c)); +console.log('degenerate', Triangle.containsPoint(new Vector3(),a,a,a)); diff --git a/tests/harness/__snapshots__/class-values.ts.txt b/tests/harness/__snapshots__/class-values.ts.txt index f00d88ef..60b12c42 100644 --- a/tests/harness/__snapshots__/class-values.ts.txt +++ b/tests/harness/__snapshots__/class-values.ts.txt @@ -79,9 +79,9 @@ class-values.ts:69:16 - error SC1090: constructing through a union of class valu | ^~~~~~~~~~ 70 | void picked; -class-values.ts:75:12 - error SC1090: 'this' in static methods (it names the RECEIVER class — a dynamic value; reference the class by name instead) are not supported yet +class-values.ts:79:56 - error SC1090: receiver-dependent static methods through a polymorphic class value is not supported yet - 74 | static self(): string { - 75 | return this.name; - | ^~~~ - 76 | } \ No newline at end of file + 78 | class OtherThis extends UsesThis {} + 79 | function self(value: typeof UsesThis): string { return value.self(); } + | ^~~~~~~~~~ + 80 | console.log(self(OtherThis)); \ No newline at end of file diff --git a/tests/harness/static-spatial.test.ts b/tests/harness/static-spatial.test.ts new file mode 100644 index 00000000..2925cf1b --- /dev/null +++ b/tests/harness/static-spatial.test.ts @@ -0,0 +1,58 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { rollup } from "rollup"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const hasZig = spawnSync("zig", ["version"], { stdio: "ignore" }).status === 0; +const require = createRequire(import.meta.url); +const wasiRunner = `const {WASI}=require('node:wasi'); const {readFileSync}=require('node:fs'); +const wasi=new WASI({version:'preview1',args:[process.argv[1]],returnOnExit:true}); +const instance=new WebAssembly.Instance(new WebAssembly.Module(readFileSync(process.argv[1])),wasi.getImportObject()); +process.exitCode=wasi.start(instance);`; +function run(command: string, args: string[]) { + const result = spawnSync(command,args,{encoding:"utf8",timeout:30_000}); + if (result.error) throw result.error; + const stderr = result.stderr.split("\n").filter(line => !line.startsWith("scriptc RC audit skipped:")).join("\n"); + return { stdout: result.stdout, stderr, status: result.status, signal: result.signal }; +} + +test.for(["native", "wasm32-wasi"])("published spatial queries and output arrays run statically on %s", async (target, context) => { + if (target === "wasm32-wasi" && !hasZig) context.skip(); + const dir = await mkdtemp("/tmp/scriptc-spatial-"); + const previousTarget = process.env["SCRIPTC_TARGET"], previousCc = process.env["SCRIPTC_CC"]; + try { + if(target === "wasm32-wasi") { process.env["SCRIPTC_TARGET"] = target; process.env["SCRIPTC_CC"] = "zigcc"; } + const pkg = join(dir,"node_modules","spatial-fixture"); + await mkdir(pkg,{recursive:true}); + await writeFile(join(pkg,"package.json"),'{"name":"spatial-fixture","type":"module","main":"index.js"}'); + const entry = join(dir,"main.mjs"); + await writeFile(entry,'import "spatial-fixture";'); + for (const fixture of ["three/collision.mjs", "npm-static/array-output-parameters.mjs"]) { + const source = join(import.meta.dirname,"../fixtures",fixture); + const bundle = await rollup({ input:source, plugins:[{name:"three-cpu-entry",resolveId(id) { + if(id === "three") return require.resolve("three/src/Three.Core.js"); + if(id.startsWith("three/")) return require.resolve(id); + return null; + }}] }); + try { await bundle.write({file:join(pkg,"index.js"),format:"es"}); } + finally { await bundle.close(); } + const wasm = target === "wasm32-wasi"; + const output = join(dir,wasm ? "program.wasm" : "program"); + const result = await compile(entry,{outDir:dir,outPath:output,backend:"llvm",dynamic:false,npmStatic:["spatial-fixture"], + sanitize:!wasm && process.env["SCRIPTC_SAN"] === "1"}); + if(!result.ok) throw new Error(result.diagnostics.map(d=>`${d.code}: ${d.message}`).join("\n")); + if(wasm) expect([...(await readFile(output)).subarray(0,4)]).toEqual([0,97,115,109]); + const reference = run(process.execPath,["--no-warnings",source]); + expect(reference.status).toBe(0); + expect(run(process.execPath,["--no-warnings",entry])).toEqual(reference); + expect(wasm ? run(process.execPath,["--no-warnings","-e",wasiRunner,output]) : run(output,[])).toEqual(reference); + } + } finally { + if(previousTarget === undefined) delete process.env["SCRIPTC_TARGET"]; else process.env["SCRIPTC_TARGET"] = previousTarget; + if(previousCc === undefined) delete process.env["SCRIPTC_CC"]; else process.env["SCRIPTC_CC"] = previousCc; + await rm(dir,{recursive:true,force:true}); + } +});