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:
co-authored by
Claude Opus 4.7
Colby McHenry
parent
56f6b3b485
commit
d151c0f922
@@ -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 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user