mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 08:47:04 +08:00
fix(ts): a this.<field> call finds a field a base class declares (#2645)
A field the calling class doesn't declare was read off any same-named class elsewhere, never off the classes it extends. It is now looked up on the calling class's base classes, each the class its subclass's file imports or declares, and a same-named class elsewhere is read last, only for a type the call's file imports or declares. A type a file imports from a package outside the project now links to nothing in the project, unless a project file declares the package with `declare module`, Flow libdefs included: microsoft-authentication's `TelemetryReporter` from `@vscode/extension-telemetry` reached another extension's interface of that name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
|
||||
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
|
||||
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* A TypeScript/JavaScript `this.<field>` call on a field a base class
|
||||
* declares links through that base, the class its subclass's file names.
|
||||
*
|
||||
* A field the calling class doesn't declare was read off any same-named
|
||||
* class elsewhere, and never off the classes it extends: on vscode, the
|
||||
* browser `FileDialogService`'s `this.openerService` was typed by the
|
||||
* electron `FileDialogService`, and a field another app's same-named class
|
||||
* declares could type it however that app did. Now the calling class's
|
||||
* bases come first, and a same-named class elsewhere only when the call's
|
||||
* file imports or declares the type it gives the field.
|
||||
*
|
||||
* A type the declaring file imports from a package outside the project has
|
||||
* no project method, which typed locals follow too: microsoft-
|
||||
* authentication's `TelemetryReporter` from `@vscode/extension-telemetry`
|
||||
* reached another extension's interface of that name.
|
||||
*/
|
||||
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-field-bases-'));
|
||||
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();
|
||||
}
|
||||
|
||||
/** Two classes named `Store`, each with the same methods. */
|
||||
const STORES = {
|
||||
'package.json': JSON.stringify({ name: 'app' }),
|
||||
'src/one/store.ts': 'export class Store {\n add(v: number) { return v; }\n}\n',
|
||||
'src/two/store.ts': 'export class Store {\n add(v: number) { return -v; }\n}\n',
|
||||
};
|
||||
|
||||
describe('a this.<field> call on a field a class it extends declares', () => {
|
||||
it('reads the field off the base its file imports, and its type off the import there', async () => {
|
||||
const calls = await callsOf({
|
||||
...STORES,
|
||||
'src/base.ts': "import { Store } from './two/store';\nexport class Base {\n protected readonly store = new Store();\n}\n",
|
||||
'src/child.ts':
|
||||
"import { Base } from './base';\n" +
|
||||
'export class Child extends Base {\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("reads the field's type off a class the base's own file declares", async () => {
|
||||
const calls = await callsOf({
|
||||
...STORES,
|
||||
// A folder that sorts after `one` and `two`, so folder nearness, which
|
||||
// ties, would pick another `Store`.
|
||||
'src/zeta/base.ts':
|
||||
'export class Store {\n add(v: number) { return v * 10; }\n}\n' +
|
||||
'export class Base {\n protected readonly store = new Store();\n}\n',
|
||||
'src/child.ts':
|
||||
"import { Base } from './zeta/base';\n" +
|
||||
'export class Child extends Base {\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/zeta/base.ts::Store::add']);
|
||||
}, 60_000);
|
||||
|
||||
it('follows an aliased import to a base of the same name', async () => {
|
||||
// vscode's sessions `EditorPart extends` the workbench's `EditorPart`.
|
||||
const calls = await callsOf({
|
||||
...STORES,
|
||||
'src/workbench/part.ts': "import { Store } from '../two/store';\nexport class Part {\n protected readonly store: Store = new Store();\n}\n",
|
||||
'src/sessions/part.ts':
|
||||
"import { Part as BasePart } from '../workbench/part';\n" +
|
||||
'export class Part extends BasePart {\n run() {\n return this.store.add(2);\n }\n}\n',
|
||||
});
|
||||
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("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,
|
||||
'src/app-a/service.ts': "import { Store } from '../one/store';\nexport class Service {\n private readonly store = new Store();\n}\n",
|
||||
'src/app-b/service.ts':
|
||||
'const makeStore = () => ({ add: (v: number) => v });\n' +
|
||||
'export class Service {\n private readonly store = makeStore();\n run() {\n return this.store.add(3);\n }\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('this.store.add'))).toEqual([]);
|
||||
}, 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.
|
||||
const calls = await callsOf({
|
||||
...STORES,
|
||||
'src/app/mock.ts':
|
||||
"import { Store } from '../two/store';\n" +
|
||||
'const wrap = <T>(value: T) => value;\n' +
|
||||
'export class Mock {\n' +
|
||||
' private readonly store = wrap(new Store());\n' +
|
||||
' run() {\n return this.store.add(1);\n }\n' +
|
||||
'}\n',
|
||||
'src/other/mock.ts':
|
||||
"import { Store } from '../one/store';\n" +
|
||||
'export class Mock {\n private readonly store = new Store();\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/app/mock.ts: this.store.add -> src/two/store.ts::Store::add']);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('a call on a value whose type a package outside the project gives', () => {
|
||||
it("links to no project class that shares the type's name", async () => {
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-telemetry': '^1.0.0' } }),
|
||||
'src/other.ts': 'export interface TelemetryReporter {\n sendTelemetryEvent(name: string): void;\n}\n',
|
||||
'src/reporter.ts':
|
||||
"import TelemetryReporter from 'ext-telemetry';\n" +
|
||||
'export class Reporter {\n' +
|
||||
' protected reporter: TelemetryReporter;\n' +
|
||||
' constructor() {\n this.reporter = new TelemetryReporter();\n }\n' +
|
||||
" send() {\n this.reporter.sendTelemetryEvent('a');\n }\n" +
|
||||
'}\n' +
|
||||
"export class Sub extends Reporter {\n more() {\n this.reporter.sendTelemetryEvent('b');\n }\n}\n",
|
||||
'src/local.ts':
|
||||
"import TelemetryReporter from 'ext-telemetry';\n" +
|
||||
"export function f() {\n const r = new TelemetryReporter();\n return r.sendTelemetryEvent('c');\n}\n",
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('sendTelemetryEvent'))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('still links to the declaration a project file gives the package', async () => {
|
||||
// vscode's `src/typings/copilot-api.d.ts` declares `@vscode/copilot-api`.
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-api': '^1.0.0' } }),
|
||||
'src/typings/ext-api.d.ts': "declare module 'ext-api' {\n export class ApiClient {\n update(): void;\n }\n}\n",
|
||||
'src/other.ts': 'export class ApiClient {\n update() { return 1; }\n}\n',
|
||||
'src/use.ts':
|
||||
"import { ApiClient } from 'ext-api';\n" +
|
||||
'export class Service {\n private readonly client: ApiClient = new ApiClient();\n' +
|
||||
' run() {\n return this.client.update();\n }\n}\n' +
|
||||
'export function f() {\n const api = new ApiClient();\n return api.update();\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('.update'))).toEqual([
|
||||
'src/use.ts: api.update -> src/typings/ext-api.d.ts::ApiClient::update',
|
||||
'src/use.ts: this.client.update -> src/typings/ext-api.d.ts::ApiClient::update',
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it('does the same for a Flow libdef, and for a `node:` import of the module a libdef declares', async () => {
|
||||
// react-native's `flow-typed/npm/tinybench_v4.1.x.js` declares
|
||||
// `tinybench`, and `flow-typed/environment/node.js` Node's modules.
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { 'ext-bench': '^1.0.0' } }),
|
||||
'flow-typed/npm/ext-bench.js': "/**\n * @flow strict\n */\ndeclare module 'ext-bench' {\n declare export class Bench {\n run(): void;\n }\n}\n",
|
||||
'flow-typed/environment/node.d.ts': "declare module 'timers' {\n export class Timeout {\n refresh(): Timeout;\n }\n}\n",
|
||||
'src/other.js': 'export class Bench {\n run() { return 1; }\n}\nexport class Timeout {\n refresh() { return this; }\n}\n',
|
||||
'src/use.js':
|
||||
"import { Bench } from 'ext-bench';\n" +
|
||||
'export function b() {\n const bench = new Bench();\n return bench.run();\n}\n',
|
||||
'src/timer.ts':
|
||||
"import { Timeout } from 'node:timers';\n" +
|
||||
'export function t(timeout: Timeout) {\n return timeout.refresh();\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('bench.run') || c.includes('timeout.refresh'))).toEqual([
|
||||
'src/timer.ts: timeout.refresh -> flow-typed/environment/node.d.ts::Timeout::refresh',
|
||||
'src/use.js: bench.run -> flow-typed/npm/ext-bench.js::Bench::run',
|
||||
]);
|
||||
}, 60_000);
|
||||
});
|
||||
+219
-58
@@ -12976,6 +12976,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
AWAITED_TYPE_MEMO.delete(context);
|
||||
AWAITED_FILES.delete(context);
|
||||
TS_IMPORTED_TYPE_FILES.delete(context);
|
||||
TS_NAMED_CLASSES.delete(context);
|
||||
TS_AMBIENT_MODULE_FILES.delete(context);
|
||||
C_STATIC_MEMO.delete(context);
|
||||
CPP_TYPE_DECLS.delete(context);
|
||||
CPP_INCLUDE_LINES.delete(context);
|
||||
@@ -14309,7 +14311,7 @@ export function matchMethodCall(
|
||||
// pins WHICH one (#314); Go: the package that declares the type (#2323);
|
||||
// TS/JS: the file declaring the class the file imports under that name.
|
||||
// Other languages disambiguate by call-site file.
|
||||
const importedFqn =
|
||||
let importedFqn =
|
||||
ref.language === 'java' || ref.language === 'kotlin'
|
||||
? context
|
||||
.getImportMappings(ref.filePath, ref.language)
|
||||
@@ -14319,6 +14321,14 @@ export function matchMethodCall(
|
||||
: ESM_FAMILY.has(ref.language) && !awaited
|
||||
? nmTimedT('mc-tsimport', ref, () => tsImportedTypeFile(decl.raw ?? inferredType!, ref, context))
|
||||
: undefined;
|
||||
// A TS/JS type the file imports from a package outside the project has
|
||||
// no project method, whatever project class shares its name, unless a
|
||||
// project file declares the package (tsOutsideTypeDeclaration).
|
||||
if (ESM_FAMILY.has(ref.language) && !awaited && importedFqn === undefined) {
|
||||
const outside = nmTimedT('mc-tsoutside', ref, () => tsOutsideTypeDeclaration(decl.raw ?? inferredType!, inferredType!, ref, context));
|
||||
if (outside === null) return null;
|
||||
importedFqn = outside;
|
||||
}
|
||||
const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
|
||||
inferredType,
|
||||
methodName!,
|
||||
@@ -15828,13 +15838,14 @@ function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContex
|
||||
|
||||
/**
|
||||
* Resolve a TS/JS `this.<field>.<method>()` call (#1496) through the field's
|
||||
* declared type, read off the ENCLOSING class's own declaration lines:
|
||||
* a field or constructor-parameter property (`private mailer: Mailer`,
|
||||
* `mailer?: Mailer`, `readonly mailer: Mailer`) or an initializer
|
||||
* (`mailer = new Mailer()`, `this.mailer = new Mailer()`). The method is then
|
||||
* VALIDATED on that type by resolveMethodOnType. Null — never a bare-name
|
||||
* fallback — when the field is not declared there or its type is external,
|
||||
* a builtin (`this.items.push()`) or not spelled out.
|
||||
* declared type, read off the ENCLOSING class's own declaration lines, or
|
||||
* a class it extends (tsFieldThroughBases): a field or constructor-parameter
|
||||
* property (`private mailer: Mailer`, `mailer?: Mailer`, `readonly mailer:
|
||||
* Mailer`) or an initializer (`mailer = new Mailer()`, `this.mailer = new
|
||||
* Mailer()`). The method is then VALIDATED on that type by
|
||||
* resolveMethodOnType. Null — never a bare-name fallback — when the field is
|
||||
* not declared there or its type is external, a builtin
|
||||
* (`this.items.push()`) or not spelled out.
|
||||
*/
|
||||
function matchTsThisFieldCall(
|
||||
field: string,
|
||||
@@ -15856,62 +15867,212 @@ function matchTsThisFieldCall(
|
||||
const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter(
|
||||
(n) => (n.kind === 'class' || n.kind === 'component') && sameLanguageFamily(n.language, ref.language)
|
||||
);
|
||||
for (const cls of owners) {
|
||||
const decl = tsFieldDeclaration(cls, field, context);
|
||||
if (!decl) continue;
|
||||
if (decl.valueType) {
|
||||
// The value's declaration may live in another file (it is imported);
|
||||
// the call site's file is preferred when several share the name.
|
||||
const holderName = decl.type.split('.').pop()!;
|
||||
const holders = preferCallSiteFile(context.getNodesByName(holderName), ref.filePath).filter(
|
||||
(n) => (n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language)
|
||||
);
|
||||
for (const holder of holders) {
|
||||
const hit = resolveObjectLiteralMember(holder, methodName, ref, context, 0.85, 'instance-method');
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
// The calling class declares the field, or a class it extends does: each
|
||||
// base is the one its subclass's file names (tsFieldThroughBases), so
|
||||
// vscode's browser `FileDialogService` finds `AbstractFileDialogService`'s
|
||||
// `openerService`.
|
||||
const found = nmTimedT('mc-thisfield-bases', ref, () => {
|
||||
for (const cls of owners) {
|
||||
if (cls.filePath !== ref.filePath) continue;
|
||||
const hit = tsFieldThroughBases(cls, field, ref, context, 0);
|
||||
if (hit) return hit;
|
||||
}
|
||||
// `ns.Mailer` → `Mailer`; a primitive or builtin names no project type.
|
||||
const typeName = decl.type.split('.').pop()!;
|
||||
if (!/^[A-Z]/.test(typeName)) return null;
|
||||
// Two apps in one repo may each declare a `UserService`. The call's file
|
||||
// imports the one its field holds, so that one's method is the call's.
|
||||
// Only the call's file: a field read off a same-named class elsewhere
|
||||
// follows that file's imports, not this one's (vscode's two
|
||||
// `MethodContextProvider`s import two `Symbols`). Without an import, the
|
||||
// bare-name path this replaces broke the tie by directory proximity, so
|
||||
// keep the same signal: among the type's declarations of the method,
|
||||
// prefer the one closest to the call site's directory (its own app),
|
||||
// never index order. resolveMethodOnType still answers the
|
||||
// single-declaration and supertype cases.
|
||||
const imported = tsImportedTypeFile(decl.type, ref, context);
|
||||
const declared = context
|
||||
.getNodesByName(methodName)
|
||||
.filter(
|
||||
(n) =>
|
||||
n.kind === 'method' &&
|
||||
sameLanguageFamily(n.language, ref.language) &&
|
||||
(n.qualifiedName === `${typeName}::${methodName}` || n.qualifiedName.endsWith(`::${typeName}::${methodName}`))
|
||||
);
|
||||
if (declared.length > 1) {
|
||||
const own = imported !== undefined ? declared.find((n) => n.filePath === imported) : undefined;
|
||||
if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'instance-method' };
|
||||
const callDirs = ref.filePath.split('/').slice(0, -1);
|
||||
const shared = (fp: string) => {
|
||||
const dirs = fp.split('/').slice(0, -1);
|
||||
let i = 0;
|
||||
while (i < dirs.length && i < callDirs.length && dirs[i] === callDirs[i]) i++;
|
||||
return i;
|
||||
};
|
||||
const nearest = [...declared].sort((a, b) => shared(b.filePath) - shared(a.filePath) || a.filePath.localeCompare(b.filePath))[0]!;
|
||||
return { original: ref, targetNodeId: nearest.id, confidence: 0.85, resolvedBy: 'instance-method' };
|
||||
return null;
|
||||
});
|
||||
if (found) return tsFieldCall(found.decl, found.cls, methodName, ref, context);
|
||||
// Else a same-named class elsewhere may declare it, read for a calling
|
||||
// class whose own declaration isn't (`this._register(new Emitter())`),
|
||||
// but only with a type the call's file imports or declares: another app's
|
||||
// class of that name says nothing about this one's field.
|
||||
const namesake = nmTimedT('mc-thisfield-namesakes', ref, () => {
|
||||
for (const cls of owners) {
|
||||
if (cls.filePath === ref.filePath) continue;
|
||||
const decl = tsFieldDeclaration(cls, field, context);
|
||||
if (decl && tsTypeVisibleIn(decl.type, ref, context)) return decl;
|
||||
}
|
||||
return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method', imported);
|
||||
return null;
|
||||
});
|
||||
return namesake ? tsFieldCall(namesake, null, methodName, ref, context) : null;
|
||||
}
|
||||
|
||||
/** A TS field's declared type, as tsFieldDeclaration reads it. */
|
||||
type TsFieldRead = NonNullable<ReturnType<typeof tsFieldDeclaration>>;
|
||||
|
||||
/**
|
||||
* `field`'s declaration on `cls` or on the classes it extends, nearest
|
||||
* first, with the class it is written in: each base is the class its
|
||||
* subclass's file names (tsNamedClass), and the field's type is the class
|
||||
* that class's file imports or declares.
|
||||
*/
|
||||
function tsFieldThroughBases(
|
||||
cls: Node,
|
||||
field: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
depth: number,
|
||||
): { decl: TsFieldRead; cls: Node } | null {
|
||||
const decl = tsFieldDeclaration(cls, field, context);
|
||||
if (decl) return { decl, cls };
|
||||
if (depth >= 4) return null;
|
||||
for (const name of declaredSupertypes(cls, context)) {
|
||||
const base = tsNamedClass(name, cls, ref, context);
|
||||
if (!base || base.id === cls.id) continue;
|
||||
const found = tsFieldThroughBases(base, field, ref, context, depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Per-context memo: `<file>\0<name>` → tsNamedClass's answer. */
|
||||
const TS_NAMED_CLASSES = new WeakMap<ResolutionContext, Map<string, Node | null>>();
|
||||
|
||||
/**
|
||||
* The class `name` names in `from`'s file: the one that file imports under
|
||||
* it, followed through re-exports and aliases (`import { EditorPart as
|
||||
* BaseEditorPart }`), or else a class the file declares.
|
||||
*/
|
||||
function tsNamedClass(name: string, from: Node, ref: UnresolvedRef, context: ResolutionContext): Node | null {
|
||||
let memo = TS_NAMED_CLASSES.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
TS_NAMED_CLASSES.set(context, memo);
|
||||
}
|
||||
const key = from.filePath + '\0' + name;
|
||||
const hit = memo.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
let found: Node | null = null;
|
||||
if (context.getImportMappings?.(from.filePath, from.language)?.some((i) => i.localName === name)) {
|
||||
const imported = context.resolveImport?.({ ...ref, filePath: from.filePath, language: from.language, referenceName: name, referenceKind: 'references' });
|
||||
const node = imported ? context.getNodeById?.(imported.targetNodeId) : undefined;
|
||||
if (node && (node.kind === 'class' || node.kind === 'component')) found = node;
|
||||
} else {
|
||||
found = context.getNodesInFile(from.filePath).find((n) => n.kind === 'class' && n.name === name) ?? null;
|
||||
}
|
||||
memo.set(key, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Per-context memo: `<package>\0<type name>` → tsOutsideTypeDeclaration's ambient file. */
|
||||
const TS_AMBIENT_MODULE_FILES = new WeakMap<ResolutionContext, Map<string, string | null>>();
|
||||
|
||||
/**
|
||||
* For a TS/JS type the file `at` names imports under the head of `written`
|
||||
* from a package outside the project: the project file that declares that
|
||||
* package with a `typeName` in it (`declare module 'pkg' { … }`, as
|
||||
* vscode's `src/typings/copilot-api.d.ts` and react-native's Flow
|
||||
* `flow-typed/npm/*.js` libdefs do; a `node:` import is its bare module's),
|
||||
* or null when none does. Undefined when the type isn't imported from
|
||||
* outside the project.
|
||||
*/
|
||||
function tsOutsideTypeDeclaration(written: string, typeName: string, at: UnresolvedRef, context: ResolutionContext): string | null | undefined {
|
||||
const head = /^[A-Za-z_$][\w$]*/.exec(written.trim())?.[0];
|
||||
if (!head) return undefined;
|
||||
const binding = context.getImportMappings?.(at.filePath, at.language)?.find((m) => m.localName === head);
|
||||
if (!binding || context.isOutOfRepoImport?.(binding.source, at.filePath, at.language) !== true) return undefined;
|
||||
let memo = TS_AMBIENT_MODULE_FILES.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
TS_AMBIENT_MODULE_FILES.set(context, memo);
|
||||
}
|
||||
const key = binding.source + '\0' + typeName;
|
||||
let file = memo.get(key);
|
||||
if (file === undefined) {
|
||||
file = null;
|
||||
const pkg = binding.source.replace(/^node:/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const declares = new RegExp(`\\bdeclare\\s+module\\s+(['"])(?:node:)?${pkg}\\1`);
|
||||
for (const n of context.getNodesByName(typeName)) {
|
||||
if (!sameLanguageFamily(n.language, at.language)) continue;
|
||||
if (declares.test(context.readFile(n.filePath) ?? '')) {
|
||||
file = n.filePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
memo.set(key, file);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Does the call's file import a type named `type` from the project, or declare one? */
|
||||
function tsTypeVisibleIn(type: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
if (tsImportedTypeFile(type, ref, context) !== undefined) return true;
|
||||
const head = type.split('.')[0]!;
|
||||
return context.getNodesInFile(ref.filePath).some((n) =>
|
||||
n.name === head && (n.kind === 'class' || n.kind === 'interface' || n.kind === 'type_alias' || n.kind === 'constant' || n.kind === 'variable'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `this.<field>.<method>()` through a declaration of the field on
|
||||
* `declaring`, the calling class or a base, or on a same-named class
|
||||
* elsewhere (null), whose type the call's file names.
|
||||
*/
|
||||
function tsFieldCall(
|
||||
decl: TsFieldRead,
|
||||
declaring: Node | null,
|
||||
methodName: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): ResolvedRef | null {
|
||||
if (decl.valueType) {
|
||||
// The value's declaration may live in another file (it is imported);
|
||||
// the call site's file is preferred when several share the name.
|
||||
const holderName = decl.type.split('.').pop()!;
|
||||
const holders = preferCallSiteFile(context.getNodesByName(holderName), ref.filePath).filter(
|
||||
(n) => (n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language)
|
||||
);
|
||||
for (const holder of holders) {
|
||||
const hit = resolveObjectLiteralMember(holder, methodName, ref, context, 0.85, 'instance-method');
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// `ns.Mailer` → `Mailer`; a primitive or builtin names no project type.
|
||||
const typeName = decl.type.split('.').pop()!;
|
||||
if (!/^[A-Z]/.test(typeName)) return null;
|
||||
// The file the field is written in names its type. Two apps in one repo
|
||||
// may each declare a `UserService`: that file imports the one the field
|
||||
// holds, or declares it, so that one's method is the call's. One it
|
||||
// imports from a package outside the project, which no project file
|
||||
// declares (as `src/typings/*.d.ts` may), has no project method:
|
||||
// microsoft-authentication's `TelemetryReporter` from
|
||||
// `@vscode/extension-telemetry` reached another extension's interface of
|
||||
// that name. Without an import, the bare-name path this replaces broke
|
||||
// the tie by directory proximity, so keep the same signal: among the
|
||||
// type's declarations of the method, prefer the one closest to the call
|
||||
// site's directory (its own app), never index order. resolveMethodOnType
|
||||
// still answers the single-declaration and supertype cases.
|
||||
const at = !declaring || declaring.filePath === ref.filePath
|
||||
? ref
|
||||
: { ...ref, filePath: declaring.filePath, language: declaring.language };
|
||||
let imported = tsImportedTypeFile(decl.type, at, context);
|
||||
if (imported === undefined) {
|
||||
const outside = nmTimedT('mc-tsoutside', ref, () => tsOutsideTypeDeclaration(decl.type, typeName, at, context));
|
||||
if (outside === null) return null;
|
||||
imported = outside ?? (at === ref ? undefined : at.filePath);
|
||||
}
|
||||
const declared = context
|
||||
.getNodesByName(methodName)
|
||||
.filter(
|
||||
(n) =>
|
||||
n.kind === 'method' &&
|
||||
sameLanguageFamily(n.language, ref.language) &&
|
||||
(n.qualifiedName === `${typeName}::${methodName}` || n.qualifiedName.endsWith(`::${typeName}::${methodName}`))
|
||||
);
|
||||
if (declared.length > 1) {
|
||||
const own = imported !== undefined ? declared.find((n) => n.filePath === imported) : undefined;
|
||||
if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'instance-method' };
|
||||
const callDirs = ref.filePath.split('/').slice(0, -1);
|
||||
const shared = (fp: string) => {
|
||||
const dirs = fp.split('/').slice(0, -1);
|
||||
let i = 0;
|
||||
while (i < dirs.length && i < callDirs.length && dirs[i] === callDirs[i]) i++;
|
||||
return i;
|
||||
};
|
||||
const nearest = [...declared].sort((a, b) => shared(b.filePath) - shared(a.filePath) || a.filePath.localeCompare(b.filePath))[0]!;
|
||||
return { original: ref, targetNodeId: nearest.id, confidence: 0.85, resolvedBy: 'instance-method' };
|
||||
}
|
||||
return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method', imported);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one fallback a TS/JS/Python call-receiver chain keeps (#1683): a STORE
|
||||
* ACCESSOR. Zustand's `get()` inside the store factory and
|
||||
|
||||
Reference in New Issue
Block a user