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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+54
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user