Skip to content

Commit d151c0f

Browse files
andreinknvcolbymchenryclaude
authored
feat(resolution): tsconfig path aliases + re-export chain following (colbymchenry#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>
1 parent 56f6b3b commit d151c0f

5 files changed

Lines changed: 754 additions & 59 deletions

File tree

__tests__/resolution.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,4 +711,139 @@ def bootstrap():
711711
expect(result?.targetNodeId).toBe('func:di.ts:Inject:10');
712712
});
713713
});
714+
715+
describe('tsconfig path aliases', () => {
716+
it('resolves an aliased import to the alias-mapped file (not a same-named file elsewhere)', async () => {
717+
// Two same-named exports in different directories. Without alias
718+
// resolution, name-matcher would pick whichever it finds first;
719+
// with alias resolution, the import path uniquely picks one.
720+
fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true });
721+
fs.mkdirSync(path.join(tempDir, 'src/legacy'), { recursive: true });
722+
fs.writeFileSync(
723+
path.join(tempDir, 'src/utils/format.ts'),
724+
`export function pickMe(): number { return 1; }\n`
725+
);
726+
fs.writeFileSync(
727+
path.join(tempDir, 'src/legacy/format.ts'),
728+
`export function pickMe(): number { return 99; }\n`
729+
);
730+
fs.writeFileSync(
731+
path.join(tempDir, 'src/main.ts'),
732+
`import { pickMe } from '@utils/format';\nexport function go(): number { return pickMe(); }\n`
733+
);
734+
fs.writeFileSync(
735+
path.join(tempDir, 'tsconfig.json'),
736+
JSON.stringify({
737+
compilerOptions: {
738+
baseUrl: './src',
739+
paths: { '@utils/*': ['utils/*'] },
740+
},
741+
})
742+
);
743+
744+
cg = await CodeGraph.init(tempDir, { index: true });
745+
cg.resolveReferences();
746+
747+
// The two pickMe nodes live in different files. The aliased
748+
// import should attach the call edge to the @utils-mapped one,
749+
// not the legacy duplicate.
750+
const all = cg.getNodesByKind('function').filter((n) => n.name === 'pickMe');
751+
const utilsNode = all.find((n) => n.filePath === 'src/utils/format.ts');
752+
const legacyNode = all.find((n) => n.filePath === 'src/legacy/format.ts');
753+
expect(utilsNode).toBeDefined();
754+
expect(legacyNode).toBeDefined();
755+
756+
const utilsCallers = cg.getCallers(utilsNode!.id);
757+
const legacyCallers = cg.getCallers(legacyNode!.id);
758+
expect(utilsCallers.length).toBeGreaterThan(0);
759+
expect(utilsCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
760+
// The legacy node should NOT have a caller from src/main.ts —
761+
// the alias correctly picked the utils version.
762+
expect(legacyCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(false);
763+
});
764+
765+
it('falls back gracefully when tsconfig is absent', async () => {
766+
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
767+
fs.writeFileSync(
768+
path.join(tempDir, 'src/a.ts'),
769+
`export function aFn(): void {}\n`
770+
);
771+
fs.writeFileSync(
772+
path.join(tempDir, 'src/b.ts'),
773+
`import { aFn } from './a';\nexport function bFn(): void { aFn(); }\n`
774+
);
775+
776+
cg = await CodeGraph.init(tempDir, { index: true });
777+
// No tsconfig present — index should still complete and the
778+
// relative-import-based call edge should be created.
779+
const aFn = cg.getNodesByKind('function').find((n) => n.name === 'aFn');
780+
expect(aFn).toBeDefined();
781+
const callers = cg.getCallers(aFn!.id);
782+
expect(callers.some((c) => c.node.filePath === 'src/b.ts')).toBe(true);
783+
});
784+
});
785+
786+
describe('re-export chain following', () => {
787+
it('chases a 3-hop barrel chain (wildcard → named → declaration)', async () => {
788+
// main.ts → all.ts (wildcard) → index.ts (named) → auth.ts (declaration).
789+
// Without chain following, `signIn` resolves to nothing because
790+
// none of the barrel files declare it directly.
791+
fs.mkdirSync(path.join(tempDir, 'src/services'), { recursive: true });
792+
fs.writeFileSync(
793+
path.join(tempDir, 'src/services/auth.ts'),
794+
`export function signIn(): void {}\n`
795+
);
796+
fs.writeFileSync(
797+
path.join(tempDir, 'src/services/index.ts'),
798+
`export { signIn } from './auth';\n`
799+
);
800+
fs.writeFileSync(
801+
path.join(tempDir, 'src/all.ts'),
802+
`export * from './services/index';\n`
803+
);
804+
fs.writeFileSync(
805+
path.join(tempDir, 'src/main.ts'),
806+
`import { signIn } from './all';\nexport function go(): void { signIn(); }\n`
807+
);
808+
809+
cg = await CodeGraph.init(tempDir, { index: true });
810+
cg.resolveReferences();
811+
812+
const signInNode = cg
813+
.getNodesByKind('function')
814+
.find((n) => n.name === 'signIn' && n.filePath === 'src/services/auth.ts');
815+
expect(signInNode).toBeDefined();
816+
const callers = cg.getCallers(signInNode!.id);
817+
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
818+
});
819+
820+
it('follows a renamed named re-export (export { foo as bar } from ...)', async () => {
821+
// The chase has to look up `foo` in the upstream module even
822+
// though the importer asked for `bar` — exercises the rename
823+
// branch of findExportedSymbol.
824+
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
825+
fs.writeFileSync(
826+
path.join(tempDir, 'src/auth.ts'),
827+
`export function signIn(): void {}\n`
828+
);
829+
fs.writeFileSync(
830+
path.join(tempDir, 'src/index.ts'),
831+
`export { signIn as login } from './auth';\n`
832+
);
833+
fs.writeFileSync(
834+
path.join(tempDir, 'src/main.ts'),
835+
`import { login } from './index';\nexport function go(): void { login(); }\n`
836+
);
837+
838+
cg = await CodeGraph.init(tempDir, { index: true });
839+
cg.resolveReferences();
840+
841+
const signInNode = cg
842+
.getNodesByKind('function')
843+
.find((n) => n.name === 'signIn' && n.filePath === 'src/auth.ts');
844+
expect(signInNode).toBeDefined();
845+
const callers = cg.getCallers(signInNode!.id);
846+
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
847+
});
848+
});
714849
});

0 commit comments

Comments
 (0)