import-resolver.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  1. /**
  2. * Import Resolver
  3. *
  4. * Resolves import paths to actual files and symbols.
  5. */
  6. import * as path from 'path';
  7. import { Language, Node } from '../types';
  8. import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
  9. import { applyAliases } from './path-aliases';
  10. /**
  11. * Extension resolution order by language
  12. */
  13. const EXTENSION_RESOLUTION: Record<string, string[]> = {
  14. typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'],
  15. javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'],
  16. tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
  17. jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
  18. python: ['.py', '/__init__.py'],
  19. go: ['.go'],
  20. rust: ['.rs', '/mod.rs'],
  21. java: ['.java'],
  22. csharp: ['.cs'],
  23. php: ['.php'],
  24. ruby: ['.rb'],
  25. objc: ['.h', '.m', '.mm'],
  26. };
  27. /**
  28. * Resolve an import path to an actual file
  29. */
  30. export function resolveImportPath(
  31. importPath: string,
  32. fromFile: string,
  33. language: Language,
  34. context: ResolutionContext
  35. ): string | null {
  36. // Skip external/npm packages — but pass the context so the
  37. // bare-specifier heuristic can consult the project's tsconfig
  38. // alias map first (custom prefixes like `@components/*` would
  39. // otherwise be misclassified as npm).
  40. if (isExternalImport(importPath, language, context)) {
  41. return null;
  42. }
  43. const projectRoot = context.getProjectRoot();
  44. const fromDir = path.dirname(path.join(projectRoot, fromFile));
  45. // Handle relative imports
  46. if (importPath.startsWith('.')) {
  47. return resolveRelativeImport(importPath, fromDir, language, context);
  48. }
  49. // Handle absolute/aliased imports (like @/ or src/)
  50. return resolveAliasedImport(importPath, projectRoot, language, context);
  51. }
  52. /**
  53. * Check if an import is external (npm package, etc.)
  54. *
  55. * `context` is consulted for project-defined path aliases
  56. * (tsconfig/jsconfig `paths`). Without that check, custom prefixes
  57. * like `@components/*` would fail the bare-specifier heuristic and
  58. * be classified as external before alias resolution can run.
  59. */
  60. function isExternalImport(
  61. importPath: string,
  62. language: Language,
  63. context?: ResolutionContext
  64. ): boolean {
  65. // Relative imports are not external
  66. if (importPath.startsWith('.')) {
  67. return false;
  68. }
  69. // Common external patterns
  70. if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
  71. // Node built-ins
  72. if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
  73. return true;
  74. }
  75. // Project-defined alias prefix? Treat as local.
  76. const aliases = context?.getProjectAliases?.();
  77. if (aliases) {
  78. for (const pat of aliases.patterns) {
  79. if (importPath.startsWith(pat.prefix)) return false;
  80. }
  81. }
  82. // Scoped packages or bare specifiers that don't start with aliases
  83. if (!importPath.startsWith('@/') && !importPath.startsWith('~/') && !importPath.startsWith('src/')) {
  84. // Likely an npm package
  85. return true;
  86. }
  87. }
  88. if (language === 'python') {
  89. // Standard library modules
  90. const stdLibs = ['os', 'sys', 'json', 're', 'math', 'datetime', 'collections', 'typing', 'pathlib', 'logging'];
  91. if (stdLibs.includes(importPath.split('.')[0]!)) {
  92. return true;
  93. }
  94. }
  95. if (language === 'go') {
  96. // Relative imports (rare in idiomatic Go but the grammar allows them).
  97. if (importPath.startsWith('.')) {
  98. return false;
  99. }
  100. // In-module imports look like `<module-path>/sub/pkg` — local to
  101. // this project. Without the module-path check we'd flag every
  102. // cross-package call in a Go monorepo as external (issue #388).
  103. const mod = context?.getGoModule?.();
  104. if (mod && (importPath === mod.modulePath || importPath.startsWith(mod.modulePath + '/'))) {
  105. return false;
  106. }
  107. // `internal/` packages stay local even when go.mod is missing —
  108. // preserves the pre-#388 escape hatch for repos without a parsed module path.
  109. if (importPath.includes('/internal/')) {
  110. return false;
  111. }
  112. // Anything else is the Go standard library or a third-party module.
  113. return true;
  114. }
  115. return false;
  116. }
  117. /**
  118. * Resolve a relative import
  119. */
  120. function resolveRelativeImport(
  121. importPath: string,
  122. fromDir: string,
  123. language: Language,
  124. context: ResolutionContext
  125. ): string | null {
  126. const projectRoot = context.getProjectRoot();
  127. const extensions = EXTENSION_RESOLUTION[language] || [];
  128. // Try the path as-is first
  129. const basePath = path.resolve(fromDir, importPath);
  130. const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
  131. // Try each extension
  132. for (const ext of extensions) {
  133. const candidatePath = relativePath + ext;
  134. if (context.fileExists(candidatePath)) {
  135. return candidatePath;
  136. }
  137. }
  138. // Try without extension (might already have one)
  139. if (context.fileExists(relativePath)) {
  140. return relativePath;
  141. }
  142. return null;
  143. }
  144. /**
  145. * Resolve an aliased/absolute import.
  146. *
  147. * Tries, in order:
  148. * 1. Project-defined `compilerOptions.paths` (tsconfig/jsconfig).
  149. * Each pattern can have multiple replacements; tried in tsconfig
  150. * priority order with extension permutations.
  151. * 2. The legacy hard-coded fallback list (`@/`, `~/`, `src/`, ...)
  152. * for projects that have aliases but no tsconfig paths block.
  153. * 3. Direct path lookup (with extensions).
  154. */
  155. function resolveAliasedImport(
  156. importPath: string,
  157. projectRoot: string,
  158. language: Language,
  159. context: ResolutionContext
  160. ): string | null {
  161. const extensions = EXTENSION_RESOLUTION[language] || [];
  162. const tryWithExt = (basePath: string): string | null => {
  163. for (const ext of extensions) {
  164. const candidate = basePath + ext;
  165. if (context.fileExists(candidate)) return candidate;
  166. }
  167. if (context.fileExists(basePath)) return basePath;
  168. return null;
  169. };
  170. // 1. Project tsconfig/jsconfig paths.
  171. const aliasMap = context.getProjectAliases?.();
  172. if (aliasMap) {
  173. const candidates = applyAliases(importPath, aliasMap, projectRoot);
  174. for (const c of candidates) {
  175. const hit = tryWithExt(c);
  176. if (hit) return hit;
  177. }
  178. }
  179. // 2. Hard-coded fallback list. Kept for projects that use these
  180. // conventional aliases without declaring them in tsconfig.
  181. const fallbackAliases: Record<string, string> = {
  182. '@/': 'src/',
  183. '~/': 'src/',
  184. '@src/': 'src/',
  185. 'src/': 'src/',
  186. '@app/': 'app/',
  187. 'app/': 'app/',
  188. };
  189. for (const [alias, replacement] of Object.entries(fallbackAliases)) {
  190. if (importPath.startsWith(alias)) {
  191. const hit = tryWithExt(importPath.replace(alias, replacement));
  192. if (hit) return hit;
  193. }
  194. }
  195. // 3. Direct path.
  196. return tryWithExt(importPath);
  197. }
  198. /**
  199. * Extract import mappings from a file
  200. */
  201. export function extractImportMappings(
  202. _filePath: string,
  203. content: string,
  204. language: Language
  205. ): ImportMapping[] {
  206. const mappings: ImportMapping[] = [];
  207. if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
  208. mappings.push(...extractJSImports(content));
  209. } else if (language === 'python') {
  210. mappings.push(...extractPythonImports(content));
  211. } else if (language === 'go') {
  212. mappings.push(...extractGoImports(content));
  213. } else if (language === 'php') {
  214. mappings.push(...extractPHPImports(content));
  215. }
  216. return mappings;
  217. }
  218. /**
  219. * Extract JS/TS import mappings
  220. */
  221. function extractJSImports(content: string): ImportMapping[] {
  222. const mappings: ImportMapping[] = [];
  223. // ES6 imports
  224. const importRegex = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+(\w+))?\s*from\s*['"]([^'"]+)['"]/g;
  225. let match;
  226. while ((match = importRegex.exec(content)) !== null) {
  227. const [, defaultImport, namedImports, star, namespaceAlias, source] = match;
  228. // Default import
  229. if (defaultImport) {
  230. mappings.push({
  231. localName: defaultImport,
  232. exportedName: 'default',
  233. source: source!,
  234. isDefault: true,
  235. isNamespace: false,
  236. });
  237. }
  238. // Named imports
  239. if (namedImports) {
  240. const names = namedImports.split(',').map((s) => s.trim());
  241. for (const name of names) {
  242. const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
  243. if (aliasMatch) {
  244. mappings.push({
  245. localName: aliasMatch[2]!,
  246. exportedName: aliasMatch[1]!,
  247. source: source!,
  248. isDefault: false,
  249. isNamespace: false,
  250. });
  251. } else if (name) {
  252. mappings.push({
  253. localName: name,
  254. exportedName: name,
  255. source: source!,
  256. isDefault: false,
  257. isNamespace: false,
  258. });
  259. }
  260. }
  261. }
  262. // Namespace import
  263. if (star && namespaceAlias) {
  264. mappings.push({
  265. localName: namespaceAlias,
  266. exportedName: '*',
  267. source: source!,
  268. isDefault: false,
  269. isNamespace: true,
  270. });
  271. }
  272. }
  273. // Require statements
  274. const requireRegex = /(?:const|let|var)\s+(?:(\w+)|{([^}]+)})\s*=\s*require\(['"]([^'"]+)['"]\)/g;
  275. while ((match = requireRegex.exec(content)) !== null) {
  276. const [, defaultName, destructured, source] = match;
  277. if (defaultName) {
  278. mappings.push({
  279. localName: defaultName,
  280. exportedName: 'default',
  281. source: source!,
  282. isDefault: true,
  283. isNamespace: false,
  284. });
  285. }
  286. if (destructured) {
  287. const names = destructured.split(',').map((s) => s.trim());
  288. for (const name of names) {
  289. const aliasMatch = name.match(/(\w+)\s*:\s*(\w+)/);
  290. if (aliasMatch) {
  291. mappings.push({
  292. localName: aliasMatch[2]!,
  293. exportedName: aliasMatch[1]!,
  294. source: source!,
  295. isDefault: false,
  296. isNamespace: false,
  297. });
  298. } else if (name) {
  299. mappings.push({
  300. localName: name,
  301. exportedName: name,
  302. source: source!,
  303. isDefault: false,
  304. isNamespace: false,
  305. });
  306. }
  307. }
  308. }
  309. }
  310. return mappings;
  311. }
  312. /**
  313. * Extract Python import mappings
  314. */
  315. function extractPythonImports(content: string): ImportMapping[] {
  316. const mappings: ImportMapping[] = [];
  317. // from X import Y
  318. const fromImportRegex = /from\s+([\w.]+)\s+import\s+([^#\n]+)/g;
  319. let match;
  320. while ((match = fromImportRegex.exec(content)) !== null) {
  321. const [, source, imports] = match;
  322. const names = imports!.split(',').map((s) => s.trim());
  323. for (const name of names) {
  324. const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
  325. if (aliasMatch) {
  326. mappings.push({
  327. localName: aliasMatch[2]!,
  328. exportedName: aliasMatch[1]!,
  329. source: source!,
  330. isDefault: false,
  331. isNamespace: false,
  332. });
  333. } else if (name && name !== '*') {
  334. mappings.push({
  335. localName: name,
  336. exportedName: name,
  337. source: source!,
  338. isDefault: false,
  339. isNamespace: false,
  340. });
  341. }
  342. }
  343. }
  344. // import X
  345. const importRegex = /^import\s+([\w.]+)(?:\s+as\s+(\w+))?/gm;
  346. while ((match = importRegex.exec(content)) !== null) {
  347. const [, source, alias] = match;
  348. const localName = alias || source!.split('.').pop()!;
  349. mappings.push({
  350. localName,
  351. exportedName: '*',
  352. source: source!,
  353. isDefault: false,
  354. isNamespace: true,
  355. });
  356. }
  357. return mappings;
  358. }
  359. /**
  360. * Extract Go import mappings
  361. */
  362. function extractGoImports(content: string): ImportMapping[] {
  363. const mappings: ImportMapping[] = [];
  364. // import "path" or import alias "path"
  365. const singleImportRegex = /import\s+(?:(\w+)\s+)?["']([^"']+)["']/g;
  366. let match;
  367. while ((match = singleImportRegex.exec(content)) !== null) {
  368. const [, alias, source] = match;
  369. const packageName = source!.split('/').pop()!;
  370. mappings.push({
  371. localName: alias || packageName,
  372. exportedName: '*',
  373. source: source!,
  374. isDefault: false,
  375. isNamespace: true,
  376. });
  377. }
  378. // import ( ... ) block
  379. const blockImportRegex = /import\s*\(\s*([^)]+)\s*\)/gs;
  380. while ((match = blockImportRegex.exec(content)) !== null) {
  381. const block = match[1]!;
  382. const lineRegex = /(?:(\w+)\s+)?["']([^"']+)["']/g;
  383. let lineMatch;
  384. while ((lineMatch = lineRegex.exec(block)) !== null) {
  385. const [, alias, source] = lineMatch;
  386. const packageName = source!.split('/').pop()!;
  387. mappings.push({
  388. localName: alias || packageName,
  389. exportedName: '*',
  390. source: source!,
  391. isDefault: false,
  392. isNamespace: true,
  393. });
  394. }
  395. }
  396. return mappings;
  397. }
  398. /**
  399. * Extract PHP import mappings (use statements)
  400. */
  401. function extractPHPImports(content: string): ImportMapping[] {
  402. const mappings: ImportMapping[] = [];
  403. // use Namespace\Class; or use Namespace\Class as Alias;
  404. const useRegex = /use\s+([\w\\]+)(?:\s+as\s+(\w+))?;/g;
  405. let match;
  406. while ((match = useRegex.exec(content)) !== null) {
  407. const [, fullPath, alias] = match;
  408. const className = fullPath!.split('\\').pop()!;
  409. mappings.push({
  410. localName: alias || className,
  411. exportedName: className,
  412. source: fullPath!,
  413. isDefault: false,
  414. isNamespace: false,
  415. });
  416. }
  417. return mappings;
  418. }
  419. // Cache import mappings per file to avoid re-reading and re-parsing
  420. const importMappingCache = new Map<string, ImportMapping[]>();
  421. /**
  422. * Clear the import mapping cache (call between indexing runs)
  423. */
  424. export function clearImportMappingCache(): void {
  425. importMappingCache.clear();
  426. }
  427. /**
  428. * Strip JS line + block comments from `content` while preserving
  429. * string literals (so `"//"` inside a string stays intact). Used by
  430. * {@link extractReExports} so commented-out export-from statements
  431. * don't generate phantom re-export edges.
  432. *
  433. * Scanner is deliberately small: it only tracks the three contexts
  434. * relevant for JS/TS — single-quote string, double-quote string, and
  435. * template literal. Comment recognition is the JS spec subset, no
  436. * regex-literal awareness (which is fine for our use case: we don't
  437. * apply this to function bodies, only to top-level files).
  438. */
  439. function stripJsComments(content: string): string {
  440. let out = '';
  441. let i = 0;
  442. let str: '"' | "'" | '`' | null = null;
  443. while (i < content.length) {
  444. const ch = content[i]!;
  445. if (str !== null) {
  446. out += ch;
  447. if (ch === '\\' && i + 1 < content.length) {
  448. out += content[i + 1]!;
  449. i += 2;
  450. continue;
  451. }
  452. if (ch === str) str = null;
  453. i++;
  454. continue;
  455. }
  456. if (ch === '"' || ch === "'" || ch === '`') {
  457. str = ch;
  458. out += ch;
  459. i++;
  460. continue;
  461. }
  462. if (ch === '/' && content[i + 1] === '/') {
  463. while (i < content.length && content[i] !== '\n') i++;
  464. continue;
  465. }
  466. if (ch === '/' && content[i + 1] === '*') {
  467. i += 2;
  468. while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++;
  469. i += 2;
  470. continue;
  471. }
  472. out += ch;
  473. i++;
  474. }
  475. return out;
  476. }
  477. /**
  478. * Extract JS/TS re-export declarations from `content`.
  479. *
  480. * Recognised forms:
  481. * export { foo } from './a';
  482. * export { foo as bar } from './a';
  483. * export * from './a';
  484. * export * as ns from './a'; (treated as wildcard for chasing)
  485. * export { default as Foo } from './a';
  486. *
  487. * The walker intentionally stays regex-based — the import-resolver
  488. * elsewhere in this file already chooses regex over a fresh
  489. * tree-sitter pass, and this function shares that trade-off. Errors
  490. * fall through silently; resolution simply skips the broken file.
  491. */
  492. export function extractReExports(content: string, language: Language): ReExport[] {
  493. if (
  494. language !== 'typescript' &&
  495. language !== 'javascript' &&
  496. language !== 'tsx' &&
  497. language !== 'jsx'
  498. ) {
  499. return [];
  500. }
  501. const out: ReExport[] = [];
  502. // Pre-strip block comments + line comments so a commented-out
  503. // `// export { x } from '...'` doesn't produce a phantom edge.
  504. // (Template literals are still a possible source of false positives;
  505. // a project that builds export statements as runtime strings is
  506. // out of scope.)
  507. const cleaned = stripJsComments(content);
  508. // Wildcard: `export * from '...'` or `export * as ns from '...'`
  509. const wildcardRe = /export\s*\*(?:\s+as\s+\w+)?\s*from\s*['"]([^'"]+)['"]/g;
  510. let m: RegExpExecArray | null;
  511. while ((m = wildcardRe.exec(cleaned)) !== null) {
  512. out.push({ kind: 'wildcard', source: m[1]! });
  513. }
  514. // Named: `export { a, b as c } from '...'`
  515. const namedRe = /export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
  516. while ((m = namedRe.exec(cleaned)) !== null) {
  517. const inner = m[1]!;
  518. const source = m[2]!;
  519. for (const raw of inner.split(',')) {
  520. const item = raw.trim();
  521. if (!item) continue;
  522. const aliasMatch = item.match(/^(\w+)\s+as\s+(\w+)$/);
  523. if (aliasMatch) {
  524. out.push({
  525. kind: 'named',
  526. exportedName: aliasMatch[2]!,
  527. originalName: aliasMatch[1]!,
  528. source,
  529. });
  530. } else if (/^\w+$/.test(item)) {
  531. out.push({
  532. kind: 'named',
  533. exportedName: item,
  534. originalName: item,
  535. source,
  536. });
  537. }
  538. }
  539. }
  540. return out;
  541. }
  542. /**
  543. * Resolve a reference using import mappings
  544. */
  545. export function resolveViaImport(
  546. ref: UnresolvedRef,
  547. context: ResolutionContext
  548. ): ResolvedRef | null {
  549. // Use cached import mappings (avoids re-reading and re-parsing per ref)
  550. const imports = context.getImportMappings(ref.filePath, ref.language);
  551. if (imports.length === 0 && !context.readFile(ref.filePath)) {
  552. return null;
  553. }
  554. // Go cross-package calls: `pkga.FuncX(...)` extracts to referenceName
  555. // `pkga.FuncX` and the import `github.com/example/myproject/pkga`
  556. // maps to a *package directory* containing one or more .go files.
  557. // The generic file-based lookup below can't follow that — issue #388.
  558. if (ref.language === 'go') {
  559. const goResult = resolveGoCrossPackageReference(ref, imports, context);
  560. if (goResult) return goResult;
  561. }
  562. // Check if the reference name matches any import
  563. for (const imp of imports) {
  564. if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
  565. // Resolve the import path
  566. const resolvedPath = resolveImportPath(
  567. imp.source,
  568. ref.filePath,
  569. ref.language,
  570. context
  571. );
  572. if (resolvedPath) {
  573. const exportedName = imp.isDefault ? 'default' : imp.exportedName;
  574. const memberName = imp.isNamespace
  575. ? ref.referenceName.replace(imp.localName + '.', '')
  576. : null;
  577. const targetNode = findExportedSymbol(
  578. resolvedPath,
  579. { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName },
  580. ref.language,
  581. context,
  582. new Set()
  583. );
  584. if (targetNode) {
  585. return {
  586. original: ref,
  587. targetNodeId: targetNode.id,
  588. confidence: 0.9,
  589. resolvedBy: 'import',
  590. };
  591. }
  592. }
  593. }
  594. }
  595. return null;
  596. }
  597. /**
  598. * Resolve a Go cross-package qualified reference (`pkga.FuncX`) by matching
  599. * the package alias against an in-module import, stripping the module prefix
  600. * to a project-relative directory, and locating the exported symbol in any
  601. * `.go` file under that directory. Returns `null` for stdlib / third-party
  602. * imports (no `go.mod`-relative match) so the rest of `resolveViaImport`
  603. * can still try the file-based path.
  604. */
  605. function resolveGoCrossPackageReference(
  606. ref: UnresolvedRef,
  607. imports: ImportMapping[],
  608. context: ResolutionContext
  609. ): ResolvedRef | null {
  610. const mod = context.getGoModule?.();
  611. if (!mod) return null;
  612. // Qualified call: receiver before `.`, member after. A bare reference
  613. // (no dot) is a same-file/in-package call — handled elsewhere.
  614. const dotIdx = ref.referenceName.indexOf('.');
  615. if (dotIdx <= 0) return null;
  616. const receiver = ref.referenceName.substring(0, dotIdx);
  617. const memberName = ref.referenceName.substring(dotIdx + 1);
  618. if (!memberName) return null;
  619. for (const imp of imports) {
  620. if (imp.localName !== receiver) continue;
  621. // Only in-module imports map to a known directory.
  622. if (imp.source !== mod.modulePath && !imp.source.startsWith(mod.modulePath + '/')) {
  623. continue;
  624. }
  625. const pkgDir = imp.source === mod.modulePath
  626. ? ''
  627. : imp.source.substring(mod.modulePath.length + 1);
  628. // Look up the member by name and pick the candidate whose file lives
  629. // directly in the package directory. Match the immediate parent dir
  630. // exactly so a call to `pkga.FuncX` doesn't accidentally land on a
  631. // `FuncX` declared in `pkga/subpkg/`.
  632. const candidates = context.getNodesByName(memberName);
  633. for (const node of candidates) {
  634. if (node.language !== 'go') continue;
  635. if (!node.isExported) continue;
  636. const fp = node.filePath.replace(/\\/g, '/');
  637. const lastSlash = fp.lastIndexOf('/');
  638. const fileDir = lastSlash >= 0 ? fp.substring(0, lastSlash) : '';
  639. if (fileDir === pkgDir) {
  640. return {
  641. original: ref,
  642. targetNodeId: node.id,
  643. confidence: 0.9,
  644. resolvedBy: 'import',
  645. };
  646. }
  647. }
  648. }
  649. return null;
  650. }
  651. /** Recursive depth cap for re-export chain following. Real codebases
  652. * rarely chain barrels more than 2–3 deep; 8 is a generous safety
  653. * net that still bounds worst-case work. */
  654. const REEXPORT_MAX_DEPTH = 8;
  655. /**
  656. * Find an exported symbol in `filePath`, following `export { x } from
  657. * './other'` and `export * from './other'` chains until the original
  658. * declaration is reached. Cycle-safe via the `visited` set.
  659. *
  660. * Without this, every barrel-style import (`import { Foo } from
  661. * './index'` where `index.ts` only re-exports) used to resolve to
  662. * nothing — the existing code only looked for declarations IN the
  663. * resolved file, not declarations the file forwarded.
  664. */
  665. function findExportedSymbol(
  666. filePath: string,
  667. want: {
  668. isDefault: boolean;
  669. isNamespace: boolean;
  670. exportedName: string;
  671. memberName: string | null;
  672. },
  673. language: Language,
  674. context: ResolutionContext,
  675. visited: Set<string>,
  676. depth = 0
  677. ): Node | undefined {
  678. if (depth > REEXPORT_MAX_DEPTH) return undefined;
  679. if (visited.has(filePath)) return undefined;
  680. visited.add(filePath);
  681. const nodesInFile = context.getNodesInFile(filePath);
  682. // 1. Direct hit: the symbol is declared in this file.
  683. if (want.isDefault) {
  684. const direct = nodesInFile.find(
  685. (n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
  686. );
  687. if (direct) return direct;
  688. } else if (want.isNamespace && want.memberName) {
  689. const direct = nodesInFile.find(
  690. (n) => n.name === want.memberName && n.isExported
  691. );
  692. if (direct) return direct;
  693. } else {
  694. const direct = nodesInFile.find(
  695. (n) => n.name === want.exportedName && n.isExported
  696. );
  697. if (direct) return direct;
  698. }
  699. // 2. Re-export hit: the file forwards the symbol to another module.
  700. const reExports = context.getReExports?.(filePath, language) ?? [];
  701. if (reExports.length === 0) return undefined;
  702. // Look for explicit `export { want } from './other'` (with optional rename).
  703. const targetName = want.isDefault ? 'default' : want.exportedName;
  704. for (const rex of reExports) {
  705. if (rex.kind === 'named' && rex.exportedName === targetName) {
  706. const next = resolveImportPath(rex.source, filePath, language, context);
  707. if (!next) continue;
  708. // After rename: `export { foo as bar } from './x'` — to chase
  709. // `bar`, we look for `foo` in `./x`.
  710. const chained = findExportedSymbol(
  711. next,
  712. {
  713. isDefault: rex.originalName === 'default',
  714. isNamespace: false,
  715. exportedName: rex.originalName,
  716. memberName: null,
  717. },
  718. language,
  719. context,
  720. visited,
  721. depth + 1
  722. );
  723. if (chained) return chained;
  724. }
  725. }
  726. // 3. Wildcard re-export: `export * from './other'` — try every
  727. // forwarding source. This is the barrel-of-barrels case.
  728. for (const rex of reExports) {
  729. if (rex.kind === 'wildcard') {
  730. const next = resolveImportPath(rex.source, filePath, language, context);
  731. if (!next) continue;
  732. const chained = findExportedSymbol(next, want, language, context, visited, depth + 1);
  733. if (chained) return chained;
  734. }
  735. }
  736. return undefined;
  737. }