From 0d33925aa3ac4d34ba9f425b24029332597f1941 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 19 Oct 2026 11:44:17 +0000 Subject: [PATCH] fix(ts): a method whose name has a $ resolves through its receiver's type (#2662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method-call pattern read the method name as \w+, which has no $, so a JS/TS call like bitwarden's this.organizationService.organizations$(id) or vscode's RPC this._proxy.$acceptModelChanged(…) matched no method-call shape and linked nothing, however well its receiver was typed. For the JS family the method part now admits $; the receiver part is unchanged, so RxJS receivers like this.destroy$.next() stay out of name guessing. A/B on main b4e10138: vscode +1514 (mostly RPC calls onto their shape interfaces), bitwarden +2116, qwik +68, none removed or retargeted; 12 other JS/TS repos byte-identical, Vue 2 options-API apps included. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ts-dollar-method-calls.test.ts | 127 +++++++++++++++++++++++ src/resolution/name-matcher.ts | 4 + 3 files changed, 132 insertions(+) create mode 100644 __tests__/ts-dollar-method-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1ba56ee..e50503f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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(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, a call to a method whose name contains a `$` now links to that method through its receiver's type, like any other method call: RxJS-style `this.organizationService.organizations$(userId)`, or vscode's `this._proxy.$acceptModelChanged(…)` on the interface its RPC proxy is declared with. Before, such a call linked nothing, however well its receiver was typed. Re-index JavaScript and 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 diff --git a/__tests__/ts-dollar-method-calls.test.ts b/__tests__/ts-dollar-method-calls.test.ts new file mode 100644 index 000000000..e0a58ab48 --- /dev/null +++ b/__tests__/ts-dollar-method-calls.test.ts @@ -0,0 +1,127 @@ +/** + * A TypeScript or JavaScript call to a method whose name carries a `$` goes + * through the receiver's type like any other method call: bitwarden's + * `this.organizationService.organizations$(userId)` on its abstract + * `OrganizationService`, and vscode's RPC + * `this._proxy.$acceptModelChanged(…)` on a `MainThread…Shape` interface. + * + * The method-call pattern took the method name as `\w+`, which has no `$`, + * so such a call matched no method-call shape and linked nothing. + */ +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 `: -> ::`. */ +async function callsOf(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-dollar-')); + 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(); +} + +describe('a call to a method whose name has a $', () => { + it("reaches the method of a field's or a parameter's class", async () => { + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app' }), + 'src/common/organization.service.ts': + 'export abstract class OrganizationService {\n abstract organizations$(userId: string): string[];\n}\n', + 'src/legacy/organization.service.ts': + 'export class OrganizationService {\n organizations$(userId: string) {\n return [userId];\n }\n}\n', + 'src/app/vault.component.ts': + "import { OrganizationService } from '../common/organization.service';\n" + + 'export class VaultComponent {\n constructor(private readonly organizationService: OrganizationService) {}\n' + + " load() {\n return this.organizationService.organizations$('u');\n }\n}\n" + + "export function load(service: OrganizationService) {\n return service.organizations$('u');\n}\n", + }); + expect(calls.filter((c) => c.includes('organizations$'))).toEqual([ + 'src/app/vault.component.ts: service.organizations$ -> src/common/organization.service.ts::OrganizationService::organizations$', + 'src/app/vault.component.ts: this.organizationService.organizations$ -> src/common/organization.service.ts::OrganizationService::organizations$', + ]); + }, 60_000); + + it("reaches an RPC proxy's method on the interface the field is declared with", async () => { + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app' }), + 'src/api/extHost.protocol.ts': 'export interface MainThreadDocumentsShape {\n $acceptModelChanged(uri: string): void;\n}\n', + 'src/api/extHostDocuments.ts': + "import { MainThreadDocumentsShape } from './extHost.protocol';\n" + + 'export class ExtHostDocuments {\n constructor(private readonly _proxy: MainThreadDocumentsShape) {}\n' + + " change() {\n this._proxy.$acceptModelChanged('a');\n }\n}\n", + }); + expect(calls.filter((c) => c.includes('$acceptModelChanged'))).toEqual([ + 'src/api/extHostDocuments.ts: this._proxy.$acceptModelChanged -> src/api/extHost.protocol.ts::MainThreadDocumentsShape::$acceptModelChanged', + ]); + }, 60_000); + + it('is linked by a sync that adds the class after the caller', async () => { + const caller = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/app/vault.component.ts': + "import { OrganizationService } from '../common/organization.service';\n" + + "export function load(service: OrganizationService) {\n return service.organizations$('u');\n}\n", + }; + const service = { + 'src/common/organization.service.ts': + 'export abstract class OrganizationService {\n abstract organizations$(userId: string): string[];\n}\n', + }; + const write = (root: string, files: Record) => { + 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 root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-dollar-sync-')); + roots.push(root); + write(root, caller); + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + const links = () => { + const nodes = graph.getFiles().flatMap((f) => graph.getNodesInFile(f.path)); + return graph.getOutgoingEdgesFrom(nodes.map((n) => n.id), ['calls']) + .map((e) => `${e.metadata?.refName} -> ${graph.getNode(e.target)?.qualifiedName}`); + }; + expect(links()).toEqual([]); + + write(root, service); + expect((await graph.sync()).filesAdded).toBe(1); + expect(links()).toEqual(['service.organizations$ -> OrganizationService::organizations$']); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 120_000); + + it("links nothing when the field's class and its bases don't declare it", async () => { + const calls = await callsOf({ + 'package.json': JSON.stringify({ name: 'app' }), + 'src/state/store.ts': 'export class Store {\n get(key: string) {\n return key;\n }\n}\n', + 'src/state/other.ts': 'export class Other {\n state$() {\n return 1;\n }\n}\n', + 'src/app/view.ts': + "import { Store } from '../state/store';\n" + + 'export class View {\n private readonly store = new Store();\n run() {\n return this.store.state$();\n }\n}\n', + }); + expect(calls.filter((c) => c.includes('state$'))).toEqual([]); + }, 60_000); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 3ed888ae4..6489cfda7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14156,7 +14156,11 @@ export function matchMethodCall( // operator form requires at least one non-word char after `operator`, and // every downstream strategy compares the method part by exact string // equality, so a stray match can't invent an edge. + // A JS/TS method name may carry `$`, which `\w` doesn't: an RxJS-style + // `this.organizationService.organizations$()`, vscode's RPC + // `this._proxy.$acceptModelChanged()`. const dotMatch = + (JS_FAMILY.has(ref.language) ? ref.referenceName.match(/^([\w.]+)\.([\w$]+)$/) : null) ?? ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/) ?? (ref.language === 'cpp' ? ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/)