diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23105eb67..a570451ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -165,6 +165,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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 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.
## [1.6.2] - 2026-10-03
diff --git a/__tests__/ts-imported-type-receivers.test.ts b/__tests__/ts-imported-type-receivers.test.ts
index eabe582d2..0f2064a86 100644
--- a/__tests__/ts-imported-type-receivers.test.ts
+++ b/__tests__/ts-imported-type-receivers.test.ts
@@ -178,3 +178,58 @@ describe('a call on a TypeScript value whose type the file imports', () => {
expect(calls.filter((c) => c.includes('s.add'))).toEqual(['src/own.ts: s.add -> src/own.ts::Store::add']);
}, 60_000);
});
+
+describe('a call on a value another file exports, whose class that file imports', () => {
+ it("links to the class the value's file imports, whichever of the same-named classes it is", async () => {
+ // `import { store } from './state'; store.add(…)` with
+ // `export const store = new Store()` in `state.ts`.
+ const calls = await callsOf({
+ ...STORES,
+ 'src/state-one.ts': "import { Store } from './one/store';\nexport const store = new Store();\n",
+ 'src/state-two.ts': "import { Store } from './two/store';\nexport const store: Store = new Store();\n",
+ 'src/use-one.ts': "import { store } from './state-one';\nexport function a() {\n return store.add(1);\n}\n",
+ 'src/use-two.ts': "import { store } from './state-two';\nexport function b() {\n return store.add(2);\n}\n",
+ });
+ expect(calls.filter((c) => c.includes('store.add'))).toEqual([
+ 'src/use-one.ts: store.add -> src/one/store.ts::Store::add',
+ 'src/use-two.ts: store.add -> src/two/store.ts::Store::add',
+ ]);
+ }, 60_000);
+
+ it('links to the class the value\'s own file declares', async () => {
+ const own = (n: number) =>
+ `export class Store {\n add(v: number) { return v * ${n}; }\n}\n` +
+ 'export const store = new Store();\n';
+ const calls = await callsOf({
+ ...STORES,
+ 'src/own-a.ts': own(3),
+ 'src/own-b.ts': own(4),
+ 'src/use-a.ts': "import { store } from './own-a';\nexport function a() {\n return store.add(1);\n}\n",
+ 'src/use-b.ts': "import { store } from './own-b';\nexport function b() {\n return store.add(2);\n}\n",
+ });
+ expect(calls.filter((c) => c.includes('store.add'))).toEqual([
+ 'src/use-a.ts: store.add -> src/own-a.ts::Store::add',
+ 'src/use-b.ts: store.add -> src/own-b.ts::Store::add',
+ ]);
+ }, 60_000);
+});
+
+describe('a call in a Vue component on a value another file exports', () => {
+ it("links to the class the value's file imports", async () => {
+ const vue = (state: string) =>
+ '\n' +
+ `\n`;
+ const calls = await callsOf({
+ ...STORES,
+ 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3.4.0' } }),
+ 'src/state-one.ts': "import { Store } from './one/store';\nexport const store = new Store();\n",
+ 'src/state-two.ts': "import { Store } from './two/store';\nexport const store = new Store();\n",
+ 'src/One.vue': vue('state-one'),
+ 'src/Two.vue': vue('state-two'),
+ });
+ expect(calls.filter((c) => c.includes('store.add'))).toEqual([
+ 'src/One.vue: store.add -> src/one/store.ts::Store::add',
+ 'src/Two.vue: store.add -> src/two/store.ts::Store::add',
+ ]);
+ }, 60_000);
+});
diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts
index 9fa572206..3cd62cc38 100644
--- a/src/resolution/import-resolver.ts
+++ b/src/resolution/import-resolver.ts
@@ -24,6 +24,7 @@ import {
goRefQualifier,
inferLocalReceiverType,
arrayElementType,
+ tsImportedTypeFile,
} from './name-matcher';
import { JS_ARRAY_METHODS } from './js-builtins';
@@ -2959,7 +2960,12 @@ function resolveImportedInstanceMember(
if (!m || !m[1]) continue;
const typeName = normalizeInferredTypeName(m[1]);
if (!typeName) continue;
- const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method');
+ // Several classes may share the name. The value's file says which one:
+ // the class it imports under that name, or else the one it declares.
+ const declaring = ESM_IMPORT_LANGUAGES.has(value.language)
+ ? tsImportedTypeFile(m[1], { ...ref, filePath: value.filePath, language: value.language }, context) ?? value.filePath
+ : undefined;
+ const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method', declaring);
if (resolved) return resolved;
}
return null;
diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts
index fa463210e..ff9b61a6f 100644
--- a/src/resolution/name-matcher.ts
+++ b/src/resolution/name-matcher.ts
@@ -1366,6 +1366,13 @@ function lexicalScopeOf(candidate: Node, context: ResolutionContext): { start: n
/** Languages whose module boundary is `import`/`export` (or CommonJS). */
const ESM_FAMILY = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']);
+/**
+ * The ES-module family with single-file components' script blocks: a file
+ * declaring the class an import names (tsImportedTypeFile) is the same
+ * signal in each.
+ */
+const ES_IMPORT_LANGUAGES = new Set([...ESM_FAMILY, 'vue', 'svelte', 'astro']);
+
/**
* A line-initial `import` statement — the marker that a JS/TS file is a MODULE
* rather than a classic script. Line-anchored and followed by a name, brace,
@@ -8960,7 +8967,9 @@ export function resolveMethodOnType(
* For Go it is the project-relative directory of the package that
* declares `typeName`, when that is known (resolveGoMethodInPackage).
* For TS/JS it is the file declaring the class the caller's file
- * imports under that name (tsImportedTypeFile).
+ * imports under that name (tsImportedTypeFile), or, for a type written
+ * in another file (an imported value's), the class that file imports or
+ * declares.
*/
preferredFqn?: string,
/** Recursion guard for the supertype/conformance walk. */
@@ -9026,11 +9035,11 @@ export function resolveMethodOnType(
return null;
}
- if (matches.length > 1 && preferredFqn && ESM_FAMILY.has(ref.language)) {
+ if (matches.length > 1 && preferredFqn && ES_IMPORT_LANGUAGES.has(ref.language)) {
const chosen = matches.find((m) => m.filePath === preferredFqn);
if (chosen) return { original: ref, targetNodeId: chosen.id, confidence, resolvedBy };
}
- if (matches.length > 1 && preferredFqn && ref.language !== 'go' && !ESM_FAMILY.has(ref.language)) {
+ if (matches.length > 1 && preferredFqn && ref.language !== 'go' && !ES_IMPORT_LANGUAGES.has(ref.language)) {
const ext = ref.language === 'kotlin' ? '.kt' : '.java';
const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
const chosen = matches.find((m) => {
@@ -12775,7 +12784,7 @@ const TS_IMPORTED_TYPE_FILES = new WeakMap