Procházet zdrojové kódy

feat(resolution): tsconfig path aliases + re-export chain following (#130)

* feat(resolution): tsconfig path aliases + re-export chain following

Two related correctness improvements that unlock accurate import
resolution on modern JS/TS codebases.

1) tsconfig/jsconfig path aliases.

The resolver previously had a hard-coded list of common aliases
(@/, ~/, src/, app/) and ignored any project-defined paths from
tsconfig.json compilerOptions.paths — which means every import
through @components/Foo, @lib/utils, etc. on Vite/Next/Nuxt/Nest
projects silently failed to resolve. Adds src/resolution/path-
aliases.ts that reads tsconfig.json (and falls back to jsconfig.json),
honours baseUrl, supports the * wildcard, and respects the priority
order of multiple replacement targets per alias. JSONC tolerant
(strips comments + trailing commas, common in the wild). The new
ResolutionContext.getProjectAliases() lazily loads + caches the
result; resolveAliasedImport consults it before the legacy fallback
list.

Verified live on a synthetic project with @utils/* and @lib custom
aliases: both resolved to the correct files and produced edges,
unresolved_refs empty.

2) Re-export chain following.

`import { Foo } from './barrel'` where barrel.ts only re-exports
(`export { Foo } from './real'` or `export * from './real'`) used
to fail because the resolver only looked for declarations IN the
resolved file — it never followed the export chain to the actual
definition. Adds extractReExports() (named + wildcard + as-rename
forms), a per-file getReExports() context method, and a recursive
findExportedSymbol() helper with depth cap (8) and visited-set
cycle protection. resolveViaImport now uses it whenever the symbol
isn't directly declared in the imported file.

Verified live on a synthetic 3-hop chain (main → all.ts wildcard →
index.ts named → auth.ts declaration): signIn resolved correctly,
unresolved_refs empty.

Full test suite: 380 passed, 0 failed.

* fix(resolution): address reviewer findings — isExternalImport bypass, JSONC strings, comment stripping, optional context method

Five fixes from independent semantic review:

- isExternalImport now consults context.getProjectAliases() before
  the bare-specifier heuristic. Without this, custom prefixes like
  '@components/*' from tsconfig.paths were classified as npm and
  resolveAliasedImport never even ran. Adds a context parameter
  (optional, for backward compat with mock contexts).

- stripJsonc rewritten as a string-aware state machine. The previous
  regex-only version corrupted any URL embedded in a JSON string
  value ('https://cdn.example.com' lost everything after '//').

- extractReExports now strips JS line+block comments from content
  before applying the regex, so a commented-out 'export { x } from
  ...' no longer creates a phantom re-export edge. New
  stripJsComments helper preserves string literals (single, double,
  template) so '//' inside a string stays intact.

- ResolutionContext.getProjectAliases() made optional so existing
  mock contexts in __tests__/resolution.test.ts (which TypeScript
  doesn't type-check because tsconfig excludes __tests__) don't
  throw at runtime when resolveAliasedImport hits them. Caller
  uses ?.

- Two new integration tests in __tests__/resolution.test.ts:
  * Path-alias resolution with name-collision: two pickMe() in
    different dirs, only the @utils-aliased one should be the
    call target. Asserts via getCallers on each candidate node.
  * No-tsconfig fallback: relative import still produces the call
    edge.

Full test suite: 832 passed (was 380; the increase is from the
biomarkers + LLM hooks that ship via parent branches).

* fix(resolution): allow re-export rename chains past the pre-filter

The fast pre-filter in resolveOne() bails when no symbol with the
reference name exists project-wide, which is incompatible with the
new chain-following code: a renamed re-export (`import { login }
from './barrel'` where the barrel does `export { signIn as login }
from './auth'`) intentionally calls a name that has no project-wide
declaration. The chain finds the renamed upstream symbol — but only
if resolution is allowed to run.

Add an import-mapping escape so the pre-filter only bails when the
ref also doesn't match any local import. Adds two tests covering the
3-hop wildcard chain and the named-rename branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
andreinknv před 1 měsícem
rodič
revize
d151c0f922

+ 135 - 0
__tests__/resolution.test.ts

@@ -711,4 +711,139 @@ def bootstrap():
       expect(result?.targetNodeId).toBe('func:di.ts:Inject:10');
     });
   });
+
+  describe('tsconfig path aliases', () => {
+    it('resolves an aliased import to the alias-mapped file (not a same-named file elsewhere)', async () => {
+      // Two same-named exports in different directories. Without alias
+      // resolution, name-matcher would pick whichever it finds first;
+      // with alias resolution, the import path uniquely picks one.
+      fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true });
+      fs.mkdirSync(path.join(tempDir, 'src/legacy'), { recursive: true });
+      fs.writeFileSync(
+        path.join(tempDir, 'src/utils/format.ts'),
+        `export function pickMe(): number { return 1; }\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/legacy/format.ts'),
+        `export function pickMe(): number { return 99; }\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/main.ts'),
+        `import { pickMe } from '@utils/format';\nexport function go(): number { return pickMe(); }\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'tsconfig.json'),
+        JSON.stringify({
+          compilerOptions: {
+            baseUrl: './src',
+            paths: { '@utils/*': ['utils/*'] },
+          },
+        })
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+
+      // The two pickMe nodes live in different files. The aliased
+      // import should attach the call edge to the @utils-mapped one,
+      // not the legacy duplicate.
+      const all = cg.getNodesByKind('function').filter((n) => n.name === 'pickMe');
+      const utilsNode = all.find((n) => n.filePath === 'src/utils/format.ts');
+      const legacyNode = all.find((n) => n.filePath === 'src/legacy/format.ts');
+      expect(utilsNode).toBeDefined();
+      expect(legacyNode).toBeDefined();
+
+      const utilsCallers = cg.getCallers(utilsNode!.id);
+      const legacyCallers = cg.getCallers(legacyNode!.id);
+      expect(utilsCallers.length).toBeGreaterThan(0);
+      expect(utilsCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
+      // The legacy node should NOT have a caller from src/main.ts —
+      // the alias correctly picked the utils version.
+      expect(legacyCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(false);
+    });
+
+    it('falls back gracefully when tsconfig is absent', async () => {
+      fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
+      fs.writeFileSync(
+        path.join(tempDir, 'src/a.ts'),
+        `export function aFn(): void {}\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/b.ts'),
+        `import { aFn } from './a';\nexport function bFn(): void { aFn(); }\n`
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+      // No tsconfig present — index should still complete and the
+      // relative-import-based call edge should be created.
+      const aFn = cg.getNodesByKind('function').find((n) => n.name === 'aFn');
+      expect(aFn).toBeDefined();
+      const callers = cg.getCallers(aFn!.id);
+      expect(callers.some((c) => c.node.filePath === 'src/b.ts')).toBe(true);
+    });
+  });
+
+  describe('re-export chain following', () => {
+    it('chases a 3-hop barrel chain (wildcard → named → declaration)', async () => {
+      // main.ts → all.ts (wildcard) → index.ts (named) → auth.ts (declaration).
+      // Without chain following, `signIn` resolves to nothing because
+      // none of the barrel files declare it directly.
+      fs.mkdirSync(path.join(tempDir, 'src/services'), { recursive: true });
+      fs.writeFileSync(
+        path.join(tempDir, 'src/services/auth.ts'),
+        `export function signIn(): void {}\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/services/index.ts'),
+        `export { signIn } from './auth';\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/all.ts'),
+        `export * from './services/index';\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/main.ts'),
+        `import { signIn } from './all';\nexport function go(): void { signIn(); }\n`
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+
+      const signInNode = cg
+        .getNodesByKind('function')
+        .find((n) => n.name === 'signIn' && n.filePath === 'src/services/auth.ts');
+      expect(signInNode).toBeDefined();
+      const callers = cg.getCallers(signInNode!.id);
+      expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
+    });
+
+    it('follows a renamed named re-export (export { foo as bar } from ...)', async () => {
+      // The chase has to look up `foo` in the upstream module even
+      // though the importer asked for `bar` — exercises the rename
+      // branch of findExportedSymbol.
+      fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
+      fs.writeFileSync(
+        path.join(tempDir, 'src/auth.ts'),
+        `export function signIn(): void {}\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/index.ts'),
+        `export { signIn as login } from './auth';\n`
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'src/main.ts'),
+        `import { login } from './index';\nexport function go(): void { login(); }\n`
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+
+      const signInNode = cg
+        .getNodesByKind('function')
+        .find((n) => n.name === 'signIn' && n.filePath === 'src/auth.ts');
+      expect(signInNode).toBeDefined();
+      const callers = cg.getCallers(signInNode!.id);
+      expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
+    });
+  });
 });

+ 287 - 57
src/resolution/import-resolver.ts

@@ -6,7 +6,8 @@
 
 import * as path from 'path';
 import { Language, Node } from '../types';
-import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping } from './types';
+import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
+import { applyAliases } from './path-aliases';
 
 /**
  * Extension resolution order by language
@@ -34,8 +35,11 @@ export function resolveImportPath(
   language: Language,
   context: ResolutionContext
 ): string | null {
-  // Skip external/npm packages
-  if (isExternalImport(importPath, language)) {
+  // Skip external/npm packages — but pass the context so the
+  // bare-specifier heuristic can consult the project's tsconfig
+  // alias map first (custom prefixes like `@components/*` would
+  // otherwise be misclassified as npm).
+  if (isExternalImport(importPath, language, context)) {
     return null;
   }
 
@@ -53,8 +57,17 @@ export function resolveImportPath(
 
 /**
  * Check if an import is external (npm package, etc.)
+ *
+ * `context` is consulted for project-defined path aliases
+ * (tsconfig/jsconfig `paths`). Without that check, custom prefixes
+ * like `@components/*` would fail the bare-specifier heuristic and
+ * be classified as external before alias resolution can run.
  */
