From 95712ea5906d7eefafa973ec44ed2c783ef80ff6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 08:32:52 -0700 Subject: [PATCH] Keep refused local classes out of speculative type queries Whole-program analyses that query types before reachability is known (the computed-key write census for JavaScript method dispatch, and the runtime-optional array analysis) can name a function-local class whose collection is refused. No statement owns that refusal there, so it escaped as an uncaught PoisonError from collectClassShapeInner and crashed the compiler (the ts-api-utils probe, @typescript-eslint/typescript-estree, and small TypeScript programs). These analyses now run under Lowerer.speculativeTypeQueries: a refused local class is unmappable for the query and its diagnostics are dropped, and the class's own declaration collects it again and reports them if the program reaches it. Collection and body lowering keep their poison handling. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 62 ++++++++++++++++--- .../6102-local-class-type-query.js.json | 6 ++ .../local-class-type-query-brand.ts.json | 6 ++ .../local-class-type-query.ts.json | 6 ++ tests/corpus/6102-local-class-type-query.js | 42 +++++++++++++ .../local-class-type-query-brand.ts | 14 +++++ tests/diagnostics/local-class-type-query.ts | 14 +++++ .../local-class-type-query-brand.ts.txt | 6 ++ .../local-class-type-query.ts.txt | 6 ++ 10 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-brand.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query.ts.json create mode 100644 tests/corpus/6102-local-class-type-query.js create mode 100644 tests/diagnostics/local-class-type-query-brand.ts create mode 100644 tests/diagnostics/local-class-type-query.ts create mode 100644 tests/harness/__snapshots__/local-class-type-query-brand.ts.txt create mode 100644 tests/harness/__snapshots__/local-class-type-query.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 06739166c..bfb7e06b8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6385,7 +6385,7 @@ export function collectInheritedJsFieldWrites( * hierarchy identifies the original declarations through aliases as well. */ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.SourceFile[]): void { const families: [ts.MethodDeclaration, ts.MethodDeclaration][] = []; - collectClassMethodMutations(lowerer, files); + lowerer.speculativeTypeQueries(() => collectClassMethodMutations(lowerer, files)); const visit = (node: ts.Node): void => { if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { const accessors = node.members.filter( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index aca306900..147a6232b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2825,7 +2825,8 @@ export class Lowerer { return null; } const className = this.classNamer(decl); - if (!this.collectingExprClasses.has(decl)) this.lowerClassExpressionInfo(decl); + if (!this.collectingExprClasses.has(decl) && !this.collectLocalClassForType(decl)) + return null; return { kind: "object", className }; }, mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened), @@ -6084,9 +6085,11 @@ export class Lowerer { for (const fp of parts) this.checker.prefetchSourceFile(fp.sf); } this.collectProgram(parts); - this.analyzeRuntimeOptionalArrayReads(parts); - this.analyzeDateJsonResults(parts); - this.analyzeGuardedRecordReadGlobals(parts); + this.speculativeTypeQueries(() => { + this.analyzeRuntimeOptionalArrayReads(parts); + this.analyzeDateJsonResults(parts); + this.analyzeGuardedRecordReadGlobals(parts); + }); // Decorated classes analyze AFTER the whole collection pass: a // decorator's return type may name a subclass declared below the // class, and reference lowering needs each class's rebindability @@ -6470,9 +6473,11 @@ export class Lowerer { collectionEdges.add(name); }; this.collectProgram(parts); - this.analyzeRuntimeOptionalArrayReads(parts); - this.analyzeDateJsonResults(parts); - this.analyzeGuardedRecordReadGlobals(parts); + this.speculativeTypeQueries(() => { + this.analyzeRuntimeOptionalArrayReads(parts); + this.analyzeDateJsonResults(parts); + this.analyzeGuardedRecordReadGlobals(parts); + }); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct // thunks) reachable emit must see. @@ -9615,6 +9620,49 @@ export class Lowerer { return lowerClassExpressionInfo(this, expr); } + /** Depth of the whole-program analyses that query types across every + * file before reachability is known (speculativeTypeQueries). */ + private speculativeTypeQueryDepth = 0; + + /** Runs a whole-program analysis whose type queries are speculative: they + * visit every file, reached or not, and no statement or declaration owns + * a refusal they trigger (collectLocalClassForType). */ + speculativeTypeQueries(analysis: () => void): void { + this.speculativeTypeQueryDepth++; + try { + analysis(); + } finally { + this.speculativeTypeQueryDepth--; + } + } + + /** Collects a function-local class because a type query named its + * instance type. Collection and body lowering own a refusal of that + * class: the poison reaches the declaration or statement that asked, as + * before. A speculative whole-program analysis owns none, so there the + * class is unmappable for the query and its diagnostics are dropped; + * lowering the class's own declaration collects it again and reports + * them if the program reaches it. */ + collectLocalClassForType(decl: ts.ClassLikeDeclaration): boolean { + if (this.speculativeTypeQueryDepth === 0 || this.fnStack.length > 0) { + this.lowerClassExpressionInfo(decl); + return true; + } + const sink: ScrDiagnostic[] = []; + const previousSink = this.diagSink; + this.diagSink = sink; + try { + this.lowerClassExpressionInfo(decl); + } catch (e) { + if (e instanceof PoisonError) return false; + throw e; + } finally { + this.diagSink = previousSink; + } + for (const d of sink) this.pushDiag(d); + return true; + } + lowerClassExpression(expr: ts.ClassLikeDeclaration): IrExpr { return lowerClassExpression(this, expr); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json new file mode 100644 index 000000000..86c6ba9e8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6102-local-class-type-query.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6102-local-class-type-query.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-brand.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-brand.ts.json new file mode 100644 index 000000000..e17ea3bf5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query-brand.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/local-class-type-query-brand.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query.ts.json new file mode 100644 index 000000000..3c89fba9f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/local-class-type-query.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/local-class-type-query.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6102-local-class-type-query.js b/tests/corpus/6102-local-class-type-query.js new file mode 100644 index 000000000..92fe35725 --- /dev/null +++ b/tests/corpus/6102-local-class-type-query.js @@ -0,0 +1,42 @@ +// A whole-program analysis that queries types before reachability is known +// (the computed-key write census here) can name a function-local class whose +// collection is refused. That query has no owner for the refusal: the class +// stays unmapped for it, and the refusal belongs to the class's own +// declaration, which this program never executes (it used to crash the +// compiler with an uncaught PoisonError). +function main(run) { + if (!run) return "skipped"; + const Project = class { + constructor() { + this.name = "p"; + } + close() { + if (this.missingFilesMap) { + this.missingFilesMap.clear(); + this.missingFilesMap = void 0; + } + } + add(k) { + (this.missingFilesMap || (this.missingFilesMap = new Map())).set(k, 1); + } + }; + const holder = { p: new Project() }; + const key = String(run); + holder.p[key] = "q"; + return holder.p.name; +} + +class Counter { + constructor() { + this.counts = {}; + } + bump(key) { + this.counts[key] = (this.counts[key] || 0) + 1; + return this.counts[key]; + } +} + +const counter = new Counter(); +const word = process.argv.length > 99 ? "unused" : "word"; +counter.bump(word); +console.log(main(0), counter.bump(word)); diff --git a/tests/diagnostics/local-class-type-query-brand.ts b/tests/diagnostics/local-class-type-query-brand.ts new file mode 100644 index 000000000..566304627 --- /dev/null +++ b/tests/diagnostics/local-class-type-query-brand.ts @@ -0,0 +1,14 @@ +// The same speculative element-type query, reaching a function-local class +// with a private brand through the function's returned array. +function make() { + class Box { + #v = 1; + read(): number { + return this.#v; + } + } + return [new Box()]; +} +const xs = make(); +const x = xs[0]; +console.log(x === undefined ? -1 : x.read()); diff --git a/tests/diagnostics/local-class-type-query.ts b/tests/diagnostics/local-class-type-query.ts new file mode 100644 index 000000000..217653ff2 --- /dev/null +++ b/tests/diagnostics/local-class-type-query.ts @@ -0,0 +1,14 @@ +// A whole-program analysis queries the element type of `items` before any +// body lowers. The function-local class it names is refused: the refusal is +// reported once, at the class (it used to crash the compiler with an uncaught +// PoisonError). +function make() { + class Box { + static count = 0; + v = 1; + } + return new Box(); +} +const items = [make()]; +const first = items[0]; +console.log(first ? first.v : 0); diff --git a/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt b/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt new file mode 100644 index 000000000..6db3418a8 --- /dev/null +++ b/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt @@ -0,0 +1,6 @@ +local-class-type-query-brand.ts:4:3 - error SC1090: function-local classes with private brands are not supported yet + + 3 | function make() { + 4 | class Box { + | ^~~~~~~~~~~ + 5 | #v = 1; \ No newline at end of file diff --git a/tests/harness/__snapshots__/local-class-type-query.ts.txt b/tests/harness/__snapshots__/local-class-type-query.ts.txt new file mode 100644 index 000000000..87489fcd5 --- /dev/null +++ b/tests/harness/__snapshots__/local-class-type-query.ts.txt @@ -0,0 +1,6 @@ +local-class-type-query.ts:6:3 - error SC1090: function-local classes with statics, decorators, or generic members are not supported yet + + 5 | function make() { + 6 | class Box { + | ^~~~~~~~~~~ + 7 | static count = 0; \ No newline at end of file