Run private members and super calls over foreign receivers like Node (#1975)

This commit is contained in:
Chris Tate
2026-10-24 18:50:35 -05:00
committed by GitHub
8 changed files with 420 additions and 7 deletions
@@ -378,8 +378,33 @@ function reflectedMethodValue(
!isJsSourceFile(ts.getSourceFileOfNode(declaration)) ||
info.localClass !== undefined ||
lowerer.errorHierarchyClassOf(info.def.name);
// `super.m(...)` in a prototype method reads the base class's prototype,
// which lowerSuperMethodCall answers over the call-site receiver when the
// base has a prototype object. Other uses of `super` keep the native
// receiver.
const superCalls = !staticMethod && info.base !== null && prototypeChainViewable(info.base);
// Over a dynamic receiver, private fields are brand-checked and read, and private methods
// brand-checked and called; a private accessor, or a private method read as a value, would become a
// lookup of its name.
const privateAccessors = new Set<string>();
const privateMethods = new Set<string>();
for (const other of declaration.members.nodes)
if (other.name !== undefined && ts.isPrivateIdentifier(other.name)) {
if (ts.isAccessor(other)) privateAccessors.add(other.name.text);
else if (ts.isMethodDeclaration(other)) privateMethods.add(other.name.text);
}
ts.walkPreorder(member, (node) => {
if (ts.isPrivateIdentifier(node) || node.kind === ts.SyntaxKind.SuperKeyword)
if (node.kind === ts.SyntaxKind.SuperKeyword && !(superCalls && isSuperMethodCallee(node)))
needsNativeReceiver = true;
if (!ts.isPrivateIdentifier(node)) return;
// Private members on a static method's receiver (the class value) keep
// the native receiver; prototype methods brand-check theirs, except
// private accessors and private methods used other than by a call.
if (
staticMethod ||
privateAccessors.has(node.text) ||
(privateMethods.has(node.text) && !isPrivateMethodCallee(node))
)
needsNativeReceiver = true;
});
if (needsNativeReceiver) return null;
@@ -403,7 +428,11 @@ function reflectedMethodValue(
loc: locOf(member),
};
}
// The method may be lowered on top of an unrelated caller's frames (a
// `super` call in another class's method reflects this class): its `this`
// is its own receiver, never an enclosing function's.
const context = newFnCtx(false, null, null, DYN);
context.thisBoundary = true;
const previousClass = lowerer.currentClass;
lowerer.currentClass = info;
lowerer.fnStack.push(context);
@@ -441,6 +470,55 @@ function reflectedMethodValue(
}
}
/** Whether `base` and every class above it have a complete prototype object
* (reflectClassPrototype): module-level program classes that neither extend
* Date nor a runtime-provided class. */
function prototypeChainViewable(base: ClassInfo): boolean {
for (let c: ClassInfo | null = base; c; c = c.base)
if (
c.decl === null ||
c.localClass !== undefined ||
c.generic !== undefined ||
c.genericInstance !== undefined ||
c.mixinInstance !== undefined ||
c.callableBase !== undefined ||
c.extendsDate !== undefined ||
c.def.runtime ||
c.builtinEmitter ||
c.builtinStream ||
c.builtinError ||
c.builtinEventTarget
)
return false;
return true;
}
/** `super` as the receiver of a called member: `super.m(...)`. */
/** Whether `node`, a private name, is the method a call calls (`x.#m(...)`). */
function isPrivateMethodCallee(node: ts.Node): boolean {
const access = node.parent;
return (
access !== undefined &&
ts.isPropertyAccessExpression(access) &&
access.name === node &&
access.parent !== undefined &&
ts.isCallExpression(access.parent) &&
access.parent.expression === access
);
}
function isSuperMethodCallee(node: ts.Node): boolean {
const access = node.parent;
return (
access !== undefined &&
ts.isPropertyAccessExpression(access) &&
access.expression === node &&
access.parent !== undefined &&
ts.isCallExpression(access.parent) &&
access.parent.expression === access
);
}
/** Complete adapters after all native class bodies and instantiations exist. */
export function finalizeClassMethodValues(lowerer: Lowerer, functions: IrFunction[]): void {
const byName = new Map(functions.map((fn) => [fn.name, fn]));
@@ -423,7 +423,10 @@ export function reflectClassPrototype(
loc,
},
);
// The members lower on top of whatever frames requested the view (a
// `super` call in a subclass's method); their `this` is their own receiver.
const context = newFnCtx(false, null, null, DYN);
context.thisBoundary = true;
lowerer.fnStack.push(context);
try {
for (const member of info.decl.members.nodes) {
@@ -1,3 +1,4 @@
import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js";
import { assertFunctionAliasOf } from "./lower-assert.js";
import { keyedHelperName } from "./stable-names.js";
import { perKeyOrder } from "./key-order-witness.js";
@@ -589,6 +590,17 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
) {
return lowerer.lowerSuperMethodCall(expr, expr.expression);
}
// `value.#m(...)` on a checked-dynamic value (`this` in a JavaScript
// class's receiver-independent method): the private method of the
// brand-checked receiver.
if (
ts.isPropertyAccessExpression(expr.expression) &&
ts.isPrivateIdentifier(expr.expression.name) &&
!expr.expression.questionDotToken
) {
const privateCall = lowerCheckedPrivateMethodCall(lowerer, expr, expr.expression);
if (privateCall) return privateCall;
}
const consoleMember = lowerer.consoleCallMember(expr);
const consoleMethod = consoleMember === null ? lowerer.consoleMethodMember(expr) : null;
@@ -11430,3 +11442,35 @@ function lowerShimIntrinsicCall(
);
return { kind: "libCall", fn: intrinsic.fn, args, type: intrinsic.ret, loc };
}
/** `value.#m(args)` where `value` lowers to a checked-dynamic value and the
* declaration-level class whose body declares `#m` declares it as a method:
* the method called on the brand-checked receiver
* (checkedPrivateMethodReceiver). Null for any other private member call. */
function lowerCheckedPrivateMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (!ts.isPrivateIdentifier(access.name)) return null;
const name = access.name.text;
const owner = privateNameClass(lowerer, access.name);
const found = owner ? lowerer.findMethodOn(owner, name) : null;
if (!owner || !found || found.declarer !== owner || found.sig.abstract === true) return null;
const probed = tryLowerExpression(lowerer, access.expression);
if (!probed || !checkedDynamicOperand(probed)) return null;
const value = checkedDynamicOperand(lowerer.lowerExpr(access.expression));
if (!value) return null;
const loc = locOf(call);
const callee = `%${owner.def.name}.${name}`;
lowerer.noteEdge(callee);
const receiver = checkedPrivateMethodReceiver(lowerer, owner, name, value, loc);
const args = lowerer.completeArgs(call.arguments.nodes, found.sig.params, loc, call);
return { kind: "call", callee, args: [receiver, ...args], type: found.sig.ret, loc };
}
/** A checked-dynamic value, or the dynamic value under a checked extraction. */
function checkedDynamicOperand(expr: IrExpr): IrExpr | null {
if (expr.type.kind === "dyn") return expr;
return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null;
}
@@ -190,7 +190,7 @@ import {
import { lowerObjectFactoryNew } from "./object-factory-new.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { lowerInstanceConstructorNew } from "./class-instance-constructor.js";
import { classPrototypeData } from "./class-prototypes.js";
import { classPrototypeData, reflectClassPrototype } from "./class-prototypes.js";
import { constructionEscape } from "./construction-escapes.js";
import { checkedClassConstruction, checkedClassConstructionPacked } from "./class-construction.js";
import { initializeRuntimeStatics, hasRuntimeStatics } from "./class-runtime-statics.js";
@@ -9430,8 +9430,16 @@ export function lowerSuperMethodCall(
if (!thisLocal) lowerer.unsupported("SC1080", access);
const loc = locOf(call);
const thisRef: IrExpr = { kind: "varRef", localId: thisLocal.id, type: thisLocal.type, loc };
if (lowerer.prototypeMethodAccesses.has(access.name.text)) {
const prototype = classPrototypeData(lowerer, cls.base, loc);
// A method whose `this` is the call-site receiver (a JavaScript class's
// receiver-independent method value, reflectedClassMethodValue) looks the
// method up on the base class's complete prototype, as `super` reads
// [[HomeObject]].[[Prototype]], and calls it with that receiver.
const dynamicThis = thisRef.type.kind === "dyn";
if (dynamicThis || lowerer.prototypeMethodAccesses.has(access.name.text)) {
const prototype =
dynamicThis && cls.base.decl
? reflectClassPrototype(lowerer, cls.base, loc, classValueRef(lowerer, cls.base, access))
: classPrototypeData(lowerer, cls.base, loc);
if (prototype) {
const callee: IrExpr = {
kind: "dynKeyGet",
@@ -9440,12 +9448,21 @@ export function lowerSuperMethodCall(
type: DYN,
loc,
};
// Spread arguments are spread into the dynamic call, as in a dynamic method call.
const spreads: { arg: number; what: string }[] = [];
const args = call.arguments.nodes.map((arg, index) => {
if (!ts.isSpreadElement(arg)) return lowerer.lowerExprExpecting(arg, DYN);
const spelling = ts.getTextOfNode(arg.expression);
spreads.push({ arg: index, what: spelling });
return checkedIterableSpread(lowerer, lowerer.lowerExpr(arg.expression), spelling, loc);
});
const value: IrExpr = {
kind: "dynCall",
callee,
receiver: lowerer.coerceToExpected(thisRef, DYN),
calleeName: ts.getTextOfNode(access),
args: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
args,
...(spreads.length > 0 ? { spreads } : {}),
type: DYN,
loc,
};
@@ -17,8 +17,9 @@ import type { Lowerer } from "./lowerer.js";
import type { ClassInfo } from "./lower-classes.js";
/** How a brand helper uses its receiver: `in` answers `#x in v`, `read` and
* `write` return the receiver as the declaring class for a field access. */
type BrandUse = "in" | "read" | "write";
* `write` return the receiver as the declaring class for a field access, and
* `call` for a private method call. */
type BrandUse = "in" | "read" | "write" | "call";
const PREFIX = "%private.";
@@ -214,6 +215,35 @@ export function checkedPrivateValue(
return { kind: "call", callee: helper, args: [receiver], type: resultType, loc };
}
/** `value.#m(...)` on a checked-dynamic `value` in a declaration-level class
* `info` that declares the private method `#m`: the receiver as an instance
* of `info` when the value is one, else Node's TypeError ("Receiver must be
* an instance of class C"). */
export function checkedPrivateMethodReceiver(
lowerer: Lowerer,
info: ClassInfo,
name: string,
receiver: IrExpr,
loc: SrcLoc,
): IrExpr {
const className = info.def.name;
const resultType: IrType = { kind: "object", className };
const helper = `${PREFIX}call:${className}:${name}`;
if (!lowerer.valueHelpers.has(helper)) {
lowerer.liftedFns.push({
name: helper,
params: [{ localId: "value.0", name: "value", type: DYN }],
returnType: resultType,
locals: [{ id: "value.0", name: "value", type: DYN, mutable: false }],
// finalizePrivateBrands fills the body.
body: [],
loc: helperLoc(loc),
});
lowerer.valueHelpers.set(helper, helper);
}
return { kind: "call", callee: helper, args: [receiver], type: resultType, loc };
}
/** A plain assignment, a destructuring target, or a for-in/of target writes
* the field. Compound assignments and updates read it first, and Node
* reports the failed read. */
@@ -358,6 +388,22 @@ export function finalizePrivateBrands(lowerer: Lowerer, functions: IrFunction[])
},
{ kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc },
);
} else if (use === "call") {
// V8 names the class by its own identifier (not a name inferred from
// a binding), "anonymous" without one, also for a nullish receiver.
const owner = lowerer.classes.get(className);
const identifier = owner?.decl?.name;
const shown = identifier !== undefined ? identifier.text : "anonymous";
for (const unit of ["undefined", "null"] as const) {
body.push({
kind: "if",
cond: { kind: "dynTest", test: unit, value, type: BOOL, loc },
then: [typeError(strLit(`Cannot read properties of ${unit} (reading '${shown}')`, loc))],
else_: null,
loc,
});
}
body.push(typeError(strLit(`Receiver must be an instance of class ${shown}`, loc)));
} else {
const verb = use === "read" ? "reading" : "setting";
const action = use === "read" ? "Cannot read properties" : "Cannot set properties";
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7621-class-methods-foreign-receivers.mjs"
],
"diags": []
}
@@ -0,0 +1,218 @@
// Prototype methods called with a receiver that is not an instance of
// their class (`C.prototype.m.call(other)`): `#private` members throw
// Node's TypeErrors for such a receiver, and `super.m()` looks the method up
// on the base class's prototype and calls it with the receiver.
function report(label, run) {
try {
console.log(label, run());
} catch (error) {
console.log(label, error.constructor.name, error.message);
}
}
class Counter {
#count = 0;
#step = 1;
constructor(name) {
this.name = name;
}
read() {
return this.#count;
}
bump() {
this.#count += this.#step;
return this.#count;
}
reset(value) {
this.#count = value;
return this.name;
}
#label() {
return `${this.name}#${this.#count}`;
}
label() {
return this.#label();
}
owns(value) {
return #count in value;
}
ownsSelf() {
return #count in this;
}
}
const counter = new Counter("c");
console.log(counter.bump(), counter.bump(), counter.read(), counter.label());
console.log(counter.owns(counter), counter.owns({}), counter.ownsSelf(), counter.reset(7), counter.read());
const plain = { name: "plain" };
report("read", () => Counter.prototype.read.call(plain));
report("bump", () => Counter.prototype.bump.call(plain));
report("reset", () => Counter.prototype.reset.call(plain, 3));
report("label", () => Counter.prototype.label.call(plain));
report("ownsSelf", () => Counter.prototype.ownsSelf.call(plain));
report("owns primitive", () => Counter.prototype.owns.call(counter, 5));
report("label undefined", () => Counter.prototype.label.call(undefined));
report("label null", () => Counter.prototype.label.call(null));
report("label number", () => Counter.prototype.label.call(4));
report("read other instance", () => Counter.prototype.read.call(new Counter("d")));
report("label other instance", () => Counter.prototype.label.call(new Counter("e")));
const Anonymous = class {
#m() {
return 1;
}
call() {
return this.#m();
}
};
const Named = class Inner {
#m() {
return 2;
}
call() {
return this.#m();
}
};
report("anonymous", () => Anonymous.prototype.call.call({}));
report("named", () => Named.prototype.call.call({}));
console.log(new Anonymous().call(), new Named().call());
class Shape {
constructor(name) {
this.name = name;
}
describe() {
return `shape ${this.name}`;
}
area() {
return 0;
}
}
class Square extends Shape {
describe() {
return `square: ${super.describe()} area ${this.area()}`;
}
area() {
return this.size * this.size;
}
both() {
return [super.describe(), super.area()].join("|");
}
}
class Tiny extends Square {
#tag = "t";
describe() {
return `tiny<${super.describe()}>`;
}
tag() {
return `${this.#tag}:${super.both()}`;
}
}
const square = new Square("s");
square.size = 3;
console.log(square.describe(), square.both());
const tiny = new Tiny("t");
tiny.size = 1;
console.log(tiny.describe(), tiny.tag());
const foreign = {
name: "f",
size: 2,
area() {
return 40;
},
};
report("square describe", () => Square.prototype.describe.call(foreign));
report("square both", () => Square.prototype.both.call(foreign));
report("tiny describe", () => Tiny.prototype.describe.call(foreign));
report("tiny tag", () => Tiny.prototype.tag.call(foreign));
report("square describe on tiny", () => Square.prototype.describe.call(tiny));
// A base method that builds `new this.constructor()` on a subclass instance
// builds the subclass, also once the base prototype is complete (a super call
// above reads it) and `copy` is read through its prototype slot.
class Node3 {
constructor() {
this.a = 0;
}
copy(source) {
this.a = source.a;
return this;
}
clone() {
return new this.constructor().copy(this);
}
}
class Mesh3 extends Node3 {
constructor(geometry) {
super();
this.geometry = geometry;
}
copy(source) {
super.copy(source);
this.geometry = source.geometry;
return this;
}
}
const copyValue = Node3.prototype.copy;
const mesh = new Mesh3({ id: 1 });
mesh.a = 5;
const meshClone = mesh.clone();
console.log(
meshClone instanceof Mesh3,
meshClone.constructor === Mesh3,
mesh.constructor === Mesh3,
meshClone.a,
JSON.stringify(meshClone.geometry),
typeof copyValue,
);
// Methods that use a private accessor or return a private method keep their own receiver, as values too.
class Vault {
#v = 1;
get #value() {
return this.#v * 10;
}
set #value(x) {
this.#v = x;
}
#hidden() {
return "hidden " + this.#v;
}
read() {
return this.#value;
}
bump() {
this.#value = this.#v + 1;
return this.#v;
}
hiddenValue() {
return this.#hidden;
}
}
const vault = new Vault();
const readVault = Vault.prototype.read;
const bumpVault = Vault.prototype.bump;
const hiddenOf = Vault.prototype.hiddenValue;
console.log(readVault.call(vault), bumpVault.call(vault), readVault.call(vault), typeof hiddenOf.call(vault));
console.log(hiddenOf.call(vault).call(vault));
try {
readVault.call({});
} catch (error) {
console.log(error.constructor.name);
}
// A method value whose body calls super with spread arguments.
class Summer {
add(...xs) {
return xs.reduce((sum, x) => sum + x, 0);
}
}
class Doubler extends Summer {
add(...xs) {
return super.add(...xs) * 2;
}
}
const addValue = Doubler.prototype.add;
console.log(addValue.call(new Doubler(), 1, 2, 3), addValue.apply(new Doubler(), [4, 5]));
@@ -0,0 +1 @@
{ "plain": 6415, "san": 10107 }