* 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>
243 lines
7.8 KiB
TypeScript
243 lines
7.8 KiB
TypeScript
/**
|
|
* 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 [];
|
|
}
|