mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 16:57:09 +08:00
fix(ts): a call on a known class goes through that class's own bases (#2656)
A TS/JS call on a field or a typed local whose class the file's import names went, when that class didn't declare the method itself, to any class with the method and a matching name: vscode's `this._storage.dispose` (`Storage extends Disposable`) reached a test fixture's `Disposable`, Copilot's `Emitter extends lsp.Emitter`, whose `fire` is the package's, reached a test fixture's `Emitter::fire`, and `this.logService.trace()` on `ILogService extends ILogger` linked nowhere. Now that class decides: its own method, or the nearest one up its head's `extends` / `implements`, each supertype found the way its subtype's file names it. For a field, a chain that is fully known and has no such method, or leaves the project for a package, links nothing. A typed local, whose type an unscoped scan reads, takes only a method found. A supertype that can't be found, or a same-named function-typed property, leaves the call to the lookups that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -171,6 +171,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- 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.
|
||||
- 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 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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* A TypeScript call on a field or a local whose class its file's import
|
||||
* names reaches that class's method, or the method of a class it extends as
|
||||
* that class's own file names it, and never a same-named class elsewhere.
|
||||
*
|
||||
* When the class didn't declare the method itself, the call went to any
|
||||
* class with the method and the right name: vscode's `this._storage.dispose`
|
||||
* (`Storage extends Disposable`) reached a test fixture's `Disposable`, and
|
||||
* Copilot's `Emitter extends lsp.Emitter`, whose `fire` is the package's,
|
||||
* reached a test fixture's `Emitter::fire`. An interface's call through the
|
||||
* interface it extends, like `this.logService.trace()` on `ILogService
|
||||
* extends ILogger`, linked nowhere.
|
||||
*/
|
||||
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-known-class-'));
|
||||
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 on a value of a class its file names', () => {
|
||||
it("reaches the method the class inherits from the base its own file imports, not the call's file's namesake", async () => {
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app' }),
|
||||
'src/base/lifecycle.ts': 'export class Disposable {\n dispose() {\n return 1;\n }\n}\n',
|
||||
'src/base/storage.ts':
|
||||
"import { Disposable } from './lifecycle';\nexport class Storage extends Disposable {\n get(key: string) {\n return key;\n }\n}\n",
|
||||
'src/app/main.ts':
|
||||
"import { Storage } from '../base/storage';\n" +
|
||||
'class Disposable {\n dispose() {\n return 2;\n }\n}\n' +
|
||||
'export class Main {\n private readonly _storage = new Storage();\n private readonly local = new Disposable();\n' +
|
||||
' close() {\n this._storage.dispose();\n }\n}\n' +
|
||||
'export function shut(storage: Storage) {\n return storage.dispose();\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('dispose'))).toEqual([
|
||||
'src/app/main.ts: storage.dispose -> src/base/lifecycle.ts::Disposable::dispose',
|
||||
'src/app/main.ts: this._storage.dispose -> src/base/lifecycle.ts::Disposable::dispose',
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it("links nothing when the method is the class's package base's, whatever project class shares the names", async () => {
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-lsp': '^1.0.0' } }),
|
||||
'src/util/event.ts':
|
||||
"import * as lsp from 'ext-lsp';\nexport class Emitter<T> extends lsp.Emitter<T> {\n get size() {\n return 0;\n }\n}\n",
|
||||
'src/fixtures/testbed/events.ts': 'export class Emitter {\n fire() {\n return 0;\n }\n}\n',
|
||||
'src/app/model.ts':
|
||||
"import { Emitter } from '../util/event';\n" +
|
||||
'export class Model {\n private readonly _onDidChange = new Emitter<void>();\n run() {\n this._onDidChange.fire();\n }\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('.fire'))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('links nothing when neither the class nor anything it extends declares the method', async () => {
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app' }),
|
||||
'src/one/widget.ts': 'export class Base {\n show() {}\n}\nexport class Widget extends Base {\n hide() {}\n}\n',
|
||||
'src/two/widget.ts': 'export class Widget {\n focus() {}\n}\n',
|
||||
'src/app/view.ts':
|
||||
"import { Widget } from '../one/widget';\n" +
|
||||
'export class View {\n private readonly widget = new Widget();\n run() {\n this.widget.show();\n this.widget.focus();\n }\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('this.widget.'))).toEqual(['src/app/view.ts: this.widget.show -> src/one/widget.ts::Base::show']);
|
||||
}, 60_000);
|
||||
|
||||
it('reaches a method an interface inherits from the interface it extends', async () => {
|
||||
// vscode's `ILogService` is also a constant: `createDecorator<ILogService>('logService')`.
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app' }),
|
||||
'src/platform/log.ts':
|
||||
'function createDecorator<T>(id: string): T {\n return id as unknown as T;\n}\n' +
|
||||
'export interface ILogger {\n trace(message: string): void;\n}\n' +
|
||||
"export const ILogService = createDecorator<ILogService>('logService');\n" +
|
||||
'export interface ILogService extends ILogger {\n flush(): void;\n}\n',
|
||||
'src/app/legacy/log.ts': 'export interface ILogger {\n trace(message: string): void;\n}\n',
|
||||
'src/app/service.ts':
|
||||
"import { ILogService } from '../platform/log';\n" +
|
||||
'export class Service {\n constructor(private readonly logService: ILogService) {}\n' +
|
||||
" run() {\n this.logService.trace('a');\n this.logService.flush();\n }\n}\n",
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('this.logService.'))).toEqual([
|
||||
'src/app/service.ts: this.logService.flush -> src/platform/log.ts::ILogService::flush',
|
||||
'src/app/service.ts: this.logService.trace -> src/platform/log.ts::ILogger::trace',
|
||||
]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -13086,6 +13086,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
TS_FIELD_DECL_MEMO.delete(context);
|
||||
TS_CLASS_LINES.delete(context);
|
||||
TS_PASS_THROUGH_WRAPPERS.delete(context);
|
||||
TS_NAMED_TYPES.delete(context);
|
||||
TARGET_LANGUAGE.delete(context);
|
||||
}
|
||||
|
||||
@@ -13475,15 +13476,15 @@ function readDeclaredSupertypes(cls: Node, context: ResolutionContext): string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A TS/JS class's (or interface'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);
|
||||
const named = new RegExp(`\\b(?:class|interface)\\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++) {
|
||||
@@ -14355,6 +14356,15 @@ export function matchMethodCall(
|
||||
if (outside === null) return null;
|
||||
importedFqn = outside;
|
||||
}
|
||||
// The class the file names, through its own supertypes
|
||||
// (tsKnownTypeCall), before any same-named class elsewhere. Only a
|
||||
// method found counts: a local's type is read by an unscoped scan
|
||||
// that can find another function's `agent: IAgent`, so one without
|
||||
// the method mustn't rule out what follows, as a field's does.
|
||||
if (ESM_FAMILY.has(ref.language) && !awaited) {
|
||||
const known = tsKnownTypeCall(inferredType, importedFqn ?? ref.filePath, methodName!, ref, ref, context, 0.9);
|
||||
if (known) return known;
|
||||
}
|
||||
const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
|
||||
inferredType,
|
||||
methodName!,
|
||||
@@ -15952,6 +15962,137 @@ function tsClassMethods(cls: Node, name: string, at: UnresolvedRef, context: Res
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The kinds of node a TS/JS type name can name for a member call: classes and interfaces. */
|
||||
const TS_MEMBER_OWNER_KINDS = new Set<string>(['class', 'interface']);
|
||||
|
||||
/** The class or interface named `name` that `file` declares. */
|
||||
function tsTypeDeclaredIn(file: string, name: string, context: ResolutionContext): Node | undefined {
|
||||
return context.getNodesInFile(file).find((n) => n.name === name && TS_MEMBER_OWNER_KINDS.has(n.kind));
|
||||
}
|
||||
|
||||
/**
|
||||
* The supertypes a TS/JS class or interface head names, as written
|
||||
* (`Disposable`, `lsp.Emitter`): what it extends (an interface may extend
|
||||
* several), then what it implements. Null when the head extends something
|
||||
* that isn't a name, like a mixin call.
|
||||
*/
|
||||
function tsHeadSupertypes(cls: Node, context: ResolutionContext): string[] | null {
|
||||
const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split(/\r?\n/) ?? [];
|
||||
const head = tsClassHead(lines, cls);
|
||||
const out: string[] = [];
|
||||
for (const keyword of ['extends', 'implements']) {
|
||||
const clause = new RegExp(`\\b${keyword}\\b([\\s\\S]*?)(?:\\b(?:extends|implements)\\b|$)`).exec(head)?.[1];
|
||||
if (clause === undefined) continue;
|
||||
for (const part of clause.split(',')) {
|
||||
const name = part.trim();
|
||||
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(name)) {
|
||||
if (keyword === 'extends') return null;
|
||||
continue;
|
||||
}
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Per-context memo: `<file>\0<name as written>` → tsNamedType's answer. */
|
||||
const TS_NAMED_TYPES = new WeakMap<ResolutionContext, Map<string, Node | 'outside' | null>>();
|
||||
|
||||
/**
|
||||
* The class or interface `written` names in `from`'s file: the one an import
|
||||
* leads to (aliases, re-exports, a namespace import's member), or one the
|
||||
* file declares. 'outside' when the file imports it from a package outside
|
||||
* the project that no project file declares; null when it can't be found.
|
||||
*/
|
||||
function tsNamedType(written: string, from: Node, at: UnresolvedRef, context: ResolutionContext): Node | 'outside' | null {
|
||||
let memo = TS_NAMED_TYPES.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
TS_NAMED_TYPES.set(context, memo);
|
||||
}
|
||||
const key = from.filePath + '\0' + written;
|
||||
const hit = memo.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const last = written.split('.').pop()!;
|
||||
const ref: UnresolvedRef = { ...at, filePath: from.filePath, language: from.language };
|
||||
const mapping = context.getImportMappings?.(from.filePath, from.language)?.find((i) => i.localName === written.split('.')[0]);
|
||||
let found: Node | 'outside' | null = null;
|
||||
if (mapping && context.isOutOfRepoImport?.(mapping.source, from.filePath, from.language) === true) {
|
||||
const ambient = tsOutsideTypeDeclaration(written, last, ref, context);
|
||||
found = ambient === null ? 'outside' : ambient === undefined ? null : tsTypeDeclaredIn(ambient, last, context) ?? null;
|
||||
} else if (mapping) {
|
||||
const imported = context.resolveImport?.({ ...ref, referenceName: written, referenceKind: 'references' });
|
||||
const node = imported ? context.getNodeById?.(imported.targetNodeId) : undefined;
|
||||
found = node && TS_MEMBER_OWNER_KINDS.has(node.kind) ? node : null;
|
||||
} else if (!written.includes('.')) {
|
||||
found = context.getNodesInFile(from.filePath).find((n) => n.id !== from.id && n.name === written && TS_MEMBER_OWNER_KINDS.has(n.kind)) ?? null;
|
||||
}
|
||||
memo.set(key, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The method `name` of the TS/JS class or interface `cls`: its own, or else
|
||||
* the nearest supertype's up its head's `extends` (then `implements`), each
|
||||
* found the way its subtype's file names it (tsNamedType). `complete` when
|
||||
* every supertype was found in the project or is a package's class, so a
|
||||
* missing method has no project declaration; a supertype that can't be found,
|
||||
* or a property of that name (a function-typed field), leaves the answer
|
||||
* incomplete.
|
||||
*/
|
||||
function tsMethodUpChain(cls: Node, name: string, at: UnresolvedRef, context: ResolutionContext, depth = 0): { method: Node | null; complete: boolean } {
|
||||
const members = context.getNodesInFile(cls.filePath).filter((n) =>
|
||||
n.name === name && n.startLine >= cls.startLine && n.endLine <= cls.endLine &&
|
||||
n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./).pop() === cls.name);
|
||||
const own = members.find((n) => n.kind === 'method');
|
||||
if (own) return { method: own, complete: true };
|
||||
// A property of that name is what the call calls — bitwarden's `abstract
|
||||
// getAuthStatus: (userId?: string) => Promise<…>` — but no method to link,
|
||||
// so the answer is left to the lookups that follow.
|
||||
if (members.some((n) => n.kind === 'property' || n.kind === 'field')) return { method: null, complete: false };
|
||||
if (depth >= TS_BASE_WALK_DEPTH) return { method: null, complete: false };
|
||||
const supers = tsHeadSupertypes(cls, context);
|
||||
if (supers === null) return { method: null, complete: false };
|
||||
let complete = true;
|
||||
for (const written of supers) {
|
||||
const base = tsNamedType(written, cls, at, context);
|
||||
if (base === 'outside') continue;
|
||||
if (!base || base.id === cls.id) {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
const up = tsMethodUpChain(base, name, at, context, depth + 1);
|
||||
if (up.method) return up;
|
||||
if (!up.complete) complete = false;
|
||||
}
|
||||
return { method: null, complete };
|
||||
}
|
||||
|
||||
/**
|
||||
* A TS/JS member call on a value of the class or interface `typeName` that
|
||||
* `file` declares: that type's method or its nearest supertype's
|
||||
* (tsMethodUpChain); null when the whole chain is known and has none, as
|
||||
* when the method is a package's: `Emitter extends lsp.Emitter` has no
|
||||
* project `fire`, whatever other `Emitter` declares one. Undefined when
|
||||
* `file` doesn't declare the type or a supertype can't be found, for the
|
||||
* caller's own fallback.
|
||||
*/
|
||||
function tsKnownTypeCall(
|
||||
typeName: string,
|
||||
file: string,
|
||||
methodName: string,
|
||||
ref: UnresolvedRef,
|
||||
at: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
confidence: number,
|
||||
): ResolvedRef | null | undefined {
|
||||
const known = tsTypeDeclaredIn(file, typeName, context);
|
||||
if (!known) return undefined;
|
||||
const up = nmTimedT('mc-tsknown', ref, () => tsMethodUpChain(known, methodName, at, context));
|
||||
if (up.method) return { original: ref, targetNodeId: up.method.id, confidence, resolvedBy: 'instance-method' };
|
||||
return up.complete ? null : undefined;
|
||||
}
|
||||
|
||||
/** `(o: T): T` as the TS extractor writes a signature: one parameter, of the type returned. */
|
||||
const TS_PASS_THROUGH_SIGNATURE = /^\(\s*[\w$]+\s*:\s*([A-Za-z_$][\w$]*)\s*\)\s*:\s*([A-Za-z_$][\w$]*)$/;
|
||||
|
||||
@@ -16243,6 +16384,12 @@ function tsFieldCall(
|
||||
if (outside === null) return null;
|
||||
imported = outside ?? (at === ref ? undefined : at.filePath);
|
||||
}
|
||||
// That class decides, through its own supertypes: a same-named class
|
||||
// elsewhere answered when it didn't declare the method itself, as vscode's
|
||||
// `Storage`, which inherits `dispose` from `Disposable`, reached a test
|
||||
// fixture's `Disposable`.
|
||||
const known = tsKnownTypeCall(typeName, imported ?? at.filePath, methodName, ref, at, context, 0.85);
|
||||
if (known !== undefined) return known;
|
||||
const declared = context
|
||||
.getNodesByName(methodName)
|
||||
.filter(
|
||||
|
||||
Reference in New Issue
Block a user