diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9637922..4f29d6c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,6 +169,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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())` or `= this._store.add(new Emitter())`, 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(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 extends Composite`, 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. +- In TypeScript, an abstract method like `abstract send(path: string): Promise;` 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 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-abstract-methods.test.ts b/__tests__/ts-abstract-methods.test.ts new file mode 100644 index 000000000..ac7aaddb7 --- /dev/null +++ b/__tests__/ts-abstract-methods.test.ts @@ -0,0 +1,84 @@ +/** + * A TypeScript abstract method (`abstract send(path: string): Promise;`) + * is indexed as a method of its class, as an interface's method signature is. + * + * Neither extractor read `abstract_method_signature`, so an abstract class + * used as a dependency-injection token, like bitwarden's `abstract class + * ApiService` in `libs/common/src/abstractions`, had none of its methods in + * the graph. A call through a field of that type then linked to whichever + * class of the same name declared the method, such as the browser app's + * `I18nService` for a library's `this.i18nService.t()`. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const FILES: Record = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/abstractions/api.service.ts': + 'export abstract class ApiService {\n' + + ' abstract send(method: string, path: string): Promise;\n' + + ' protected abstract nativeFetch(request: Request): Promise;\n' + + ' ping() {\n return 1;\n }\n' + + '}\n', + 'src/services/api.service.ts': + "import { ApiService as ApiServiceAbstraction } from '../abstractions/api.service';\n" + + 'export class ApiService implements ApiServiceAbstraction {\n' + + ' send(method: string, path: string) {\n return Promise.resolve(method + path);\n }\n' + + ' nativeFetch(request: Request) {\n return fetch(request);\n }\n' + + ' ping() {\n return 2;\n }\n' + + '}\n', + 'src/app/sync.ts': + "import { ApiService } from '../abstractions/api.service';\n" + + 'export class SyncService {\n' + + ' constructor(private readonly apiService: ApiService) {}\n' + + " run() {\n return this.apiService.send('GET', '/sync');\n }\n" + + '}\n', +}; + +describe('a TypeScript abstract method', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-abstract-methods-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + kernel = process.env.CODEGRAPH_KERNEL; + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it.each(['default', 'wasm'])('is a method of its class, and what a call through the abstract type links to (%s)', async (backend) => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const declared = graph.getNodesInFile('src/abstractions/api.service.ts') + .filter((n) => n.kind === 'method' || n.kind === 'function') + .map((n) => `${n.kind} ${n.qualifiedName}`) + .sort(); + expect(declared).toEqual(['method ApiService::nativeFetch', 'method ApiService::ping', 'method ApiService::send']); + + const run = graph.getNodesInFile('src/app/sync.ts').find((n) => n.qualifiedName === 'SyncService::run')!; + const calls = graph.getOutgoingEdgesFrom([run.id], ['calls']).map((e) => { + const target = graph.getNode(e.target)!; + return `${target.filePath}::${target.qualifiedName}`; + }); + expect(calls).toEqual(['src/abstractions/api.service.ts::ApiService::send']); + }, 60_000); +}); diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index a19fb1fa9..b00db3e43 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -51,7 +51,7 @@ impl Variant { /// typescriptExtractor.methodTypes / javascriptExtractor.methodTypes. fn is_method_type(v: Variant, kind: &str) -> bool { kind == "method_definition" - || (v.is_ts() && matches!(kind, "public_field_definition" | "method_signature")) + || (v.is_ts() && matches!(kind, "public_field_definition" | "method_signature" | "abstract_method_signature")) || (!v.is_ts() && kind == "field_definition") } @@ -74,7 +74,7 @@ fn is_property_type(v: Variant, kind: &str) -> bool { /// `function stop` beside the real `Handle::stop`. Mirrors the TS extractor's /// SIGNATURE_METHOD_NODE_TYPES (extraction/tree-sitter.ts). fn is_signature_method_type(kind: &str) -> bool { - kind == "method_signature" + matches!(kind, "method_signature" | "abstract_method_signature") } fn is_function_type(kind: &str) -> bool { diff --git a/src/extraction/languages/typescript.ts b/src/extraction/languages/typescript.ts index 15ef82398..7f280c5f4 100644 --- a/src/extraction/languages/typescript.ts +++ b/src/extraction/languages/typescript.ts @@ -102,7 +102,7 @@ export const typescriptExtractor: LanguageExtractor = { // an interface's members never enter the graph, so a `.d.ts` platform API has // no declaration node for call sites to attach to (#1638). Java/C# don't need // an equivalent — their grammars reuse `method_declaration`. - methodTypes: ['method_definition', 'public_field_definition', 'method_signature'], + methodTypes: ['method_definition', 'public_field_definition', 'method_signature', 'abstract_method_signature'], classifyMethodNode: classifyTsClassMember, // The interface counterpart of `public_field_definition`. It carries no value, // so it is always a property and never needs classifyMethodNode. diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 42eb7b7cc..a9da052d4 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -67,9 +67,10 @@ const REACT_COMPONENT_HOCS = new Set(['forwardRef', 'memo', 'React.forwardRef', * inside a type literal (`type Handle = { stop(): void }`), whose members * `extractTypeAlias` already extracts and attaches to the alias (#359) — take * the fallback and the file gains a phantom top-level `function stop` beside - * the real `Handle::stop`. + * the real `Handle::stop`. An abstract class's `abstract send(): void` is + * one too. */ -const SIGNATURE_METHOD_NODE_TYPES = new Set(['method_signature']); +const SIGNATURE_METHOD_NODE_TYPES = new Set(['method_signature', 'abstract_method_signature']); /** Vue store collections whose object-literal members are the symbols an agent * looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are