diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e79cd420..b5c54f8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,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 ` items={…} />` or excalidraw's ` …>`, 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, `` 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 `` 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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ts-this-field-bases.test.ts b/__tests__/ts-this-field-bases.test.ts new file mode 100644 index 000000000..55130995f --- /dev/null +++ b/__tests__/ts-this-field-bases.test.ts @@ -0,0 +1,190 @@ +/** + * A TypeScript/JavaScript `this.` 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 `: -> ::`. */ +async function callsOf(files: Record): Promise { + 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. 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())`, 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 = (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); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5bb2175e8..7fbdb941c 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12967,6 +12967,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); @@ -14277,7 +14279,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) @@ -14287,6 +14289,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!, @@ -15729,13 +15739,14 @@ function tsFieldDeclaration( /** * Resolve a TS/JS `this..()` 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, @@ -15757,62 +15768,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>; + +/** + * `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: `\0` → tsNamedClass's answer. */ +const TS_NAMED_CLASSES = new WeakMap>(); + +/** + * 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: `\0` → tsOutsideTypeDeclaration's ambient file. */ +const TS_AMBIENT_MODULE_FILES = new WeakMap>(); + +/** + * 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..()` 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