Call factory-defined static methods with their receiver class

A static method that a function-local JavaScript class defines at
runtime and a program class inherits (Animal.extend("Dog")) is now
called directly with this bound to the receiver class when it captures
nothing from the factory's scope, so the subclass it builds has a static
base and constructs like Node.

Fixes #938
Co-authored-by: phall <25888173+phall1@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-10-17 17:31:30 -05:00
parent 1783196f60
commit a5675b2279
6 changed files with 266 additions and 0 deletions
@@ -1431,6 +1431,17 @@ export function implicitCallInstance(
shapes.push({ type: bound, mode: "required" });
return;
}
// `this` in a static method specialized for its receiver class is
// that class: bind it as a call naming the class would.
if (stored?.kind === "classval" && arg.kind === ts.SyntaxKind.ThisKeyword) {
const classDecl = lowerer.classes.get(stored.className)?.decl;
const classType = classDecl?.name ? lowerer.typeOf(classDecl.name) : null;
if (classType) {
argTypes.set(sym, classType);
shapes.push({ type: stored, mode: "required" });
return;
}
}
// A represented native array must keep its storage when a direct
// inferred helper receives it. Re-boxing a checker-any array would
// copy it, losing writes (and attempting to read unfilled slots).
@@ -386,6 +386,9 @@ export interface ClassInfo {
def: IrClassDef;
expressionInitializationEmitted?: true;
runtimeStatics?: readonly ts.ClassElement[];
/** Direct calls of runtime static methods (directRuntimeStaticCall), by
* member name. */
runtimeStaticCalls?: Map<string, GenericFnInfo>;
runtimePrototypeMembers?: readonly ts.ClassElement[];
localPrototypeData?: true;
prototypeMethodValues?: Map<string, IrExpr>;
@@ -5642,6 +5645,8 @@ function staticCallOn(
throughValue: boolean,
): IrExpr | null {
const loc = locOf(call);
const runtimeStatic = directRuntimeStaticCall(lowerer, call, access, info, throughValue);
if (runtimeStatic) return runtimeStatic;
if (
(hasRuntimeStatics(info) && !findStaticOn(lowerer, info, access.name.text)?.field) ||
(info.callableBase &&
@@ -5780,6 +5785,179 @@ function staticCallOn(
return { kind: "call", callee: fnName, args, type: found.method.ret, loc };
}
/** `C.m(...)` where `m` is a static method that a function-local
* JavaScript class defines at runtime (a factory's `class extends base`),
* inherited by `C` through its statically known base chain. When the body
* captures nothing from the factory's scope, every evaluation of the class
* defines the same method, so the call lowers directly with `this` bound
* to `C`, as a free function given `C` would. A receiver-dependent result
* (a class built from `this`) then keeps a static base. Null when another
* declaration of `m` answers first, the program assigns that member, or
* the method form has no direct lowering. */
function directRuntimeStaticCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
info: ClassInfo,
throughValue: boolean,
): IrExpr | null {
const name = access.name.text;
if (throughValue || name.startsWith("#")) return null;
let declarer: ClassInfo | null = null;
let member: ts.MethodDeclaration | null = null;
for (let c: ClassInfo | null = info; c; c = c.base) {
if (
c.staticFields.some((field) => field.name === name) ||
c.staticMethods?.has(name) === true ||
c.genericStatics?.has(name) === true
)
return null;
const runtime = c.runtimeStatics?.find(
(element) =>
(element as { name?: ts.PropertyName }).name !== undefined &&
ts.isIdentifier((element as { name: ts.PropertyName }).name) &&
(element as { name: ts.Identifier }).name.text === name,
);
if (runtime) {
if (!ts.isMethodDeclaration(runtime) || !runtime.body) return null;
declarer = c;
member = runtime;
break;
}
}
if (!declarer || !member || !declarer.decl) return null;
if (member.asteriskToken || member.typeParameters) return null;
if (member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword))
return null;
if (hasLexicalSuper(member) || capturesEnclosingScope(lowerer, member, declarer.decl))
return null;
if (memberAssigned(lowerer, member, name) || bodyReadsArguments(member)) return null;
const implicit =
member.parameters.length === 0 ? null : implicitAnyParamSymbolsOf(lowerer, member, true);
if (member.parameters.length > 0 && !implicit) return null;
let calls = declarer.runtimeStaticCalls;
if (!calls) {
calls = new Map();
declarer.runtimeStaticCalls = calls;
}
const method: GenericFnInfo = calls.get(name) ?? {
decl: member,
baseName: name,
qualifiedName: `%${declarer.def.name}.runtimeStatic:${name}`,
typeParams: [],
instances: new Map(),
member: { cls: declarer, kind: "static" },
...(implicit ? { implicitParams: implicit } : {}),
};
calls.set(name, method);
// A direct lowering the method form does not support keeps the dynamic
// call: its refusal is not this program's error.
const sink: ScrDiagnostic[] = [];
const previousSink = lowerer.diagSink;
lowerer.diagSink = sink;
let lowered: IrExpr;
try {
const receiver = staticMethodForReceiver(lowerer, access, method, info, false);
const instance = implicit
? implicitCallInstance(lowerer, call, receiver)
: genericCallInstance(lowerer, call, receiver);
lowered = {
kind: "call",
callee: instance.name,
args: lowerer.completeArgs(call.arguments, instance.params, locOf(call), call),
type: instance.returnType,
loc: locOf(call),
};
} catch (error) {
if (error instanceof PoisonError) return null;
throw error;
} finally {
lowerer.diagSink = previousSink;
}
return sink.length === 0 ? lowered : null;
}
/** `super` inside the method (arrows included) names the defining
* class's base, which a receiver specialization does not track. */
function hasLexicalSuper(root: ts.Node): boolean {
let found = false;
const visit = (node: ts.Node): void => {
if (node.kind === ts.SyntaxKind.SuperKeyword) found = true;
if (
(ts.isFunctionLike(node) && !ts.isArrowFunction(node)) ||
ts.isClassDeclaration(node) ||
ts.isClassExpression(node)
)
return;
node.forEachChild(visit);
};
root.forEachChild(visit);
return found;
}
/** The method reads a binding declared in a function that encloses its
* class: each evaluation of the class can see a different value. */
function capturesEnclosingScope(
lowerer: Lowerer,
member: ts.MethodDeclaration,
classDecl: ts.ClassLikeDeclaration,
): boolean {
let scope: ts.Node | undefined = classDecl.parent;
while (scope && !ts.isFunctionLike(scope) && !ts.isSourceFile(scope)) scope = scope.parent;
if (!scope || ts.isSourceFile(scope)) return false;
const inside = (node: ts.Node, container: ts.Node): boolean => {
for (let n: ts.Node | undefined = node; n; n = n.parent) if (n === container) return true;
return false;
};
let captured = false;
const visit = (node: ts.Node): void => {
if (captured) return;
if (ts.isIdentifier(node)) {
const symbol = lowerer.checker.getSymbolAtLocation(node);
const decl = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined;
// The enclosing function's own name is the same binding for every
// evaluation.
if (decl && decl !== scope && inside(decl, scope) && !inside(decl, member)) captured = true;
return;
}
node.forEachChild(visit);
};
member.forEachChild(visit);
return captured;
}
/** Some code assigns or deletes a member of this name, or defines it by
* reflection: the member a runtime lookup finds can then differ. */
function memberAssigned(lowerer: Lowerer, member: ts.MethodDeclaration, name: string): boolean {
let found = false;
for (const sf of lowerer.program.getSourceFiles()) {
if (sf.isDeclarationFile || found) continue;
if (!sf.text.includes(name)) continue;
ts.walkPreorder(sf, (node) => {
if (found) return "skip";
if (ts.isPropertyAccessExpression(node) && node.name.text === name) {
const parent = node.parent;
if (
parent &&
((ts.isBinaryExpression(parent) &&
parent.left === node &&
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
ts.isDeleteExpression(parent))
)
found = true;
}
if (ts.isStringLiteral(node) && node.text === name && node !== member.name) {
const parent = node.parent;
if (parent && (ts.isCallExpression(parent) || ts.isElementAccessExpression(parent)))
found = true;
}
return undefined;
});
}
return found;
}
function staticMethodForReceiver(
lowerer: Lowerer,
access: ts.PropertyAccessExpression,
+25
View File
@@ -0,0 +1,25 @@
// `class Dog extends Animal.extend("Dog")`: the static `extend` a factory
// class defines is inherited by a program class and builds the next
// subclass from its receiver, as Effect's Schema.Class does.
import { Class } from "class-factory/classes.js";
class Animal extends Class("Animal") {}
class Dog extends Animal.extend("Dog") {}
class Puppy extends Dog.extend("Puppy") {}
const rex = new Dog("Rex");
const plain = new Animal("Ada");
const pup = new Puppy("Bit");
console.log(
JSON.stringify({
dog: rex instanceof Dog,
animal: rex instanceof Animal,
plainDog: plain instanceof Dog,
plainAnimal: plain instanceof Animal,
kind: rex.kind,
value: rex.value,
plainKind: plain.kind,
}),
);
console.log(pup instanceof Puppy, pup instanceof Dog, pup instanceof Animal, rex instanceof Puppy);
console.log(rex.describe(), plain.describe(), pup.describe());
+25
View File
@@ -0,0 +1,25 @@
// A class factory whose classes extend themselves through a static
// method: `extend` builds a subclass of whichever class it is called on.
function factory(base, name) {
const out = class extends base {
constructor(value) {
super(value);
this.kind = name;
}
static extend(name2) {
return factory(this, name2);
}
describe() {
return `${this.kind}:${this.value}`;
}
};
return out;
}
class Root {
constructor(value) {
this.value = value;
}
}
export function Class(name) {
return factory(Root, name);
}
+1
View File
@@ -0,0 +1 @@
{ "name": "class-factory", "version": "1.0.0", "type": "module", "exports": { "./classes.js": "./classes.js" } }
+26
View File
@@ -592,6 +592,32 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
},
);
test.each(["release", "dev"] as const)(
"static methods a factory class defines build subclasses of their receiver (%s)",
async (optimization) => {
const entry = join(pilotRoot, "class-factory-cli.ts");
const outDir = join(cacheDir, `class-factory-${optimization}-${sanitize ? "san" : "plain"}`);
const result = await compile(entry, {
backend: "llvm",
npmStatic: ["class-factory"],
sanitize,
optimization,
outDir,
outPath: join(outDir, "program"),
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const [reference, native] = await Promise.all([
runBinary(process.execPath, [entry]),
runBinary(result.binaryPath, []),
]);
expect(reference.exitCode).toBe(0);
expect(native.stdout).toEqual(reference.stdout);
expect(comparableStderr(native.stderr)).toEqual(reference.stderr);
expect(native.exitCode).toBe(reference.exitCode);
},
);
test.for([
["escape-string-regexp", "escape-cli.ts"],
["slash", "slash-cli.ts"],