Test262: private static members with brand checks (#2549)

This commit is contained in:
Chris Tate
2026-10-29 15:31:06 -05:00
committed by GitHub
12 changed files with 767 additions and 13 deletions
@@ -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(
@@ -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);
@@ -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)) {
@@ -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);
@@ -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,
@@ -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 {
@@ -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
@@ -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.<use>:<class>:<#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 },
};
}
@@ -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([]);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/js-private-static-receivers.js"
],
"diags": []
}
+182
View File
@@ -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);
@@ -0,0 +1 @@
{ "plain": 2124, "san": 75913 }