From 37e73cbb26ba0d08ef879361898602b770ca6fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 21:11:24 +0100 Subject: [PATCH] feat(ingestion): per-language resolveImportTarget adapter (#922, RFC #909 Ring 2 PKG) (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 }`. 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_=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. --- .../core/ingestion/import-target-adapter.ts | 124 ++++++++++++++ .../import-target-adapter.test.ts | 152 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 gitnexus/src/core/ingestion/import-target-adapter.ts create mode 100644 gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts diff --git a/gitnexus/src/core/ingestion/import-target-adapter.ts b/gitnexus/src/core/ingestion/import-target-adapter.ts new file mode 100644 index 0000000000..80a5a265e5 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-target-adapter.ts @@ -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; +} + +/** + * 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, + resolveCtx: ResolveCtx, +): ImportTargetWorkspace { + const perLanguage = new Map(); + 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; +} diff --git a/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts b/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts new file mode 100644 index 0000000000..8459fc4bad --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/import-target-adapter.test.ts @@ -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(); + 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(); + }); +});