Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript and JavaScript, a call through a field the calling class inherits, like `this._store.add(…)` in a class whose base class declares `_store`, now links to the method of the field's class, with the base class found the way the subclass's file imports it, under its own name, an alias or a re-export. Before, such a call linked only when some other class with the calling class's name happened to declare the field, which could be another app's class: that last resort now needs a type the calling file imports or declares. A call on a field or variable whose type comes from a package outside your project, like `TelemetryReporter` from `@vscode/extension-telemetry`, no longer links to a project class that only shares the type's name, unless a file in your project declares that package (`declare module '…'`). Re-index TypeScript and JavaScript projects after upgrading.
- 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.

## [1.6.2] - 2026-10-03

Expand Down
23 changes: 20 additions & 3 deletions __tests__/ts-this-field-bases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ describe('a this.<field> call on a field a class it extends declares', () => {
expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/sessions/part.ts: this.store.add -> src/two/store.ts::Store::add']);
}, 60_000);

it('reads it off a base many classes up, as vscode editors inherit `_store` from `Disposable`', async () => {
const files: Record<string, string> = {
...STORES,
'src/base.ts': "import { Store } from './two/store';\nexport class Base {\n protected readonly store = new Store();\n}\n",
};
let parent = ['Base', './base'];
for (const name of ['L1', 'L2', 'L3', 'L4', 'L5']) {
files[`src/${name.toLowerCase()}.ts`] = `import { ${parent[0]} } from '${parent[1]}';\nexport class ${name} extends ${parent[0]} {}\n`;
parent = [name, `./${name.toLowerCase()}`];
}
const calls = await callsOf({
...files,
'src/child.ts': "import { L5 } from './l5';\nexport class Child extends L5 {\n run() {\n return this.store.add(1);\n }\n}\n",
});
expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/two/store.ts::Store::add']);
}, 60_000);

it("doesn't read the field off an unrelated class of the same name whose type the call's file doesn't name", async () => {
const calls = await callsOf({
...STORES,
Expand All @@ -109,9 +126,9 @@ describe('a this.<field> call on a field a class it extends declares', () => {
}, 60_000);

it('still reads it off a same-named class elsewhere whose type the call\'s file imports', async () => {
// vscode's copilot `MockPromptsService` declares its emitter as
// `this._register(new Emitter<void>())`, which isn't read; the
// workbench's `MockPromptsService` declares the same field.
// The calling class's own declaration goes through a wrapper the reader
// can't vouch for (`wrap` declares no return type); the same-named
// class elsewhere declares the field.
const calls = await callsOf({
...STORES,
'src/app/mock.ts':
Expand Down
207 changes: 207 additions & 0 deletions __tests__/ts-wrapped-field-initializers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* A TypeScript `this.<field>` call on a field initialized through a wrapper
* that hands back its argument — vscode's `this._register(new Emitter())`
* and `this._store.add(new Emitter())` — links through the wrapped class.
*
* The field reader knew `field: T`, `field: typeof X` and `field = new X()`,
* so vscode's most common declaration, `private readonly _onDidChange =
* this._register(new Emitter<void>())`, gave the field no type and its
* `fire()` calls linked nowhere. A wrapper counts only when its own
* declaration returns its argument (`_register<T extends IDisposable>(o: T):
* T`) and its call ends the statement: `this.box(value)` may return a box of
* it, and `this._register(new DomEmitter(…)).event` a member of it.
*/
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-wrapped-fields-'));
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 (`src/app/legacy`) that a guess by method name or
* folder would pick.
*/
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' +
' private readonly items: IDisposable[] = [];\n' +
' add<T extends IDisposable>(o: T): T {\n this.items.push(o);\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',
};

const IMPORTS = "import { Disposable, DisposableStore } from '../base/lifecycle';\nimport { Emitter } from '../base/event';\n";

describe('a this.<field> call on a field a wrapper initializes', () => {
it('reads `this._register(new X())` as an X when the inherited `_register` returns its argument', async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/model.ts':
IMPORTS +
'export class Model extends Disposable {\n' +
' private readonly _onDidChange = this._register(new Emitter<string>());\n' +
' private readonly _onDidMove = this._register(new Emitter<number>(\n' +
' ));\n' +
' private readonly _onDidOpen;\n' +
' constructor() {\n super();\n this._onDidOpen = this._register(new Emitter<void>());\n }\n' +
" change() {\n this._onDidChange.fire('a');\n this._onDidMove.fire(1);\n this._onDidOpen.fire();\n }\n" +
'}\n',
});
expect(calls.filter((c) => c.includes('.fire'))).toEqual([
'src/app/model.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidMove.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidOpen.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);

it('reads `this.<store>.add(new X())` through the class the store field is declared as', async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/view.ts':
IMPORTS +
'export class View extends Disposable {\n' +
' private readonly _disposables = new DisposableStore();\n' +
' private readonly _onDidShow = this._store.add(new Emitter<void>());\n' +
' private readonly _onDidHide = this._disposables.add(new Emitter<void>());\n' +
' show() {\n this._onDidShow.fire();\n }\n' +
' hide() {\n this._onDidHide.fire();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('.fire'))).toEqual([
'src/app/view.ts: this._onDidHide.fire -> src/base/event.ts::Emitter::fire',
'src/app/view.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);

it('finds the wrapper and the store as many classes up as vscode editors inherit them from `Disposable`', async () => {
// `… extends EditorPane extends Composite extends Component extends Themable extends Disposable`
const chain = ['Themable', 'Component', 'Composite', 'Pane', 'EditorPane'];
const files: Record<string, string> = { ...LIFECYCLE };
chain.forEach((name, i) => {
const base = i === 0 ? ['Disposable', '../base/lifecycle'] : [chain[i - 1]!, `./${chain[i - 1]!.toLowerCase()}`];
files[`src/app/${name.toLowerCase()}.ts`] = `import { ${base[0]} } from '${base[1]}';\nexport class ${name} extends ${base[0]} {}\n`;
});
const calls = await callsOf({
...files,
'src/app/editor.ts':
"import { EditorPane } from './editorpane';\nimport { Emitter } from '../base/event';\n" +
'export class Editor extends EditorPane {\n' +
' private readonly _onDidChange = this._register(new Emitter<void>());\n' +
' private readonly _onDidShow = this._store.add(new Emitter<void>());\n' +
' run() {\n this._onDidChange.fire();\n this._onDidShow.fire();\n }\n' +
'}\n',
});
// `_onDidShow` needs `_store` found on `Disposable` too.
expect(calls.filter((c) => c.includes('.fire'))).toEqual([
'src/app/editor.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire',
'src/app/editor.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);

it('reads them in files with CRLF line endings, as a Windows checkout has', async () => {
const files: Record<string, string> = {
...LIFECYCLE,
'src/app/model.ts':
IMPORTS +
'class DomEmitter {\n readonly event = (listener: () => void) => listener;\n dispose() {}\n}\n' +
'export class Model extends Disposable {\n' +
' private readonly _onDidChange = this._register(new Emitter<string>());\n' +
' private readonly _onDidShow = this._store.add(new Emitter<void>());\n' +
' private readonly _onDidHide = this._register(new Emitter<void>())\n' +
' private readonly onKeyDown = this._register(new DomEmitter()).event;\n' +
" change() {\n this._onDidChange.fire('a');\n this._onDidShow.fire();\n this._onDidHide.fire();\n this.onKeyDown.dispose();\n }\n" +
'}\n',
};
const calls = await callsOf(Object.fromEntries(Object.entries(files).map(([file, text]) => [file, text.replace(/\n/g, '\r\n')])));
expect(calls.filter((c) => c.includes('.fire') || c.includes('.dispose'))).toEqual([
'src/app/model.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidHide.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);

it("doesn't read a wrapper whose declaration doesn't return its argument", async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/box.ts':
IMPORTS +
'class Box<T> {\n constructor(readonly value: T) {}\n}\n' +
'class Widget {\n fire() {}\n}\n' +
'class FancyWidget extends Widget {\n fire() {}\n}\n' +
'export class Holder extends Disposable {\n' +
' private readonly _boxed = this.box(new Emitter<string>());\n' +
' private readonly _kept = this.keep(new Emitter<string>());\n' +
' private readonly _widget = this.replace(new FancyWidget());\n' +
' private readonly items = new Set<Emitter<string>>();\n' +
' private readonly _set = this.items.add(new Emitter<string>());\n' +
' private box<T>(value: T): Box<T> {\n return new Box(value);\n }\n' +
' private keep<T>(value: T) {\n return value;\n }\n' +
// Typed with a class, not a type parameter of its own: another Widget may come back.
' private replace(widget: Widget): Widget {\n return widget instanceof FancyWidget ? new Widget() : widget;\n }\n' +
" run() {\n this._boxed.fire('a');\n this._kept.fire('b');\n this._widget.fire();\n this._set.fire('c');\n }\n" +
'}\n',
'src/app/plain.ts':
"import { Emitter } from '../base/event';\n" +
'export class Plain {\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([]);
}, 60_000);

it("doesn't read the wrapped class when a member of what the wrapper returns is taken", async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/input.ts':
IMPORTS +
'class DomEmitter {\n readonly event = (listener: () => void) => listener;\n dispose() {}\n}\n' +
'export class Input extends Disposable {\n' +
' private readonly onKeyDown = this._register(new DomEmitter()).event;\n' +
' private readonly onKeyUp = this._register(new DomEmitter())\n .event;\n' +
' run() {\n this.onKeyDown.dispose();\n this.onKeyUp.dispose();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('.dispose'))).toEqual([]);
}, 60_000);
});
Loading