fix(typescript): reuse suffix index in scope resolver (#1840)

* fix(typescript): reuse suffix index in scope resolver

Build a suffix index once per TypeScript scope-resolution pass and pass it into standard import resolution so package-style imports avoid repeated linear file-list scans.\n\nFixes #1839

* test(typescript): add wiring-level test for scope-resolver suffix index

- Test typescriptScopeResolver.resolveImportTarget directly (the real
  production entry point) with package-style, unresolvable, and relative
  imports
- Use vi.spyOn on buildSuffixIndex to verify the index is built inside
  the makeTsResolveImportTarget closure — fails if index wiring is removed
- Fix existing test to pass real file lists instead of empty arrays
  alongside the prebuilt index, matching production wiring

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Test <test@example.com>
This commit is contained in:
dale 2026-05-27 02:23:13 +07:00 committed by GitHub
parent 6c572749b0
commit d903152eba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 78 additions and 2 deletions

View file

@ -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<string, string | null>;
/** 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,
);
}

View file

@ -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<string>;
readonly allFileList: readonly string[];
readonly normalizedFileList: readonly string[];
readonly index: SuffixIndex;
readonly resolveCache: Map<string, string | null>;
}
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,
};

View file

@ -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');
});
});