From 6115135d96affbf01e4960f270e9e7eac2e22ed5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:18:48 -0500 Subject: [PATCH] Dispatch new.target over generic subclasses and own static names A constructor's new.target dispatch skipped generic class families, so a generic subclass instantiated only in a body lowered later answered with the base's name, and `new.target === Base` held for its instances. A family now has its own arm, tested by its instanceof interval, which holds every instantiation: `new.target.name` reads the family's name and comparisons with a program class are false. Under an instantiation, the generic subclasses of its family are candidates too. `new.target` as a value refuses generic subclasses, which have no common class value. `new.target.name` reads a class's own static `name` field, which replaces its name (a subclass has its own name); a static method `name` or a non-string field is refused. --- .../src/frontend/lowering/new-target.ts | 68 ++++++++++++++---- ...364-new-target-late-instantiations.ts.json | 6 ++ .../new-target-value-generic-subclass.ts.json | 6 ++ .../7364-new-target-late-instantiations.ts | 71 +++++++++++++++++++ .../new-target-value-generic-subclass.ts | 5 ++ .../new-target-value-generic-subclass.ts.txt | 6 ++ ...364-new-target-late-instantiations.ts.json | 1 + 7 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json create mode 100644 tests/corpus/7364-new-target-late-instantiations.ts create mode 100644 tests/diagnostics/new-target-value-generic-subclass.ts create mode 100644 tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt create mode 100644 tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json diff --git a/packages/compiler/src/frontend/lowering/new-target.ts b/packages/compiler/src/frontend/lowering/new-target.ts index 8b5f5858a..4cf271469 100644 --- a/packages/compiler/src/frontend/lowering/new-target.ts +++ b/packages/compiler/src/frontend/lowering/new-target.ts @@ -43,7 +43,9 @@ function bindingOf(node: ts.Node): Binding { interface Dispatch { info: ClassInfo; - /** The constructor's class and its descendants, deepest first. */ + /** The constructor's descendants, deepest first, then its class. A + * generic family stands for its instantiations, also those that later + * bodies instantiate: its interval holds them all. */ classes: ClassInfo[]; self: IrExpr | null; } @@ -69,11 +71,23 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { ); return null; } - const classes = [info]; + // An instantiation's subclasses can also be instantiations of generic + // subclasses of its family (`class G extends Box` under Box). + const family = info.genericInstance?.family; + const descendants: ClassInfo[] = []; for (const other of lowerer.classes.values()) { - if (other === info || other.generic) continue; - if (lowerer.isSubclassOf(other.def.name, info.def.name)) classes.push(other); + if (other === info) continue; + if ( + lowerer.isSubclassOf(other.def.name, info.def.name) || + (family !== undefined && + other.generic !== undefined && + other !== family && + lowerer.isSubclassOf(other.def.name, family.def.name)) + ) + descendants.push(other); } + descendants.sort((a, b) => depth(b) - depth(a)); + const classes = [...descendants, info]; for (const c of classes) { if (c.localClass) { lowerer.unsupported( @@ -84,7 +98,6 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { return null; } } - classes.sort((a, b) => depth(b) - depth(a)); let self: IrExpr | null = null; if (classes.length > 1) { const thisLocal = lowerer.resolveThis(expr); @@ -140,6 +153,13 @@ export function lowerNewTarget(lowerer: Lowerer, expr: ts.MetaProperty): IrExpr const dispatch = dispatchOf(lowerer, expr)!; const type: IrType = { kind: "classval", className: dispatch.info.def.name }; for (const c of dispatch.classes) { + if (c.generic) { + lowerer.unsupported( + "SC1090", + expr, + `'new.target' as a value where '${c.def.jsName ?? c.def.name}' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead)`, + ); + } if (c !== dispatch.info && !lowerer.ctorAbiEquals(c, dispatch.info)) { lowerer.unsupported( "SC1090", @@ -166,13 +186,37 @@ export function lowerNewTargetName( const dispatch = dispatchOf(lowerer, target); if (!dispatch) return null; const loc = locOf(access); - return select(dispatch, STRING, loc, (c) => ({ - kind: "libCall", - fn: "class.name", - args: [classValueRef(lowerer, c, target)], - type: STRING, - loc, - })); + return select(dispatch, STRING, loc, (c) => { + // An own static `name` field replaces the class's name; a subclass has + // its own name. An instantiation's statics are its family's. + const owner = c.genericInstance?.family ?? c; + const field = owner.staticFields.find((f) => f.name === "name"); + if (field !== undefined) { + if (field.type.kind !== "string") + lowerer.unsupported( + "SC1090", + access, + `'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static 'name' that is not a string`, + ); + return { kind: "varRef", localId: field.globalId, type: field.type, loc }; + } + if (owner.staticMethods?.has("name") || owner.genericStatics?.has("name")) + lowerer.unsupported( + "SC1090", + access, + `'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static method 'name'`, + ); + // A generic family's instantiations share its name. + if (c.generic) + return { kind: "strLit", value: c.def.jsName ?? c.generic.baseName, type: STRING, loc }; + return { + kind: "libCall", + fn: "class.name", + args: [classValueRef(lowerer, c, target)], + type: STRING, + loc, + }; + }); } /** `new.target === C` (and `!==`, `==`, `!=`, either operand order) with a diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json new file mode 100644 index 000000000..20d761c6c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7364-new-target-late-instantiations.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json new file mode 100644 index 000000000..1abcce3ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/new-target-value-generic-subclass.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7364-new-target-late-instantiations.ts b/tests/corpus/7364-new-target-late-instantiations.ts new file mode 100644 index 000000000..3fa7ff94a --- /dev/null +++ b/tests/corpus/7364-new-target-late-instantiations.ts @@ -0,0 +1,71 @@ +// new.target in a constructor whose subclasses are generic classes +// instantiated only in bodies lowered later, and classes whose own static +// `name` field replaces their name. +class Plain { + readonly label: string; + readonly plain: boolean; + constructor() { + this.label = new.target.name; + this.plain = new.target === Plain; + } +} +class Sub extends Plain { + readonly v: T; + constructor(v: T) { + super(); + this.v = v; + } +} + +class Box { + readonly label: string; + readonly exact: boolean; + readonly value: T; + constructor(value: T) { + this.value = value; + this.label = new.target.name; + this.exact = new.target === Leaf; + } +} +class Tagged extends Box {} +class Leaf extends Tagged {} + +class Named { + static name = "custom"; + readonly label: string; + constructor() { + this.label = new.target.name; + } +} +class Child extends Named {} +class Other extends Named { + static name = "other"; +} +class Family { + static name = "family"; + readonly label: string; + readonly v: T; + constructor(v: T) { + this.label = new.target.name; + this.v = v; + } +} + +function later(): void { + const n = new Sub(1); + const p = new Plain(); + console.log(n.label, new Sub("s").label, p.label, n.plain, p.plain); + const t = new Tagged(1); + const s = new Tagged("a"); + const b = new Box(2); + const l = new Leaf(3); + console.log(t.label, s.label, b.label, l.label, t.exact, l.exact, b.exact); + console.log(new Family(true).label); +} + +// The bases construct here first; their subclasses only in later(). +console.log(new Plain().label, new Box(false).label); +console.log(new Named().label, new Child().label, new Other().label); +Named.name = "changed"; +console.log(new Named().label, new Child().label, new Other().label); +later(); diff --git a/tests/diagnostics/new-target-value-generic-subclass.ts b/tests/diagnostics/new-target-value-generic-subclass.ts new file mode 100644 index 000000000..2107a39a5 --- /dev/null +++ b/tests/diagnostics/new-target-value-generic-subclass.ts @@ -0,0 +1,5 @@ +// new.target as a class value where a subclass is generic: its +// instantiations have no common class value. +class A { readonly t: typeof A; constructor() { this.t = new.target; } } +class B extends A { readonly v: T | undefined = undefined; } +console.log(new B().t.name); diff --git a/tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt b/tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt new file mode 100644 index 000000000..0e5cf2e52 --- /dev/null +++ b/tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt @@ -0,0 +1,6 @@ +new-target-value-generic-subclass.ts:3:58 - error SC1090: 'new.target' as a value where 'B' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead) is not supported yet + + 2 | // instantiations have no common class value. + 3 | class A { readonly t: typeof A; constructor() { this.t = new.target; } } + | ^~~~~~~~~~ + 4 | class B extends A { readonly v: T | undefined = undefined; } \ No newline at end of file diff --git a/tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json b/tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json new file mode 100644 index 000000000..725cf769c --- /dev/null +++ b/tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 17500 }