-function isExternalImport(importPath: string, language: Language): boolean {
+function isExternalImport(
+  importPath: string,
+  language: Language,
+  context?: ResolutionContext
+): boolean {
   // Relative imports are not external
   if (importPath.startsWith('.')) {
     return false;
@@ -66,6 +79,13 @@ function isExternalImport(importPath: string, language: Language): boolean {
     if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
       return true;
     }
+    // Project-defined alias prefix? Treat as local.
+    const aliases = context?.getProjectAliases?.();
+    if (aliases) {
+      for (const pat of aliases.patterns) {
+        if (importPath.startsWith(pat.prefix)) return false;
+      }
+    }
     // Scoped packages or bare specifiers that don't start with aliases
     if (!importPath.startsWith('@/') && !importPath.startsWith('~/') && !importPath.startsWith('src/')) {
       // Likely an npm package
@@ -124,18 +144,45 @@ function resolveRelativeImport(
 }
 
 /**
- * Resolve an aliased/absolute import
+ * Resolve an aliased/absolute import.
+ *
+ * Tries, in order:
+ *   1. Project-defined `compilerOptions.paths` (tsconfig/jsconfig).
+ *      Each pattern can have multiple replacements; tried in tsconfig
+ *      priority order with extension permutations.
+ *   2. The legacy hard-coded fallback list (`@/`, `~/`, `src/`, ...)
+ *      for projects that have aliases but no tsconfig paths block.
+ *   3. Direct path lookup (with extensions).
  */
 function resolveAliasedImport(
   importPath: string,
-  _projectRoot: string,
+  projectRoot: string,
   language: Language,
   context: ResolutionContext
 ): string | null {
   const extensions = EXTENSION_RESOLUTION[language] || [];
+  const tryWithExt = (basePath: string): string | null => {
+    for (const ext of extensions) {
+      const candidate = basePath + ext;
+      if (context.fileExists(candidate)) return candidate;
+    }
+    if (context.fileExists(basePath)) return basePath;
+    return null;
+  };
 
-  // Common aliases
-  const aliases: Record<string, string> = {
+  // 1. Project tsconfig/jsconfig paths.
+  const aliasMap = context.getProjectAliases?.();
+  if (aliasMap) {
+    const candidates = applyAliases(importPath, aliasMap, projectRoot);
+    for (const c of candidates) {
+      const hit = tryWithExt(c);
+      if (hit) return hit;
+    }
+  }
+
+  // 2. Hard-coded fallback list. Kept for projects that use these
+  //    conventional aliases without declaring them in tsconfig.
+  const fallbackAliases: Record<string, string> = {
     '@/': 'src/',
     '~/': 'src/',
     '@src/': 'src/',
@@ -143,36 +190,15 @@ function resolveAliasedImport(
     '@app/': 'app/',
     'app/': 'app/',
   };
-
-  // Try each alias
-  for (const [alias, replacement] of Object.entries(aliases)) {
+  for (const [alias, replacement] of Object.entries(fallbackAliases)) {
     if (importPath.startsWith(alias)) {
-      const resolvedPath = importPath.replace(alias, replacement);
-
-      // Try with extensions
-      for (const ext of extensions) {
-        const candidatePath = resolvedPath + ext;
-        if (context.fileExists(candidatePath)) {
-          return candidatePath;
-        }
-      }
-
-      // Try as-is
-      if (context.fileExists(resolvedPath)) {
-        return resolvedPath;
-      }
+      const hit = tryWithExt(importPath.replace(alias, replacement));
+      if (hit) return hit;
     }
   }
 
-  // Try direct path
-  for (const ext of extensions) {
-    const candidatePath = importPath + ext;
-    if (context.fileExists(candidatePath)) {
-      return candidatePath;
-    }
-  }
-
-  return null;
+  // 3. Direct path.
+  return tryWithExt(importPath);
 }
 
 /**
@@ -435,6 +461,127 @@ export function clearImportMappingCache(): void {
   importMappingCache.clear();
 }
 
+/**
+ * Strip JS line + block comments from `content` while preserving
+ * string literals (so `"//"` inside a string stays intact). Used by
+ * {@link extractReExports} so commented-out export-from statements
+ * don't generate phantom re-export edges.
+ *
+ * Scanner is deliberately small: it only tracks the three contexts
+ * relevant for JS/TS — single-quote string, double-quote string, and
+ * template literal. Comment recognition is the JS spec subset, no
+ * regex-literal awareness (which is fine for our use case: we don't
+ * apply this to function bodies, only to top-level files).
+ */
+function stripJsComments(content: string): string {
+  let out = '';
+  let i = 0;
+  let str: '"' | "'" | '`' | null = null;
+  while (i < content.length) {
+    const ch = content[i]!;
+    if (str !== null) {
+      out += ch;
+      if (ch === '\\' && i + 1 < content.length) {
+        out += content[i + 1]!;
+        i += 2;
+        continue;
+      }
+      if (ch === str) str = null;
+      i++;
+      continue;
+    }
+    if (ch === '"' || ch === "'" || ch === '`') {
+      str = ch;
+      out += ch;
+      i++;
+      continue;
+    }
+    if (ch === '/' && content[i + 1] === '/') {
+      while (i < content.length && content[i] !== '\n') i++;
+      continue;
+    }
+    if (ch === '/' && content[i + 1] === '*') {
+      i += 2;
+      while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++;
+      i += 2;
+      continue;
+    }
+    out += ch;
+    i++;
+  }
+  return out;
+}
+
+/**
+ * Extract JS/TS re-export declarations from `content`.
+ *
+ * Recognised forms:
+ *   export { foo } from './a';
+ *   export { foo as bar } from './a';
+ *   export * from './a';
+ *   export * as ns from './a';   (treated as wildcard for chasing)
+ *   export { default as Foo } from './a';
+ *
+ * The walker intentionally stays regex-based — the import-resolver
+ * elsewhere in this file already chooses regex over a fresh
+ * tree-sitter pass, and this function shares that trade-off. Errors
+ * fall through silently; resolution simply skips the broken file.
+ */
+export function extractReExports(content: string, language: Language): ReExport[] {
+  if (
+    language !== 'typescript' &&
+    language !== 'javascript' &&
+    language !== 'tsx' &&
+    language !== 'jsx'
+  ) {
+    return [];
+  }
+  const out: ReExport[] = [];
+
+  // Pre-strip block comments + line comments so a commented-out
+  // `// export { x } from '...'` doesn't produce a phantom edge.
+  // (Template literals are still a possible source of false positives;
+  // a project that builds export statements as runtime strings is
+  // out of scope.)
+  const cleaned = stripJsComments(content);
+
+  // Wildcard: `export * from '...'` or `export * as ns from '...'`
+  const wildcardRe = /export\s*\*(?:\s+as\s+\w+)?\s*from\s*['"]([^'"]+)['"]/g;
+  let m: RegExpExecArray | null;
+  while ((m = wildcardRe.exec(cleaned)) !== null) {
+    out.push({ kind: 'wildcard', source: m[1]! });
+  }
+
+  // Named: `export { a, b as c } from '...'`
+  const namedRe = /export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
+  while ((m = namedRe.exec(cleaned)) !== null) {
+    const inner = m[1]!;
+    const source = m[2]!;
+    for (const raw of inner.split(',')) {
+      const item = raw.trim();
+      if (!item) continue;
+      const aliasMatch = item.match(/^(\w+)\s+as\s+(\w+)$/);
+      if (aliasMatch) {
+        out.push({
+          kind: 'named',
+          exportedName: aliasMatch[2]!,
+          originalName: aliasMatch[1]!,
+          source,
+        });
+      } else if (/^\w+$/.test(item)) {
+        out.push({
+          kind: 'named',
+          exportedName: item,
+          originalName: item,
+          source,
+        });
+      }
+    }
+  }
+
+  return out;
+}
+
 /**
  * Resolve a reference using import mappings
  */
@@ -460,30 +607,18 @@ export function resolveViaImport(
       );
 
       if (resolvedPath) {
-        // Find the exported symbol in the resolved file
-        const nodesInFile = context.getNodesInFile(resolvedPath);
         const exportedName = imp.isDefault ? 'default' : imp.exportedName;
-
-        // Look for the symbol
-        let targetNode: Node | undefined;
-
-        if (imp.isDefault) {
-          // Find default export or main class/function
-          targetNode = nodesInFile.find(
-            (n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
-          );
-        } else if (imp.isNamespace) {
-          // Namespace import - look for the specific member
-          const memberName = ref.referenceName.replace(imp.localName + '.', '');
-          targetNode = nodesInFile.find(
-            (n) => n.name === memberName && n.isExported
-          );
-        } else {
-          // Named import
-          targetNode = nodesInFile.find(
-            (n) => n.name === exportedName && n.isExported
-          );
-        }
+        const memberName = imp.isNamespace
+          ? ref.referenceName.replace(imp.localName + '.', '')
+          : null;
+
+        const targetNode = findExportedSymbol(
+          resolvedPath,
+          { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName },
+          ref.language,
+          context,
+          new Set()
+        );
 
         if (targetNode) {
           return {
@@ -499,3 +634,98 @@ export function resolveViaImport(
 
   return null;
 }
+
+/** Recursive depth cap for re-export chain following. Real codebases
+ *  rarely chain barrels more than 2–3 deep; 8 is a generous safety
+ *  net that still bounds worst-case work. */
+const REEXPORT_MAX_DEPTH = 8;
+
+/**
+ * Find an exported symbol in `filePath`, following `export { x } from
+ * './other'` and `export * from './other'` chains until the original
+ * declaration is reached. Cycle-safe via the `visited` set.
+ *
+ * Without this, every barrel-style import (`import { Foo } from
+ * './index'` where `index.ts` only re-exports) used to resolve to
+ * nothing — the existing code only looked for declarations IN the
+ * resolved file, not declarations the file forwarded.
+ */
+function findExportedSymbol(
+  filePath: string,
+  want: {
+    isDefault: boolean;
+    isNamespace: boolean;
+    exportedName: string;
+    memberName: string | null;
+  },
+  language: Language,
+  context: ResolutionContext,
+  visited: Set<string>,
+  depth = 0
+): Node | undefined {
+  if (depth > REEXPORT_MAX_DEPTH) return undefined;
+  if (visited.has(filePath)) return undefined;
+  visited.add(filePath);
+
+  const nodesInFile = context.getNodesInFile(filePath);
+
+  // 1. Direct hit: the symbol is declared in this file.
+  if (want.isDefault) {
+    const direct = nodesInFile.find(
+      (n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
+    );
+    if (direct) return direct;
+  } else if (want.isNamespace && want.memberName) {
+    const direct = nodesInFile.find(
+      (n) => n.name === want.memberName && n.isExported
+    );
+    if (direct) return direct;
+  } else {
+    const direct = nodesInFile.find(
+      (n) => n.name === want.exportedName && n.isExported
+    );
+    if (direct) return direct;
+  }
+
+  // 2. Re-export hit: the file forwards the symbol to another module.
+  const reExports = context.getReExports?.(filePath, language) ?? [];
+  if (reExports.length === 0) return undefined;
+
+  // Look for explicit `export { want } from './other'` (with optional rename).
+  const targetName = want.isDefault ? 'default' : want.exportedName;
+  for (const rex of reExports) {
+    if (rex.kind === 'named' && rex.exportedName === targetName) {
+      const next = resolveImportPath(rex.source, filePath, language, context);
+      if (!next) continue;
+      // After rename: `export { foo as bar } from './x'` — to chase
+      // `bar`, we look for `foo` in `./x`.
+      const chained = findExportedSymbol(
+        next,
+        {
+          isDefault: rex.originalName === 'default',
+          isNamespace: false,
+          exportedName: rex.originalName,
+          memberName: null,
+        },
+        language,
+        context,
+        visited,
+        depth + 1
+      );
+      if (chained) return chained;
+    }
+  }
+
+  // 3. Wildcard re-export: `export * from './other'` — try every
+  //    forwarding source. This is the barrel-of-barrels case.
+  for (const rex of reExports) {
+    if (rex.kind === 'wildcard') {
+      const next = resolveImportPath(rex.source, filePath, language, context);
+      if (!next) continue;
+      const chained = findExportedSymbol(next, want, language, context, visited, depth + 1);
+      if (chained) return chained;
+    }
+  }
+
+  return undefined;
+}

+ 54 - 2
src/resolution/index.ts

@@ -17,9 +17,11 @@ import {
   ImportMapping,
 } from './types';
 import { matchReference } from './name-matcher';
-import { resolveViaImport, extractImportMappings } from './import-resolver';
+import { resolveViaImport, extractImportMappings, extractReExports } from './import-resolver';
 import { detectFrameworks } from './frameworks';
+import { loadProjectAliases, type AliasMap } from './path-aliases';
 import { logDebug } from '../errors';
+import type { ReExport } from './types';
 
 // Re-export types
 export * from './types';
@@ -122,12 +124,17 @@ export class ReferenceResolver {
   private nodeCache: Map<string, Node[]> = new Map(); // per-file node cache (bounded)
   private fileCache: Map<string, string | null> = new Map(); // per-file content cache (bounded)
   private importMappingCache: Map<string, ImportMapping[]> = new Map();
+  private reExportCache: Map<string, ReExport[]> = new Map();
   private nameCache: Map<string, Node[]> = new Map(); // name → nodes cache
   private lowerNameCache: Map<string, Node[]> = new Map(); // lower(name) → nodes cache
   private qualifiedNameCache: Map<string, Node[]> = new Map(); // qualified_name → nodes cache
   private knownNames: Set<string> | null = null; // all known symbol names for fast pre-filtering
   private knownFiles: Set<string> | null = null;
   private cachesWarmed = false;
+  // tsconfig/jsconfig path-alias map. `undefined` = not yet computed,
+  // `null` = computed and absent. Treated as immutable for the
+  // resolver's lifetime; callers re-create the resolver if config changes.
+  private projectAliases: AliasMap | null | undefined = undefined;
 
   constructor(projectRoot: string, queries: QueryBuilder) {
     this.projectRoot = projectRoot;
@@ -168,6 +175,7 @@ export class ReferenceResolver {
     this.nodeCache.clear();
     this.fileCache.clear();
     this.importMappingCache.clear();
+    this.reExportCache.clear();
     this.nameCache.clear();
     this.lowerNameCache.clear();
     this.qualifiedNameCache.clear();
@@ -272,6 +280,26 @@ export class ReferenceResolver {
         this.importMappingCache.set(cacheKey, mappings);
         return mappings;
       },
+
+      getProjectAliases: () => {
+        if (this.projectAliases === undefined) {
+          this.projectAliases = loadProjectAliases(this.projectRoot);
+        }
+        return this.projectAliases;
+      },
+
+      getReExports: (filePath: string, language) => {
+        const cached = this.reExportCache.get(filePath);
+        if (cached) return cached;
+        const content = this.context.readFile(filePath);
+        if (!content) {
+          this.reExportCache.set(filePath, []);
+          return [];
+        }
+        const reExports = extractReExports(content, language);
+        this.reExportCache.set(filePath, reExports);
+        return reExports;
+      },
     };
   }
 
@@ -379,6 +407,25 @@ export class ReferenceResolver {
     return false;
   }
 
+  /**
+   * Does `ref.referenceName` match an import declared in its containing
+   * file? Used as a pre-filter escape so re-export chain resolution
+   * still gets a chance when the name has no project-wide declaration.
+   */
+  private matchesAnyImport(ref: UnresolvedRef): boolean {
+    const imports = this.context.getImportMappings(ref.filePath, ref.language);
+    if (imports.length === 0) return false;
+    for (const imp of imports) {
+      if (
+        imp.localName === ref.referenceName ||
+        ref.referenceName.startsWith(imp.localName + '.')
+      ) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   /**
    * Resolve a single reference
    */
@@ -389,7 +436,12 @@ export class ReferenceResolver {
     }
 
     // Fast pre-filter: skip if no symbol with this name exists anywhere
-    if (!this.hasAnyPossibleMatch(ref.referenceName)) {
+    // AND the name doesn't match a local import. The import escape is
+    // necessary because re-export rename chains (`import { login }
+    // from './barrel'` where the barrel has `export { signIn as login }
+    // from './auth'`) intentionally call a name that has no
+    // declaration anywhere — only the renamed upstream symbol does.
+    if (!this.hasAnyPossibleMatch(ref.referenceName) && !this.matchesAnyImport(ref)) {
       return null;
     }
 

+ 242 - 0
src/resolution/path-aliases.ts

@@ -0,0 +1,242 @@
+/**
+ * Project-level import-path alias loading.
+ *
+ * Reads `compilerOptions.paths` from `tsconfig.json` / `jsconfig.json`
+ * at the project root and converts the patterns into a form the
+ * import-resolver can consult.
+ *
+ * This is the single biggest blocker to accurate resolution on modern
+ * JS/TS codebases: aliases like `@/components/Foo` (Next, Nuxt, Nest,
+ * Vite scaffolds) point into a `paths` map the resolver previously
+ * ignored — every import through an alias was treated as unresolvable
+ * unless it happened to match the small hard-coded fallback list.
+ *
+ * Scope deliberately small for v1:
+ *   - reads tsconfig.json, then jsconfig.json
+ *   - honours top-level `compilerOptions.baseUrl` and `compilerOptions.paths`
+ *   - supports `*` wildcard (the only TS-supported wildcard)
+ *   - does NOT follow `extends` chains yet (most projects don't need it)
+ *   - does NOT read Vite/webpack/Rollup configs (separate follow-up)
+ *
+ * The file is parsed as JSON-with-comments-tolerant — tsconfigs in the
+ * wild routinely contain `//` and `/* *\/` comments and trailing
+ * commas, which JSON.parse rejects. We strip those before parsing.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { logDebug } from '../errors';
+
+/** A single alias pattern from `compilerOptions.paths`. */
+export interface AliasPattern {
+  /** The literal prefix before `*` (or the whole pattern if no `*`). */
+  prefix: string;
+  /** The literal suffix after `*` (almost always empty). */
+  suffix: string;
+  /** Whether the pattern contains a `*` wildcard. */
+  hasWildcard: boolean;
+  /**
+   * Replacement templates. When `hasWildcard` is true, `*` in the
+   * replacement is filled with the captured wildcard portion of the
+   * import path. Stored relative to {@link AliasMap.baseUrl}.
+   * tsconfig allows multiple targets per alias (priority order).
+   */
+  replacements: string[];
+}
+
+export interface AliasMap {
+  /** Absolute path. The directory `compilerOptions.paths` is rooted at. */
+  baseUrl: string;
+  /**
+   * Patterns ordered by specificity: longer prefix first, then literal-
+   * before-wildcard, so the resolver tries the most-specific match.
+   */
+  patterns: AliasPattern[];
+}
+
+/**
+ * Strip JSONC comments + trailing commas so a tsconfig with the usual
+ * VS Code-style annotations parses cleanly. Walks the source as a
+ * tiny state machine that tracks string context — the previous
+ * regex-only version corrupted any URL inside a string value
+ * (`"baseUrl": "https://cdn.example.com"` had everything after `//`
+ * truncated).
+ */
+function stripJsonc(src: string): string {
+  let out = '';
+  let i = 0;
+  let inString = false;
+  while (i < src.length) {
+    const ch = src[i]!;
+    if (inString) {
+      out += ch;
+      if (ch === '\\' && i + 1 < src.length) {
+        out += src[i + 1]!;
+        i += 2;
+        continue;
+      }
+      if (ch === '"') inString = false;
+      i++;
+      continue;
+    }
+    if (ch === '"') {
+      inString = true;
+      out += ch;
+      i++;
+      continue;
+    }
+    if (ch === '/' && src[i + 1] === '/') {
+      while (i < src.length && src[i] !== '\n') i++;
+      continue;
+    }
+    if (ch === '/' && src[i + 1] === '*') {
+      i += 2;
+      while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
+      i += 2;
+      continue;
+    }
+    out += ch;
+    i++;
+  }
+  // Trailing commas before } or ] — outside strings, so safe to
+  // run on the comment-stripped output.
+  return out.replace(/,(\s*[}\]])/g, '$1');
+}
+
+interface RawTsconfig {
+  compilerOptions?: {
+    baseUrl?: string;
+    paths?: Record<string, string[]>;
+  };
+}
+
+function readTsconfigLike(filePath: string): RawTsconfig | null {
+  try {
+    const raw = fs.readFileSync(filePath, 'utf-8');
+    const parsed = JSON.parse(stripJsonc(raw)) as RawTsconfig;
+    return parsed && typeof parsed === 'object' ? parsed : null;
+  } catch (err) {
+    logDebug('path-aliases: failed to parse', { filePath, err: String(err) });
+    return null;
+  }
+}
+
+function splitWildcard(pattern: string): {
+  prefix: string;
+  suffix: string;
+  hasWildcard: boolean;
+} {
+  const star = pattern.indexOf('*');
+  if (star === -1) return { prefix: pattern, suffix: '', hasWildcard: false };
+  return {
+    prefix: pattern.slice(0, star),
+    suffix: pattern.slice(star + 1),
+    hasWildcard: true,
+  };
+}
+
+/**
+ * Load aliases for `projectRoot`. Returns `null` when no tsconfig /
+ * jsconfig is present or when the file has no usable `paths`.
+ *
+ * Cheap to call repeatedly — caching is the caller's job (the
+ * resolver does it via {@link aliasCache}).
+ */
+export function loadProjectAliases(projectRoot: string): AliasMap | null {
+  const candidates = ['tsconfig.json', 'jsconfig.json'];
+  let raw: RawTsconfig | null = null;
+  let usedFile: string | null = null;
+  for (const name of candidates) {
+    const p = path.join(projectRoot, name);
+    if (fs.existsSync(p)) {
+      raw = readTsconfigLike(p);
+      if (raw) {
+        usedFile = name;
+        break;
+      }
+    }
+  }
+  if (!raw) return null;
+
+  const co = raw.compilerOptions ?? {};
+  const baseUrlRel = co.baseUrl ?? '.';
+  const baseUrl = path.resolve(projectRoot, baseUrlRel);
+
+  const paths = co.paths;
+  if (!paths || typeof paths !== 'object') {
+    // baseUrl alone isn't an "alias" per se; with no paths we'd just
+    // be redirecting the whole tree. Skip — the existing resolver
+    // already handles relative imports.
+    return null;
+  }
+
+  const patterns: AliasPattern[] = [];
+  for (const [pattern, targets] of Object.entries(paths)) {
+    if (!Array.isArray(targets) || targets.length === 0) continue;
+    const filtered = targets.filter((t): t is string => typeof t === 'string');
+    if (filtered.length === 0) continue;
+    const { prefix, suffix, hasWildcard } = splitWildcard(pattern);
+    patterns.push({ prefix, suffix, hasWildcard, replacements: filtered });
+  }
+
+  if (patterns.length === 0) return null;
+
+  // Specificity sort: longer prefix first; literal patterns before
+  // wildcard patterns of the same prefix length. TypeScript itself
+  // uses a similar "most specific match wins" rule.
+  patterns.sort((a, b) => {
+    if (a.prefix.length !== b.prefix.length) return b.prefix.length - a.prefix.length;
+    if (a.hasWildcard !== b.hasWildcard) return a.hasWildcard ? 1 : -1;
+    return 0;
+  });
+
+  logDebug('path-aliases loaded', {
+    file: usedFile,
+    baseUrl,
+    patternCount: patterns.length,
+  });
+
+  return { baseUrl, patterns };
+}
+
+/**
+ * Resolve an import path through an {@link AliasMap}. Returns the list
+ * of candidate filesystem paths (relative to `projectRoot`), in the
+ * priority order defined by tsconfig (multiple replacements per alias
+ * are tried in order). Returns `[]` when no alias matches.
+ *
+ * Callers still need to try each candidate with the language's
+ * extension list — this function only does the alias rewrite.
+ */
+export function applyAliases(
+  importPath: string,
+  aliases: AliasMap,
+  projectRoot: string
+): string[] {
+  for (const pat of aliases.patterns) {
+    if (!importPath.startsWith(pat.prefix)) continue;
+    if (pat.suffix && !importPath.endsWith(pat.suffix)) continue;
+
+    let captured = '';
+    if (pat.hasWildcard) {
+      captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length);
+    } else if (importPath !== pat.prefix) {
+      // Literal pattern must match exactly.
+      continue;
+    }
+
+    const out: string[] = [];
+    for (const target of pat.replacements) {
+      const filled = pat.hasWildcard ? target.replace('*', captured) : target;
+      // baseUrl is absolute; produce a path relative to projectRoot
+      const absolute = path.resolve(aliases.baseUrl, filled);
+      const relative = path.relative(projectRoot, absolute);
+      // Skip if the rewrite escapes the project root (unsafe + can't
+      // be looked up via the file index anyway).
+      if (relative.startsWith('..')) continue;
+      out.push(relative.replace(/\\/g, '/'));
+    }
+    return out;
+  }
+  return [];
+}

+ 36 - 0
src/resolution/types.ts

@@ -83,6 +83,21 @@ export interface ResolutionContext {
   getNodesByLowerName(lowerName: string): Node[];
   /** Get cached import mappings for a file */
   getImportMappings(filePath: string, language: Language): ImportMapping[];
+  /**
+   * Project import-path aliases (tsconfig/jsconfig `paths`). Returns
+   * `null` when the project doesn't define any. Cached per resolver
+   * instance — safe to call from any resolver code path. Optional so
+   * existing test fixtures and external context implementations
+   * compile without modification; production resolver implements it.
+   */
+  getProjectAliases?(): import('./path-aliases').AliasMap | null;
+  /**
+   * Re-exports declared by a file (`export { x } from './other'`,
+   * `export * from './other'`). Empty array when the file has none.
+   * Optional so older callers compile; the import resolver follows
+   * re-export chains when this is provided.
+   */
+  getReExports?(filePath: string, language: Language): ReExport[];
 }
 
 /**
@@ -116,3 +131,24 @@ export interface ImportMapping {
   /** Resolved file path (if local) */
   resolvedPath?: string;
 }
+
+/**
+ * Re-export from a file: `export { x } from './other'` or
+ * `export * from './other'`. Used by the resolver to chase
+ * symbols through barrel files.
+ */
+export type ReExport =
+  | {
+      kind: 'named';
+      /** Name as exported by THIS file. */
+      exportedName: string;
+      /** Name in the upstream module (differs when renamed: `as`). */
+      originalName: string;
+      /** Module specifier of the upstream module. */
+      source: string;
+    }
+  | {
+      kind: 'wildcard';
+      /** Module specifier of the upstream module. */
+      source: string;
+    };