From 51dc23165628ba5bc915c1a616edfec13dbe4b27 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:24:13 -0700 Subject: [PATCH 1/4] Keep static private members in native storage behind a brand check A JavaScript class with a public static accessor gets its constructor object at run time, but a private static member kept the whole class off that path, so the Test262 shape `static get m() { return this.#m; }` refused. Defining the privates on the constructor object instead would make them string keys, readable through any subclass. Private statics now keep their native storage (the static function and module global), initialized in source order with the public statics, and an access through a receiver other than the class's own name first checks that the receiver is the declaring class object, throwing V8's TypeError otherwise. The class-name spelling is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-runtime-statics.ts | 15 + .../frontend/lowering/expressions/binary.ts | 9 +- .../lowering/expressions/property-access.ts | 7 + .../src/frontend/lowering/lower-calls.ts | 3 + .../src/frontend/lowering/lower-classes.ts | 50 ++- .../src/frontend/lowering/lower-stmts.ts | 4 + .../src/frontend/lowering/private-statics.ts | 297 ++++++++++++++++++ 7 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/private-statics.ts 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 e20604f920..012b9a5623 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 3bcaf10133..1881b9402b 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/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e9d79213c3..a0e00179b0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -5,6 +5,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"; @@ -669,6 +670,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; } diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 4c8743d322..89b7887096 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2452,9 +2452,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) @@ -2523,8 +2529,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) && @@ -5139,11 +5159,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", @@ -5848,7 +5872,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), @@ -6746,7 +6770,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)) @@ -7201,7 +7227,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 377b08917b..d1436ea665 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"; @@ -8664,6 +8665,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..7a7d56fbb8 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/private-statics.ts @@ -0,0 +1,297 @@ +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"; + +/** 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); + if (found.field !== undefined) return null; + const value = checkedDynamicReceiver(lowerer, access.expression); + if (!value) return null; + const loc = locOf(call); + 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.#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, + }; +} From 9f3fd5d9d912a11e1860899f6ca8bbcf0c7fe31a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:24:17 -0700 Subject: [PATCH 2/4] Cover static private receivers in the corpus and an IR unit test The corpus program runs under Node and natively with identical output: a getter returning a private method, private generator and async generator methods, private fields read and written through static accessors, a private method called through `this`, every subclass-receiver TypeError, receivers passed in as a value, the anonymous-class message, the absence of "#x" string keys, and source-order initialization of public and private static fields with a static block. The IR unit test pins that `this.#x` in a runtime static accessor calls the brand helper and that no private name becomes a string literal. Co-Authored-By: Claude Opus 5.5 --- .../test/private-static-receivers.test.ts | 69 ++++++++++ .../js-private-static-receivers.js.json | 6 + tests/corpus/js-private-static-receivers.js | 119 ++++++++++++++++++ .../js-private-static-receivers.js.json | 1 + 4 files changed, 195 insertions(+) create mode 100644 packages/compiler/test/private-static-receivers.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-private-static-receivers.js.json create mode 100644 tests/corpus/js-private-static-receivers.js create mode 100644 tests/harness/corpus-costs/js-private-static-receivers.js.json 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..7295e55923 --- /dev/null +++ b/packages/compiler/test/private-static-receivers.test.ts @@ -0,0 +1,69 @@ +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([]); +}); 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..33b0acf974 --- /dev/null +++ b/tests/corpus/js-private-static-receivers.js @@ -0,0 +1,119 @@ +// 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)); 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 } From 14babd62315f0fcd1dc49e8f95ef72bae7871f12 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 11:13:10 -0500 Subject: [PATCH 3/4] Call private static function fields and update private static fields through this A call of a function-valued private static field through a receiver other than the class's own name (this.#fn(a) in a static accessor) checks the receiver and calls the stored function with this bound to the receiver, instead of reading a public "#fn" property. this.#x++, --this.#x and this.#x op= v check the receiver where the old value is read and update the field's native storage, instead of a public "#x" property. --- .../frontend/lowering/expressions/updates.ts | 36 ++++++++ .../src/frontend/lowering/private-statics.ts | 84 ++++++++++++++++++- .../test/private-static-receivers.test.ts | 31 +++++++ tests/corpus/js-private-static-receivers.js | 47 +++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) 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/private-statics.ts b/packages/compiler/src/frontend/lowering/private-statics.ts index 7a7d56fbb8..3ca3602092 100644 --- a/packages/compiler/src/frontend/lowering/private-statics.ts +++ b/packages/compiler/src/frontend/lowering/private-statics.ts @@ -14,6 +14,7 @@ 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 { omittedArgFor } from "./call-arguments.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. */ @@ -193,10 +194,11 @@ export function lowerStaticPrivateCall( const name = access.name.text; const found = lowerer.findStaticOn(info, name); if (!found) unsupportedStaticPrivate(lowerer, call, name); - if (found.field !== undefined) return null; 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) { @@ -247,6 +249,62 @@ export function lowerStaticPrivateCall( }; } +/** `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. */ +function lowerStaticPrivateFieldCall( + lowerer: Lowerer, + call: ts.CallExpression, + info: ClassInfo, + name: string, + field: ClassInfo["staticFields"][number], + value: IrExpr, +): IrExpr { + const fieldType = field.type; + if (fieldType.kind !== "func") { + return lowerer.unsupported( + "SC1090", + call, + `calling the private static field '${name}' when it does not hold a function of a known type`, + ); + } + const loc = locOf(call); + const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); + const args = call.arguments.nodes.map((arg, index) => + lowerer.lowerExprExpecting(arg, fieldType.params[index]), + ); + for (let index = args.length; index < fieldType.params.length; index++) { + const absent = omittedArgFor(lowerer, fieldType.params[index]!, loc); + if (!absent) { + lowerer.unsupported( + "SC1090", + call, + "calls omitting a non-optional parameter of the callee's type", + ); + } + args.push(absent); + } + const result: IrExpr = { + kind: "callValue", + callee: varRef(field.globalId, fieldType, loc), + receiver: varRef(receiver.id, DYN, loc), + args, + type: fieldType.ret, + 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. */ @@ -295,3 +353,27 @@ export function lowerStaticPrivateAssignment( 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 index 7295e55923..c9260f1998 100644 --- a/packages/compiler/test/private-static-receivers.test.ts +++ b/packages/compiler/test/private-static-receivers.test.ts @@ -67,3 +67,34 @@ test("the declaring class's own name reads native storage without a check", asyn 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/tests/corpus/js-private-static-receivers.js b/tests/corpus/js-private-static-receivers.js index 33b0acf974..4dc626cf67 100644 --- a/tests/corpus/js-private-static-receivers.js +++ b/tests/corpus/js-private-static-receivers.js @@ -117,3 +117,50 @@ for (const other of [{}, 1, undefined]) { 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); +} From 714385609e8e42d36c9f5499c1623c80a75ee1d9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 15:27:36 -0500 Subject: [PATCH 4/4] Call private static fields through the general value-call lowering A call through a function-valued private static field (this.#fn(...)) completed its arguments against the field's fixed parameter list, so a rest-parameter function lost the arguments for its rest parameter, and a function that reads arguments was refused. The call now takes the ordinary call-through-value lanes (typed rests, runtime-arity closures, checked-dynamic values) and binds this to the checked receiver. --- .../src/frontend/lowering/lower-calls.ts | 2 +- .../src/frontend/lowering/private-statics.ts | 44 +++++++------------ tests/corpus/js-private-static-receivers.js | 16 +++++++ 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index a0e00179b0..132e435cf0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -2127,7 +2127,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/private-statics.ts b/packages/compiler/src/frontend/lowering/private-statics.ts index 3ca3602092..b8823a523a 100644 --- a/packages/compiler/src/frontend/lowering/private-statics.ts +++ b/packages/compiler/src/frontend/lowering/private-statics.ts @@ -14,7 +14,7 @@ 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 { omittedArgFor } from "./call-arguments.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. */ @@ -252,7 +252,9 @@ export function lowerStaticPrivateCall( /** `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. */ + * 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, @@ -261,38 +263,22 @@ function lowerStaticPrivateFieldCall( field: ClassInfo["staticFields"][number], value: IrExpr, ): IrExpr { - const fieldType = field.type; - if (fieldType.kind !== "func") { + 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, - `calling the private static field '${name}' when it does not hold a function of a known type`, + `this call of the private static field '${name}' (spread arguments)`, ); } - const loc = locOf(call); - const receiver = lowerer.declareHiddenLocal("%staticPrivateReceiver", DYN); - const args = call.arguments.nodes.map((arg, index) => - lowerer.lowerExprExpecting(arg, fieldType.params[index]), - ); - for (let index = args.length; index < fieldType.params.length; index++) { - const absent = omittedArgFor(lowerer, fieldType.params[index]!, loc); - if (!absent) { - lowerer.unsupported( - "SC1090", - call, - "calls omitting a non-optional parameter of the callee's type", - ); - } - args.push(absent); - } - const result: IrExpr = { - kind: "callValue", - callee: varRef(field.globalId, fieldType, loc), - receiver: varRef(receiver.id, DYN, loc), - args, - type: fieldType.ret, - loc, - }; + const result: IrExpr = { ...lowered, receiver: varRef(receiver.id, DYN, loc) }; return { kind: "seqExpr", stmts: [ diff --git a/tests/corpus/js-private-static-receivers.js b/tests/corpus/js-private-static-receivers.js index 4dc626cf67..6d1b5da6d7 100644 --- a/tests/corpus/js-private-static-receivers.js +++ b/tests/corpus/js-private-static-receivers.js @@ -164,3 +164,19 @@ try { } 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);