Preserve JavaScript class values and inherited behavior

- Keep fields, accessor parameters, and return values inferred as undefined in native checked storage.
- Preserve virtual dispatch for overridden methods in JavaScript packages.
- Route assignments through class accessors without creating shadow fields.
This commit is contained in:
Chris Tate
2026-09-28 01:16:59 -05:00
committed by GitHub
parent f52b3b5b73
commit f251066719
11 changed files with 171 additions and 3 deletions
@@ -1004,6 +1004,9 @@ function completeFuncValueArgs(
const sig = lowerer.checker.getSignatureFromDeclaration(decl);
if (!sig) lowerer.unsupported("SC1090", decl, "this function form");
const retTsType = lowerer.checker.getReturnTypeOfSignature(sig);
// An inferred undefined return in JS can read a mutable checked-value
// field. Keep the returned value instead of erasing it to void.
if (isJsSourceFile(decl.getSourceFile()) && (retTsType.flags & ts.TypeFlags.Undefined) !== 0) return DYN;
// A body that always throws infers `never` — as a RETURN type that is
// void with a stronger guarantee (`() => never` is assignable to
// `() => void`), and throw-only callbacks are ordinary code
@@ -1812,6 +1812,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
if (
implicitMonoFile(decl.getSourceFile()) &&
ts.isIdentifier(member.name) &&
!lowerer.virtualJsMethods.has(member) &&
inst === undefined && decl.typeParameters === undefined &&
!fields.has(member.name.text) &&
!lowerer.findMethodOn(base, member.name.text) &&
@@ -2116,6 +2117,10 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
// forbids via strictPropertyInitialization — so they keep a named
// fence instead of a silent undefined.
if (isJsSourceFile(decl.getSourceFile())) {
const hasAccessor = (name: string): boolean =>
methods.has(`get:${name}`) || methods.has(`set:${name}`) ||
lowerer.findMethodOn(base, `get:${name}`) !== null ||
lowerer.findMethodOn(base, `set:${name}`) !== null;
// Named classes (declarations and self-binding expressions) resolve
// by name; the nameless default-export declaration by its module's
// default-export symbol.
@@ -2150,7 +2155,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
const name = assign.name.text;
// Later assignments to an already-declared field (own or
// inherited) are writes, not declarations.
if (fields.has(name)) continue;
if (fields.has(name) || hasAccessor(name)) continue;
if (methods.has(name) || lowerer.findMethodOn(base, name)) {
lowerer.unsupported("SC1090", assign, "constructor-assigned fields shadowing methods");
}
@@ -2249,7 +2254,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
// assignment site.
for (const p of instType ? lowerer.checker.getPropertiesOfType(instType) : []) {
if (fields.has(p.name) || methods.has(p.name)) continue;
if (methods.has(`get:${p.name}`) || methods.has(`set:${p.name}`)) continue;
if (hasAccessor(p.name)) continue;
if (base && (base.fields.has(p.name) || lowerer.findMethodOn(base, p.name))) continue;
const site = lowerer.checker.declarationsOf(p).find(
(d) =>
@@ -3655,6 +3660,47 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
);
}
/** Discover overrides before choosing inferred method ABIs. Class collection
* visits bases first, including imported bases and class expressions, so the
* collected subclass graph cannot answer this question yet. The checker
* hierarchy identifies the original declarations through aliases as well. */
export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.SourceFile[]): void {
if (!files.some(implicitMonoFile)) return;
const visit = (node: ts.Node): void => {
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
const names = node.members.flatMap((member) =>
ts.isMethodDeclaration(member) && ts.isIdentifier(member.name) &&
!member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword)
? [member.name.text] : [],
);
if (node.heritageClauses?.length && names.length > 0) {
const symbol = lowerer.typeOf(node).getSymbol();
const instance = symbol ? lowerer.checker.getDeclaredTypeOfSymbol(symbol) : null;
const seen = new Set<ts.Type>();
const markBases = (type: ts.Type): void => {
const target = type.isTypeReference() ? type.getTarget() : type;
if (!target.isClassOrInterface() || seen.has(target)) return;
seen.add(target);
for (const base of lowerer.checker.getBaseTypes(target)) {
for (const name of names) {
const property = lowerer.checker.getPropertyOfType(base, name);
for (const declaration of property ? lowerer.checker.declarationsOf(property) : []) {
if (ts.isMethodDeclaration(declaration) && implicitMonoFile(declaration.getSourceFile())) {
lowerer.virtualJsMethods.add(declaration);
}
}
}
markBases(base);
}
};
if (instance) markBases(instance);
}
}
node.forEachChild(visit);
};
for (const file of files) visit(file);
}
/** The receiver's EXACT runtime class, when the expression proves it: a
* `new C(...)` expression directly, or a const binding initialized with
* one (the binding can never be reassigned to a subclass instance).
@@ -25,7 +25,7 @@ import { collectExpandoMembers } from "./lower-expando.js";
import { recordTextCodecClass } from "../../ir/ir.js";
import { isUnitOnlyTsType, unitOnlyUnion } from "../type-mapper.js";
import type { ClassInfo } from "./lower-classes.js";
import { decoratorNodesOf, genericIfaceBindingKeepsClass, guaranteedDecorationThrow } from "./lower-classes.js";
import { collectVirtualJsMethods, decoratorNodesOf, genericIfaceBindingKeepsClass, guaranteedDecorationThrow } from "./lower-classes.js";
import { isMixinFnBinding, mixinResultBindingClassOf } from "./lower-mixins.js";
import { cjsModuleRef, cjsModuleRegistryPrelude } from "./lower-node-module.js";
import { forkTargetPaths } from "../fork-target.js";
@@ -248,6 +248,7 @@ export function appendForkModules(
export function collectProgram(lowerer: Lowerer, parts: FileParts[]): void {
lowerer.collecting = true;
try {
collectVirtualJsMethods(lowerer, parts.map((part) => part.sf));
for (const fp of parts) for (const decl of fp.classDecls) lowerer.collectClassShape(decl);
for (const fp of parts) for (const decl of fp.fnDecls) lowerer.collectSignature(decl);
} finally {
@@ -1682,6 +1682,9 @@ export class Lowerer {
/** Keyed by program-wide qualified class name (what IR object types carry). */
readonly classes = new Map<string, ClassInfo>();
readonly classBySymbol = new Map<ts.Symbol, ClassInfo>();
/** Inferred JS methods participating in an override chain keep a vtable
* ABI instead of call-site specialization. Filled before class collection. */
readonly virtualJsMethods = new WeakSet<ts.MethodDeclaration>();
/** The class whose members are lowering — `super` binds lexically to it
* (arrows inside methods lower within this window, so they see it too). */
currentClass: ClassInfo | null = null;
@@ -4971,6 +4974,9 @@ export class Lowerer {
irTypeOf(node: ts.Node): IrType {
const t = this.typeOf(node);
// JS inference can leave mutable fields, accessor parameters and
// locals at undefined. They still carry values; void has no storage.
if (isJsSourceFile(node.getSourceFile()) && (t.flags & ts.TypeFlags.Undefined) !== 0) return DYN;
// A never-tainted JS type (neverTaintedJsType) maps — never rides as
// f64 — but must not: pre-empt the mapping so the JS fallback below
// answers instead.
+55
View File
@@ -103,3 +103,58 @@ console.log(BaseRenderable.renderableNumber--, --BaseRenderable.renderableNumber
BaseRenderable.renderableNumber++;
--BaseRenderable.renderableNumber;
console.log(BaseRenderable.renderableNumber);
// Explicit undefined is a value in JS fields, accessors and returns.
// Getter inference also determines an unannotated setter's parameter.
class Listener {
// @ts-expect-error Deliberately observe the slot before initialization.
before = this.value;
value = undefined;
#private = undefined;
get current() { return this.value; }
set current(value) { this.value = value; }
readPrivate() { return this.#private; }
setPrivate(value) { this.#private = value; }
}
const listener = new Listener();
console.log(listener.before, listener.value, listener.current, listener.readPrivate());
listener.current = "ready";
listener.setPrivate("secret");
console.log(listener.value, listener.current, listener.readPrivate());
listener.current = undefined;
listener.setPrivate(undefined);
console.log(listener.current, listener.readPrivate());
stringView(listener);
class UndefinedBase {
value = undefined;
constructor(value) { this.value = value; }
}
class UndefinedDerived extends UndefinedBase {
// @ts-expect-error Observe the base write before the derived reset.
seen = this.value;
value = undefined;
}
const reset = new UndefinedDerived("base");
console.log(reset.seen, reset.value);
function undefinedResult() { return undefined; }
console.log(undefinedResult());
// Assigning an accessor invokes its setter; it never declares a field,
// including from a constructor and through an inherited setter body.
class Positioned {
_left = 0;
constructor() { this.left = 1; }
get left() { return this._left; }
set left(value) { this._left = value; }
get x() { return this.left; }
set x(value) { this.left = value; }
}
class PositionedChild extends Positioned {
constructor() { super(); this.left = 2; }
}
const positioned = new PositionedChild();
console.log(positioned.left);
positioned.x = 3;
console.log(positioned.left);
stringView(positioned);
+4
View File
@@ -0,0 +1,4 @@
export class Base {
render(value) { console.log("base", value); }
dispatch(value) { this.render(value); }
}
+1
View File
@@ -0,0 +1 @@
export function exercise(): void;
+28
View File
@@ -0,0 +1,28 @@
import { Base as ImportedBase } from "./base.js";
class Middle extends ImportedBase {}
class Derived extends Middle {
render(value) { console.log("derived", value); }
}
const Expression = class extends ImportedBase {
render(value) { console.log("expression", value); }
};
class Leaf extends Derived {
render(value) { super.render(value); console.log("leaf", value); }
}
function dispatch(receiver, value) { receiver.render(value); }
// An unrelated method with the same name still specializes its argument.
class Standalone {
render(point) { console.log("standalone", point.x + point.y); }
}
export function exercise() {
new ImportedBase().dispatch("base call");
new Derived().dispatch(7);
new Leaf().dispatch("leaf call");
new Expression().dispatch("expression call");
dispatch(new Derived(), "helper call");
new Standalone().render({ x: 2, y: 3 });
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "virtual-classes",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"types": "index.d.ts"
}
+2
View File
@@ -0,0 +1,2 @@
import { exercise } from "virtual-classes";
exercise();
+15
View File
@@ -99,6 +99,21 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise
}
describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
test.each(["c", "llvm"] as const)("untyped package methods preserve virtual overrides (%s)", async (backend) => {
const entry = join(pilotRoot, "virtual-classes-cli.ts");
const { coverage } = analyze(entry, { npmStatic: "auto" });
expect(coverage.npmStatic).toEqual([{ package: "virtual-classes", status: "static" }]);
expect(coverage.diagnostics).toHaveLength(0);
expect(coverage.runtimeFences ?? []).toHaveLength(0);
const outDir = join(cacheDir, "virtual-classes", sanitize ? "san" : "plain", backend);
const result = await compile(entry, { backend, dynamic: false, npmStatic: "auto", sanitize, outDir, outPath: join(outDir, "program") });
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const [nodeRes, nativeRes] = await Promise.all([runBinary(process.execPath, [entry]), runBinary(result.binaryPath, [])]);
expect(nativeRes.stdout).toEqual(nodeRes.stdout);
expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr);
expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
});
test.each(["c", "llvm"] as const)("a package's literal createRequire calls compile without an engine (%s)", async (backend) => {
const entry = join(pilotRoot, "module-loader-cli.ts");
const { coverage } = analyze(entry, { npmStatic: "auto" });