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');
|
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 * as path from 'path';
|
||||||
import { Language, Node } from '../types';
|
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
|
* Extension resolution order by language
|
||||||
@@ -34,8 +35,11 @@ export function resolveImportPath(
|
|||||||
language: Language,
|
language: Language,
|
||||||
context: ResolutionContext
|
context: ResolutionContext
|
||||||
): string | null {
|
): string | null {
|
||||||
// Skip external/npm packages
|
// Skip external/npm packages — but pass the context so the
|
||||||
if (isExternalImport(importPath, language)) {
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +57,17 @@ export function resolveImportPath(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an import is external (npm package, etc.)
|
* 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
|
// Relative imports are not external
|
||||||
if (importPath.startsWith('.')) {
|
if (importPath.startsWith('.')) {
|
||||||
return false;
|
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)) {
|
if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
|
||||||
return true;
|
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
|
// Scoped packages or bare specifiers that don't start with aliases
|
||||||
if (!importPath.startsWith('@/') && !importPath.startsWith('~/') && !importPath.startsWith('src/')) {
|
if (!importPath.startsWith('@/') && !importPath.startsWith('~/') && !importPath.startsWith('src/')) {
|
||||||
// Likely an npm package
|
// 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(
|
function resolveAliasedImport(
|
||||||
importPath: string,
|
importPath: string,
|
||||||
_projectRoot: string,
|
projectRoot: string,
|
||||||
language: Language,
|
language: Language,
|
||||||
context: ResolutionContext
|
context: ResolutionContext
|
||||||
): string | null {
|
): string | null {
|
||||||
const extensions = EXTENSION_RESOLUTION[language] || [];
|
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
|
// 1. Project tsconfig/jsconfig paths.
|
||||||
const aliases: Record<string, string> = {
|
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/',
|
||||||
'@src/': 'src/',
|
'@src/': 'src/',
|
||||||
@@ -143,36 +190,15 @@ function resolveAliasedImport(
|
|||||||
'@app/': 'app/',
|
'@app/': 'app/',
|
||||||
'app/': 'app/',
|
'app/': 'app/',
|
||||||
};
|
};
|
||||||
|
for (const [alias, replacement] of Object.entries(fallbackAliases)) {
|
||||||
// Try each alias
|
|
||||||
for (const [alias, replacement] of Object.entries(aliases)) {
|
|
||||||
if (importPath.startsWith(alias)) {
|
if (importPath.startsWith(alias)) {
|
||||||
const resolvedPath = importPath.replace(alias, replacement);
|
const hit = tryWithExt(importPath.replace(alias, replacement));
|
||||||
|
if (hit) return hit;
|
||||||
// Try with extensions
|
|
||||||
for (const ext of extensions) {
|
|
||||||
const candidatePath = resolvedPath + ext;
|
|
||||||
if (context.fileExists(candidatePath)) {
|
|
||||||
return candidatePath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try as-is
|
// 3. Direct path.
|
||||||
if (context.fileExists(resolvedPath)) {
|
return tryWithExt(importPath);
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct path
|
|
||||||
for (const ext of extensions) {
|
|
||||||
const candidatePath = importPath + ext;
|
|
||||||
if (context.fileExists(candidatePath)) {
|
|
||||||
return candidatePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,6 +461,127 @@ export function clearImportMappingCache(): void {
|
|||||||
importMappingCache.clear();
|
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
|
* Resolve a reference using import mappings
|
||||||
*/
|
*/
|
||||||
@@ -460,30 +607,18 @@ export function resolveViaImport(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (resolvedPath) {
|
if (resolvedPath) {
|
||||||
// Find the exported symbol in the resolved file
|
|
||||||
const nodesInFile = context.getNodesInFile(resolvedPath);
|
|
||||||
const exportedName = imp.isDefault ? 'default' : imp.exportedName;
|
const exportedName = imp.isDefault ? 'default' : imp.exportedName;
|
||||||
|
const memberName = imp.isNamespace
|
||||||
|
? ref.referenceName.replace(imp.localName + '.', '')
|
||||||
|
: null;
|
||||||
|
|
||||||
// Look for the symbol
|
const targetNode = findExportedSymbol(
|
||||||
let targetNode: Node | undefined;
|
resolvedPath,
|
||||||
|
{ isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName },
|
||||||
if (imp.isDefault) {
|
ref.language,
|
||||||
// Find default export or main class/function
|
context,
|
||||||
targetNode = nodesInFile.find(
|
new Set()
|
||||||
(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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetNode) {
|
if (targetNode) {
|
||||||
return {
|
return {
|
||||||
@@ -499,3 +634,98 @@ export function resolveViaImport(
|
|||||||
|
|
||||||
return null;
|
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,
|
ImportMapping,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { matchReference } from './name-matcher';
|
import { matchReference } from './name-matcher';
|
||||||
import { resolveViaImport, extractImportMappings } from './import-resolver';
|
import { resolveViaImport, extractImportMappings, extractReExports } from './import-resolver';
|
||||||
import { detectFrameworks } from './frameworks';
|
import { detectFrameworks } from './frameworks';
|
||||||
|
import { loadProjectAliases, type AliasMap } from './path-aliases';
|
||||||
import { logDebug } from '../errors';
|
import { logDebug } from '../errors';
|
||||||
|
import type { ReExport } from './types';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export * from './types';
|
export * from './types';
|
||||||
@@ -122,12 +124,17 @@ export class ReferenceResolver {
|
|||||||
private nodeCache: Map<string, Node[]> = new Map(); // per-file node cache (bounded)
|
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 fileCache: Map<string, string | null> = new Map(); // per-file content cache (bounded)
|
||||||
private importMappingCache: Map<string, ImportMapping[]> = new Map();
|
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 nameCache: Map<string, Node[]> = new Map(); // name → nodes cache
|
||||||
private lowerNameCache: Map<string, Node[]> = new Map(); // lower(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 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 knownNames: Set<string> | null = null; // all known symbol names for fast pre-filtering
|
||||||
private knownFiles: Set<string> | null = null;
|
private knownFiles: Set<string> | null = null;
|
||||||
private cachesWarmed = false;
|
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) {
|
constructor(projectRoot: string, queries: QueryBuilder) {
|
||||||
this.projectRoot = projectRoot;
|
this.projectRoot = projectRoot;
|
||||||
@@ -168,6 +175,7 @@ export class ReferenceResolver {
|
|||||||
this.nodeCache.clear();
|
this.nodeCache.clear();
|
||||||
this.fileCache.clear();
|
this.fileCache.clear();
|
||||||
this.importMappingCache.clear();
|
this.importMappingCache.clear();
|
||||||
|
this.reExportCache.clear();
|
||||||
this.nameCache.clear();
|
this.nameCache.clear();
|
||||||
this.lowerNameCache.clear();
|
this.lowerNameCache.clear();
|
||||||
this.qualifiedNameCache.clear();
|
this.qualifiedNameCache.clear();
|
||||||
@@ -272,6 +280,26 @@ export class ReferenceResolver {
|
|||||||
this.importMappingCache.set(cacheKey, mappings);
|
this.importMappingCache.set(cacheKey, mappings);
|
||||||
return 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;
|
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
|
* Resolve a single reference
|
||||||
*/
|
*/
|
||||||
@@ -389,7 +436,12 @@ export class ReferenceResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fast pre-filter: skip if no symbol with this name exists anywhere
|
// 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;
|
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[];
|
getNodesByLowerName(lowerName: string): Node[];
|
||||||
/** Get cached import mappings for a file */
|
/** Get cached import mappings for a file */
|
||||||
getImportMappings(filePath: string, language: Language): ImportMapping[];
|
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) */
|
/** Resolved file path (if local) */
|
||||||
resolvedPath?: string;
|
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