Include star re-exports in namespace objects, and read causes and instanceof Error like Node under a runtime Error parent

- A module namespace converted to an untyped object, and a computed
  namespace read, use the module's resolved exports, which include the
  members of `export *` declarations.
- `super(message, options)` through a runtime Error parent installs the
  cause as Error does: an inherited `cause` counts, the value is read
  through its getter, and the getter's exception propagates.
- `value instanceof Error` on an instance of a class whose heritage is
  evaluated at run time tests that class's prototype chain for
  Error.prototype. A program with such a class checks prototype chains
  for `instanceof Error` on untyped values.
- The cache of names written on the global object is a Lowerer field
  instead of a WeakMap keyed by the Lowerer.
- errors.test.ts: `"prototype" in Map` answers from the builtin
  constructor's keys now, so it leaves the refused reads and joins a
  check of `in` probes against Node's answers.
This commit is contained in:
Chris Tate
2026-10-22 22:00:01 -05:00
parent f210b1102a
commit 77c0636826
13 changed files with 188 additions and 21 deletions
@@ -908,6 +908,11 @@ export class ClassDynamicDispatch {
private readonly prototypeClasses = new Set<string>();
private prototypeIdentityHelper: IrFunction | null = null;
private readonly prototypeIdentityClasses = new Set<string>();
/** `value instanceof Error` through Error.prototype: boxed instances of
* classes with a heritage evaluated at run time test their prototype
* data's chain. */
private errorPrototypeHelper: IrFunction | null = null;
private readonly errorPrototypeClasses = new Set<string>();
private readonly instancePrototypes = new Map<string, { fn: IrFunction; classes: Set<string> }>();
private readonly checkedCasts = new Map<string, { fn: IrFunction; classes: Set<string> }>();
/** The for-in key helpers, by libCall (dyn and native string[] keys). */
@@ -1114,6 +1119,49 @@ export class ClassDynamicDispatch {
});
changed = true;
}
if (
this.errorPrototypeHelper &&
!this.errorPrototypeClasses.has(className) &&
hasRuntimeHeritage(info)
) {
this.errorPrototypeClasses.add(className);
const loc = this.errorPrototypeHelper.loc;
const type: IrType = { kind: "object", className };
const value = varRef("value", DYN, loc);
const prototype = classPrototypeData(lowerer, info, loc, {
kind: "dynCheck",
value,
type,
loc,
});
if (prototype)
this.errorPrototypeHelper.body.unshift({
kind: "if",
cond: {
kind: "libCall",
fn: "dyn.typedRefIs",
args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }],
type: BOOL,
loc,
},
then: [
{
kind: "return",
value: {
kind: "libCall",
fn: "dyn.inheritsErrorPrototype",
args: [prototype],
type: BOOL,
loc,
},
loc,
},
],
else_: null,
loc,
});
changed = true;
}
if (
this.serializesClasses &&
!this.jsonPrototypes.has(className) &&
@@ -1516,6 +1564,31 @@ export class ClassDynamicDispatch {
}
return { kind: "call", callee: cast.fn.name, args: [expr.value], type, loc: expr.loc };
}
if (expr.kind === "libCall" && expr.fn === "dyn.inheritsErrorPrototype") {
if (!this.errorPrototypeHelper) {
const loc = expr.loc;
this.errorPrototypeHelper = {
name: "%dyn.class.inheritsErrorPrototype",
params: [{ localId: "value", name: "value", type: DYN }],
locals: [{ id: "value", name: "value", type: DYN, mutable: false }],
returnType: BOOL,
loc,
body: [
{ kind: "return", value: { ...expr, args: [varRef("value", DYN, loc)] }, loc },
],
};
lowerer.liftedFns.push(this.errorPrototypeHelper);
this.generated.add(this.errorPrototypeHelper);
changed = true;
}
return {
kind: "call",
callee: this.errorPrototypeHelper.name,
args: expr.args,
type: BOOL,
loc: expr.loc,
};
}
if (
expr.kind === "libCall" &&
expr.fn === "dyn.getPrototype" &&
@@ -5942,3 +6015,11 @@ const INHERITED_OBJECT_METHODS: ReadonlySet<string> = new Set([
"hasOwnProperty",
"propertyIsEnumerable",
]);
/** Whether a class or one of its bases has a heritage evaluated at run
* time, whose prototype chain may reach Error.prototype. */
function hasRuntimeHeritage(info: ClassInfo): boolean {
for (let current: ClassInfo | null = info; current; current = current.base)
if (current.callableBase) return true;
return false;
}
@@ -380,6 +380,13 @@ export function collectClassMethodMutations(
markTarget(node.initializer, node);
if (ts.isPropertyAccessExpression(node) && isErrorPrototypeValue(lowerer, node))
lowerer.errorPrototypeValues = true;
// A heritage evaluated at run time may be Error, which links the
// class's instances to Error.prototype.
if (
(ts.isClassDeclaration(node) || ts.isClassExpression(node)) &&
runtimeHeritage(lowerer, node)
)
lowerer.errorPrototypeValues = true;
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
const base = unwrap(node.expression);
const prototype =
@@ -621,3 +628,22 @@ export function collectClassMethodMutations(
if (!receiverFields.has(field)) lowerer.receiverFreeCallbackFields.add(field);
}
}
/** Whether a class's `extends` expression is a value computed at run time:
* anything other than a name of a class declaration or of a builtin
* constructor (`extends Parent` over `const Parent = params.Parent ?? Object`,
* `extends make()`). */
function runtimeHeritage(
lowerer: Lowerer,
node: ts.ClassDeclaration | ts.ClassExpression,
): boolean {
const heritage = node.heritageClauses?.nodes.find(
(clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
)?.types?.nodes[0]?.expression;
if (heritage === undefined) return false;
if (!ts.isIdentifier(heritage) && !ts.isPropertyAccessExpression(heritage)) return true;
let symbol = lowerer.checker.getSymbolAtLocation(heritage);
if (symbol === undefined) return true;
if (symbol.flags & ts.SymbolFlags.Alias) symbol = lowerer.checker.getAliasedSymbol(symbol);
return !(symbol.flags & ts.SymbolFlags.Class) && !lowerer.isStdlibSymbol(symbol);
}
@@ -80,6 +80,7 @@ const DISPATCH_LIB_CALLS = new Set<string>([
"dyn.propertyIsEnumerableComputed",
"dyn.defineProperty",
"dyn.getPrototype",
"dyn.inheritsErrorPrototype",
"dyn.reflectGet",
"dyn.reflectSet",
]);
@@ -4504,15 +4504,13 @@ function contextualArgumentCount(lowerer: Lowerer, fn: ts.Node): number | null {
return count;
}
const globalThisWrites = new WeakMap<Lowerer, Set<string>>();
/** The property names the program assigns on the global object, through
* `globalThis` or a binding of its type (`(_a = globalThis).x = v`). */
function globalThisWrittenNames(lowerer: Lowerer, globalType: ts.Type): Set<string> {
const known = globalThisWrites.get(lowerer);
const known = lowerer.globalThisWrittenNames;
if (known) return known;
const names = new Set<string>();
globalThisWrites.set(lowerer, names);
lowerer.globalThisWrittenNames = names;
for (const sf of lowerer.program.getImplementationSourceFiles()) {
if (sf.isDeclarationFile || !sf.text.includes("globalThis")) continue;
ts.walkPreorder(sf, (node) => {
@@ -2969,9 +2969,14 @@ export class Lowerer {
* ABI and shared prototype descriptors instead of call-site specialization. */
readonly prototypeMethodAccesses = new Map<string, ts.Node>();
/** Whether the program reads `Error.prototype`, the checked-dynamic
* object an ES5 constructor function can inherit from: only then can a
* value that is not an Error instance be `instanceof Error`. */
* object an ES5 constructor function can inherit from, or has a class
* whose heritage is evaluated at run time (`extends (Base ?? Error)`):
* only then can a value that is not an Error instance be
* `instanceof Error`. */
errorPrototypeValues = false;
/** The property names the program assigns on the global object
* (property-access.ts), collected on first use. */
globalThisWrittenNames: Set<string> | null = null;
/** Well-known symbol methods (`sym:iterator`) the program may replace
* through a symbol-keyed write: an instance or prototype property, or a
* reflection call. */
@@ -32,12 +32,13 @@ export function lowerModuleNamespaceElement(
try {
const body: IrStmt[] = [];
const symbol = lowerer.checker.getSymbolAtLocation(source);
symbol?.getExports().forEach((exported, key) => {
for (const exported of symbol ? lowerer.checker.getExportsOfModule(symbol) : []) {
const key = ts.symbolName(exported);
const target =
exported.flags & ts.SymbolFlags.Alias
? lowerer.checker.getAliasedSymbol(exported)
: exported;
if (!(target.flags & ts.SymbolFlags.Value)) return;
if (!(target.flags & ts.SymbolFlags.Value)) continue;
const declaration = target.valueDeclaration;
const identifier = declaration?.name as ts.Node | undefined;
if (!identifier || !ts.isIdentifier(identifier)) {
@@ -50,7 +51,7 @@ export function lowerModuleNamespaceElement(
kind: "strEq",
negated: false,
left: varRef("key", STRING, loc),
right: strLit(String(key), loc),
right: strLit(key, loc),
type: { kind: "bool" },
loc,
},
@@ -58,7 +59,7 @@ export function lowerModuleNamespaceElement(
else_: null,
loc,
});
});
}
body.push({ kind: "return", value: dynUndefinedExpr(loc), loc });
const fn: IrFunction = {
name,
@@ -111,10 +112,12 @@ export function moduleNamespaceObject(
if (!symbol) return null;
const exports: { key: string; identifier: ts.Identifier }[] = [];
let exact = true;
symbol.getExports().forEach((exported, key) => {
// The module's resolved exports, which include `export *` members.
for (const exported of lowerer.checker.getExportsOfModule(symbol)) {
const key = ts.symbolName(exported);
const target =
exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported;
if (!(target.flags & ts.SymbolFlags.Value)) return;
if (!(target.flags & ts.SymbolFlags.Value)) continue;
const declaration = target.valueDeclaration;
const identifier = declaration?.name as ts.Node | undefined;
const immutable =
@@ -125,10 +128,10 @@ export function moduleNamespaceObject(
(ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) !== 0));
if (!identifier || !ts.isIdentifier(identifier) || !immutable) {
exact = false;
return;
break;
}
exports.push({ key: String(key), identifier });
});
exports.push({ key, identifier });
}
if (!exact) return null;
exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
const name = `%module.object:${ns.type.moduleId}`;
@@ -631,6 +631,14 @@ export class CheckerFacade {
return aliased;
}
/** A module symbol's exports, with the members of its `export *`
* declarations resolved (a symbol's own export table lists only the
* module's own declarations). */
getExportsOfModule(symbol: Ts7Symbol): readonly Ts7Symbol[] {
this.ensureActive();
return this.raw.getExportsOfModule(symbol);
}
getDeclaredTypeOfSymbol(symbol: Ts7Symbol): Type {
this.ensureActive();
let type = this.cache.declaredTypeOfSymbol.get(symbol);
@@ -2,6 +2,7 @@
"order": [
"<repo>/tests/corpus/7487-namespace-spread-untyped/shapes.js",
"<repo>/tests/corpus/7487-namespace-spread-untyped/extra.js",
"<repo>/tests/corpus/7487-namespace-spread-untyped/all.js",
"<repo>/tests/corpus/7487-namespace-spread-untyped/main.js"
],
"diags": []
+10 -3
View File
@@ -5146,10 +5146,17 @@ void scr_dyn_class_super(ScrDyn *constructor, ScrDyn *receiver, ScrDyn *argument
scr_dyn_release(value);
if (scr_exc_pending()) return;
}
if (argc > 1 && argv[1]->kind == SCR_DYN_OBJ && scr_dyn_find_entry(argv[1], "cause", 5)) {
ScrDyn *cause = scr_dyn_obj_get(argv[1], "cause", 5);
/* InstallErrorCause: an options object with a `cause` property, own or
* inherited, defines the value its getter or data property reads. */
ScrDyn *options = argc > 1 ? argv[1] : NULL;
if (options && options->kind != SCR_DYN_NULL &&
(options->kind == SCR_DYN_FUNC || scr_dyn_is_object(options))) {
ScrStr *key = scr_str_new("cause", 5);
scr_dyn_define_hidden(receiver, key, cause ? cause : scr_dyn_undefined());
if (scr_dyn_in(options, key) && !scr_exc_pending()) {
ScrDyn *cause = scr_dyn_obj_read(options, "cause", 5);
if (cause && !scr_exc_pending()) scr_dyn_define_hidden(receiver, key, cause);
scr_dyn_release(cause);
}
scr_str_release(key);
if (scr_exc_pending()) return;
}
@@ -22,3 +22,30 @@ try {
} catch (err) {
console.log(err instanceof E, err.message);
}
console.log(e instanceof Error, new A() instanceof Error);
try {
throw new E("caught");
} catch (err) {
console.log(err instanceof Error, err.message);
}
// The cause option is read like Error's: inherited and accessor causes
// count, and a throwing getter's exception propagates.
const inherited = new E("inherited", Object.create({ cause: "from prototype" }));
console.log(inherited.cause, Object.prototype.hasOwnProperty.call(inherited, "cause"));
let reads = 0;
const accessor = new E("accessor", {
get cause() {
reads++;
return "from getter";
},
});
console.log(accessor.cause, reads, "cause" in new E("none", {}), "cause" in new E("undef", { cause: undefined }));
try {
new E("throws", {
get cause() {
throw new Error("getter failed");
},
});
} catch (err) {
console.log(err.message);
}
@@ -0,0 +1,2 @@
export * from "./shapes.js";
export const own = "own";
@@ -9,3 +9,8 @@ const z = untyped({ ...shapes, ...extra, extra });
console.log(Object.keys(z).join(","));
for (const key of ["circle", "square"]) console.log(key, z[key](2).area.toFixed(2));
console.log(z.VERSION, typeof z.extra.tag, z.extra.tag("x"), new z.Box(4).size);
// A namespace with star re-exports holds the re-exported members too.
import * as all from "./all.js";
const w = untyped({ ...all });
console.log(Object.keys(w).join(","), w.circle(1).area.toFixed(2), w.own, new w.Box(2).size);
for (const key of ["circle", "own", "VERSION", "missing"]) console.log(key, typeof all[key]);
+6 -3
View File
@@ -1711,9 +1711,10 @@ describe("builtin constructor values (JS lane)", () => {
test("members without a lowering stay refused instead of reading undefined", async () => {
// Builtin constructors read as interned function values that model only
// name, length and constructor. Every other static member or prototype
// read (direct, bracketed, `in`, through an alias or globalThis), and
// read (direct, bracketed, through an alias or globalThis), and
// reflection on or redefinition of their members, must stay a located
// SC2020/SC1090 refusal, never an undefined or otherwise wrong value.
// `key in Ctor` answers from the constructor's fixed keys.
// Test262 lost 769 refusals to undefined reads before this was pinned.
const reads = [
["Map.prototype", "Map.prototype"],
@@ -1731,7 +1732,6 @@ describe("builtin constructor values (JS lane)", () => {
],
["isExtensible(Date.prototype)", "Object.isExtensible(Date.prototype)"],
["Map['prototype']", 'Map["prototype"]'],
["'prototype' in Map", '"prototype" in Map'],
["globalThis.Map.prototype", "globalThis.Map.prototype"],
["Map alias prototype", "MapAlias.prototype"],
["Date alias prototype", "DateAlias.prototype"],
@@ -1754,6 +1754,7 @@ console.log(Date.length, RegExp.length, Promise.length, Object.length, Map.lengt
console.log(Date.name, Map.name, Map.constructor === Function, Object.constructor === Function);
const promisePrototype = Promise.prototype;
console.log(typeof promisePrototype, typeof promisePrototype.then, promisePrototype.constructor === Promise);
console.log("prototype" in Map, "groupBy" in Map, "captureStackTrace" in Error, "nope" in Map);
`;
const r = await compileAndRun("builtin-constructor-members", source, "cjs");
expect(r.stderr).toBe("");
@@ -1768,11 +1769,13 @@ console.log(typeof promisePrototype, typeof promisePrototype.then, promiseProtot
),
),
);
// Promise.prototype reads as an object holding the runtime's methods.
// Promise.prototype reads as an object holding the runtime's methods,
// and `in` on a builtin constructor answers from its fixed keys.
expect(lines.slice(reads.length)).toEqual([
"7 2 1 1 0 0",
"Date Map true true",
"object function true",
"true true true false",
]);
});
});