From a5675b2279f29bf10e151f1bbbabfec6ca0090fd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 17:31:30 -0500 Subject: [PATCH] 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> --- .../frontend/lowering/generic-functions.ts | 11 ++ .../src/frontend/lowering/lower-classes.ts | 178 ++++++++++++++++++ .../fixtures/npm-static/class-factory-cli.ts | 25 +++ .../node_modules/class-factory/classes.js | 25 +++ .../node_modules/class-factory/package.json | 1 + tests/harness/npm-static.test.ts | 26 +++ 6 files changed, 266 insertions(+) create mode 100644 tests/fixtures/npm-static/class-factory-cli.ts create mode 100644 tests/fixtures/npm-static/node_modules/class-factory/classes.js create mode 100644 tests/fixtures/npm-static/node_modules/class-factory/package.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 712863072..32901cc4e 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -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). diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 4577ae452..aa8a7ef59 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -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; runtimePrototypeMembers?: readonly ts.ClassElement[]; localPrototypeData?: true; prototypeMethodValues?: Map; @@ -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, diff --git a/tests/fixtures/npm-static/class-factory-cli.ts b/tests/fixtures/npm-static/class-factory-cli.ts new file mode 100644 index 000000000..c2a96f66c --- /dev/null +++ b/tests/fixtures/npm-static/class-factory-cli.ts @@ -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()); diff --git a/tests/fixtures/npm-static/node_modules/class-factory/classes.js b/tests/fixtures/npm-static/node_modules/class-factory/classes.js new file mode 100644 index 000000000..38e531b31 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-factory/classes.js @@ -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); +} diff --git a/tests/fixtures/npm-static/node_modules/class-factory/package.json b/tests/fixtures/npm-static/node_modules/class-factory/package.json new file mode 100644 index 000000000..521d00d93 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-factory/package.json @@ -0,0 +1 @@ +{ "name": "class-factory", "version": "1.0.0", "type": "module", "exports": { "./classes.js": "./classes.js" } } diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 8de3886c8..70fcf0c9f 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -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"],