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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 08:32:52 -07:00
co-authored by Claude Opus 5.5
parent ce87ba722d
commit 95712ea590
10 changed files with 156 additions and 8 deletions
@@ -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(
@@ -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);
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6102-local-class-type-query.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/local-class-type-query-brand.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/local-class-type-query.ts"
],
"diags": []
}
@@ -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));
@@ -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());
@@ -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);
@@ -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;
@@ -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;