diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 729886960..86115448f 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -191,24 +191,50 @@ export async function writeBridge(groupDir: string, input: WriteBridgeInput): Pr // Use repo-scoped matching: find FROM node by (repo, role=consumer) and TO by (repo, role=provider) // with symbolRef matching, because link.contractId is the consumer's ID which may differ // from the provider's contractId (e.g. wildcard consumer vs method-level provider). + const findContractNode = async ( + repo: string, + role: 'consumer' | 'provider', + symbolUid: string, + filePath: string, + symbolName: string, + ): Promise => { + if (symbolUid) { + const uidRows = await queryBridge<{ id: string }>( + handle, + `MATCH (c:Contract) WHERE c.repo = $repo AND c.role = $role + AND c.symbolUid = $symbolUid RETURN c.id AS id LIMIT 1`, + { repo, role, symbolUid }, + ); + if (uidRows.length > 0) return uidRows[0].id; + } + + const refRows = await queryBridge<{ id: string }>( + handle, + `MATCH (c:Contract) WHERE c.repo = $repo AND c.role = $role + AND c.filePath = $filePath AND c.symbolName = $symbolName + RETURN c.id AS id LIMIT 1`, + { repo, role, filePath, symbolName }, + ); + if (refRows.length > 0) return refRows[0].id; + return null; + }; + for (const link of input.crossLinks) { - // Find from-node: match by repo + role + symbolRef (consumer side) - const fromRows = await queryBridge<{ id: string }>( - handle, - `MATCH (c:Contract) WHERE c.repo = $repo AND c.role = 'consumer' - AND c.filePath = $fp AND c.symbolName = $sn RETURN c.id AS id LIMIT 1`, - { repo: link.from.repo, fp: link.from.symbolRef.filePath, sn: link.from.symbolRef.name }, + const fromId = await findContractNode( + link.from.repo, + 'consumer', + link.from.symbolUid, + link.from.symbolRef.filePath, + link.from.symbolRef.name, ); - // Find to-node: match by repo + role + symbolRef (provider side) - const toRows = await queryBridge<{ id: string }>( - handle, - `MATCH (c:Contract) WHERE c.repo = $repo AND c.role = 'provider' - AND c.filePath = $fp AND c.symbolName = $sn RETURN c.id AS id LIMIT 1`, - { repo: link.to.repo, fp: link.to.symbolRef.filePath, sn: link.to.symbolRef.name }, + const toId = await findContractNode( + link.to.repo, + 'provider', + link.to.symbolUid, + link.to.symbolRef.filePath, + link.to.symbolRef.name, ); - if (!fromRows.length || !toRows.length) continue; - const fromId = fromRows[0].id; - const toId = toRows[0].id; + if (!fromId || !toId) continue; await queryBridge( handle, ` diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index d1461cbe4..3609b8aa6 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -89,6 +89,28 @@ export class ManifestExtractor { LIMIT 1`, { contract: link.contract }, ); + } else if (link.type === 'grpc') { + const [serviceName, methodName = ''] = link.contract.split('/'); + rows = await executor( + `MATCH (n) + WHERE n.name CONTAINS $serviceName + OR n.name CONTAINS $methodName + OR n.filePath ENDS WITH '.proto' + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + LIMIT 1`, + { contract: link.contract, serviceName, methodName }, + ); + } else if (link.type === 'lib') { + const packageName = link.contract.split('/').pop() ?? link.contract; + rows = await executor( + `MATCH (n) + WHERE n.name = $contract + OR n.name CONTAINS $packageName + OR n.filePath CONTAINS $packageName + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + LIMIT 1`, + { contract: link.contract, packageName }, + ); } else { return null; } diff --git a/gitnexus/test/unit/group/bridge-db-edge.test.ts b/gitnexus/test/unit/group/bridge-db-edge.test.ts index 03238e35f..da1033d13 100644 --- a/gitnexus/test/unit/group/bridge-db-edge.test.ts +++ b/gitnexus/test/unit/group/bridge-db-edge.test.ts @@ -121,4 +121,70 @@ describe('bridge-db edge cases', () => { expect(contractRows).toHaveLength(2); await closeBridgeDb(handle!); }); + + it('test_writeBridge_manifest_grpc_link_with_symbol_uids_persists_queryable_contract_edge', async () => { + const provider = makeContract({ + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + role: 'provider', + repo: 'platform/auth', + symbolUid: 'uid-auth-login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + symbolName: 'auth.AuthService/Login', + }); + const consumer = makeContract({ + contractId: 'grpc::auth.AuthService/Login', + type: 'grpc', + role: 'consumer', + repo: 'platform/orders', + symbolUid: 'uid-orders-client', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + symbolName: 'auth.AuthService/Login', + }); + const link: CrossLink = { + from: { + repo: 'platform/orders', + symbolUid: 'uid-orders-client', + symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' }, + }, + to: { + repo: 'platform/auth', + symbolUid: 'uid-auth-login', + symbolRef: { filePath: 'src/auth.proto', name: 'Login' }, + }, + type: 'grpc', + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + confidence: 1.0, + }; + + await writeBridge(tmpDir, { + contracts: [provider, consumer], + crossLinks: [link], + repoSnapshots: {}, + missingRepos: [], + }); + + const handle = await openBridgeDbReadOnly(tmpDir); + expect(handle).not.toBeNull(); + const rows = await queryBridge<{ + contractId: string; + matchType: string; + fromRepo: string; + toRepo: string; + }>( + handle!, + `MATCH (a:Contract)-[l:ContractLink]->(b:Contract) + RETURN l.contractId AS contractId, l.matchType AS matchType, l.fromRepo AS fromRepo, l.toRepo AS toRepo`, + ); + expect(rows).toEqual([ + { + contractId: 'grpc::auth.AuthService/Login', + matchType: 'manifest', + fromRepo: 'platform/orders', + toRepo: 'platform/auth', + }, + ]); + await closeBridgeDb(handle!); + }); }); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index 27de3a4d6..19b0654dc 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -60,6 +60,116 @@ describe('ManifestExtractor', () => { expect(result.crossLinks[0].to.repo).toBe('sales/crm/backend'); }); + it('resolves grpc manifest links to concrete provider and consumer symbols', async () => { + const links: GroupManifestLink[] = [ + { + from: 'platform/orders', + to: 'platform/auth', + type: 'grpc', + contract: 'auth.AuthService/Login', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/auth', + async (_cypher, params) => { + if (params?.contract !== 'auth.AuthService/Login') return []; + return [ + { + uid: 'uid-auth-login', + name: 'Login', + filePath: 'src/auth.proto', + }, + ]; + }, + ], + [ + 'platform/orders', + async (_cypher, params) => { + if (params?.contract !== 'auth.AuthService/Login') return []; + return [ + { + uid: 'uid-orders-client', + name: 'AuthServiceClient', + filePath: 'src/client.ts', + }, + ]; + }, + ], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + expect(provider?.symbolUid).toBe('uid-auth-login'); + expect(provider?.symbolRef.filePath).toBe('src/auth.proto'); + expect(consumer?.symbolUid).toBe('uid-orders-client'); + expect(consumer?.symbolRef.filePath).toBe('src/client.ts'); + expect(result.crossLinks[0].to.symbolRef.filePath).toBe('src/auth.proto'); + expect(result.crossLinks[0].from.symbolRef.filePath).toBe('src/client.ts'); + }); + + it('resolves lib manifest links to concrete provider and consumer symbols', async () => { + const links: GroupManifestLink[] = [ + { + from: 'platform/web', + to: 'platform/shared-lib', + type: 'lib', + contract: '@platform/contracts', + role: 'consumer', + }, + ]; + + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/shared-lib', + async (_cypher, params) => { + if (params?.contract !== '@platform/contracts') return []; + return [ + { + uid: 'uid-lib', + name: '@platform/contracts', + filePath: 'src/index.ts', + }, + ]; + }, + ], + [ + 'platform/web', + async (_cypher, params) => { + if (params?.contract !== '@platform/contracts') return []; + return [ + { + uid: 'uid-importer', + name: 'contractsClient', + filePath: 'src/app.ts', + }, + ]; + }, + ], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + expect(provider?.symbolUid).toBe('uid-lib'); + expect(consumer?.symbolUid).toBe('uid-importer'); + expect(result.crossLinks[0].to.symbolUid).toBe('uid-lib'); + expect(result.crossLinks[0].from.symbolUid).toBe('uid-importer'); + }); + it('returns empty for no links', async () => { const result = await extractor.extractFromManifest([]); expect(result.contracts).toHaveLength(0);