From df9f83bbc1b863f400b9ced701fcf7f7398cbd69 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:47:37 -0500 Subject: [PATCH] Refuse new.target over function-local subclasses lowered later Lowering collects a function-local class with the body declaring it, so a local subclass whose function lowers after its base's constructor was missing from new.target's dispatch, which then answered with the base for its instances. The refusal no longer depends on that order: the program's function-local classes are found once, and one that extends the constructor's class, directly or through other classes its extends clauses name, refuses new.target as a collected one does. --- .../src/frontend/lowering/lower-classes.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 3 + .../src/frontend/lowering/new-target.ts | 96 ++++++++++++++++++- .../new-target-late-local-subclass.ts.json | 6 ++ .../new-target-late-local-subclass.ts | 15 +++ .../new-target-late-local-subclass.ts.txt | 6 ++ 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json create mode 100644 tests/diagnostics/new-target-late-local-subclass.ts create mode 100644 tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index b23697cab..1b8941e3e 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -238,7 +238,7 @@ export function returnsOnlyThisValue(member: ts.MethodDeclaration): boolean { return valid; } -function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { +export function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { for ( let parent: ts.Node | undefined = decl.parent; parent && !ts.isSourceFile(parent); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 93c6246d9..9d7c83810 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2184,6 +2184,9 @@ export class Lowerer { * and erased instances). */ erasingDepth = 0; localClassInstantiations: { owner: ts.FunctionLikeDeclaration; name: string }[] = []; + /** The function-local classes of the program with an `extends` clause, whether lowering collected + * them yet or not (new-target.ts); found on first use. */ + functionLocalSubclasses: ts.ClassLikeDeclaration[] | undefined = undefined; /** Instance types (by checker type id) of the local classes generic * factory calls return, and the per-instance class each names. */ readonly localClassInstanceTypes = new Map(); diff --git a/packages/compiler/src/frontend/lowering/new-target.ts b/packages/compiler/src/frontend/lowering/new-target.ts index 4cf271469..83994ca6f 100644 --- a/packages/compiler/src/frontend/lowering/new-target.ts +++ b/packages/compiler/src/frontend/lowering/new-target.ts @@ -16,7 +16,7 @@ import { varRef } from "../../ir/build.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; -import { classValueRef } from "./lower-classes.js"; +import { classValueRef, functionLocalClass } from "./lower-classes.js"; export function isNewTarget(node: ts.Node): node is ts.MetaProperty { return ( @@ -56,6 +56,90 @@ function depth(info: ClassInfo): number { return d; } +/** The function-local classes of the program that extend a class. */ +function functionLocalSubclasses(lowerer: Lowerer): ts.ClassLikeDeclaration[] { + let found = lowerer.functionLocalSubclasses; + if (found === undefined) { + const classes: ts.ClassLikeDeclaration[] = []; + for (const sf of lowerer.moduleOrder) + ts.walkPreorder(sf, (node) => { + if ( + (ts.isClassDeclaration(node) || ts.isClassExpression(node)) && + baseExpressionOf(node) !== undefined && + functionLocalClass(node) + ) + classes.push(node); + }); + lowerer.functionLocalSubclasses = found = classes; + } + return found; +} + +function baseExpressionOf(node: ts.ClassLikeDeclaration): ts.Expression | undefined { + let base = node.heritageClauses?.nodes.find( + (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword, + )?.types?.nodes[0]?.expression; + while (base !== undefined && ts.isParenthesizedExpression(base)) base = base.expression; + return base; +} + +/** Whether the class `node` extends the class of `info` (for an + * instantiation, its family), directly or through other classes, as far as + * its `extends` clauses name classes. */ +function derivesFrom( + lowerer: Lowerer, + node: ts.ClassLikeDeclaration, + info: ClassInfo, + seen: Set, +): boolean { + if (seen.has(node)) return false; + seen.add(node); + const base = baseExpressionOf(node); + const name = + base === undefined + ? undefined + : ts.isIdentifier(base) + ? base + : ts.isPropertyAccessExpression(base) + ? base.name + : undefined; + let symbol = name === undefined ? undefined : lowerer.checker.getSymbolAtLocation(name); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + if (symbol === undefined) return false; + const named = lowerer.classBySymbol.get(symbol); + if (named !== undefined) + return ( + named === info || + named === info.genericInstance?.family || + lowerer.isSubclassOf(named.def.name, info.def.name) + ); + // A class lowering has not collected: through its declaration. + for (const declaration of symbol.declarations) { + let cls: ts.Node | undefined = declaration; + if (ts.isVariableDeclaration(cls)) { + cls = cls.initializer; + while (cls !== undefined && ts.isParenthesizedExpression(cls)) cls = cls.expression; + } + if ( + cls !== undefined && + (ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) && + derivesFrom(lowerer, cls, info, seen) + ) + return true; + } + return false; +} + +/** `node`'s name, quoted, for diagnostics. */ +function localClassName(node: ts.ClassLikeDeclaration): string { + if (node.name) return `'${node.name.text}'`; + const parent = node.parent; + return parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name) + ? `'${parent.name.text}'` + : "a class expression"; +} + /** The classes `new.target` can denote in the constructor being lowered, * or null after a diagnostic. */ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { @@ -98,6 +182,16 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { return null; } } + // Lowering collects a function-local class with the body declaring it, + // which may come after this constructor. + for (const local of functionLocalSubclasses(lowerer)) { + if (!derivesFrom(lowerer, local, info, new Set())) continue; + lowerer.unsupported( + "SC1090", + expr, + `'new.target' where ${localClassName(local)} is a function-local class (each evaluation creates a new class)`, + ); + } let self: IrExpr | null = null; if (classes.length > 1) { const thisLocal = lowerer.resolveThis(expr); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json new file mode 100644 index 000000000..7046e28d5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/new-target-late-local-subclass.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/new-target-late-local-subclass.ts b/tests/diagnostics/new-target-late-local-subclass.ts new file mode 100644 index 000000000..658a7148e --- /dev/null +++ b/tests/diagnostics/new-target-late-local-subclass.ts @@ -0,0 +1,15 @@ +// A function-local subclass refuses new.target in its base's constructor +// also when lowering collects it after that constructor: make() is lowered +// after the top-level code that constructs Base. +class Base { + readonly label: string; + constructor() { + this.label = new.target.name; + } +} +console.log(new Base().label); +function make(): Base { + const Local = class extends Base {}; + return new Local(); +} +console.log(make().label); diff --git a/tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt b/tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt new file mode 100644 index 000000000..feb663813 --- /dev/null +++ b/tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt @@ -0,0 +1,6 @@ +new-target-late-local-subclass.ts:7:18 - error SC1090: 'new.target' where 'Local' is a function-local class (each evaluation creates a new class) is not supported yet + + 6 | constructor() { + 7 | this.label = new.target.name; + | ^~~~~~~~~~ + 8 | } \ No newline at end of file