Evaluate constructor arguments before the class-value check

`new kinds[i](args)` over an array of class values read the element and
narrowed it to the class value before the arguments ran, so an index past
the end threw "is not a constructor" without evaluating them. JavaScript
evaluates the arguments first: the construction is now a call of a lifted
helper (checkedElementConstruction) that takes the element read and the
arguments, in that order, then runs the check and constructs. Keeping the
arguments in one call also keeps a spread's argument pack alive until
every argument has read it.

A checked class-value boundary accepted every subclass's class value into a
base class's slot, so construction through the slot could call a subclass
constructor that takes different arguments with the base constructor's. It
now accepts only the subclasses whose class-value constructor takes the
same arguments (classValueAbiEquals, the rule the classval upcast validator
already applied); any other subclass fails the check with a TypeError.
This commit is contained in:
Chris Tate
2026-10-24 20:11:25 -05:00
parent 5517139b0e
commit d64f18d2a8
7 changed files with 198 additions and 37 deletions
+8 -2
View File
@@ -174,6 +174,10 @@ export interface DynHost extends WalkerHost {
dynPromiseAdapter(t: IrType): string;
isErrorClass(className: string): boolean;
classSubtypes(className: string): readonly string[];
/** The strict subclasses whose class-value constructor takes the same
* arguments as `className`'s (classValueAbiEquals): the class values a
* checked `classval` slot accepts besides its own class. */
classValueSubtypes(className: string): readonly string[];
/** emitter.ts's pendingTestLines: the inline pending-exception test. */
pendingTestLines(dest: string): string[];
}
@@ -1808,8 +1812,10 @@ export class LlDyn {
const B = new BlockBuilder();
if (t.kind === "classval") {
host.declare(`declare ptr @scr_dyn_class_check(ptr, ptr, ptr)`);
// A subclass's class value checks into the base class's slot.
const subclasses = host.classSubtypes(t.className).filter((name) => name !== t.className);
// A subclass's class value checks into the base class's slot when
// construction through the slot can call its thunk with the base
// constructor's arguments; any other subclass fails the check.
const subclasses = host.classValueSubtypes(t.className);
if (subclasses.length > 0) {
host.declare(`declare zeroext i1 @scr_dyn_class_is(ptr, ptr)`);
for (const className of subclasses) {
+18 -8
View File
@@ -55,6 +55,7 @@ import {
typeKey,
VOID,
classJsName,
classValueAbiEquals,
} from "../../ir/ir.js";
import { moduleRuntimeFeatures, type RuntimeFeatures } from "../../ir/runtime-features.js";
import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
@@ -1895,14 +1896,14 @@ export class LlEmitter {
const meta = this.classMeta.get(t.className);
return meta !== undefined && programLifetimeClass(this.shapeHost, meta);
},
classSubtypes: (name) => {
const target = this.classMetaOf(name);
const intervals = classMembershipIntervals(this.classMeta, target.def.name);
return [...this.classMeta.values()]
.filter((meta) =>
intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post),
)
.map((meta) => meta.def.name);
classSubtypes: (name) => this.classSubtypes(name),
classValueSubtypes: (name) => {
const target = this.classMetaOf(name).def;
return this.classSubtypes(name).filter(
(sub) =>
sub !== target.name &&
classValueAbiEquals(this.classMetaOf(sub).def, target, this.fnByName),
);
},
pendingTestLines: (dest) => this.pendingTestLines(dest),
virtualEntry: (implFn) => this.virtualEntry(implFn),
@@ -3965,6 +3966,15 @@ export class LlEmitter {
// ── class plumbing ──────────────────────────────────────────────────────
/** `name` and every class whose membership interval falls in its own. */
private classSubtypes(name: string): string[] {
const target = this.classMetaOf(name);
const intervals = classMembershipIntervals(this.classMeta, target.def.name);
return [...this.classMeta.values()]
.filter((meta) => intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post))
.map((meta) => meta.def.name);
}
classMetaOf(className: string): LlClassMeta {
const meta = this.classMeta.get(className);
if (!meta) throw new InternalCompilerError(`llvm emitter bug: unknown class ${className}`);
@@ -9782,6 +9782,75 @@ function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType
return lowerer.currentClass?.fields.get(parent.left.name.text) ?? null;
}
/** An array element read that construction narrows to a class value with a
* check (`%new.callee`, which throws "is not a constructor"). */
interface NarrowedConstructorRead {
read: IrExpr;
check: string;
unionT: IrType & { kind: "union" };
calleeT: IrType & { kind: "classval" };
message: IrExpr;
}
/** `new arr[i](args)` through a narrowed element read: an interned lifted
* function takes the read and the completed arguments, which evaluate in
* that order as call arguments, then runs the check and constructs. JS
* evaluates the arguments before it checks that the callee constructs, and
* the arguments stay in the one call, where a spread's argument pack lives
* until every argument has read it. */
function checkedElementConstruction(
lowerer: Lowerer,
info: ClassInfo,
narrowed: NarrowedConstructorRead,
args: IrExpr[],
loc: SrcLoc,
): IrExpr {
const type: IrType = { kind: "object", className: info.def.name };
const key = `newChecked:${narrowed.check}:${info.def.name}:${args.map((a) => typeKey(a.type)).join(",")}`;
let name = lowerer.valueHelpers.get(key);
if (!name) {
name = keyedHelperName(lowerer, "%new.checked", key);
lowerer.valueHelpers.set(key, name);
const params: IrParam[] = [
{ localId: "value", name: "value", type: narrowed.unionT },
{ localId: "message", name: "message", type: STRING },
...args.map((arg, i) => ({ localId: `arg.${i}`, name: `arg${i}`, type: arg.type })),
];
lowerer.liftedFns.push({
name,
params,
returnType: type,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
body: [
{
kind: "return",
value: {
kind: "newValue",
callee: {
kind: "call",
callee: narrowed.check,
args: [varRef("value", narrowed.unionT, loc), varRef("message", STRING, loc)],
type: narrowed.calleeT,
loc,
},
args: params.slice(2).map((p) => varRef(p.localId, p.type, loc)),
type,
loc,
},
loc,
},
],
loc,
});
}
return {
kind: "call",
callee: name,
args: [narrowed.read, narrowed.message, ...args],
type,
loc,
};
}
/** Map, Set and the error classes construct in lowerNew's identifier
* branch, which a module member alias of one (`new mod.M()`) also takes. */
function isCollectionOrErrorClass(ref: BuiltinClassRef | null): boolean {
@@ -11045,6 +11114,9 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
}
let callee = lowerer.lowerExpr(expr.expression);
if (callee.type.kind === "dyn") return lowerCheckedConstructor(lowerer, expr, callee);
/** The union-typed element read a narrowing check guards, and the
* check (`%new.callee`) that yields the class value or throws. */
let narrowedCallee: NarrowedConstructorRead | null = null;
// An array element read (`new T[0]()` over `[Dog, Animal]`, whose
// checker type is the common base) keeps the undefined an index past
// the end reads: the class value is narrowed out with a check, which
@@ -11118,18 +11190,25 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
loc,
});
}
// JavaScript evaluates the arguments before it checks that the
// callee constructs, so the check runs at the construction
// (checkedElementConstruction).
narrowedCallee = {
read: callee,
check: name,
unionT,
calleeT,
message: {
kind: "strLit",
value: `${expr.expression.getText()} is not a constructor`,
type: STRING,
loc,
},
};
callee = {
kind: "call",
callee: name,
args: [
callee,
{
kind: "strLit",
value: `${expr.expression.getText()} is not a constructor`,
type: STRING,
loc,
},
],
args: [callee, narrowedCallee.message],
type: calleeT,
loc,
};
@@ -11175,13 +11254,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
const built = constructedFromValue(
lowerer,
info,
{
kind: "newValue",
callee,
args,
type: { kind: "object", className: info.def.name },
loc,
},
narrowedCallee !== null
? checkedElementConstruction(lowerer, info, narrowedCallee, args, loc)
: {
kind: "newValue",
callee,
args,
type: { kind: "object", className: info.def.name },
loc,
},
expr,
);
// A runtime class slot widened from a union (`[Readable, Writable]`'s
+22
View File
@@ -1630,6 +1630,28 @@ export function classValueConstructor(def: { name: string; runtime?: true }): {
: { fn: `%${def.name}.constructor`, firstArg: 1 };
}
/** Whether construction through a class value typed as `sup` may call
* `sub`'s construct thunk: both class-value constructors exist and take the
* same argument types after the receiver. The classval upcast rule and the
* checked class-value boundary both rest on it. */
export function classValueAbiEquals(
sub: { name: string; runtime?: true },
sup: { name: string; runtime?: true },
functions: ReadonlyMap<string, IrFunction>,
): boolean {
const subValueCtor = classValueConstructor(sub);
const supValueCtor = classValueConstructor(sup);
const subCtor = functions.get(subValueCtor.fn);
const supCtor = functions.get(supValueCtor.fn);
if (!subCtor || !supCtor) return false;
const subArgs = subCtor.params.slice(subValueCtor.firstArg);
const supArgs = supCtor.params.slice(supValueCtor.firstArg);
return (
subArgs.length === supArgs.length &&
subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type))
);
}
/** The JavaScript `.name` of a class: its jsName, or the runtime-provided
* class's own name (`%TypeError` is TypeError). */
export function classJsName(def: { name: string; runtime?: true; jsName?: string }): string {
+10 -11
View File
@@ -58,7 +58,12 @@ import {
unionContainerArmsOk,
VOID,
} from "./ir.js";
import { DYN_CLASS_PROPERTIES, callSiteReturnType, classValueConstructor } from "./ir.js";
import {
DYN_CLASS_PROPERTIES,
callSiteReturnType,
classValueAbiEquals,
classValueConstructor,
} from "./ir.js";
import { unionWideningTags } from "./analysis.js";
import { alwaysReturns } from "./control-flow.js";
import { dynamicOperationOf, functionTier } from "./tiers.js";
@@ -2997,19 +3002,13 @@ function validateFunction(
}
const subClass = classes.get(sub);
const supClass = classes.get(sup);
const subValueCtor = subClass ? classValueConstructor(subClass) : null;
const supValueCtor = supClass ? classValueConstructor(supClass) : null;
const subCtor = subValueCtor ? functions.get(subValueCtor.fn) : undefined;
const supCtor = supValueCtor ? functions.get(supValueCtor.fn) : undefined;
if (!subCtor || !supCtor || !subValueCtor || !supValueCtor) {
const subCtor = subClass ? functions.get(classValueConstructor(subClass).fn) : undefined;
const supCtor = supClass ? functions.get(classValueConstructor(supClass).fn) : undefined;
if (!subClass || !supClass || !subCtor || !supCtor) {
err(`classval upcast: "${sub}"/"${sup}" lack constructor functions`, e.loc);
break;
}
const subArgs = subCtor.params.slice(subValueCtor.firstArg);
const supArgs = supCtor.params.slice(supValueCtor.firstArg);
const abiEqual =
subArgs.length === supArgs.length &&
subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type));
const abiEqual = classValueAbiEquals(subClass, supClass, functions);
if (!abiEqual) {
err(`classval upcast: "${sub}" and "${sup}" constructor ABIs differ`, e.loc);
}
+23
View File
@@ -28,3 +28,26 @@ try {
}
const byName = { dog: Dog, cat: Cat };
console.log(new byName.dog().speak(), new byName["cat"]().speak());
// The arguments are evaluated before the missing element throws.
class Point {
constructor(x, y) {
this.sum = x + y;
}
}
class Point3 extends Point {}
const points = [Point3, Point];
const evaluated = [];
const arg = (v) => (evaluated.push(v), v);
console.log(new points[0](arg(1), arg(2)).sum, evaluated.join());
try {
new points[2](arg(3), arg(4));
} catch (e) {
console.log(e.name, e.message, evaluated.join());
}
// Spread arguments too, through a valid index and past the end.
console.log(new points[1](...[5, 6]).sum);
try {
new points[3](...[arg(7), arg(8)]);
} catch (e) {
console.log(e.name, e.message, evaluated.join());
}
+20
View File
@@ -26,3 +26,23 @@ try {
} catch (e) {
console.log((e as Error).name, (e as Error).message);
}
// A class value checked into a `typeof Shape` slot: a subclass whose
// constructor takes the same arguments constructs, and one whose constructor
// takes an argument the slot does not pass throws a TypeError (Node's from
// reading the missing argument, the compiled program's from the check).
class Sized extends Shape {
label: string;
constructor(side: number) {
super();
this.label = side.toFixed(1);
}
}
const values: any[] = [Square, Sized, Shape];
for (const value of values) {
try {
const S: typeof Shape = value;
console.log(new S().area());
} catch (e) {
console.log((e as Error).name);
}
}