diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index cba6087b4..af7c3e686 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -61,6 +61,23 @@ function defaultResolveHandle(allEntries: RegistryEntry[]) { }; } +/** + * Dedupe cross-links that point from the same consumer endpoint to the same + * provider endpoint for the same contract. Preserves first-seen order so the + * caller controls precedence (e.g., pass manifest links first). + */ +function dedupeCrossLinks(links: CrossLink[]): CrossLink[] { + const seen = new Set(); + const out: CrossLink[] = []; + for (const link of links) { + const key = `${link.from.repo}::${link.from.symbolUid}|${link.to.repo}::${link.to.symbolUid}|${link.type}|${link.contractId}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(link); + } + return out; +} + export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise { const missingRepos: string[] = []; const repoSnapshots: Record = {}; @@ -167,6 +184,19 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis // UIDs when dbExecutors is undefined or a pool is closed, so cross-links // are always generated regardless of whether real DB executors are available. if (config.links.length > 0) { + // Warn about dangling links that reference repos not declared in config.repos. + // They still generate cross-links via synthetic UIDs (determinism is preserved), + // but the operator probably meant something that now silently does nothing useful. + const knownRepos = new Set(Object.keys(config.repos)); + for (const link of config.links) { + const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r)); + if (dangling.length > 0) { + console.warn( + `[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`, + ); + } + } + const manifestEx = new ManifestExtractor(); const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors); autoContracts.push(...manifestResult.contracts); @@ -179,7 +209,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } const { matched, unmatched } = runExactMatch(autoContracts); - const crossLinks: CrossLink[] = [...matched, ...manifestCrossLinks]; + + // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a + // manifest-declared link can also emit a matchType:'exact' CrossLink with the + // same endpoints. Prefer the manifest version — it reflects operator intent + // and carries matchType:'manifest' which downstream consumers may rely on. + const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]); const allContracts: StoredContract[] = autoContracts; const registry: ContractRegistry = { diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 686b62df0..5aa586c25 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -248,6 +248,67 @@ describe('syncGroup', () => { expect(manifestLinks[0].from.repo).toBe('app/consumer'); expect(manifestLinks[0].to.repo).toBe('app/provider'); expect(manifestLinks[0].confidence).toBe(1.0); + + // With no DB executors available, UIDs fall back to the deterministic + // synthetic form `manifest::::`. + expect(manifestLinks[0].from.symbolUid).toBe('manifest::app/consumer::http::GET::/api/orders'); + expect(manifestLinks[0].to.symbolUid).toBe('manifest::app/provider::http::GET::/api/orders'); + + // Manifest contracts also participate in runExactMatch; we must not emit a + // duplicate matchType:'exact' cross-link for the same endpoint pair. + const exactForSameContract = result.crossLinks.filter( + (cl) => cl.matchType === 'exact' && cl.contractId === 'http::GET::/api/orders', + ); + expect(exactForSameContract).toHaveLength(0); + expect(result.crossLinks).toHaveLength(1); + }); + + it('manifest links referencing unknown repos still produce cross-links via synthetic UIDs', async () => { + const links: GroupManifestLink[] = [ + { + from: 'app/known', + to: 'app/dangling', // not present in config.repos + type: 'http', + contract: 'POST::/api/missing', + role: 'consumer', + }, + ]; + + const config: GroupConfig = { + version: 1, + name: 'test', + description: '', + repos: { 'app/known': 'known-repo' }, + links, + packages: {}, + detect: { + http: true, + grpc: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (msg: string) => warnings.push(String(msg)); + try { + const result = await syncGroup(config, { + extractorOverride: async () => [], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('manifest'); + expect(result.crossLinks[0].to.symbolUid).toBe( + 'manifest::app/dangling::http::POST::/api/missing', + ); + expect(warnings.some((w) => w.includes('app/dangling'))).toBe(true); + } finally { + console.warn = origWarn; + } }); it('writes registry to groupDir when skipWrite is false', async () => {