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>
This commit is contained in:
andreinknv
2026-05-07 20:53:16 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 Colby McHenry
parent 56f6b3b485
commit d151c0f922
5 changed files with 753 additions and 58 deletions
+286 -56
View File
@@ -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;
const memberName = imp.isNamespace
? ref.referenceName.replace(imp.localName + '.', '')
: null;
// 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 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 23 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;
}