mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 00:25:34 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
export class Base {
|
||||
render(value) { console.log("base", value); }
|
||||
dispatch(value) { this.render(value); }
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export function exercise(): void;
|
||||
+28
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "virtual-classes",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { exercise } from "virtual-classes";
|
||||
exercise();
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user