GitNexus/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts
Gergő Magyar 0c37eda482
feat(ingestion): per-language resolveImportTarget adapter (#922, RFC #909 Ring 2 PKG) (#971)
Bridges the CLI's existing per-language `ImportResolverFn`s (16 languages
already implemented) to the shared `FinalizeHooks.resolveImportTarget`
contract consumed by `finalize()` (#915) and
`finalizeScopeModel` (#921).

No resolver logic is reimplemented — the adapter wraps
`provider.importResolver` from each `LanguageProvider` verbatim.

## Shipped

### `import-target-adapter.ts` (new)

```ts
buildImportTargetWorkspace(providers, resolveCtx): ImportTargetWorkspace
resolveImportTargetAcrossLanguages(targetRaw, fromFile, workspaceIndex): string | null
```

  - `ImportTargetWorkspace` is the opaque `workspaceIndex` shape the
    adapter recognizes: `{ perLanguage: Map<SupportedLanguages,
    { resolver, ctx }> }`. Callers build it once per ingestion run from
    the active language providers.
  - `resolveImportTargetAcrossLanguages` is the `FinalizeHook`
    implementation. It:
      1. Reads `getLanguageFromFilename(fromFile)`.
      2. Looks up the per-language entry.
      3. Calls the existing `ImportResolverFn` — same signature, same
         code path the legacy DAG uses today.
      4. Picks `result.files[0]` (covers both `'files'` and `'package'`
         result kinds; the legacy pipeline's richer multi-file + dirSuffix
         semantics stay accessible through `importResolver` directly).
      5. Returns `null` on any null result, empty files[], unknown
         extension, missing workspace, or resolver exception.
  - Exceptions from resolvers are swallowed — the finalize algorithm
    treats `null` as `linkStatus: 'unresolved'`, which is the right
    fallback for malformed inputs.

### What's deliberately NOT here

  - **Re-implementation of any per-language resolver.** Wraps the
    existing `importResolver` field on each provider.
  - **Dynamic-import handling.** The shared finalize algorithm short-
    circuits `ParsedImport { kind: 'dynamic-unresolved' }` before
    calling `resolveImportTarget`, so the adapter never sees them.
  - **`importPathPreprocessor`.** Preprocessing belongs inside the
    provider's `interpretImport` hook that produces
    `ParsedImport.targetRaw`; the adapter forwards that verbatim.

## Tests (12, all passing)

  - **`buildImportTargetWorkspace`** (3): registers providers with
    importResolver · skips providers without · threads shared ctx
    into every entry
  - **`resolveImportTargetAcrossLanguages`** (9): forwards targetRaw +
    fromFile · dispatches by extension · null resolver result →
    null · `package`-kind takes first file · empty files[] → null ·
    no registered resolver → null · unknown extension → null ·
    undefined/malformed workspace → null · resolver throw → null

Real per-language resolver correctness is covered by the existing
per-language resolver test suites — the adapter is the bridge layer.

## Verification

  - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
  - `gitnexus-shared` build clean
  - 12/12 new tests pass
  - Full scope-resolution / shadow / model / flag suite: **333/333 pass**

## Integration flow

```ts
const workspace = buildImportTargetWorkspace(providers, resolveCtx);
const indexes = finalizeScopeModel(parsedFiles, {
  hooks: { resolveImportTarget: resolveImportTargetAcrossLanguages },
  workspaceIndex: workspace,
});
model.attachScopeIndexes(indexes);
```

## Closes part of #909. Unblocks

  - Ring 3 language migrations (#926+): a language flipping to
    `REGISTRY_PRIMARY_<LANG>=true` now has correct import-target
    resolution out of the box via its existing `importResolver`.
  - #923 shadow harness — can run the dual-path comparison knowing
    both sides use the same per-language resolution semantics.
2026-04-18 21:11:24 +01:00

152 lines
6.6 KiB
TypeScript

/**
* Unit tests for `import-target-adapter` (RFC #909 Ring 2 PKG #922).
*
* Exercises the language-dispatching FinalizeHook. We don't need the
* real per-language resolvers here — mock `ImportResolverFn`s let each
* branch be tested in isolation. Real-resolver integration is covered
* by the existing per-language import-resolver test suites.
*/
import { describe, it, expect } from 'vitest';
import { SupportedLanguages } from 'gitnexus-shared';
import {
buildImportTargetWorkspace,
resolveImportTargetAcrossLanguages,
type ImportTargetWorkspace,
} from '../../../src/core/ingestion/import-target-adapter.js';
import type {
ImportResolverFn,
ResolveCtx,
} from '../../../src/core/ingestion/import-resolvers/types.js';
import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js';
// ─── Helpers ───────────────────────────────────────────────────────────────
const emptyCtx: ResolveCtx = {
allFilePaths: new Set(),
allFileList: [],
normalizedFileList: [],
index: { bySuffix: new Map() } as unknown as ResolveCtx['index'],
resolveCache: new Map(),
configs: {
tsconfigPaths: null,
goModule: null,
composerConfig: null,
swiftPackageConfig: null,
csharpConfigs: [],
},
};
function fakeProvider(importResolver: ImportResolverFn | undefined): LanguageProvider {
return { importResolver } as unknown as LanguageProvider;
}
function workspace(
entries: Array<[SupportedLanguages, ImportResolverFn | undefined]>,
): ImportTargetWorkspace {
const providers = new Map<SupportedLanguages, LanguageProvider>();
for (const [lang, resolver] of entries) providers.set(lang, fakeProvider(resolver));
return buildImportTargetWorkspace(providers, emptyCtx);
}
// ─── buildImportTargetWorkspace ────────────────────────────────────────────
describe('buildImportTargetWorkspace', () => {
it('registers languages that expose an importResolver', () => {
const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.py'] });
const ws = workspace([[SupportedLanguages.Python, pyResolver]]);
expect(ws.perLanguage.has(SupportedLanguages.Python)).toBe(true);
});
it("skips providers whose importResolver is absent (defensive — shouldn't happen in practice)", () => {
const ws = workspace([[SupportedLanguages.Python, undefined]]);
expect(ws.perLanguage.size).toBe(0);
});
it('threads the shared ResolveCtx into every entry', () => {
const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['x.py'] });
const tsResolver: ImportResolverFn = () => ({ kind: 'files', files: ['x.ts'] });
const ws = workspace([
[SupportedLanguages.Python, pyResolver],
[SupportedLanguages.TypeScript, tsResolver],
]);
expect(ws.perLanguage.get(SupportedLanguages.Python)!.ctx).toBe(emptyCtx);
expect(ws.perLanguage.get(SupportedLanguages.TypeScript)!.ctx).toBe(emptyCtx);
});
});
// ─── resolveImportTargetAcrossLanguages ────────────────────────────────────
describe('resolveImportTargetAcrossLanguages', () => {
it('dispatches to the resolver for the fromFile extension', () => {
let seenPath: string | undefined;
const pyResolver: ImportResolverFn = (raw, _file) => {
seenPath = raw;
return { kind: 'files', files: ['models/user.py'] };
};
const ws = workspace([[SupportedLanguages.Python, pyResolver]]);
const result = resolveImportTargetAcrossLanguages('models.user', 'src/app.py', ws);
expect(seenPath).toBe('models.user');
expect(result).toBe('models/user.py');
});
it('routes to different resolvers based on the fromFile extension', () => {
const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.py'] });
const tsResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.ts'] });
const ws = workspace([
[SupportedLanguages.Python, pyResolver],
[SupportedLanguages.TypeScript, tsResolver],
]);
expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBe('resolved.py');
expect(resolveImportTargetAcrossLanguages('x', 'a.ts', ws)).toBe('resolved.ts');
});
it('returns null when the resolver returns null', () => {
const pyResolver: ImportResolverFn = () => null;
const ws = workspace([[SupportedLanguages.Python, pyResolver]]);
expect(resolveImportTargetAcrossLanguages('external_pkg', 'app.py', ws)).toBeNull();
});
it('takes the first file from a package-kind result', () => {
const resolver: ImportResolverFn = () => ({
kind: 'package',
files: ['pkg/index.py', 'pkg/other.py'],
dirSuffix: 'pkg',
});
const ws = workspace([[SupportedLanguages.Python, resolver]]);
expect(resolveImportTargetAcrossLanguages('pkg', 'app.py', ws)).toBe('pkg/index.py');
});
it('returns null when a result has kind=files but an empty files[]', () => {
// Defensive: resolvers shouldn't return this shape, but tolerate it.
const resolver: ImportResolverFn = () => ({ kind: 'files', files: [] });
const ws = workspace([[SupportedLanguages.Python, resolver]]);
expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBeNull();
});
it('returns null when no resolver is registered for the language', () => {
const ws = workspace([]); // empty
// .py file but no Python resolver registered
expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBeNull();
});
it('returns null when fromFile has an unknown extension', () => {
const pyResolver: ImportResolverFn = () => ({ kind: 'files', files: ['resolved.py'] });
const ws = workspace([[SupportedLanguages.Python, pyResolver]]);
expect(resolveImportTargetAcrossLanguages('x', 'README.xyz', ws)).toBeNull();
});
it('returns null when workspaceIndex is undefined / malformed', () => {
expect(resolveImportTargetAcrossLanguages('x', 'a.py', undefined)).toBeNull();
// Cast to exercise the runtime guard against caller misuse.
expect(resolveImportTargetAcrossLanguages('x', 'a.py', {} as unknown)).toBeNull();
});
it('swallows resolver exceptions and returns null (treated upstream as unresolved)', () => {
const throwingResolver: ImportResolverFn = () => {
throw new Error('resolver boom');
};
const ws = workspace([[SupportedLanguages.Python, throwingResolver]]);
expect(resolveImportTargetAcrossLanguages('x', 'a.py', ws)).toBeNull();
});
});