diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 86115448f..32e045463 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -5,6 +5,7 @@ import lbug from '@ladybugdb/core'; import type { LbugValue } from '@ladybugdb/core'; import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js'; import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; +import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; export function contractNodeId( repo: string, @@ -120,6 +121,8 @@ export interface WriteBridgeInput { export async function writeBridge(groupDir: string, input: WriteBridgeInput): Promise { await fsp.mkdir(groupDir, { recursive: true }); + const contracts = dedupeContracts(input.contracts); + const crossLinks = dedupeCrossLinks(input.crossLinks); const finalPath = path.join(groupDir, 'bridge.lbug'); const tmpPath = path.join(groupDir, 'bridge.lbug.tmp'); @@ -137,7 +140,7 @@ export async function writeBridge(groupDir: string, input: WriteBridgeInput): Pr await ensureBridgeSchema(handle); // Insert contracts - for (const c of input.contracts) { + for (const c of contracts) { const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath); await queryBridge( handle, @@ -216,10 +219,19 @@ export async function writeBridge(groupDir: string, input: WriteBridgeInput): Pr { repo, role, filePath, symbolName }, ); if (refRows.length > 0) return refRows[0].id; + + const fileRows = await queryBridge<{ id: string }>( + handle, + `MATCH (c:Contract) WHERE c.repo = $repo AND c.role = $role + AND c.filePath = $filePath + RETURN c.id AS id LIMIT 2`, + { repo, role, filePath }, + ); + if (fileRows.length === 1) return fileRows[0].id; return null; }; - for (const link of input.crossLinks) { + for (const link of crossLinks) { const fromId = await findContractNode( link.from.repo, 'consumer', diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index 311c50f63..d7144b480 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -122,6 +122,25 @@ function computePhase1Timeout(timeout: number): number { return Math.min(Math.ceil(timeout * 0.3), 10000); } +async function runPhase1WithTimeout( + timeout: number, + localImpactFn: (target: string, direction: string) => Promise, + target: string, + direction: 'upstream' | 'downstream', +): Promise<{ ok: true; v: unknown } | { ok: false }> { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + localImpactFn(target, direction).then((v) => ({ ok: true as const, v })), + new Promise<{ ok: false }>((resolve) => { + timer = setTimeout(() => resolve({ ok: false }), timeout); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + export async function runGroupImpactLegacy( opts: LegacyGroupImpactOptions, ): Promise { @@ -133,12 +152,12 @@ export async function runGroupImpactLegacy( const wallDeadline = tStart + timeout; const phase1Timeout = computePhase1Timeout(timeout); - const localResult = await Promise.race([ - opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })), - new Promise<{ ok: false }>((resolve) => - setTimeout(() => resolve({ ok: false }), phase1Timeout), - ), - ]); + const localResult = await runPhase1WithTimeout( + phase1Timeout, + opts.localImpactFn, + opts.target, + opts.direction, + ); let truncated = !localResult.ok; const local = localResult.ok @@ -256,15 +275,6 @@ export async function runGroupImpactLegacy( /* Cypher-based Phase 2 */ /* ------------------------------------------------------------------ */ -const UPSTREAM_QUERY_BASE = ` -MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract) -WHERE provider.repo = $sourceRepo - AND (provider.symbolUid IN $localUids - OR (NOT provider.symbolUid IN $localUids AND (provider.filePath + '::' + provider.symbolName) IN $localRefs)) - AND l.confidence >= $minConfidence`; - -const UPSTREAM_QUERY_SUBGROUP = ` AND (consumer.repo = $subgroup OR consumer.repo STARTS WITH $subgroup + '/')`; - const UPSTREAM_QUERY_RETURN = ` RETURN consumer.repo AS fanOutRepo, consumer.symbolUid AS fanOutUid, consumer.filePath AS fanOutFilePath, consumer.symbolName AS fanOutSymbolName, @@ -275,15 +285,6 @@ RETURN consumer.repo AS fanOutRepo, consumer.symbolUid AS fanOutUid, consumer.type AS contractType ORDER BY l.confidence DESC`; -const DOWNSTREAM_QUERY_BASE = ` -MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract) -WHERE consumer.repo = $sourceRepo - AND (consumer.symbolUid IN $localUids - OR (NOT consumer.symbolUid IN $localUids AND (consumer.filePath + '::' + consumer.symbolName) IN $localRefs)) - AND l.confidence >= $minConfidence`; - -const DOWNSTREAM_QUERY_SUBGROUP = ` AND (provider.repo = $subgroup OR provider.repo STARTS WITH $subgroup + '/')`; - const DOWNSTREAM_QUERY_RETURN = ` RETURN provider.repo AS fanOutRepo, provider.symbolUid AS fanOutUid, provider.filePath AS fanOutFilePath, provider.symbolName AS fanOutSymbolName, @@ -294,6 +295,45 @@ RETURN provider.repo AS fanOutRepo, provider.symbolUid AS fanOutUid, consumer.type AS contractType ORDER BY l.confidence DESC`; +function buildBridgeQuery( + direction: 'upstream' | 'downstream', + hasUids: boolean, + hasRefs: boolean, + subgroup?: string, +): string | null { + const isUpstream = direction === 'upstream'; + const sourceAlias = isUpstream ? 'provider' : 'consumer'; + const fanOutAlias = isUpstream ? 'consumer' : 'provider'; + const localMatchers: string[] = []; + + if (hasUids) { + localMatchers.push(`${sourceAlias}.symbolUid IN $localUids`); + } + if (hasRefs) { + localMatchers.push( + `(${sourceAlias}.filePath + '::' + ${sourceAlias}.symbolName) IN $localRefs`, + ); + } + if (localMatchers.length === 0) { + return null; + } + + const whereClauses = [ + `${sourceAlias}.repo = $sourceRepo`, + `(${localMatchers.join(' OR ')})`, + 'l.confidence >= $minConfidence', + ]; + const normalizedSubgroup = subgroup?.trim().replace(/\/+$/, ''); + if (normalizedSubgroup) { + whereClauses.push( + `(${fanOutAlias}.repo = $subgroup OR ${fanOutAlias}.repo STARTS WITH $subgroup + '/')`, + ); + } + + const returnClause = isUpstream ? UPSTREAM_QUERY_RETURN : DOWNSTREAM_QUERY_RETURN; + return `MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)\nWHERE ${whereClauses.join('\n AND ')}${returnClause}`; +} + interface CrossImpactRow { fanOutRepo: string; fanOutUid: string; @@ -317,12 +357,12 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise ({ ok: true as const, v })), - new Promise<{ ok: false }>((resolve) => - setTimeout(() => resolve({ ok: false }), phase1Timeout), - ), - ]); + const localResult = await runPhase1WithTimeout( + phase1Timeout, + opts.localImpactFn, + opts.target, + opts.direction, + ); let truncated = !localResult.ok; const local = localResult.ok @@ -346,27 +386,29 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise 0, + localRefs.length > 0, + normalizedSubgroup ?? undefined, + ); const queryParams: Record = { sourceRepo: opts.repoPath, - localUids: [...uids], - localRefs: [...phase1Refs], minConfidence, }; + if (localUids.length > 0) { + queryParams.localUids = localUids; + } + if (localRefs.length > 0) { + queryParams.localRefs = localRefs; + } if (normalizedSubgroup) { queryParams.subgroup = normalizedSubgroup; } - const rows = await opts.bridgeQuery(cypher, queryParams); + const rows = cypher ? await opts.bridgeQuery(cypher, queryParams) : []; let maxCrossConf = 0; const distinctRepos = new Set(); diff --git a/gitnexus/src/core/group/normalization.ts b/gitnexus/src/core/group/normalization.ts new file mode 100644 index 000000000..192aec5b9 --- /dev/null +++ b/gitnexus/src/core/group/normalization.ts @@ -0,0 +1,105 @@ +import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js'; + +function contractKey(contract: StoredContract): string { + return [contract.repo, contract.contractId, contract.role, contract.symbolRef.filePath].join( + '\0', + ); +} + +function endpointKey(endpoint: CrossLinkEndpoint): string { + return [ + endpoint.repo, + endpoint.service ?? '', + endpoint.symbolRef.filePath, + endpoint.symbolRef.name, + ].join('\0'); +} + +function contractRichness(contract: StoredContract): number { + let score = 0; + if (contract.symbolUid) score += 3; + if (contract.symbolRef.filePath) score += 2; + if (contract.symbolRef.name && contract.symbolRef.name !== contract.contractId) score += 2; + if (contract.symbolName && contract.symbolName !== contract.contractId) score += 2; + if (contract.service) score += 1; + if (contract.meta.source !== 'manifest') score += 1; + return score; +} + +function mergeContracts(existing: StoredContract, incoming: StoredContract): StoredContract { + const [primary, secondary] = + contractRichness(incoming) > contractRichness(existing) + ? [incoming, existing] + : [existing, incoming]; + const symbolRefName = primary.symbolRef.name || secondary.symbolRef.name; + return { + ...secondary, + ...primary, + symbolUid: primary.symbolUid || secondary.symbolUid, + symbolRef: { + filePath: primary.symbolRef.filePath || secondary.symbolRef.filePath, + name: symbolRefName, + }, + symbolName: primary.symbolName || secondary.symbolName || symbolRefName, + confidence: Math.max(existing.confidence, incoming.confidence), + service: primary.service ?? secondary.service, + meta: { ...secondary.meta, ...primary.meta }, + }; +} + +function mergeEndpoints( + existing: CrossLinkEndpoint, + incoming: CrossLinkEndpoint, +): CrossLinkEndpoint { + return { + repo: existing.repo, + service: existing.service ?? incoming.service, + symbolUid: existing.symbolUid || incoming.symbolUid, + symbolRef: { + filePath: existing.symbolRef.filePath || incoming.symbolRef.filePath, + name: existing.symbolRef.name || incoming.symbolRef.name, + }, + }; +} + +function crossLinkKey(link: CrossLink): string { + return [ + link.type, + link.contractId, + link.matchType, + endpointKey(link.from), + endpointKey(link.to), + ].join('\0'); +} + +export function dedupeContracts(items: StoredContract[]): StoredContract[] { + const deduped = new Map(); + for (const contract of items) { + const key = contractKey(contract); + const existing = deduped.get(key); + deduped.set(key, existing ? mergeContracts(existing, contract) : contract); + } + return [...deduped.values()]; +} + +export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] { + const deduped = new Map(); + for (const link of items) { + const key = crossLinkKey(link); + const existing = deduped.get(key); + if (!existing) { + deduped.set(key, link); + continue; + } + const keepIncoming = link.confidence > existing.confidence; + const primary = keepIncoming ? link : existing; + const secondary = keepIncoming ? existing : link; + deduped.set(key, { + ...primary, + confidence: Math.max(existing.confidence, link.confidence), + from: mergeEndpoints(primary.from, secondary.from), + to: mergeEndpoints(primary.to, secondary.to), + }); + } + return [...deduped.values()]; +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 0451c7950..62581e363 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -12,6 +12,7 @@ import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching. import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; import { writeBridge } from './bridge-db.js'; +import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; export interface SyncOptions { extractorOverride?: @@ -162,11 +163,24 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } + autoContracts = dedupeContracts(autoContracts); + manifestResult = { + contracts: dedupeContracts(manifestResult.contracts), + crossLinks: dedupeCrossLinks(manifestResult.crossLinks), + }; + const providerIndex = buildProviderIndex(autoContracts); const { matched: exactLinks, unmatched } = runExactMatch(autoContracts, providerIndex); const { matched: wildcardLinks, remaining } = runWildcardMatch(unmatched, providerIndex); - const crossLinks: CrossLink[] = [...manifestResult.crossLinks, ...exactLinks, ...wildcardLinks]; - const allContracts: StoredContract[] = [...manifestResult.contracts, ...autoContracts]; + const crossLinks: CrossLink[] = dedupeCrossLinks([ + ...manifestResult.crossLinks, + ...exactLinks, + ...wildcardLinks, + ]); + const allContracts: StoredContract[] = dedupeContracts([ + ...manifestResult.contracts, + ...autoContracts, + ]); if (opts?.groupDir && !opts.skipWrite) { await writeBridge(opts.groupDir, { diff --git a/gitnexus/test/fixtures/group/group-cross-repo.yaml b/gitnexus/test/fixtures/group/group-cross-repo.yaml new file mode 100644 index 000000000..e59a807dc --- /dev/null +++ b/gitnexus/test/fixtures/group/group-cross-repo.yaml @@ -0,0 +1,29 @@ +version: 1 +name: cross-repo-fixture +description: "Cross-repo fixture backed by split monorepo services" + +repos: + platform/auth: test-monorepo/services/auth + platform/orders: test-monorepo/services/orders + platform/gateway: test-monorepo/services/gateway + +links: + - from: platform/orders + to: platform/auth + type: grpc + contract: auth.AuthService/Login + role: consumer + +packages: {} + +detect: + http: true + grpc: true + topics: false + shared_libs: false + embedding_fallback: false + +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 diff --git a/gitnexus/test/integration/group/group-impact.test.ts b/gitnexus/test/integration/group/group-impact.test.ts index 3274283d3..1f8e64d39 100644 --- a/gitnexus/test/integration/group/group-impact.test.ts +++ b/gitnexus/test/integration/group/group-impact.test.ts @@ -1,9 +1,34 @@ /** - * Group impact wiring — mocks `localImpactFn` / `crossImpactFn`. E2E with real graphs is a follow-up. + * Group impact integration keeps a cheap mocked wiring test and adds a real + * bridge-backed fixture path through GroupService using indexed fixture repos. */ import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { runGroupImpactLegacy } from '../../../src/core/group/cross-impact.js'; -import type { ContractRegistry } from '../../../src/core/group/types.js'; +import { parseGroupConfig } from '../../../src/core/group/config-parser.js'; +import { GroupService } from '../../../src/core/group/service.js'; +import { syncGroup } from '../../../src/core/group/sync.js'; +import { runFullAnalysis } from '../../../src/core/run-analyze.js'; +import type { + ContractRegistry, + RepoHandle, + StoredContract, +} from '../../../src/core/group/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/group'); +const FIXTURE_REPOS = [ + ['platform/auth', 'test-monorepo/services/auth'], + ['platform/orders', 'test-monorepo/services/orders'], + ['platform/gateway', 'test-monorepo/services/gateway'], +] as const; +const ANALYZE_CALLBACKS = { + onProgress: () => {}, + onLog: () => {}, +}; function minimalRegistry(crossLinks: ContractRegistry['crossLinks']): ContractRegistry { return { @@ -16,6 +41,98 @@ function minimalRegistry(crossLinks: ContractRegistry['crossLinks']): ContractRe }; } +function withIsolatedHomes(run: (tempRoot: string) => Promise): Promise { + const previous = { + GITNEXUS_HOME: process.env.GITNEXUS_HOME, + USERPROFILE: process.env.USERPROFILE, + HOME: process.env.HOME, + }; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-impact-')); + process.env.GITNEXUS_HOME = path.join(tempRoot, '.gitnexus-home'); + process.env.USERPROFILE = tempRoot; + process.env.HOME = tempRoot; + + return run(tempRoot).finally(async () => { + if (previous.GITNEXUS_HOME === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previous.GITNEXUS_HOME; + if (previous.USERPROFILE === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previous.USERPROFILE; + if (previous.HOME === undefined) delete process.env.HOME; + else process.env.HOME = previous.HOME; + }); +} + +function stageFixtureRepos(tempRoot: string): string { + const fixtureRoot = path.join(tempRoot, 'fixture-root'); + for (const [, relPath] of FIXTURE_REPOS) { + const sourcePath = path.join(FIXTURES_DIR, relPath); + const targetPath = path.join(fixtureRoot, relPath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.cpSync(sourcePath, targetPath, { recursive: true }); + fs.rmSync(path.join(targetPath, '.gitnexus'), { recursive: true, force: true }); + } + return fixtureRoot; +} + +async function analyzeStagedFixtureRepos(fixtureRoot: string): Promise { + for (const [, relPath] of FIXTURE_REPOS) { + const repoPath = path.join(fixtureRoot, relPath); + await runFullAnalysis( + repoPath, + { + force: true, + embeddings: false, + skipAgentsMd: true, + }, + ANALYZE_CALLBACKS, + ); + } +} + +function makeRepoHandle( + fixtureRoot: string, + groupPath: (typeof FIXTURE_REPOS)[number][0], + registryName: string, + fixtureRelPath = registryName, +): RepoHandle { + const repoPath = path.join(fixtureRoot, fixtureRelPath); + return { + id: registryName, + path: groupPath, + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }; +} + +function makeMinimalImpact(result: { id: string; name: string; filePath: string; type?: string }): { + target: { id: string; name: string; filePath: string; type: string }; + direction: 'upstream' | 'downstream'; + impactedCount: number; + risk: 'LOW'; + summary: { direct: number; processes_affected: number; modules_affected: number }; + affected_processes: []; + affected_modules: []; + byDepth: { '1': Array<{ id: string; name: string; filePath: string }> }; +} { + return { + target: { + id: result.id, + name: result.name, + filePath: result.filePath, + type: result.type ?? 'Function', + }, + direction: 'upstream', + impactedCount: 1, + risk: 'LOW', + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: { + '1': [{ id: result.id, name: result.name, filePath: result.filePath }], + }, + }; +} + describe('Group impact integration', () => { it('runs phase 1 and fan-out when cross-link matches UID', async () => { const registry = minimalRegistry([ @@ -72,4 +189,182 @@ describe('Group impact integration', () => { expect(fanOutCalls).toBe(1); expect(result.summary.cross_repo_hits).toBe(1); }); + + it('runs real cross-repo impact through bridge and indexed fixture repos', async () => { + await withIsolatedHomes(async () => { + const groupHome = process.env.GITNEXUS_HOME!; + const groupDir = path.join(groupHome, 'groups', 'cross-repo-fixture'); + const fixtureRoot = stageFixtureRepos(groupHome); + const groupYaml = `version: 1 +name: cross-repo-fixture +description: "Cross-repo fixture backed by split monorepo services" + +repos: + platform/auth: auth + platform/orders: orders + platform/gateway: gateway + +links: + - from: platform/orders + to: platform/auth + type: grpc + contract: "auth.AuthService/Login" + role: consumer + +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 +`; + fs.mkdirSync(groupDir, { recursive: true }); + fs.writeFileSync(path.join(groupDir, 'group.yaml'), groupYaml); + + await analyzeStagedFixtureRepos(fixtureRoot); + + const config = parseGroupConfig(groupYaml); + const handles = new Map( + FIXTURE_REPOS.map(([groupPath, relPath]) => [ + path.basename(relPath), + makeRepoHandle(fixtureRoot, groupPath, path.basename(relPath), relPath), + ]), + ); + + const syncResult = await syncGroup(config, { + groupDir, + resolveRepoHandle: async (registryName, groupPath) => { + const fixtureRelPath = FIXTURE_REPOS.find( + ([, relPath]) => path.basename(relPath) === registryName, + )?.[1]; + if (!fixtureRelPath) return null; + return makeRepoHandle( + fixtureRoot, + groupPath as (typeof FIXTURE_REPOS)[number][0], + registryName, + fixtureRelPath, + ); + }, + }); + + expect(syncResult.crossLinks.length).toBeGreaterThan(0); + + const bootstrapService = new GroupService({ + resolveRepo: async (repoParam?: string) => { + const handle = handles.get(repoParam ?? ''); + if (!handle) throw new Error(`Unknown repo: ${repoParam ?? ''}`); + return { + id: handle.id, + name: repoParam ?? handle.id, + repoPath: handle.repoPath, + storagePath: handle.storagePath, + }; + }, + impact: async () => ({ error: 'bootstrap-only' }), + query: async () => ({ processes: [] }), + impactByUid: async () => null, + }); + + const bootstrapContracts = (await bootstrapService.groupContracts({ + name: 'cross-repo-fixture', + })) as { contracts?: StoredContract[] }; + const contractRows = bootstrapContracts.contracts ?? []; + const authProvider = contractRows.find( + (contract) => + contract.repo === 'platform/auth' && + contract.role === 'provider' && + contract.contractId === 'grpc::auth.AuthService/Login', + ); + const ordersConsumer = contractRows.find( + (contract) => + contract.repo === 'platform/orders' && + contract.role === 'consumer' && + contract.contractId === 'grpc::auth.AuthService/Login', + ); + + expect(authProvider).toBeDefined(); + expect(ordersConsumer).toBeDefined(); + + const groupService = new GroupService({ + resolveRepo: async (repoParam?: string) => { + const handle = handles.get(repoParam ?? ''); + if (!handle) throw new Error(`Unknown repo: ${repoParam ?? ''}`); + return { + id: handle.id, + name: repoParam ?? handle.id, + repoPath: handle.repoPath, + storagePath: handle.storagePath, + }; + }, + impact: async (_repo, params) => { + const contract = contractRows.find( + (row) => + row.repo === 'platform/auth' && + row.role === 'provider' && + row.symbolName === params.target, + ); + if (!contract) { + return { error: `Target "${params.target}" not found in bridge contracts` }; + } + return { + ...makeMinimalImpact({ + id: contract.symbolUid || `${contract.repo}::${contract.contractId}`, + name: contract.symbolName, + filePath: contract.symbolRef.filePath, + }), + direction: params.direction, + }; + }, + query: async () => ({ processes: [] }), + impactByUid: async (repoId, uid, direction) => { + const targetRepo = handles.get(repoId)?.path; + const contract = contractRows.find( + (row) => + row.repo === targetRepo && + (row.symbolUid === uid || + (row.repo === ordersConsumer?.repo && + row.contractId === ordersConsumer?.contractId && + row.symbolName === ordersConsumer?.symbolName)), + ); + if (!contract) return null; + return { + ...makeMinimalImpact({ + id: contract.symbolUid || `${contract.repo}::${contract.contractId}`, + name: contract.symbolName, + filePath: contract.symbolRef.filePath, + }), + direction, + }; + }, + }); + + const result = (await groupService.groupImpact({ + name: 'cross-repo-fixture', + repo: 'platform/auth', + target: authProvider?.symbolName ?? 'AuthService.Login', + direction: 'upstream', + })) as { + error?: string; + cross?: Array<{ repo_path: string; contract: { id: string } }>; + summary?: { cross_repo_hits: number }; + }; + + expect(result.error).toBeUndefined(); + expect(result.summary?.cross_repo_hits).toBeGreaterThan(0); + expect( + result.cross?.some( + (hit) => + hit.repo_path === 'platform/orders' && + hit.contract.id === 'grpc::auth.AuthService/Login', + ), + ).toBe(true); + }); + }, 120000); }); diff --git a/gitnexus/test/integration/group/group-sync.test.ts b/gitnexus/test/integration/group/group-sync.test.ts index 3cceb200b..0cd2e7f52 100644 --- a/gitnexus/test/integration/group/group-sync.test.ts +++ b/gitnexus/test/integration/group/group-sync.test.ts @@ -1,17 +1,101 @@ /** - * Group sync integration — uses `extractorOverride` / parsed YAML only (no LadybugDB). - * Full pipeline with indexed fixture repos is a follow-up (needs `.gitnexus/lbug`). + * Group sync integration — keeps a cheap mocked orchestration check and adds + * a real indexed-fixture path for bridge generation. */ import { describe, it, expect } from 'vitest'; import * as path from 'node:path'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import { fileURLToPath } from 'node:url'; import { parseGroupConfig } from '../../../src/core/group/config-parser.js'; import { syncGroup } from '../../../src/core/group/sync.js'; -import type { StoredContract } from '../../../src/core/group/types.js'; +import { runFullAnalysis } from '../../../src/core/run-analyze.js'; +import { bridgeExists } from '../../../src/core/group/bridge-db.js'; +import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/group'); +const CROSS_REPO_FIXTURE = path.join(FIXTURES_DIR, 'group-cross-repo.yaml'); +const FIXTURE_REPOS = [ + 'test-monorepo/services/auth', + 'test-monorepo/services/orders', + 'test-monorepo/services/gateway', +] as const; + +const ANALYZE_CALLBACKS = { + onProgress: () => {}, + onLog: () => {}, +}; + +function withIsolatedHomes(run: (tempRoot: string) => Promise): Promise { + const previous = { + GITNEXUS_HOME: process.env.GITNEXUS_HOME, + USERPROFILE: process.env.USERPROFILE, + HOME: process.env.HOME, + }; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-sync-')); + process.env.GITNEXUS_HOME = path.join(tempRoot, '.gitnexus-home'); + process.env.USERPROFILE = tempRoot; + process.env.HOME = tempRoot; + + return run(tempRoot).finally(async () => { + if (previous.GITNEXUS_HOME === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previous.GITNEXUS_HOME; + if (previous.USERPROFILE === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previous.USERPROFILE; + if (previous.HOME === undefined) delete process.env.HOME; + else process.env.HOME = previous.HOME; + await removeTree(tempRoot); + }); +} + +function stageFixtureRepos(tempRoot: string): string { + const fixtureRoot = path.join(tempRoot, 'fixture-root'); + for (const relPath of FIXTURE_REPOS) { + const sourcePath = path.join(FIXTURES_DIR, relPath); + const targetPath = path.join(fixtureRoot, relPath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.cpSync(sourcePath, targetPath, { recursive: true }); + fs.rmSync(path.join(targetPath, '.gitnexus'), { recursive: true, force: true }); + } + return fixtureRoot; +} + +async function analyzeFixtureRepos(fixtureRoot: string): Promise { + for (const relPath of FIXTURE_REPOS) { + const repoPath = path.join(fixtureRoot, relPath); + await runFullAnalysis( + repoPath, + { + force: true, + embeddings: false, + skipAgentsMd: true, + }, + ANALYZE_CALLBACKS, + ); + } +} + +async function removeTree(targetPath: string): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + try { + fs.rmSync(targetPath, { recursive: true, force: true }); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } + } +} + +function resolveFixtureRepoHandle(registryName: string, groupPath: string): RepoHandle { + const repoPath = path.join(FIXTURES_DIR, registryName); + return { + id: groupPath.replace(/[^a-z0-9]+/gi, '-').toLowerCase(), + path: groupPath, + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }; +} describe('Group sync integration', () => { it('parses fixture group.yaml', () => { @@ -76,4 +160,44 @@ describe('Group sync integration', () => { const healthUnmatched = result.unmatched.some((c) => c.contractId.includes('/api/health')); expect(healthUnmatched).toBe(true); }); + + it('builds bridge.lbug from real per-repo indexes for cross-repo fixture', async () => { + await withIsolatedHomes(async (tempRoot) => { + const config = parseGroupConfig(fs.readFileSync(CROSS_REPO_FIXTURE, 'utf-8')); + const groupDir = path.join(tempRoot, 'group-output'); + const fixtureRoot = stageFixtureRepos(tempRoot); + fs.mkdirSync(groupDir, { recursive: true }); + + await analyzeFixtureRepos(fixtureRoot); + + const result = await syncGroup(config, { + groupDir, + resolveRepoHandle: async (registryName, groupPath) => ({ + ...resolveFixtureRepoHandle(registryName, groupPath), + repoPath: path.join(fixtureRoot, registryName), + storagePath: path.join(fixtureRoot, registryName, '.gitnexus'), + }), + }); + + const grpcLink = result.crossLinks.find( + (link) => + link.type === 'grpc' && + link.matchType === 'wildcard' && + link.from.repo === 'platform/orders' && + link.to.repo === 'platform/auth', + ); + expect(grpcLink).toBeDefined(); + + const httpLink = result.crossLinks.find( + (link) => + link.type === 'http' && + link.matchType === 'exact' && + link.contractId === 'http::POST::/api/orders' && + link.from.repo === 'platform/gateway' && + link.to.repo === 'platform/orders', + ); + expect(httpLink).toBeDefined(); + expect(await bridgeExists(groupDir)).toBe(true); + }); + }, 120000); }); diff --git a/gitnexus/test/integration/group/monorepo-sync.test.ts b/gitnexus/test/integration/group/monorepo-sync.test.ts index a9dc38cec..15901af1d 100644 --- a/gitnexus/test/integration/group/monorepo-sync.test.ts +++ b/gitnexus/test/integration/group/monorepo-sync.test.ts @@ -15,7 +15,11 @@ import { detectServiceBoundaries, assignService, } from '../../../src/core/group/service-boundary-detector.js'; -import { runExactMatch } from '../../../src/core/group/matching.js'; +import { + buildProviderIndex, + runExactMatch, + runWildcardMatch, +} from '../../../src/core/group/matching.js'; import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -117,4 +121,51 @@ describe('Monorepo sync integration', () => { // Summary: we should have at least 2 cross-links expect(matched.length).toBeGreaterThanOrEqual(2); }); + + it('matches wildcard gRPC consumers to method providers with degraded confidence', async () => { + const handle = makeHandle(); + const boundaries = await detectServiceBoundaries(MONOREPO_DIR); + const grpcContracts = await new GrpcExtractor().extract(null, MONOREPO_DIR, handle); + const allContracts: StoredContract[] = grpcContracts.map((contract) => ({ + ...contract, + repo: REPO_GROUP_PATH, + service: assignService(contract.symbolRef.filePath, boundaries), + })); + + const providerIndex = buildProviderIndex(allContracts); + const { unmatched } = runExactMatch(allContracts, providerIndex); + const { matched } = runWildcardMatch(unmatched, providerIndex); + + const methodProvider = allContracts.find( + (contract) => + contract.role === 'provider' && + contract.contractId === 'grpc::auth.AuthService/Login' && + contract.symbolRef.filePath.includes('services/auth/'), + ); + + expect(methodProvider).toBeDefined(); + + const wildcardLink = matched.find( + (link) => + link.matchType === 'wildcard' && + link.type === 'grpc' && + link.contractId.endsWith('/*') && + link.to.symbolRef.filePath.includes('services/auth/') && + link.to.symbolRef.name === methodProvider?.symbolRef.name, + ); + + expect(wildcardLink).toBeDefined(); + const wildcardConsumer = allContracts.find( + (contract) => + contract.role === 'consumer' && + contract.contractId === wildcardLink?.contractId && + contract.symbolRef.filePath === wildcardLink?.from.symbolRef.filePath, + ); + + expect(wildcardConsumer).toBeDefined(); + expect(wildcardLink?.confidence).toBe( + Math.min(wildcardConsumer?.confidence ?? 1, methodProvider?.confidence ?? 1), + ); + expect(wildcardLink?.confidence).toBeLessThan(methodProvider?.confidence ?? 1); + }); }); diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts index 31f403dec..56f78c49e 100644 --- a/gitnexus/test/unit/group/bridge-db.test.ts +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -230,6 +230,78 @@ describe('writeBridge + read', () => { await closeBridgeDb(handle!); }); + it('test_writeBridge_duplicate_contracts_and_links_are_deduped', async () => { + const provider = makeContract({ + repo: 'backend', + role: 'provider', + symbolUid: '', + symbolName: 'auth.AuthService/Login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + meta: { source: 'manifest' }, + }); + const concreteProvider = makeContract({ + ...provider, + symbolUid: 'uid-auth-login', + symbolName: 'Login', + confidence: 0.85, + meta: { source: 'analyze' }, + }); + const consumer = makeContract({ + repo: 'frontend', + role: 'consumer', + symbolUid: '', + symbolName: 'auth.AuthService/Login', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + meta: { source: 'manifest' }, + }); + const link: CrossLink = { + from: { + repo: 'frontend', + symbolUid: '', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + }, + to: { + repo: 'backend', + symbolUid: '', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + }, + type: 'grpc', + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + confidence: 1, + }; + + await writeBridge(tmpDir, { + contracts: [provider, concreteProvider, consumer], + crossLinks: [link, { ...link }], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + const contracts = await queryBridge<{ repo: string; symbolUid: string; symbolName: string }>( + handle!, + 'MATCH (c:Contract) RETURN c.repo AS repo, c.symbolUid AS symbolUid, c.symbolName AS symbolName ORDER BY c.repo', + ); + const links = await queryBridge<{ fromRepo: string; toRepo: string }>( + handle!, + 'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo', + ); + + expect(contracts).toHaveLength(2); + expect(contracts[0]).toEqual({ + repo: 'backend', + symbolUid: 'uid-auth-login', + symbolName: 'Login', + }); + expect(links).toHaveLength(1); + await closeBridgeDb(handle!); + }); + it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => { const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent')); expect(handle).toBeNull(); diff --git a/gitnexus/test/unit/group/cross-impact.test.ts b/gitnexus/test/unit/group/cross-impact.test.ts index c5edba1f0..b9efd7685 100644 --- a/gitnexus/test/unit/group/cross-impact.test.ts +++ b/gitnexus/test/unit/group/cross-impact.test.ts @@ -294,6 +294,55 @@ describe('runGroupImpact (Cypher-based)', () => { expect(result.outOfScope).toHaveLength(0); }); + it('test_runGroupImpact_refs_only_bridge_query_omits_empty_uid_clause', async () => { + const bridgeQuery = makeBridgeQuery([ + { + fanOutRepo: 'app/backend', + fanOutUid: '', + fanOutFilePath: 'src/ctrl.ts', + fanOutSymbolName: 'UserController.list', + matchedLocalUid: '', + matchedLocalFilePath: 'src/api.ts', + matchedLocalSymbolName: 'fetchUsers', + matchType: 'exact', + confidence: 1, + contractId: 'http::GET::/api/users', + contractType: 'http', + }, + ]); + const crossImpactFn = vi.fn().mockResolvedValue({ + byDepth: {}, + affected_processes: [], + }); + + const result = await runGroupImpact({ + groupName: 'test', + target: 'fetchUsers', + repoPath: 'app/frontend', + direction: 'downstream', + bridgeQuery, + localImpactFn: async () => ({ + target: { id: '', name: 'fetchUsers', filePath: 'src/api.ts' }, + direction: 'downstream', + impactedCount: 1, + risk: 'LOW', + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: {}, + }), + crossImpactFn, + }); + + expect(bridgeQuery).toHaveBeenCalledOnce(); + const [cypher, params] = bridgeQuery.mock.calls[0]; + expect(cypher).toContain("(consumer.filePath + '::' + consumer.symbolName) IN $localRefs"); + expect(cypher).not.toContain('consumer.symbolUid IN $localUids'); + expect(params.localRefs).toEqual(['src/api.ts::fetchUsers']); + expect(params.localUids).toBeUndefined(); + expect(result.summary.cross_repo_hits).toBe(1); + }); + it('test_runGroupImpact_downstream_fans_out_to_provider', async () => { const bridgeQuery = makeBridgeQuery([ { diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index b07baabe7..7219ea1e7 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -114,6 +114,28 @@ describe('syncGroup', () => { expect(result.crossLinks[0].to.service).toBe('services/auth'); }); + it('deduplicates duplicate contracts and links before returning', async () => { + const config = makeConfig({ 'app/backend': 'backend-repo', 'app/frontend': 'frontend-repo' }); + + const duplicateProvider = makeContract('http::GET::/api/users', 'provider', 'app/backend'); + const duplicateConsumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend'); + + const result = await syncGroup(config, { + extractorOverride: async () => [ + duplicateProvider, + { ...duplicateProvider, confidence: 0.9, meta: { source: 'manifest' } }, + duplicateConsumer, + { ...duplicateConsumer, confidence: 0.75, meta: { source: 'manifest' } }, + ], + skipWrite: true, + }); + + expect(result.contracts).toHaveLength(2); + expect(result.crossLinks).toHaveLength(1); + expect(result.contracts.find((contract) => contract.role === 'provider')?.confidence).toBe(0.9); + expect(result.contracts.find((contract) => contract.role === 'consumer')?.confidence).toBe(0.8); + }); + function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract { return { contractId: id,