mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
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.
This commit is contained in:
parent
cefb2c1439
commit
37e73cbb26
2 changed files with 276 additions and 0 deletions
124
gitnexus/src/core/ingestion/import-target-adapter.ts
Normal file
124
gitnexus/src/core/ingestion/import-target-adapter.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Bridge between CLI-package per-language `ImportResolverFn`s and the
|
||||
* shared `FinalizeHooks.resolveImportTarget` contract
|
||||
* (RFC §5.2; Ring 2 PKG #922).
|
||||
*
|
||||
* The shared finalize algorithm (#915) asks one question:
|
||||
*
|
||||
* resolveImportTarget(targetRaw, fromFile, workspaceIndex): string | null
|
||||
*
|
||||
* The CLI already has 16 language-specific resolvers satisfying a
|
||||
* richer signature:
|
||||
*
|
||||
* ImportResolverFn(rawImportPath, filePath, resolveCtx): ImportResult
|
||||
*
|
||||
* This module builds a dispatch adapter — one FinalizeHook implementation
|
||||
* that looks up the file's language from its path and delegates to the
|
||||
* right per-language resolver. Callers package per-language resolvers +
|
||||
* a shared `ResolveCtx` into an opaque `ImportTargetWorkspace` and pass
|
||||
* it as `workspaceIndex` to `finalizeScopeModel`.
|
||||
*
|
||||
* ## What's deliberately NOT here
|
||||
*
|
||||
* - **Re-implementation of any per-language resolver.** We wrap the
|
||||
* existing `importResolver` field on each `LanguageProvider` — the
|
||||
* same code path the legacy DAG uses today.
|
||||
* - **Dynamic-import handling.** The shared finalize algorithm short-
|
||||
* circuits `ParsedImport { kind: 'dynamic-unresolved' }` before
|
||||
* calling `resolveImportTarget`, so the adapter never sees those.
|
||||
* - **`importPathPreprocessor`.** Preprocessing belongs inside the
|
||||
* provider's `interpretImport` hook (which writes the final
|
||||
* `ParsedImport.targetRaw`). By the time finalize passes a
|
||||
* `targetRaw` to this adapter, it is the string the provider wants
|
||||
* resolved verbatim.
|
||||
*/
|
||||
|
||||
import {
|
||||
getLanguageFromFilename,
|
||||
type SupportedLanguages,
|
||||
type WorkspaceIndex,
|
||||
} from 'gitnexus-shared';
|
||||
import type { ImportResolverFn, ImportResult, ResolveCtx } from './import-resolvers/types.js';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
|
||||
/** A single language's resolver bundled with the context it needs. */
|
||||
export interface LanguageResolverEntry {
|
||||
readonly resolver: ImportResolverFn;
|
||||
readonly ctx: ResolveCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The opaque `workspaceIndex` shape recognized by
|
||||
* `resolveImportTargetAcrossLanguages`. Built once per ingestion run via
|
||||
* `buildImportTargetWorkspace`, threaded through `finalizeScopeModel`.
|
||||
*/
|
||||
export interface ImportTargetWorkspace {
|
||||
readonly perLanguage: ReadonlyMap<SupportedLanguages, LanguageResolverEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the workspace index from a map of language → provider. Providers
|
||||
* whose `importResolver` is absent are silently skipped (no language will
|
||||
* ever hit that branch at dispatch time).
|
||||
*
|
||||
* The `resolveCtx` is shared across all languages. Callers assemble it
|
||||
* once per run (the existing pipeline already does this for the legacy
|
||||
* DAG) and hand it to both the legacy resolution path and this factory.
|
||||
*/
|
||||
export function buildImportTargetWorkspace(
|
||||
providers: ReadonlyMap<SupportedLanguages, LanguageProvider>,
|
||||
resolveCtx: ResolveCtx,
|
||||
): ImportTargetWorkspace {
|
||||
const perLanguage = new Map<SupportedLanguages, LanguageResolverEntry>();
|
||||
for (const [lang, provider] of providers) {
|
||||
if (provider.importResolver === undefined) continue;
|
||||
perLanguage.set(lang, { resolver: provider.importResolver, ctx: resolveCtx });
|
||||
}
|
||||
return { perLanguage };
|
||||
}
|
||||
|
||||
/**
|
||||
* The FinalizeHooks-compatible implementation. Dispatches on `fromFile`'s
|
||||
* extension → per-language resolver. Returns the first resolved file,
|
||||
* or `null` if the resolver returns `null` or doesn't know about the
|
||||
* language.
|
||||
*
|
||||
* Picks the first entry of `files[]` for both `'files'` and `'package'`
|
||||
* result kinds — the legacy pipeline uses the whole array, but the
|
||||
* shared `finalize()` hook contract is single-file. If the workspace
|
||||
* later needs richer semantics (split-target packages), this is the
|
||||
* single site to extend.
|
||||
*/
|
||||
export function resolveImportTargetAcrossLanguages(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const workspace = workspaceIndex as ImportTargetWorkspace | undefined;
|
||||
if (workspace === undefined || workspace.perLanguage === undefined) return null;
|
||||
|
||||
const lang = getLanguageFromFilename(fromFile);
|
||||
if (lang === null) return null;
|
||||
|
||||
const entry = workspace.perLanguage.get(lang);
|
||||
if (entry === undefined) return null;
|
||||
|
||||
let result: ImportResult;
|
||||
try {
|
||||
result = entry.resolver(targetRaw, fromFile, entry.ctx);
|
||||
} catch {
|
||||
// Existing resolvers can throw on malformed inputs (e.g., Python
|
||||
// relative paths above the workspace root). Swallow — the shared
|
||||
// algorithm treats a null here as `linkStatus: 'unresolved'`, which
|
||||
// is the right fallback.
|
||||
return null;
|
||||
}
|
||||
if (result === null) return null;
|
||||
|
||||
// Both `files` and `package` variants expose a `files` array; the
|
||||
// package variant also carries `dirSuffix` which we ignore at the
|
||||
// FinalizeHook boundary (single-file contract). Legacy consumers
|
||||
// continue to see the full result via `importResolver` directly.
|
||||
const first = result.files[0];
|
||||
return first ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue