Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript, an abstract method like `abstract send(path: string): Promise<unknown>;` is now indexed as a method of its class. Before, it was missing, so a call through a value typed as an abstract class, as Angular apps register many of their services, linked to whichever class elsewhere had a method of that name, often another app's, and the types written in an abstract method's parameters and return type were read as properties of the class. Such a call now links to the abstract method, which also leads to the classes that implement it. Re-index TypeScript projects after upgrading.
- In TypeScript and JavaScript, a call on a field or variable whose class the file imports now goes through that class and the classes and interfaces it extends, each found the way its own file names it, before any other class of the same name. Before, when the class didn't declare the method itself, the call went to whichever same-named class had it: in vscode, `this._storage.dispose()` reached a test fixture's `Disposable`, and calls on a library's services in bitwarden reached the browser app's copies. A call like `this.logService.trace()`, whose `ILogService` gets `trace` from the `ILogger` it extends, linked nowhere and now links to `ILogger`'s method. A field's call whose class and everything it extends are known but lack the method, as when a package's base class provides it, now links to nothing rather than to an unrelated class. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript, a call on a value of an interface that extends one also declared as a constant, like vscode's `ITerminalLogService extends ILogService` where `ILogService` is its dependency-injection decorator too, now links to the method the extended interface declares, such as `ILogger`'s `trace`. Before, the constant was taken for the extended type, so the call linked nowhere. Re-index TypeScript projects after upgrading.
- In TypeScript, a field or variable set from a dependency-injection call now has the class that call is given, so calls through it link to that class's methods: Angular's `private readonly dataService = inject(DataService)` or `inject<DialogParams>(DIALOG_DATA)`, and vscode's `this.instantiationService.createInstance(Widget, …)`, also inside a wrapper like `this._register(…)`. Before, such a value had no type, so calls through it, among the most common in Angular apps, linked nowhere or to a same-named method of another class. Only Angular's `inject` counts, and only a `createInstance` the project declares to return an instance of the class it is given. A call on a value whose type the file imports under another name, like `import { OrganizationApiServiceAbstraction as OrganizationApiService }`, now also reaches the imported class rather than a class with the local name. Re-index TypeScript projects after upgrading.
- In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading.

## [1.6.2] - 2026-10-03
Expand Down
172 changes: 172 additions & 0 deletions __tests__/ts-injected-receivers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* A TypeScript field or local set from a dependency-injection call that
* hands back an instance of the class it is given has that class's type:
* Angular's `private readonly dataService = inject(DataService)`, and
* vscode's `this._register(this.instantiationService.createInstance(Widget,
* …))` or `const widget = this.instantiationService.createInstance(Widget)`.
*
* Neither was read, so calls through them linked nowhere, or by name alone:
* on bitwarden and ghostfolio thousands of `this.<service>.<method>()` calls
* sat on `inject(X)` fields. Only Angular's `inject` counts (Vue's
* `inject(key)` returns whatever was provided under the key), and only a
* `createInstance` the project declares to return an `InstanceType` of its
* argument.
*/
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-injected-'));
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();
}

/** The service the app injects, and a decoy of the same name nearer the component. */
const SERVICES = {
'src/app/data.service.ts': 'export class DataService {\n fetchInfo() {\n return 1;\n }\n}\n',
'src/app/components/legacy/data.service.ts': 'export class DataService {\n fetchInfo() {\n return 2;\n }\n}\n',
};

/** vscode's lifecycle and instantiation service, and widgets: the imported one and a nearer decoy. */
const WORKBENCH = {
'package.json': JSON.stringify({ name: 'app' }),
'src/base/lifecycle.ts':
'export interface IDisposable {\n dispose(): void;\n}\n' +
'export abstract class Disposable implements IDisposable {\n' +
' protected _register<T extends IDisposable>(o: T): T {\n return o;\n }\n' +
' dispose() {}\n' +
'}\n',
'src/platform/instantiation.ts':
'export interface IInstantiationService {\n' +
' createInstance<Ctor extends new (...args: any[]) => unknown, R extends InstanceType<Ctor>>(ctor: Ctor, ...args: unknown[]): R;\n' +
'}\n',
'src/browser/widget.ts': 'export class Widget {\n layout() {}\n dispose() {}\n}\n',
'src/browser/parts/legacy/widget.ts': 'export class Widget {\n layout() {}\n dispose() {}\n}\n',
};

describe('a TypeScript receiver a dependency-injection call sets up', () => {
it("has the class Angular's inject(X) is given", async () => {
const calls = await callsOf({
'package.json': JSON.stringify({ name: 'app', dependencies: { '@angular/core': '^18.0.0' } }),
...SERVICES,
'src/app/components/app.component.ts':
"import { inject } from '@angular/core';\nimport { DataService } from '../data.service';\n" +
'export class AppComponent {\n' +
' private readonly dataService = inject(DataService);\n' +
' private readonly maybe = inject(DataService, { optional: true });\n' +
' ngOnInit() {\n this.dataService.fetchInfo();\n this.maybe.fetchInfo();\n }\n' +
'}\n' +
'export function load() {\n const service = inject(DataService);\n return service.fetchInfo();\n}\n',
});
expect(calls.filter((c) => c.includes('fetchInfo'))).toEqual([
'src/app/components/app.component.ts: service.fetchInfo -> src/app/data.service.ts::DataService::fetchInfo',
'src/app/components/app.component.ts: this.dataService.fetchInfo -> src/app/data.service.ts::DataService::fetchInfo',
'src/app/components/app.component.ts: this.maybe.fetchInfo -> src/app/data.service.ts::DataService::fetchInfo',
]);
}, 60_000);

it("has the type inject's type argument names, for a token that isn't a class", async () => {
// bitwarden's `protected params = inject<AdvancedUriOptionDialogParams>(DIALOG_DATA)`.
const calls = await callsOf({
'package.json': JSON.stringify({ name: 'app', dependencies: { '@angular/core': '^18.0.0', '@angular/cdk': '^18.0.0' } }),
'src/app/params.ts': 'export interface DialogParams {\n onContinue(): void;\n}\n',
'src/app/legacy/params.ts': 'export interface DialogParams {\n onContinue(): void;\n}\n',
'src/app/dialog.component.ts':
"import { inject } from '@angular/core';\nimport { DIALOG_DATA } from '@angular/cdk/dialog';\nimport { DialogParams } from './params';\n" +
'export class DialogComponent {\n' +
' protected params = inject<DialogParams>(DIALOG_DATA);\n' +
' ok() {\n this.params.onContinue();\n const local = inject<DialogParams>(DIALOG_DATA);\n local.onContinue();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('onContinue'))).toEqual([
'src/app/dialog.component.ts: local.onContinue -> src/app/params.ts::DialogParams::onContinue',
'src/app/dialog.component.ts: this.params.onContinue -> src/app/params.ts::DialogParams::onContinue',
]);
}, 60_000);

it("doesn't read a field set from an inject that isn't Angular's", async () => {
const calls = await callsOf({
'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3.0.0' } }),
...SERVICES,
'src/app/components/app.store.ts':
"import { inject } from 'vue';\nimport { DataService } from '../data.service';\n" +
'export class AppStore {\n' +
' private readonly dataService = inject(DataService);\n' +
' load() {\n return this.dataService.fetchInfo();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('this.dataService.fetchInfo'))).toEqual([]);
}, 60_000);

it('has the class an instantiation service that returns its InstanceType is given', async () => {
const calls = await callsOf({
...WORKBENCH,
'src/browser/parts/view.ts':
"import { Disposable } from '../../base/lifecycle';\n" +
"import { IInstantiationService } from '../../platform/instantiation';\n" +
"import { Widget } from '../widget';\n" +
'export class View extends Disposable {\n' +
' private readonly widget = this._register(this.instantiationService.createInstance(Widget));\n' +
' private readonly plain = this.instantiationService.createInstance(Widget);\n' +
' constructor(private readonly instantiationService: IInstantiationService) {\n super();\n }\n' +
' run() {\n this.widget.layout();\n this.plain.layout();\n const local = this.instantiationService.createInstance(Widget);\n local.layout();\n }\n' +
'}\n' +
// A member of what it hands back is that member's type, not the class's.
'class Accessor {\n widget!: Widget;\n}\n' +
'export function check(instantiationService: IInstantiationService) {\n' +
' let widget: Widget;\n' +
' widget = instantiationService.createInstance(Accessor).widget;\n' +
' return widget.dispose();\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('layout') || c.includes('widget.dispose'))).toEqual([
'src/browser/parts/view.ts: local.layout -> src/browser/widget.ts::Widget::layout',
'src/browser/parts/view.ts: this.plain.layout -> src/browser/widget.ts::Widget::layout',
'src/browser/parts/view.ts: this.widget.layout -> src/browser/widget.ts::Widget::layout',
'src/browser/parts/view.ts: widget.dispose -> src/browser/widget.ts::Widget::dispose',
]);
}, 60_000);

it("doesn't read a field from a createInstance the project doesn't declare to return its argument's instance", async () => {
const calls = await callsOf({
...WORKBENCH,
'src/platform/instantiation.ts': 'export interface IFactory {\n createInstance(name: unknown): unknown;\n}\n',
'src/browser/parts/view.ts':
"import { IFactory } from '../../platform/instantiation';\n" +
"import { Widget } from '../widget';\n" +
'export class View {\n' +
' private readonly plain = this.factory.createInstance(Widget);\n' +
' constructor(private readonly factory: IFactory) {}\n' +
' run() {\n this.plain.layout();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('this.plain.layout'))).toEqual([]);
}, 60_000);
});
17 changes: 17 additions & 0 deletions __tests__/ts-known-class-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ describe('a call on a value of a class its file names', () => {
]);
}, 60_000);

it('reaches the class an import renames on the way, not a namesake of the local name', async () => {
// bitwarden's `export { MessageSender as MessagingService } from "@bitwarden/messaging"`.
const calls = await callsOf({
'package.json': JSON.stringify({ name: 'app' }),
'src/messaging/sender.ts': 'export abstract class MessageSender {\n abstract send(command: string): void;\n}\n',
'src/common/messaging.service.ts': "export { MessageSender as MessagingService } from '../messaging/sender';\n",
'src/desktop/electron-messaging.service.ts': 'export class MessagingService {\n send(command: string) {\n return command;\n }\n}\n',
'src/desktop/settings.component.ts':
"import { MessagingService } from '../common/messaging.service';\n" +
'export class SettingsComponent {\n constructor(private readonly messagingService: MessagingService) {}\n' +
" save() {\n this.messagingService.send('saved');\n }\n}\n",
});
expect(calls.filter((c) => c.includes('this.messagingService.send'))).toEqual([
'src/desktop/settings.component.ts: this.messagingService.send -> src/messaging/sender.ts::MessageSender::send',
]);
}, 60_000);

it('reaches it through an interface another file extends, whose import also names a constant', async () => {
// vscode's `ITerminalLogService extends ILogService`, `ILogService` both
// an interface and its `createDecorator` constant.
Expand Down
Loading