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
+135
View File
@@ -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);
});
});
});
+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;
}
+54 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};