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
+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;
}