fix(ts): a class extends the class after its type parameters (#2648)

fix(ts): a class extends the class after its type parameters

A TS/JS class head was read up to its first `extends`, which for
`class EditorPane<MementoType extends object = object> extends
Composite<MementoType>` is a type parameter's constraint: EditorPane read
as extending `object` (so nothing), and `MultiWindowParts<T extends
IMultiWindowPart, …>` as extending `IMultiWindowPart`. Fields, wrapper
methods and static methods inherited through such a head were missed or
found on the wrong class. The head is now read from its `class` keyword to
its body, over several lines, with type parameter lists and type arguments
left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-19 08:11:37 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6071c9e487
commit d3a778d0b9
3 changed files with 145 additions and 2 deletions
+1
View File
@@ -168,6 +168,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript and JavaScript, a call on a variable, parameter or field whose class the file imports, like `store.add(…)` with `store: DisposableStore`, now links to the method of the class that import names when other classes in the project share its name, such as a vendored copy, a test mock or another app's class. Before, it linked to whichever of them was indexed first, or to the one in the nearest folder: in vscode, calls on classes imported from vscode's own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors or its test fixtures, in bitwarden the desktop app's `initService.init()` reached the browser extension's `InitService`, and in react-native `Keyboard` and `WebSocket` reached the Jest mock of `NativeEventEmitter`. A call whose first match was a test file's class, which then linked to nothing, now reaches the imported class too. The import is followed through re-exports, `import type`, namespace imports like `new async.Delayer()` and `require(…)`. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript, a call through a field that a wrapper sets up, like `this._onDidChange.fire()` after `private readonly _onDidChange = this._register(new Emitter<void>())` or `= this._store.add(new Emitter<void>())`, now links to the method of the class handed to the wrapper, also in a class that inherits the field. Before, such a field had no type, so its calls linked nowhere, and that is one of the most common ways vscode declares its event emitters and disposables. A wrapper counts only when its own declaration hands back what it is given, like `_register<T>(o: T): T`, so a field set from a wrapper that returns something else, or from a member of what it returns (`.event`), is never taken for the class. A call through an inherited field is also found now when the field is declared further up the class hierarchy than before. Re-index TypeScript projects after upgrading.
- In TypeScript and JavaScript, a class whose declaration constrains a type parameter, like `class EditorPane<MementoType extends object = object> extends Composite<MementoType>`, is now read as extending the class after its type parameters. Before, the constraint's type was taken for its base class, so fields, wrapped event emitters and static methods inherited through such a class were missed or found on the wrong class. Class declarations that span several lines or sit below a decorator are read too. Re-index TypeScript and JavaScript projects after upgrading.
## [1.6.2] - 2026-10-03
+117
View File
@@ -0,0 +1,117 @@
/**
* A TypeScript class whose head opens a type parameter list with a
* constraint, like vscode's `class EditorPane<MementoType extends object =
* object> extends Composite<MementoType>`, extends the class after its type
* parameters, not the constraint's type.
*
* The head was read up to its first `extends`, which there is the
* constraint: `EditorPane` read as extending `object` (so nothing),
* `MultiWindowParts<T extends IMultiWindowPart, …>` as extending
* `IMultiWindowPart`. Fields, wrapper methods and static methods a class
* inherits through such a head were then missed, or found on the wrong
* class.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
let roots: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
/** Index `files` and return each call edge as `<file>: <refName> -> <target file>::<target qualified name>`. */
async function callsOf(files: Record<string, string>): Promise<string[]> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-generic-heads-'));
roots.push(root);
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
const nodes = graph.getFiles().flatMap((f) => graph.getNodesInFile(f.path));
const byId = new Map(nodes.map((n) => [n.id, n]));
return graph.getOutgoingEdgesFrom(nodes.map((n) => n.id), ['calls'])
.map((e) => {
const target = byId.get(e.target);
return `${byId.get(e.source)?.filePath}: ${e.metadata?.refName} -> ${target?.filePath}::${target?.qualifiedName}`;
})
.sort();
}
/** vscode's lifecycle wrappers, the `Emitter` the app imports, and a decoy `Emitter` nearer the app. */
const LIFECYCLE = {
'package.json': JSON.stringify({ name: 'app' }),
'src/base/lifecycle.ts':
'export interface IDisposable {\n dispose(): void;\n}\n' +
'export class DisposableStore implements IDisposable {\n add<T extends IDisposable>(o: T): T {\n return o;\n }\n dispose() {}\n}\n' +
'export abstract class Disposable implements IDisposable {\n' +
' protected readonly _store = new DisposableStore();\n' +
' protected _register<T extends IDisposable>(o: T): T {\n return this._store.add(o);\n }\n' +
' dispose() {}\n' +
'}\n',
'src/base/event.ts': 'export class Emitter<T> {\n fire(event?: T) {\n return event;\n }\n dispose() {}\n}\n',
'src/app/legacy/event.ts': 'export class Emitter {\n fire() {\n return 0;\n }\n dispose() {}\n}\n',
};
describe('a TypeScript class whose head constrains a type parameter', () => {
it('extends the class after its type parameters, for a wrapper it inherits', async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/pane.ts':
"import { Disposable } from '../base/lifecycle';\n" +
'export abstract class Pane<MementoType extends object = object> extends Disposable {\n protected memento?: MementoType;\n}\n',
'src/app/editor.ts':
"import { Pane } from './pane';\nimport { Emitter } from '../base/event';\n" +
'export class Editor extends Pane<{ zoom: number }> {\n' +
' private readonly _onDidChange = this._register(new Emitter<void>());\n' +
' run() {\n this._onDidChange.fire();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('.fire'))).toEqual(['src/app/editor.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire']);
}, 60_000);
it('extends the class after its type parameters, for a field it inherits, not the constraint', async () => {
const calls = await callsOf({
'package.json': JSON.stringify({ name: 'app' }),
'src/store.ts': 'export class Store {\n add(v: number) {\n return v;\n }\n}\n',
// The constraint's class declares a `store` of another type with an `add`.
'src/part.ts': 'export class Ledger {\n add(v: number) {\n return -v;\n }\n}\nexport class Part {\n protected readonly store = new Ledger();\n}\n',
'src/root.ts': "import { Store } from './store';\nexport class Root {\n protected readonly store = new Store();\n}\n",
'src/parts.ts':
"import { Part } from './part';\nimport { Root } from './root';\n" +
'export class Parts<T extends Part, MementoType extends object = object> extends Root {\n protected parts: T[] = [];\n}\n',
'src/main.ts':
"import { Parts } from './parts';\nimport { Part } from './part';\n" +
'export class MainParts extends Parts<Part> {\n run() {\n return this.store.add(1);\n }\n}\n',
});
expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/main.ts: this.store.add -> src/store.ts::Store::add']);
}, 60_000);
it('reads a head that runs over several lines, below a decorator, with an arrow type in a constraint', async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/decorate.ts': 'export function Decorate(options: object) {\n return (target: unknown) => target;\n}\n',
'src/app/view.ts':
"import { Disposable } from '../base/lifecycle';\nimport { Decorate } from './decorate';\n" +
"@Decorate({ host: { class: 'view' } })\n" +
'export class View<\n Render extends (value: string) => void,\n State extends object = object\n>\n extends Disposable {\n' +
' protected render?: Render;\n}\n',
'src/app/panel.ts':
"import { View } from './view';\nimport { Emitter } from '../base/event';\n" +
'export class Panel extends View<(value: string) => void> {\n' +
' private readonly _onDidShow = this._register(new Emitter<void>());\n' +
' show() {\n this._onDidShow.fire();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('.fire'))).toEqual(['src/app/panel.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire']);
}, 60_000);
});
+27 -2
View File
@@ -13467,12 +13467,37 @@ function readDeclaredSupertypes(cls: Node, context: ResolutionContext): string[]
case 'pascal': return names(/=\s*class\s*\(([^)]*)\)/i.exec(head)?.[1]);
case 'python': return names(/\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]?.replace(/\w+\s*=\s*[\w.]+/g, ''));
case 'ruby': return names(/\bclass\s+[\w:]+\s*<\s*([\w:]+)/.exec(head)?.[1]);
case 'php': case 'typescript': case 'tsx': case 'javascript': case 'jsx':
return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]);
case 'php': return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]);
case 'typescript': case 'tsx': case 'javascript': case 'jsx':
return names(/\bextends\s+([\w.\\]+)/.exec(tsClassHead(lines, cls))?.[1]);
default: return [];
}
}
/**
* A TS/JS class's head, from its `class` keyword to its body, with type
* parameter lists and type arguments left out: the first `extends` in
* `class EditorPane<M extends object = object> extends Composite<M>` is a
* constraint, not the class it extends. Decorators above the keyword are
* skipped, and the head may run over several lines.
*/
function tsClassHead(lines: readonly string[], cls: Node): string {
const text = lines.slice(cls.startLine - 1, cls.startLine + 7).join(' ');
const named = new RegExp(`\\bclass\\s+${cls.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).exec(text);
let head = '';
let angles = 0;
for (let i = named?.index ?? /\bclass\b/.exec(text)?.index ?? 0; i < text.length; i++) {
const c = text[i]!;
if (c === '<') angles++;
else if (c === '>' && angles > 0 && text[i - 1] !== '=') angles--;
else if (angles === 0) {
if (c === '{') break;
head += c;
}
}
return head;
}
/** A method named `name` on a supertype of the given classes, nearest first. */
function inheritedClassMethod(classes: Node[], name: string, context: ResolutionContext): Node | null {
if (classes.length === 0) return null;