diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts index d082529f5..7d39e1f64 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts @@ -17,6 +17,7 @@ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { resolveImportPath } from '../../import-resolvers/standard.js'; +import type { SuffixIndex } from '../../import-resolvers/utils.js'; import type { TsconfigPaths } from '../../language-config.js'; export interface TsResolveContext { @@ -31,6 +32,8 @@ export interface TsResolveContext { readonly normalizedFileList?: readonly string[]; /** Per-call resolution cache to dedupe repeated lookups. */ readonly resolveCache?: Map; + /** Prebuilt suffix index for O(1)-style package/absolute import matching. */ + readonly index?: SuffixIndex; /** Parsed tsconfig path-aliases. `null` = no aliases configured. */ readonly tsconfigPaths?: TsconfigPaths | null; /** JavaScript vs TypeScript switch — affects the extensions the @@ -84,6 +87,7 @@ export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): strin resolveCache, language, ctx.tsconfigPaths ?? null, + ctx.index, ); } diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index 33f49307d..d4b2237dc 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -19,6 +19,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers. import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { typescriptProvider } from '../typescript.js'; import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js'; +import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; import { typescriptArityCompatibility, typescriptMergeBindings, @@ -50,6 +51,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { readonly allFilePaths: Set; readonly allFileList: readonly string[]; readonly normalizedFileList: readonly string[]; + readonly index: SuffixIndex; readonly resolveCache: Map; } let cached: PassCache | null = null; @@ -57,11 +59,13 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { return (targetRaw, fromFile, allFilePaths, resolutionConfig) => { if (cached === null || cached.key !== allFilePaths) { const allFileList = Array.from(allFilePaths); + const normalizedFileList = allFileList.map((f) => f.toLowerCase()); cached = { key: allFilePaths, allFilePaths: new Set(allFilePaths), allFileList, - normalizedFileList: allFileList.map((f) => f.toLowerCase()), + normalizedFileList, + index: buildSuffixIndex(normalizedFileList, allFileList), resolveCache: new Map(), }; } @@ -72,6 +76,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { allFilePaths: cached.allFilePaths, allFileList: cached.allFileList, normalizedFileList: cached.normalizedFileList, + index: cached.index, resolveCache: cached.resolveCache, tsconfigPaths: cfg?.tsconfigPaths ?? null, }; diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts index 8d4252a16..b372991d6 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts @@ -6,7 +6,7 @@ * set of fake file paths (with and without tsconfig path aliases). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { emitTsScopeCaptures } from '../../../../src/core/ingestion/languages/typescript/captures.js'; import { splitImportStatement } from '../../../../src/core/ingestion/languages/typescript/import-decomposer.js'; import { interpretTsImport } from '../../../../src/core/ingestion/languages/typescript/interpret.js'; @@ -14,6 +14,9 @@ import { resolveTsImportTarget, type TsResolveContext, } from '../../../../src/core/ingestion/languages/typescript/import-target.js'; +import { buildSuffixIndex } from '../../../../src/core/ingestion/import-resolvers/utils.js'; +import * as importResolverUtils from '../../../../src/core/ingestion/import-resolvers/utils.js'; +import { typescriptScopeResolver } from '../../../../src/core/ingestion/languages/typescript/scope-resolver.js'; import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; @@ -399,4 +402,68 @@ describe('resolveTsImportTarget — standard suffix + alias resolution', () => { ); expect(result).toBe('src/a.js'); }); + + it('uses a prebuilt suffix index for package-style imports', () => { + const parsed: ParsedImport = { + kind: 'named', + localName: 'Button', + importedName: 'Button', + targetRaw: 'components/Button', + }; + const files = ['src/main.ts', 'src/components/Button.ts']; + const index = buildSuffixIndex( + files.map((f) => f.toLowerCase()), + files, + ); + const result = resolveTsImportTarget( + parsed, + ctx('src/main.ts', files, { + allFileList: files, + normalizedFileList: files.map((f) => f.toLowerCase()), + index, + }), + ); + + expect(result).toBe('src/components/Button.ts'); + }); +}); + +describe('typescriptScopeResolver.resolveImportTarget — real wiring', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('builds suffix index and resolves package-style imports through the production path', () => { + const spy = vi.spyOn(importResolverUtils, 'buildSuffixIndex'); + const files = new Set(['src/main.ts', 'src/components/Button.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!( + 'components/Button', + 'src/main.ts', + files, + ); + + expect(result).toBe('src/components/Button.ts'); + expect(spy).toHaveBeenCalled(); + }); + + it('returns null for unresolvable package-style imports', () => { + const files = new Set(['src/main.ts', 'src/utils/helper.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!( + 'nonexistent/Module', + 'src/main.ts', + files, + ); + + expect(result).toBeNull(); + }); + + it('resolves relative imports through the scope-resolver entry point', () => { + const files = new Set(['src/main.ts', 'src/utils.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!('./utils', 'src/main.ts', files); + + expect(result).toBe('src/utils.ts'); + }); });