diff --git a/packages/compiler/src/frontend/lowering/class-runtime-statics.ts b/packages/compiler/src/frontend/lowering/class-runtime-statics.ts index 707c916ec8..2041fb71c6 100644 --- a/packages/compiler/src/frontend/lowering/class-runtime-statics.ts +++ b/packages/compiler/src/frontend/lowering/class-runtime-statics.ts @@ -100,6 +100,21 @@ export function initializeRuntimeStatics( } if (!member.name) continue; const memberLoc = locOf(member); + // A private static field: its native global, assigned in order. + if (ts.isPrivateIdentifier(member.name)) { + const name = member.name.text; + const field = info.staticFields.find((candidate) => candidate.name === name); + if (!ts.isPropertyDeclaration(member) || !field) { + lowerer.unsupported("SC1090", member, "private static fields without an initializer"); + } + statements.push({ + kind: "assign", + localId: field.globalId, + value: lowerer.lowerExprExpecting(member.initializer!, field.type), + loc: memberLoc, + }); + continue; + } const key: IrExpr = ts.isComputedPropertyName(member.name) ? computedKeys.get(member)! : lowerer.coerceToExpected( diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index ef21e737f2..48ea9c4908 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -1,4 +1,5 @@ /* Binary operators: arithmetic, comparisons and equality, logical and nullish pairs, and string conversion for concatenation. */ +import { lowerStaticPrivateAssignment } from "../private-statics.js"; import { keyedHelperName } from "../stable-names.js"; import * as ts from "../../ts7/adapter.js"; import { @@ -632,7 +633,11 @@ export function lowerNativeFunctionAssignment( const classType = storedClassValueType(lowerer, target.expression); const staticClass = classType?.kind === "classval" ? lowerer.classes.get(classType.className) : undefined; - if (staticClass && hasRuntimeStatics(staticClass)) { + if ( + staticClass && + hasRuntimeStatics(staticClass) && + !(ts.isPropertyAccessExpression(target) && ts.isPrivateIdentifier(target.name)) + ) { return lowerDynMemberAssignment( lowerer, expr, @@ -1110,6 +1115,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr if (op === ts.SyntaxKind.EqualsToken) { const prototypeWrite = lowerClassPrototypeComputedAssignment(lowerer, expr); if (prototypeWrite) return prototypeWrite; + const staticPrivateWrite = lowerStaticPrivateAssignment(lowerer, expr); + if (staticPrivateWrite) return staticPrivateWrite; const callableWrite = lowerNativeFunctionAssignment(lowerer, expr); if (callableWrite) return callableWrite; const dereferenced = lowerDereferencedElementAssignment(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 9cf5556c46..720d1f9e9f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,4 +1,5 @@ import { widensReducedRecords } from "../function-returns.js"; +import { lowerStaticPrivateRead } from "../private-statics.js"; import { widthNarrowHelper } from "../coercions/unions.js"; /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ import { jsSpecialGlobalValue } from "../global-values.js"; @@ -344,6 +345,12 @@ export function lowerPropertyAccessExpr( ): IrExpr { const arrayClassConstructor = lowerArrayClassConstructorRead(lowerer, expr, loc); if (arrayClassConstructor !== null) return arrayClassConstructor; + // `R.#x` for a static private on a checked-dynamic receiver: the brand + // check, then the native storage (private-statics.ts). + { + const staticPrivate = lowerStaticPrivateRead(lowerer, expr, loc); + if (staticPrivate) return staticPrivate; + } // `value.#x` on a checked-dynamic value, or on a checked extraction of // one: the field of the brand-checked receiver (checkedPrivateValue). if (ts.isPrivateIdentifier(expr.name)) { diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 35d5d1a2e3..4911a88d73 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -1,4 +1,6 @@ /* Increment, decrement and prefix unary operators, and compound field updates. */ +import { staticPrivateUpdateTarget } from "../private-statics.js"; +import { lowerCompoundValueToTarget } from "./binary.js"; import { indexedUnionArms, lowerIndexedUnionNumberRead, @@ -269,6 +271,19 @@ export function lowerIncDec( } fenceNodeModuleMutation(lowerer, access, "assignment"); if (ts.isPropertyAccessExpression(access)) { + // `this.#x++` on a static private field (private-statics.ts): the + // brand check, then the field's native global. + const staticPrivate = staticPrivateUpdateTarget(lowerer, access); + if (staticPrivate) { + const update = lowerIncDecTarget(lowerer, expr, staticPrivate.target, prefix); + return { + kind: "seqExpr", + stmts: [staticPrivate.brand], + result: update, + type: update.type, + loc, + }; + } const target = staticFieldWriteTarget(lowerer, access) ?? expandoWritableTarget(lowerer, access) ?? @@ -922,6 +937,27 @@ export function lowerFieldCompoundValue( "compound assignment through 'super' (read and write separately)", ); } + // `this.#x op= v` on a static private field (private-statics.ts): the + // brand check, then the field's native global read before the right side. + if ( + ts.isPropertyAccessExpression(access) && + rhsNode !== null && + ts.isBinaryExpression(access.parent) && + access.parent.left === access + ) { + const staticPrivate = staticPrivateUpdateTarget(lowerer, access); + if (staticPrivate) { + const { target } = staticPrivate; + const value = lowerCompoundValueToTarget(lowerer, access.parent, op, target); + return { + kind: "seqExpr", + stmts: [staticPrivate.brand], + result: { kind: "assignExpr", localId: target.id, value, type: target.type, loc }, + type: target.type, + loc, + }; + } + } const body: IrStmt[] = []; const save = (value: IrExpr, name: string): IrExpr => { const local = lowerer.declareHiddenLocal(name, value.type); diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 019e852896..936e7017d4 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -6,6 +6,7 @@ import { } from "./builtins/array-classes.js"; import { structuralCallInstance } from "./structural-instances.js"; import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js"; +import { lowerStaticPrivateCall } from "./private-statics.js"; import { assertFunctionAliasOf } from "./lower-assert.js"; import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; @@ -691,6 +692,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { ts.isPrivateIdentifier(expr.expression.name) && !expr.expression.questionDotToken ) { + const staticPrivate = lowerStaticPrivateCall(lowerer, expr, expr.expression); + if (staticPrivate) return staticPrivate; const privateCall = lowerCheckedPrivateMethodCall(lowerer, expr, expr.expression); if (privateCall) return privateCall; } @@ -2146,7 +2149,7 @@ function callableInstanceStringCall( /** A call through an evaluated callee value: checked-dynamic callees, * function unions, typed-rest and runtime-arity closures. */ -function lowerCallThroughValue( +export function lowerCallThroughValue( lowerer: Lowerer, expr: ts.CallExpression, callee: IrExpr, diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f06b972798..6bba5b8dad 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2453,9 +2453,15 @@ export function collectClassShapeInner( (modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword, ), )) && + // A private static ACCESSOR has no native storage yet, and a private + // static method that reads `arguments` would lose the arguments past + // its parameters when called through its value (`this.#m` returned by + // a getter): keep such a class on the static path, whose public + // static accessors refuse. !decl.members.nodes.some( (member) => - member.name && + (ts.isAccessor(member) || + (ts.isMethodDeclaration(member) && bodyReadsArguments(member))) && ts.isPrivateIdentifier(member.name) && ts .getModifiers(member) @@ -2524,8 +2530,22 @@ export function collectClassShapeInner( .getModifiers(member) ?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword)) ) { - runtimeStatics.push(member); - continue; + // A private static of a declaration-level class keeps its native + // storage (the %C.static:#m function, the %g.s.C.#x global): the + // constructor object never carries a property for it, and every + // access brand-checks its receiver (private-statics.ts). A field + // also stays in runtimeStatics, so its initializer runs in source + // order with the constructor's other initializers. + const privateStatic = + checkedStatics && + !functionLocalClass(decl) && + (member as { name?: ts.PropertyName }).name !== undefined && + ts.isPrivateIdentifier((member as { name: ts.PropertyName }).name); + if (!privateStatic) { + runtimeStatics.push(member); + continue; + } + if (ts.isPropertyDeclaration(member)) runtimeStatics.push(member); } if ( functionLocalClass(decl) && @@ -5140,11 +5160,15 @@ function lowerStaticFieldInitsInner(lowerer: Lowerer, info: ClassInfo): IrStmt[] | { pos: number; kind: "field"; f: ClassInfo["staticFields"][number] } | { pos: number; kind: "block"; b: ts.ClassStaticBlockDeclaration }; const items: Item[] = [ - ...info.staticFields.map((f): Item => ({ - pos: ts.tokenStartOfNode(f.initializer), - kind: "field", - f, - })), + // A runtime-statics class initializes its private static fields with + // its other initializers (initializeRuntimeStatics). + ...info.staticFields + .filter((f) => !(info.runtimeStatics && f.name.startsWith("#"))) + .map((f): Item => ({ + pos: ts.tokenStartOfNode(f.initializer), + kind: "field", + f, + })), ...(info.staticBlocks ?? []).map((b): Item => ({ pos: ts.tokenStartOfNode(b), kind: "block", @@ -5849,7 +5873,7 @@ export function lowerClassStaticRead( !(ts.isCallExpression(site.parent) && site.parent.expression === site) ) lowerer.noLowering(`Date.${name} read through a class that extends Date`, site); - if (hasRuntimeStatics(info) && !found?.field) + if (hasRuntimeStatics(info) && !found?.field && !name.startsWith("#")) return { kind: "dynKeyGet", value: lowerer.coerceToExpected(classValueRef(lowerer, info, receiver), DYN), @@ -6747,7 +6771,9 @@ function staticCallOn( if (date) return date; } if ( - (hasRuntimeStatics(info) && !findStaticOn(lowerer, info, access.name.text)?.field) || + (hasRuntimeStatics(info) && + !access.name.text.startsWith("#") && + !findStaticOn(lowerer, info, access.name.text)?.field) || (info.callableBase && !findStaticOn(lowerer, info, access.name.text) && !findGenericStaticOn(lowerer, info, access.name.text)) @@ -7202,7 +7228,9 @@ export function lowerClassValueProperty( if (info?.def.runtime && member !== "name") return null; if ( info && - ((hasRuntimeStatics(info) && !findStaticOn(lowerer, info, member)?.field) || + ((hasRuntimeStatics(info) && + !member.startsWith("#") && + !findStaticOn(lowerer, info, member)?.field) || (info.callableBase && !findStaticOn(lowerer, info, member))) ) return { diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 7497c38976..4ff95b6d88 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,3 +1,4 @@ +import { lowerStaticPrivateAssignment } from "./private-statics.js"; import type { DiagnosticCode } from "../../diagnostics/codes.js"; import { keepsInitializerLayout, notePreservedLayout } from "./structural-instances.js"; import { jsGlobalValueByName } from "./global-values.js"; @@ -8732,6 +8733,9 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm if (urlWrite) return { kind: "exprStmt", expr: urlWrite, loc: locOf(expr) }; } if (opKind === ts.SyntaxKind.EqualsToken) { + const staticPrivateWrite = lowerStaticPrivateAssignment(lowerer, expr); + if (staticPrivateWrite) + return { kind: "exprStmt", expr: staticPrivateWrite, loc: locOf(expr) }; const callableWrite = lowerNativeFunctionAssignment(lowerer, expr); if (callableWrite) return { kind: "exprStmt", expr: callableWrite, loc: locOf(expr) }; // Expando function members (`foo.bar = 12`, `foo[SYM] = v` on a diff --git a/packages/compiler/src/frontend/lowering/private-statics.ts b/packages/compiler/src/frontend/lowering/private-statics.ts new file mode 100644 index 0000000000..b8823a523a --- /dev/null +++ b/packages/compiler/src/frontend/lowering/private-statics.ts @@ -0,0 +1,365 @@ +import * as ts from "../ts7/adapter.js"; +import { + BOOL, + DYN, + VOID, + type IrExpr, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; +import { helperLoc, strLit, varRef } from "../../ir/build.js"; +import { locOf } from "../program.js"; +import { hasStaticThis } from "./static-this.js"; +import type { Lowerer } from "./lowerer.js"; +import { lowerClassStaticRead, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js"; +import { tryLowerExpression } from "./expressions/try-lower-expression.js"; +import { lowerCallThroughValue } from "./lower-calls.js"; + +/** How a static private name is used: a field read or write, or a method + * (or accessor) read or call. V8 words each failure differently. */ +export type StaticPrivateUse = "read" | "write" | "method"; + +/** The declaration-level class whose body declares `priv` as a STATIC + * member (the nearest enclosing class declaring the name, as JavaScript + * scoping resolves it). Null for an instance private, a function-local or + * generic class, or a class that was not collected. */ +export function staticPrivateOwner(lowerer: Lowerer, priv: ts.PrivateIdentifier): ClassInfo | null { + for (let node: ts.Node | undefined = priv.parent; node; node = node.parent) { + if (!ts.isClassDeclaration(node) && !ts.isClassExpression(node)) continue; + const member = node.members.nodes.find((candidate) => { + const name = (candidate as { name?: ts.PropertyName }).name; + return name !== undefined && ts.isPrivateIdentifier(name) && name.text === priv.text; + }); + if (!member) continue; + const isStatic = + ts.canHaveModifiers(member) && + ts.getModifiers(member)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true; + if (!isStatic) return null; + for (const info of lowerer.classes.values()) { + if (info.decl !== node) continue; + if (info.localClass || info.generic || info.genericInstance) return null; + return info; + } + return null; + } + return null; +} + +/** V8's TypeError text for a static private `name` of `info` used on a + * receiver that is not the declaring class object. */ +function brandMessage(info: ClassInfo, use: StaticPrivateUse, name: string): string { + if (use === "read") + return `Cannot read private member ${name} from an object whose class did not declare it`; + if (use === "write") + return `Cannot write private member ${name} to an object whose class did not declare it`; + // V8 names the class by its own identifier; an anonymous class + // expression prints ".". + return `Receiver must be class ${info.decl?.name?.text ?? "."}`; +} + +/** The brand check of a static private: JavaScript installs static private + * elements on the declaring class object alone, so the receiver must BE + * that object (a subclass, an object inheriting from it, or a primitive + * throws). An expression statement calling the lifted helper + * `%private.static.::<#name>(value: dyn): void`, which throws + * V8's TypeError; `value` is the checked-dynamic receiver. */ +export function staticPrivateBrandCheck( + lowerer: Lowerer, + info: ClassInfo, + use: StaticPrivateUse, + name: string, + value: IrExpr, + loc: SrcLoc, +): IrStmt { + const helper = `%private.static.${use}:${info.def.name}:${name}`; + if (!lowerer.valueHelpers.has(helper)) { + const at = helperLoc(loc); + const param = varRef("value.0", DYN, at); + const classValue = lowerer.coerceToExpected(lowerer.classValueRef(info, info.decl!), DYN); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "value.0", name: "value", type: DYN }], + returnType: VOID, + locals: [{ id: "value.0", name: "value", type: DYN, mutable: false }], + body: [ + { + kind: "if", + cond: { + kind: "dynScalarEq", + left: param, + right: classValue, + negated: true, + type: BOOL, + loc: at, + }, + then: [ + { + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [strLit(brandMessage(info, use, name), at)], + type: { kind: "object", className: "%TypeError" }, + loc: at, + }, + loc: at, + }, + ], + else_: null, + loc: at, + }, + ], + loc: at, + }); + lowerer.valueHelpers.set(helper, helper); + } + return { + kind: "exprStmt", + expr: { kind: "call", callee: helper, args: [value], type: VOID, loc }, + loc, + }; +} + +/** A static private the native storage does not hold (an accessor, a + * field without an initializer): a loud refusal, never a string-keyed + * read of the class object. */ +function unsupportedStaticPrivate(lowerer: Lowerer, node: ts.Node, name: string): never { + return lowerer.unsupported( + "SC1090", + node, + `the private static '${name}' in this form on a class whose constructor carries runtime statics`, + ); +} + +/** The checked-dynamic value `expr` lowers to, or null. */ +function checkedDynamicReceiver(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + const probed = tryLowerExpression(lowerer, node); + if (!probed) return null; + if ( + probed.type.kind !== "dyn" && + !(probed.kind === "dynCheck" && probed.value.type.kind === "dyn") + ) + return null; + const value = lowerer.lowerExpr(node); + if (value.type.kind === "dyn") return value; + return value.kind === "dynCheck" ? value.value : null; +} + +/** `R.#x` / `R.#m` read, where `#x` is a static private of a + * declaration-level class and `R` is a checked-dynamic value (`this` in a + * static accessor or method that the constructor object carries): the + * brand check, then the member's native storage. Null for other reads, + * which keep their paths (the class's own name reads storage directly). */ +export function lowerStaticPrivateRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + loc: SrcLoc, +): IrExpr | null { + if (!ts.isPrivateIdentifier(expr.name) || expr.questionDotToken) return null; + const info = staticPrivateOwner(lowerer, expr.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(expr.expression) === info) return null; + const value = checkedDynamicReceiver(lowerer, expr.expression); + if (!value) return null; + const name = expr.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found) unsupportedStaticPrivate(lowerer, expr, name); + const read = lowerClassStaticRead(lowerer, info, name, expr.expression, expr); + if (!read) return null; + return { + kind: "seqExpr", + stmts: [ + staticPrivateBrandCheck(lowerer, info, found.field ? "read" : "method", name, value, loc), + ], + result: read, + type: read.type, + loc, + }; +} + +/** `R.#m(args)` for a static private method on a checked-dynamic receiver: + * the brand check, then the call with `this` known to be the declaring + * class. A receiver other than `this` reaches only methods that do not + * read `this`; the others keep a loud refusal. */ +export function lowerStaticPrivateCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (!ts.isPrivateIdentifier(access.name) || access.questionDotToken) return null; + const info = staticPrivateOwner(lowerer, access.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(access.expression) === info) return null; + const name = access.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found) unsupportedStaticPrivate(lowerer, call, name); + const value = checkedDynamicReceiver(lowerer, access.expression); + if (!value) return null; + const loc = locOf(call); + if (found.field !== undefined) + return lowerStaticPrivateFieldCall(lowerer, call, info, name, found.field, value); + const brand = staticPrivateBrandCheck(lowerer, info, "method", name, value, loc); + let result: IrExpr | null; + if (access.expression.kind === ts.SyntaxKind.ThisKeyword) { + // After the check `this` IS the class: lower the call against a + // class-valued `this`, the exact-receiver static path. + const classType: IrType = { kind: "classval", className: info.def.name }; + const local = lowerer.declareHiddenLocal("%staticPrivateThis", classType); + const previous = lowerer.ctx.thisLocal; + lowerer.ctx.thisLocal = local; + try { + result = lowerStaticMethodCall(lowerer, call, access); + } finally { + lowerer.ctx.thisLocal = previous; + } + if (!result) return null; + return { + kind: "seqExpr", + stmts: [ + brand, + { + kind: "varDecl", + localId: local.id, + init: lowerer.coerceToExpected(lowerer.classValueRef(info, info.decl!), classType), + loc, + }, + ], + result, + type: result.type, + loc, + }; + } + if (found.method.member.body && hasStaticThis(found.method.member)) { + lowerer.unsupported( + "SC1090", + call, + `calling the private static '${name}' through a value other than 'this' when the method reads 'this'`, + ); + } + const fnName = `%${found.declarer.def.name}.static:${name}`; + lowerer.noteEdge(fnName); + const args = lowerer.completeArgs(call.arguments.nodes, found.method.params, loc, call); + return { + kind: "seqExpr", + stmts: [brand], + result: { kind: "call", callee: fnName, args, type: found.method.ret, loc }, + type: found.method.ret, + loc, + }; +} + +/** `R.#f(args)` for a function-valued static private field on a + * checked-dynamic receiver: the receiver, the brand check (PrivateGet's), + * then a call through the field's value with `this` bound to the receiver, + * as JavaScript calls a private field's function. The call takes the + * ordinary call-through-value lanes, so rest parameters, typed rests and + * checked-dynamic values receive every argument. */ +function lowerStaticPrivateFieldCall( + lowerer: Lowerer, + call: ts.CallExpression, + info: ClassInfo, + name: string, + field: ClassInfo["staticFields"][number], + value: IrExpr, +): IrExpr { + const loc = locOf(call); + const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); + const lowered = lowerCallThroughValue( + lowerer, + call, + varRef(field.globalId, field.type, loc), + loc, + ); + if (lowered.kind !== "callValue" && lowered.kind !== "dynCall") { + return lowerer.unsupported( + "SC1090", + call, + `this call of the private static field '${name}' (spread arguments)`, + ); + } + const result: IrExpr = { ...lowered, receiver: varRef(receiver.id, DYN, loc) }; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiver.id, init: value, loc }, + staticPrivateBrandCheck(lowerer, info, "read", name, varRef(receiver.id, DYN, loc), loc), + ], + result, + type: result.type, + loc, + }; +} + +/** `R.#x = v` for a static private field on a checked-dynamic receiver: + * the receiver, then the value, then the brand check (PrivateSet checks + * after the right side runs), then the native global. */ +export function lowerStaticPrivateAssignment( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + const target = expr.left; + if ( + expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isPropertyAccessExpression(target) || + !ts.isPrivateIdentifier(target.name) + ) + return null; + const info = staticPrivateOwner(lowerer, target.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(target.expression) === info) return null; + const name = target.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found?.field) unsupportedStaticPrivate(lowerer, expr, name); + const value = checkedDynamicReceiver(lowerer, target.expression); + if (!value) return null; + const loc = locOf(expr); + const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); + const assigned = lowerer.declareHiddenLocal("%staticPrivateValue", found.field.type); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiver.id, init: value, loc }, + { + kind: "varDecl", + localId: assigned.id, + init: lowerer.lowerExprExpecting(expr.right, found.field.type), + loc, + }, + staticPrivateBrandCheck(lowerer, info, "write", name, varRef(receiver.id, DYN, loc), loc), + { + kind: "assign", + localId: found.field.globalId, + value: varRef(assigned.id, found.field.type, loc), + loc, + }, + ], + result: varRef(assigned.id, found.field.type, loc), + type: found.field.type, + loc, + }; +} + +/** The update target of `R.#x++`, `--R.#x` or `R.#x op= v` for a static + * private field on a checked-dynamic receiver: the brand check, which runs + * where PrivateGet reads the old value (before the right side), and the + * field's native global. The write after it needs no second check: the + * receiver already passed. Null for other targets. */ +export function staticPrivateUpdateTarget( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): { brand: IrStmt; target: { id: string; type: IrType } } | null { + if (!ts.isPrivateIdentifier(access.name) || access.questionDotToken) return null; + const info = staticPrivateOwner(lowerer, access.name); + if (!info || !info.runtimeStatics) return null; + if (lowerer.exactClassOfReceiver(access.expression) === info) return null; + const name = access.name.text; + const found = lowerer.findStaticOn(info, name); + if (!found?.field) unsupportedStaticPrivate(lowerer, access, name); + const value = checkedDynamicReceiver(lowerer, access.expression); + if (!value) return null; + return { + brand: staticPrivateBrandCheck(lowerer, info, "read", name, value, locOf(access)), + target: { id: found.field.globalId, type: found.field.type }, + }; +} diff --git a/packages/compiler/test/private-static-receivers.test.ts b/packages/compiler/test/private-static-receivers.test.ts new file mode 100644 index 0000000000..c9260f1998 --- /dev/null +++ b/packages/compiler/test/private-static-receivers.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; + +/** The names of the module's functions and of every call target. */ +async function lowered( + source: string, +): Promise<{ functions: string[]; callees: string[]; keys: string[] }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-private-statics-")); + try { + const entry = join(dir, "main.js"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const callees: string[] = []; + const keys: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown; callee?: unknown; value?: unknown }; + if (node.kind === "call" && typeof node.callee === "string") callees.push(node.callee); + if (node.kind === "strLit" && typeof node.value === "string" && node.value.startsWith("#")) + keys.push(node.value); + for (const child of Object.values(value)) visit(child); + }; + visit(mod.functions); + return { functions: mod.functions.map((fn) => fn.name), callees, keys }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("a static private read through `this` in a runtime static accessor checks the brand", async () => { + const ir = await lowered( + `class C { + static #x = 1; + static #m() { return 2; } + static get x() { return this.#x; } + static get m() { return this.#m; } + } + console.log(C.x, C.m()); + `, + ); + expect(ir.callees).toContain("%private.static.read:C:#x"); + expect(ir.callees).toContain("%private.static.method:C:#m"); + // The private names never become string keys of the class object. + expect(ir.keys).toEqual([]); +}); + +test("the declaring class's own name reads native storage without a check", async () => { + const ir = await lowered( + `class C { + static #x = 1; + static get x() { return C.#x; } + } + console.log(C.x); + `, + ); + expect(ir.functions.some((name) => name.startsWith("%private.static."))).toBe(false); + expect(ir.keys).toEqual([]); +}); + +test("a function-valued static private field called through `this` calls the stored function", async () => { + const ir = await lowered( + `class C { + static #fn = function (a) { return a + 1; }; + static get viaGetter() { return this.#fn(1); } + } + console.log(C.viaGetter); + `, + ); + expect(ir.callees).toContain("%private.static.read:C:#fn"); + expect(ir.keys).toEqual([]); +}); + +test("updates of a static private field through `this` check the brand and keep native storage", async () => { + const ir = await lowered( + `class C { + static #n = 1; + static get bump() { + this.#n++; + --this.#n; + this.#n += 2; + return this.#n; + } + } + console.log(C.bump); + `, + ); + expect(ir.callees).toContain("%private.static.read:C:#n"); + expect(ir.keys).toEqual([]); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json new file mode 100644 index 0000000000..513a5bc93d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-private-static-receivers.js" + ], + "diags": [] +} diff --git a/tests/corpus/js-private-static-receivers.js b/tests/corpus/js-private-static-receivers.js new file mode 100644 index 0000000000..6d1b5da6d7 --- /dev/null +++ b/tests/corpus/js-private-static-receivers.js @@ -0,0 +1,182 @@ +// Static private members of a JavaScript class whose constructor object is +// materialized at run time (it has a public static accessor). The private +// members keep their native storage; every access through a receiver other +// than the class's own name checks that the receiver IS the declaring class, +// as Node does: a subclass, an object inheriting from the class, or a +// primitive throws V8's TypeError. + +let calls = 0; +var Getter = class { + static #method(a, b) { + calls++; + return a + b; + } + static get method() { + return this.#method; + } +}; +console.log("getter method", Getter.method(1, 2), calls); + +var Generators = class { + static *#gen(n) { + yield n; + yield n + 1; + } + static async *#agen(n) { + yield n * 2; + } + static get gen() { + return this.#gen; + } + static get agen() { + return this.#agen; + } +}; +console.log("generator", [...Generators.gen(3)].join(",")); + +class Counter { + static #count = 5; + static #label() { + return "count:" + Counter.#count; + } + static get count() { + return this.#count; + } + static set count(value) { + this.#count = value; + } + static get label() { + return this.#label; + } + static describe() { + return this.#label(); + } +} +console.log("counter", Counter.count, Counter.label(), Counter.describe()); +Counter.count = 9; +console.log("after write", Counter.count, Counter.describe()); + +class Sub extends Counter {} +const probes = [ + ["Sub.count", () => Sub.count], + ["Sub.count =", () => { + Sub.count = 1; + }], + ["Sub.label", () => Sub.label], + ["Sub.describe()", () => Sub.describe()], +]; +for (const [label, probe] of probes) { + try { + console.log(label, "returned", String(probe())); + } catch (error) { + console.log(label, error.constructor.name, error.message); + } +} +console.log("unchanged", Counter.count); + +// A private name is never a string key of the class object. +console.log("no string key", Object.hasOwn(Counter, "#count"), "#count" in Counter, Object.hasOwn(Getter, "#method")); + +// Static initializers run in source order, private ones included. +const order = []; +class Ordered { + static a = (order.push("a"), 1); + static #b = (order.push("#b"), Ordered.a + 1); + static c = (order.push("c"), Ordered.#b + 1); + static { + order.push("block " + Ordered.#b); + } + static get all() { + return [this.a, this.#b, this.c].join(","); + } +} +console.log("order", order.join(","), Ordered.all); + +// A receiver passed in as a value: the same check. V8 names an anonymous +// class expression ".". +var Anonymous = class { + static #m() { + return "m"; + } + static get m() { + return this.#m; + } + static probe(other) { + return typeof other.#m; + } +}; +console.log("probe self", Anonymous.probe(Anonymous), Anonymous.m()); +for (const other of [{}, 1, undefined]) { + try { + Anonymous.probe(other); + } catch (error) { + console.log("probe", typeof other, error.constructor.name, error.message); + } +} + +Generators.agen(4) + .next() + .then((step) => console.log("async generator", step.value, step.done)); + +// A function-valued private static field called through `this` runs with +// `this` bound to the receiver; a receiver other than the class throws the +// field read's TypeError. +class Calls { + static #fn = function (a) { + return (this === Calls ? "self " : "other ") + a; + }; + static #arrow = (a) => "arrow " + a; + static get viaGetter() { + return this.#fn(2) + " " + this.#arrow(2); + } + static viaOther(other, a) { + return other.#fn(a); + } + static get fn() { + return this.#fn; + } +} +console.log("field call", Calls.viaGetter, Calls.viaOther(Calls, 3)); +try { + Calls.viaOther({}, 4); +} catch (error) { + console.log("field call", error.constructor.name, error.message); +} + +// Updates of a private static field through `this` update its storage. +class Updates { + static #n = 1; + static #s = "a"; + static get bump() { + const a = this.#n++; + const b = ++this.#n; + const c = this.#n--; + this.#n += 10; + const d = (this.#n *= 2); + this.#s += "b"; + return [a, b, c, d, this.#n, this.#s].join(","); + } +} +console.log("updates", Updates.bump, Updates.bump, Object.hasOwn(Updates, "#n")); +class SubUpdates extends Updates {} +try { + SubUpdates.bump; +} catch (error) { + console.log("updates", error.constructor.name, error.message); +} + +// A private static field holding a rest-parameter function, or one that +// reads `arguments`, receives every argument of a call through `this`. +class Rest { + static #sum = function (...xs) { + return (this === Rest ? "self " : "other ") + xs.length + ":" + xs.reduce((a, b) => a + b, 0); + }; + static #arrow = (first, ...rest) => first + "|" + rest.join(","); + static #plain = function (a, b) { + return [a, b, arguments.length].join("/"); + }; + static get run() { + return [this.#sum(1, 2, 3), this.#sum(), this.#arrow(1, 2, 3), this.#plain(1), this.#plain(1, 2, 3)].join(" ; "); + } +} +console.log("rest", Rest.run); diff --git a/tests/harness/corpus-costs/js-private-static-receivers.js.json b/tests/harness/corpus-costs/js-private-static-receivers.js.json new file mode 100644 index 0000000000..19c5a4186c --- /dev/null +++ b/tests/harness/corpus-costs/js-private-static-receivers.js.json @@ -0,0 +1 @@ +{ "plain": 2124, "san": 75913 }