From 48f0d886faf8c8fa25c75b1b3db48d293b35f4a9 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 22 Oct 2026 08:00:58 -0500 Subject: [PATCH] fix(js,ts): default imports follow a module's imported default; calls never land on types Two resolver bugs that land an import on the wrong kind of symbol, on main before PR #2799 made them common (its A/B found them in jest, webdriverio and memfs): - A default import of a module whose `export default X` names a binding the module only imports fell back to a guess: the first function the module exports, or, through `export * from`, the first function of a forwarded module. jest-resolve (`import Resolver from './resolver'; export * from './utils'; export default Resolver`) sent 23 edges such as `Resolver.findNodeModule()` to `resolveTestEnvironment`. - A call or `new` reached a TypeScript interface or type alias: memfs's `Ok(...)` reached `interface Ok` beside `function Ok`, and `new Response()` / `new Worker()` linked to project types for those globals. Fix: - import-resolver.ts: a default naming an imported binding is that import, followed into its module (a namespace or outside package gives nothing, never a guess), and `export *` is never walked for a default, as ES modules never forward one. - gateTargetKind: a JS-family `calls`/`instantiates` result on an interface or type alias moves to the value its file declares under that name (function, class, component, constant, variable), else it is dropped. Validation: - __tests__/default-import-and-value-targets.test.ts (8 tests): 6 fail on main; the 2 that pass pin behaviour main gets right. All pass on the kernel. Each of 7 rules removed in turn fails at least one test. - Sync equals a fresh index (CLI) in 9 scenarios: defaults becoming imported or declared, a barrel gaining `export *`, a forwarded default moving, the value beside a type added or removed. - A/B on 14 TS/JS repos and 2 controls: jest 23 edges corrected to jest-resolve's Resolver; memfs 11 Ok/Err calls to their functions; webdriverio 2 allure calls to AllureReporter's methods; outline's 2 Sentry calls off a guessed function; 165 `new` of globals dropped (vite 27, nuxt 57, webdriverio 26, insomnia 47, jest 7, memfs 1) plus jest's call to `type write`. vben, t3-turbo, nitro, vue3-antdv-admin, pathe, c12, flask and gin byte-identical. - Cost: fresh index within noise (median of 3, interleaved). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../default-import-and-value-targets.test.ts | 169 ++++++++++++++++++ src/resolution/import-resolver.ts | 80 +++++++-- src/resolution/index.ts | 31 ++++ 4 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 __tests__/default-import-and-value-targets.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be4d0d65d..e28611f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -224,6 +224,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a function a file loads with a dynamic import from Node or a package, like `const { createServer } = await import('node:http')`, `const { default: chalk } = await import('chalk')` or `import('node:fs/promises').then(({ readFile }) => …)`, no longer links to a project function that only shares its name. Before, vite's call to Node's `createServer` showed up among the callers of vite's own `createServer`. The import counts only where it is in scope, so the rest of the file links as before, and a dynamic import of your own project's module still leads to that module. Thanks @Ancienttwo for the report. (#2491) - In JavaScript and TypeScript, code outside your tests no longer links to a Jest or Vitest manual mock under `__mocks__/`. Before, when an import of a workspace package couldn't be followed, a call like `formatLabel(name)` linked by name to the mock beside it instead of the package's own function, so the real function lost its callers, and the same happened to rendered components and event handlers. Production code is now matched as if the mocks weren't there, so it reaches the real function, and adding or deleting a mock leaves it alone. Tests, other mocks, and a file that imports a mock by its path, like a `jest.setup.ts`, still reach the mock. Thanks @Y-Osawa2022 for the report and @danusha2345. (#2455) - In JavaScript and TypeScript, an import of a package from your own repository now leads to the file its `package.json` names: the `exports` entry for the import, conditions and subpaths like `"./utils": "./src/utils.ts"` included, or else its `main`, `module`, `types` or `source`. A package another one depends on by path, like `"acme-shared": "file:../common"` or `link:`, now counts as part of your project too, for the files of the package that declares it, even with no root `workspaces`. Before, an import of a pnpm workspace member declared with `"main": "./src/index.ts"` and no `index` file at its root, or of a `file:` dependency, couldn't be followed, so its calls were matched by name alone and a same-named private function nearer the caller could take them. An entry that names build output, like `./dist/esm/index.mjs`, or an import of a path inside it, like `@jsonjoy.com/fs-node-utils/lib/types/misc`, leads to the matching source file, like `src/index.ts` or `src/types/misc.ts`, when your project has one, and to nothing otherwise. `codegraph sync` follows edits to these `package.json` fields. Re-index JavaScript and TypeScript monorepos after upgrading. Thanks @elderbernardi and @Y-Osawa2022 for the reports, and @maxmilian and @danusha2345. (#2502, #2456) +- In JavaScript and TypeScript, a default import no longer lands on a function its module only passes on or happens to export beside its default. A barrel like `import Resolver from './resolver'; export * from './utils'; export default Resolver` sent `Resolver.findNodeModule()` to the first function in `utils`, and a module that default-exports an import of its own, like `import log from 'electron-log'` then `export default log`, sent its importers' `log.info()` calls to whichever function it exported first. A call or `new` also no longer lands on a TypeScript interface or type alias: when a file declares both, like `interface Ok` and `function Ok`, the call reaches the function, and `new Response()` or `new Event()` no longer links to a project type that only shares the global's name. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, a value the file imports from your own project and passes along now links only to what it imports: a constant like `isCloudHosted`, a class like a `Star` model, a whole module (`import * as Slack from '../slack'`), or a stylesheet, image or GraphQL document (`import TCO21 from './themes/TCO21.scss'`) no longer links to a function elsewhere that only shares its name. Before, when the project had exactly one function of that name, such a value linked to it: outline's settings screens showed up among the callers of the server's `isCloudHosted()` check, and its stores among those of the `` icon, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A component that wraps the function declared beside it, like `export default styled(Disclosure)`, and a page loaded lazily, like `lazy(() => import('../pages/Home'))`, still lead to that function. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript, an import of a TypeScript module written without an extension, like `import AudioModal from './audio_modal'` in a `.jsx` file next to `audio_modal.tsx`, now links to that module, as it already did from a TypeScript file. Before, a `.js` or `.jsx` file's imports of TypeScript modules were never followed, so in a codebase moving to TypeScript they were matched by name instead: an import under another name linked nothing, and a name that repeats could link to the wrong file, so mastodon's `` and `` tags rendered other components of those names than the ones each file imports. A JavaScript module beside a TypeScript one of the same name still comes first. Re-index JavaScript projects after upgrading. - `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named. diff --git a/__tests__/default-import-and-value-targets.test.ts b/__tests__/default-import-and-value-targets.test.ts new file mode 100644 index 000000000..6b0aa911c --- /dev/null +++ b/__tests__/default-import-and-value-targets.test.ts @@ -0,0 +1,169 @@ +/** + * Two ways an import landed on the wrong kind of symbol, both on main before + * the workspace-package fix made them common (jest, memfs, webdriverio): + * + * - A default import of a module whose `export default X` names a binding the + * module imports was answered by a guess: the first function or class the + * module exports itself, or, through `export * from './utils'`, the first + * function of the forwarded module — jest-resolve's + * `import Resolver from './resolver'; export * from './utils'; export default Resolver` + * sent `Resolver.findNodeModule()` to `resolveTestEnvironment`. An ES + * module's `export *` never hands on a default, and a default the module + * imports is no node of the module. + * - A call or `new` written with a name that a TypeScript file declares both as + * a type and a value (`interface Ok` beside `function Ok`) reached the type, + * and one that only a project type answers (`interface Response` for the + * global `Response`) was linked to it. Nothing calls or makes an instance of + * an interface or a type alias. + */ +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'; + +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 = []; +}); + +async function index(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-default-value-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + const file = path.join(root, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + return graph; +} + +/** `kind name@file` of every edge of `kinds` out of `file`'s nodes. */ +function edgesFrom(graph: CodeGraph, file: string, kinds: string[]): string[] { + const out: string[] = []; + for (const node of graph.getNodesInFile(file)) { + for (const e of graph.getOutgoingEdges(node.id)) { + if (!kinds.includes(e.kind)) continue; + const target = graph.getNode(e.target)!; + out.push(`${e.kind} ${target.name}(${target.kind})@${target.filePath}`); + } + } + return out.sort(); +} + +describe('a default import of a module that hands on a default it does not declare', () => { + /** jest-resolve's index, cut down. */ + const resolverBarrel: Record = { + 'package.json': JSON.stringify({ name: 'app' }), + 'lib/resolver.ts': + 'export default class Resolver {\n static findNodeModule(name: string): string | null {\n return name;\n }\n}\n', + 'lib/utils.ts': + "import Resolver from './resolver';\n\nexport const resolveTestEnvironment = (name: string) => Resolver.findNodeModule(name);\nexport function resolveRunner(name: string): string {\n return name;\n}\n", + 'lib/index.ts': "import Resolver from './resolver';\n\nexport * from './utils';\n\nexport default Resolver;\n", + 'use.ts': + "import Resolver, { resolveRunner } from './lib';\n\nexport const find = (name: string) => Resolver.findNodeModule(name);\nexport const make = () => new Resolver();\nexport const keep = (r: Resolver) => [r, resolveRunner('x')];\n", + }; + + it('never reaches a function `export *` forwards', async () => { + const graph = await index(resolverBarrel); + const edges = edgesFrom(graph, 'use.ts', ['calls', 'instantiates', 'references']); + expect(edges.filter((e) => e.includes('resolveTestEnvironment'))).toEqual([]); + expect(edges).toContain('calls findNodeModule(method)@lib/resolver.ts'); + expect(edges).toContain('instantiates Resolver(class)@lib/resolver.ts'); + expect(edges).toContain('references Resolver(class)@lib/resolver.ts'); + // `export *` still hands on the named exports. + expect(edges).toContain('calls resolveRunner(function)@lib/utils.ts'); + }, 120_000); + + it("follows the import to its module's own default, past a namesake elsewhere", async () => { + // webdriver's node entry hands on the default of the module it also star-exports, + // and a CommonJS twin declares a class of the same name. + const graph = await index({ + 'package.json': JSON.stringify({ name: 'app' }), + 'lib/index.ts': 'export default class WebDriver {\n static newSession(): number {\n return 1;\n }\n}\n', + 'lib/index.cts': 'class WebDriver {\n static newSession(): number {\n return 2;\n }\n}\nmodule.exports = WebDriver;\n', + 'lib/node.ts': "import WebDriver from './index.js';\n\nexport default WebDriver;\nexport * from './index.js';\n", + 'use.ts': "import WebDriver from './lib/node';\n\nexport const start = () => WebDriver.newSession();\n", + }); + expect(edgesFrom(graph, 'use.ts', ['calls'])).toEqual(['calls newSession(method)@lib/index.ts']); + }, 120_000); + + it('never reaches a function `export *` forwards when the default is an expression', async () => { + // A barrel whose default is no binding at all: `export *` still forwards none. + const graph = await index({ + 'package.json': JSON.stringify({ name: 'app' }), + 'lib/api.ts': "export function createApi(): { get(): number } {\n return { get: () => 1 };\n}\n", + 'lib/utils.ts': 'export function formatDate(d: Date): string {\n return d.toISOString();\n}\n', + 'lib/index.ts': "import { createApi } from './api';\n\nexport * from './utils';\n\nexport default createApi();\n", + 'use.ts': "import api from './lib';\n\nexport const load = () => api.get();\n", + }); + expect(edgesFrom(graph, 'use.ts', ['calls']).filter((e) => e.includes('formatDate'))).toEqual([]); + }, 120_000); + + it('never reaches a function the module exports beside it', async () => { + // insomnia's main/log.ts and outline's logging/sentry.ts: the default is a package's. + const graph = await index({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { 'electron-log': '^5.0.0', '@sentry/node': '^8.0.0' } }), + 'lib/log.ts': "import log from 'electron-log/main';\n\nexport function getLogDirectory(): string {\n return '/tmp';\n}\n\nexport default log;\n", + 'lib/sentry.ts': + "import * as Sentry from '@sentry/node';\n\nexport function requestErrorHandler(error: unknown): void {\n Sentry.captureException(error);\n}\n\nexport default Sentry;\n", + 'use.ts': + "import log from './lib/log';\nimport Sentry from './lib/sentry';\n\nexport function report(error: unknown): void {\n log.info('failed');\n Sentry.captureException(error);\n}\n", + }); + expect(edgesFrom(graph, 'use.ts', ['calls', 'references'])).toEqual([]); + }, 120_000); + + it('still reaches what a module declares and exports as its default', async () => { + const graph = await index({ + 'package.json': JSON.stringify({ name: 'app' }), + 'lib/store.ts': "export function helper(): number {\n return 1;\n}\n\nfunction createStore(): number {\n return 2;\n}\n\nexport default createStore;\n", + 'use.ts': "import createStore from './lib/store';\n\nexport const store = () => createStore();\n", + }); + expect(edgesFrom(graph, 'use.ts', ['calls'])).toEqual(['calls createStore(function)@lib/store.ts']); + }, 120_000); +}); + +describe('a call or `new` never lands on an interface or a type alias', () => { + /** memfs's fs-core/src/result.ts: each result type beside the function that makes one. */ + const result = 'export interface Ok {\n ok: true;\n value: T;\n}\n\nexport type Result = Ok;\n\nexport function Ok(value: T): Ok {\n return { ok: true, value };\n}\n'; + + it('reaches the value declared beside the type, directly and through a barrel', async () => { + const graph = await index({ + 'package.json': JSON.stringify({ name: 'app' }), + 'lib/result.ts': result, + 'lib/index.ts': "export * from './result';\n", + 'direct.ts': "import { Ok } from './lib/result';\n\nexport const direct = () => Ok(1);\nexport const typed = (r: Ok) => r;\n", + 'barrel.ts': "import { Ok } from './lib';\n\nexport const barrel = () => Ok(2);\n", + }); + expect(edgesFrom(graph, 'direct.ts', ['calls'])).toEqual(['calls Ok(function)@lib/result.ts']); + expect(edgesFrom(graph, 'barrel.ts', ['calls'])).toEqual(['calls Ok(function)@lib/result.ts']); + // A type position still names the type. + expect(edgesFrom(graph, 'direct.ts', ['references'])).toContain('references Ok(interface)@lib/result.ts'); + }, 120_000); + + it('links nothing for a value only a project type answers', async () => { + // insomnia's `interface Response` and jest-circus's `type Event`, for the globals. + const graph = await index({ + 'package.json': JSON.stringify({ name: 'app' }), + 'types/postman.ts': 'export interface Response {\n code: number;\n}\n\nexport type Event = { name: string };\n', + 'fetch.ts': "export function respond(): unknown {\n return [new Response('ok'), new Event('ready')];\n}\n", + }); + expect(edgesFrom(graph, 'fetch.ts', ['calls', 'instantiates'])).toEqual([]); + }, 120_000); + + it('reaches the class an interface merges with', async () => { + const graph = await index({ + 'package.json': JSON.stringify({ name: 'app' }), + 'store.ts': 'export interface Store {\n size: number;\n}\n\nexport class Store {\n size = 0;\n}\n', + 'use.ts': "import { Store } from './store';\n\nexport const make = () => new Store();\n", + }); + expect(edgesFrom(graph, 'use.ts', ['instantiates'])).toEqual(['instantiates Store(class)@store.ts']); + }, 120_000); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 01712149b..7907c6cc2 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -136,10 +136,12 @@ const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/; /** * What a file's own `export default` statement exports: the node it declares - * or names, `'anonymous'` for a function or class with no name (no node - * stands for it), `'unstated'` when the file says nothing a node answers. + * or names, the import that binds a name it declares nothing of (what that + * import brings, not one of the file's own nodes), `'anonymous'` for a + * function or class with no name (no node stands for it), `'unstated'` when + * the file says nothing a node answers. */ -type StatedDefault = Node | 'anonymous' | 'unstated'; +type StatedDefault = Node | { imported: ImportMapping } | 'anonymous' | 'unstated'; const EXPORT_DEFAULT_HEAD = /^[ \t]*export\s+default\s+/gm; /** `[async] function[*] [NAME]` / `[abstract] class [NAME]`; the name is group 1 or 2. */ @@ -234,11 +236,19 @@ function statedDefaultExport(filePath: string, idx: FileExportIndex, context: Re stated = node; break; } + // `import Resolver from './resolver'; export default Resolver`: the + // default is what the import brings, so no function of this file is it. + if (!value.declared && stated === 'unstated') { + // Every JS-family language reads imports alike (extractImportMappings). + const imported = context.getImportMappings(filePath, 'typescript').find((i) => i.localName === value.name); + if (imported) stated = { imported }; + } } } idx.statedDefault = stated ?? 'unstated'; return idx.statedDefault; } + const fileExportIndexes = new WeakMap>(); /** @@ -291,17 +301,47 @@ function nodesInFileNamed(filePath: string, name: string, context: ResolutionCon /** * The declaration a module default-exports: what its own `export default` - * statement declares or names (see {@link statedDefaultExport}), and nothing - * for an anonymous default. Only a default the statement leaves unnamed - * (`export default memo(Card)`, `new Service()`), or a module with no - * statement, falls back: to the component a single-file component file is, - * else to the first exported function or class. + * statement declares or names (see {@link statedDefaultExport}), followed + * into the module an import it names comes from, and nothing for an anonymous + * default or an import from outside the project. Only a default the statement + * leaves unnamed (`export default memo(Card)`, `new Service()`), or a module + * with no statement, falls back: to the component a single-file component + * file is, else to the first exported function or class. */ -function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined { +function esmDefaultExport( + filePath: string, + idx: FileExportIndex, + language: Language, + context: ResolutionContext, + visited: Set, + depth: number +): Node | undefined { const stated = statedDefaultExport(filePath, idx, context); if (stated === 'anonymous') return undefined; - if (stated !== 'unstated') return stated; - return idx.defaultComponent ?? idx.defaultFnClass; + if (stated === 'unstated') return idx.defaultComponent ?? idx.defaultFnClass; + return 'imported' in stated ? importedDefault(filePath, stated.imported, language, context, visited, depth) : stated; +} + +/** + * What the import a module default-exports binds, in the module it comes + * from: `import Resolver from './resolver'; export default Resolver` is + * `resolver.ts`'s own default. Nothing for a namespace (`import * as Sentry`, + * an object no node stands for) or an import from outside the project. + */ +function importedDefault( + filePath: string, + imported: ImportMapping, + language: Language, + context: ResolutionContext, + visited: Set, + depth: number +): Node | undefined { + if (imported.isNamespace) return undefined; + const next = resolveImportPath(imported.source, filePath, language, context); + if (!next) return undefined; + const isDefault = imported.isDefault || imported.exportedName === 'default'; + const want = { isDefault, isNamespace: false, exportedName: isDefault ? 'default' : imported.exportedName, memberName: null }; + return findExportedSymbol(next, want, language, context, visited, depth + 1); } /** @@ -4013,15 +4053,19 @@ function findExportedSymbolWalk( // 1. Direct hit: the symbol is declared in this file. if (want.isDefault) { // What the file's own `export default` statement exports beats any - // guess; an anonymous default is no node at all, and no neighbour is it. - if (statedDefaultExport(filePath, exportIndex, context) === 'anonymous') return undefined; + // guess; an anonymous default is no node at all, and no neighbour is it, + // nor is any of them a default the file imports. + const stated = statedDefaultExport(filePath, exportIndex, context); + if (stated === 'anonymous') return undefined; // Svelte/Vue single-file components ARE the module's default export, // but are extracted as kind 'component' (not function/class), so the // fallback prefers the component node. Without it, an `export { default // as X } from './X.svelte'` barrel never resolves and the component shows // a false 0 callers (#629). - const direct = esmDefaultExport(filePath, exportIndex, context); + const direct = esmDefaultExport(filePath, exportIndex, language, context, visited, depth); if (direct) return direct; + // A default the module imports is that import's, found or not. + if (typeof stated === 'object' && 'imported' in stated) return undefined; // CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`. const commonJs = commonJsDefaultExport(filePath, context); if (commonJs?.source) { @@ -4052,7 +4096,7 @@ function findExportedSymbolWalk( ESM_IMPORT_LANGUAGES.has(language) && hasEsmDefaultExport(filePath, exportIndex, context) ) { - const esmDefault = esmDefaultExport(filePath, exportIndex, context); + const esmDefault = esmDefaultExport(filePath, exportIndex, language, context, visited, depth); if (esmDefault) return esmDefault; } } @@ -4102,7 +4146,11 @@ function findExportedSymbolWalk( } // 3. Wildcard re-export: `export * from './other'` — try every - // forwarding source. This is the barrel-of-barrels case. + // forwarding source. This is the barrel-of-barrels case. An ES module's + // `export *` never hands on a default: a default import of a barrel that + // has none of its own has none, not the first function a module it + // forwards exports. + if (want.isDefault && !PYTHON_MODULE_FILE.test(filePath)) return undefined; for (const rex of reExports) { if (rex.kind === 'wildcard') { const next = resolveImportPath(rex.source, filePath, language, context); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 4093a92d9..33d9eb2ca 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -158,6 +158,9 @@ const GO_BUILT_INS = new Set([ /** What a Pascal typecast's name resolves to: `TFoo(x)` casts, it doesn't call (see createEdges). */ const PASCAL_CAST_TARGET_KINDS: ReadonlySet = new Set(['class', 'struct', 'interface', 'enum', 'type_alias']); +/** The values a TypeScript type may share its name with in its file, which a call or `new` of the name means. */ +const TYPE_COMPANION_VALUE_KINDS: ReadonlySet = new Set(['function', 'class', 'component', 'constant', 'variable']); + const PASCAL_UNIT_PREFIXES = [ 'System.', 'Winapi.', 'Vcl.', 'Fmx.', 'Data.', 'Datasnap.', 'Soap.', 'Xml.', 'Web.', 'REST.', 'FireDAC.', 'IBX.', @@ -3707,6 +3710,21 @@ export class ReferenceResolver { !ref.referenceName.includes('.') && !isGoBareName(ref, this.context)) return null; } + // A TypeScript interface or type alias is no value: nothing calls one or + // makes an instance of it. A strategy that found one by name meant the + // value its file declares beside it (`interface Ok` and `function Ok` in a + // result module); with none there, the name is a value from elsewhere + // that the project only types (`new Response()`, `new Worker()`), and + // links nothing. + if ((ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && ESM_IMPORT_LANGUAGES.has(ref.language)) { + const target = this.nodeById(result.targetNodeId); + if (target && (target.kind === 'interface' || target.kind === 'type_alias')) { + const value = this.sameNamedValueOfType(target); + if (!value) return null; + result = { ...result, targetNodeId: value.id }; + } + } + // An `imports` reference names something importable — never a member that // only exists inside a type, nor its own import statement. A module path // that names no file (`./missing`, `stdio.h`, `pkg.mod`, a C# `using` of @@ -3948,6 +3966,19 @@ export class ReferenceResolver { } /** The one supertype-kind node a TypeScript value shares its name and file with. */ + /** + * The value a file declares beside a type of the same name — `export + * function Ok()` beside `export interface Ok`, a class merged with an + * interface — which a call or `new` written with the name reaches. The + * first by position; null when the file declares none. + */ + private sameNamedValueOfType(type: Node): Node | null { + return this.context + .getNodesInFile(type.filePath) + .filter((n) => n.name === type.name && TYPE_COMPANION_VALUE_KINDS.has(n.kind)) + .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0] ?? null; + } + private sameNamedTypeOfValue(value: Node): Node | null { if (value.kind !== 'constant' && value.kind !== 'variable') return null; if (value.language !== 'typescript' && value.language !== 'tsx') return null;