diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 0ebb1fc38..8267e6de8 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -339,6 +339,78 @@ export function registerGroupCommands(program: Command): void { } }); + group + .command('graph ') + .description('Traverse the cross-repo knowledge graph for a symbol') + .option('--repo ', 'Repo containing the symbol') + .option('--depth ', 'Cross-repo traversal depth (max 2)', '1') + .option('--direction ', 'upstream | downstream | both', 'both') + .option('--json', 'JSON output') + .action( + async ( + name: string, + symbol: string, + opts: { repo?: string; depth?: string; direction?: string; json?: boolean }, + ) => { + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const depth = parseInt(opts.depth || '1', 10) || 1; + const backend = new LocalBackend(); + try { + await backend.init(); + console.log(`Traversing cross-repo graph for "${symbol}" in group "${name}"...\n`); + + const raw = await backend.getGroupService().groupGraph({ + name, + symbol, + repo: opts.repo, + depth, + direction: opts.direction || 'both', + }); + + const result = raw as { + error?: string; + sourceRepo?: string; + localContext?: unknown; + crossConnections?: Array<{ + direction: string; + remoteRepo: string; + contractId: string; + contractType: string; + confidence: number; + }>; + totalCrossLinks?: number; + }; + + if (result.error) { + console.error(result.error); + process.exitCode = 1; + return; + } + + if (opts.json) { + console.log(JSON.stringify(raw, null, 2)); + } else { + console.log(`Source repo: ${result.sourceRepo}`); + console.log(`Cross-repo connections: ${result.totalCrossLinks}\n`); + + for (const conn of result.crossConnections || []) { + const arrow = conn.direction === 'outgoing' ? '→' : '←'; + console.log( + ` ${arrow} ${conn.remoteRepo} [${conn.contractType}] ${conn.contractId} (conf=${conn.confidence})`, + ); + } + + if ((result.crossConnections || []).length === 0) { + console.log(' No cross-repo connections found for this symbol.'); + } + } + } finally { + await backend.dispose().catch(() => {}); + } + }, + ); + group .command('query ') .description('Search execution flows across all repos in a group') diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 1b8d5690d..2f54e1b4f 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -521,6 +521,162 @@ export class GroupService { }; } + /** + * Traverse the cross-repo knowledge graph for a symbol. + * Returns the symbol's local context plus all cross-repo connections via CrossLinks. + */ + async groupGraph(params: Record): Promise { + const name = String(params.name ?? '').trim(); + const symbol = String(params.symbol ?? '').trim(); + const repoParam = typeof params.repo === 'string' ? params.repo.trim() : undefined; + const depth = typeof params.depth === 'number' ? Math.min(params.depth, 2) : 1; + const direction = + typeof params.direction === 'string' ? params.direction : 'both'; + + if (!name || !symbol) return { error: 'name and symbol are required' }; + + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + const registry = await readContractRegistry(groupDir); + + if (!registry) { + return { error: `No contracts.json for group "${name}". Run group_sync first.` }; + } + + // Find the symbol in a repo + let sourceRepo: GroupRepoHandle | null = null; + let localContext: unknown = null; + + if (repoParam) { + // User specified which repo + try { + sourceRepo = await this.port.resolveRepo(repoParam); + localContext = await this.port.query(sourceRepo, { + query: symbol, + limit: 5, + max_symbols: 10, + include_content: false, + }); + } catch { + return { error: `Cannot resolve repo: ${repoParam}` }; + } + } else { + // Search all repos in the group for the symbol + for (const [, registryName] of Object.entries(config.repos)) { + try { + const repo = await this.port.resolveRepo(registryName); + const result = await this.port.query(repo, { + query: symbol, + limit: 3, + max_symbols: 5, + include_content: false, + }); + const processes = (result as { processes?: unknown[] }).processes || []; + if (processes.length > 0) { + sourceRepo = repo; + localContext = result; + break; + } + } catch { + // Skip inaccessible repos + } + } + } + + if (!sourceRepo) { + return { error: `Symbol "${symbol}" not found in any repo in group "${name}"` }; + } + + // Find cross-repo connections via CrossLinks + const crossConnections: Array<{ + direction: 'outgoing' | 'incoming'; + link: typeof registry.crossLinks[0]; + remoteRepo: string; + remoteContext: unknown; + }> = []; + + const visited = new Set([sourceRepo.name]); + + const findConnections = async ( + repoName: string, + currentDepth: number, + ): Promise => { + if (currentDepth > depth) return; + + for (const link of registry.crossLinks) { + const isFrom = link.from.repo === repoName || + Object.entries(config.repos).some(([gp, rn]) => gp === link.from.repo && rn === repoName); + const isTo = link.to.repo === repoName || + Object.entries(config.repos).some(([gp, rn]) => gp === link.to.repo && rn === repoName); + + let remoteRepoGroupPath: string | null = null; + let linkDirection: 'outgoing' | 'incoming' | null = null; + + if (isFrom && (direction === 'downstream' || direction === 'both')) { + remoteRepoGroupPath = link.to.repo; + linkDirection = 'outgoing'; + } else if (isTo && (direction === 'upstream' || direction === 'both')) { + remoteRepoGroupPath = link.from.repo; + linkDirection = 'incoming'; + } + + if (!remoteRepoGroupPath || !linkDirection) continue; + + // Find registry name for remote repo + const remoteRegistryName = config.repos[remoteRepoGroupPath]; + if (!remoteRegistryName || visited.has(remoteRegistryName)) continue; + visited.add(remoteRegistryName); + + let remoteContext: unknown = null; + try { + const remoteRepo = await this.port.resolveRepo(remoteRegistryName); + // Get context for the connected symbol + const remoteSymbol = + linkDirection === 'outgoing' ? link.to.symbolRef.name : link.from.symbolRef.name; + remoteContext = await this.port.query(remoteRepo, { + query: remoteSymbol, + limit: 3, + max_symbols: 5, + include_content: false, + }); + } catch { + // Remote repo not accessible + } + + crossConnections.push({ + direction: linkDirection, + link, + remoteRepo: remoteRepoGroupPath, + remoteContext, + }); + } + }; + + // Find connections from source repo + const sourceGroupPath = Object.entries(config.repos) + .find(([, rn]) => rn === sourceRepo!.name)?.[0] || sourceRepo.name; + await findConnections(sourceGroupPath, 1); + + return { + group: name, + symbol, + sourceRepo: sourceRepo.name, + localContext, + crossConnections: crossConnections.map((cc) => ({ + direction: cc.direction, + remoteRepo: cc.remoteRepo, + contractId: cc.link.contractId, + contractType: cc.link.type, + matchType: cc.link.matchType, + confidence: cc.link.confidence, + from: cc.link.from, + to: cc.link.to, + remoteContext: cc.remoteContext, + })), + totalCrossLinks: crossConnections.length, + }; + } + /** * Auto-discover indexed repos in a directory and create a group with code-level dependency detection. */ diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 88e9a6a3e..f826bdd28 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -3046,6 +3046,8 @@ export class LocalBackend { return this.groupQuery(params); case 'group_status': return this.groupStatus(params); + case 'group_graph': + return this.groupGraph(params); case 'group_discover': return this.groupDiscover(params); default: @@ -3194,6 +3196,11 @@ export class LocalBackend { return JSON.stringify(raw, null, 2); } + private async groupGraph(params: Record): Promise { + await this.refreshRepos(); + return this.getGroupService().groupGraph(params); + } + private async groupDiscover(params: Record): Promise { await this.refreshRepos(); return this.getGroupService().groupDiscover(params); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 2211d3089..917ee3fbf 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -553,6 +553,27 @@ WHEN TO USE: Before group_sync or when agents should refresh indexes.`, required: ['name'], }, }, + { + name: 'group_graph', + description: `Traverse the cross-repo knowledge graph for a symbol. Returns the symbol's local context plus all code-level connections to other repos in the group via CrossLinks. + +WHEN TO USE: After group_sync, to explore how a symbol in one repo connects to symbols in other repos (e.g. who imports this function from another package).`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + symbol: { type: 'string', description: 'Symbol name to search for' }, + repo: { type: 'string', description: 'Repo containing the symbol (optional, searches all if omitted)' }, + depth: { type: 'number', description: 'Cross-repo traversal depth (default: 1, max: 2)' }, + direction: { + type: 'string', + enum: ['upstream', 'downstream', 'both'], + description: 'Direction of traversal (default: both)', + }, + }, + required: ['name', 'symbol'], + }, + }, { name: 'group_discover', description: `Auto-discover indexed repos in a directory and create a group with code-level dependency detection. diff --git a/gitnexus/test/integration/group/group-graph.test.ts b/gitnexus/test/integration/group/group-graph.test.ts new file mode 100644 index 000000000..4a848e06f --- /dev/null +++ b/gitnexus/test/integration/group/group-graph.test.ts @@ -0,0 +1,273 @@ +/** + * Integration test for cross-repo graph traversal. + * + * Tests that groupGraph() finds CrossLink connections and fetches remote context. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + GroupService, + type GroupToolPort, + type GroupRepoHandle, +} from '../../../src/core/group/service.js'; +import { writeContractRegistry } from '../../../src/core/group/storage.js'; +import type { ContractRegistry } from '../../../src/core/group/types.js'; + +describe('Group graph traversal integration', () => { + let tmpDir: string; + let gitnexusHome: string; + let groupDir: string; + let originalHome: string | undefined; + + const MOCK_REGISTRY: ContractRegistry = { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots: { + 'libs/shared': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'abc' }, + 'apps/web': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'def' }, + }, + missingRepos: [], + contracts: [ + { + contractId: 'lib::@test/shared::formatDate', + type: 'lib', + role: 'provider', + symbolUid: 'fn-formatDate', + symbolRef: { filePath: 'src/utils.ts', name: 'formatDate' }, + symbolName: 'formatDate', + confidence: 0.9, + meta: {}, + repo: 'libs/shared', + }, + { + contractId: 'lib::@test/shared::formatDate', + type: 'lib', + role: 'consumer', + symbolUid: '', + symbolRef: { filePath: 'src/app.ts', name: 'formatDate' }, + symbolName: 'formatDate', + confidence: 0.9, + meta: {}, + repo: 'apps/web', + }, + { + contractId: 'lib::@test/shared::Logger', + type: 'lib', + role: 'provider', + symbolUid: 'class-Logger', + symbolRef: { filePath: 'src/logger.ts', name: 'Logger' }, + symbolName: 'Logger', + confidence: 0.9, + meta: {}, + repo: 'libs/shared', + }, + { + contractId: 'lib::@test/shared::Logger', + type: 'lib', + role: 'consumer', + symbolUid: '', + symbolRef: { filePath: 'src/app.ts', name: 'Logger' }, + symbolName: 'Logger', + confidence: 0.9, + meta: {}, + repo: 'apps/web', + }, + ], + crossLinks: [ + { + from: { + repo: 'apps/web', + symbolUid: '', + symbolRef: { filePath: 'src/app.ts', name: 'formatDate' }, + }, + to: { + repo: 'libs/shared', + symbolUid: 'fn-formatDate', + symbolRef: { filePath: 'src/utils.ts', name: 'formatDate' }, + }, + type: 'lib', + contractId: 'lib::@test/shared::formatDate', + matchType: 'exact', + confidence: 1.0, + }, + { + from: { + repo: 'apps/web', + symbolUid: '', + symbolRef: { filePath: 'src/app.ts', name: 'Logger' }, + }, + to: { + repo: 'libs/shared', + symbolUid: 'class-Logger', + symbolRef: { filePath: 'src/logger.ts', name: 'Logger' }, + }, + type: 'lib', + contractId: 'lib::@test/shared::Logger', + matchType: 'exact', + confidence: 1.0, + }, + ], + }; + + beforeEach(async () => { + tmpDir = path.join(os.tmpdir(), `gitnexus-graph-${Date.now()}`); + gitnexusHome = path.join(tmpDir, '.gitnexus-home'); + groupDir = path.join(gitnexusHome, 'groups', 'test-workspace'); + + originalHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = gitnexusHome; + + // Create group dir with group.yaml and contracts.json + fs.mkdirSync(groupDir, { recursive: true }); + + const { createRequire } = await import('node:module'); + const _require = createRequire(import.meta.url); + const yaml = _require('js-yaml') as typeof import('js-yaml'); + + const config = { + version: 1, + name: 'test-workspace', + description: '', + repos: { 'libs/shared': 'shared-utils', 'apps/web': 'web-app' }, + links: [], + packages: {}, + detect: { http: false, grpc: false, topics: false, shared_libs: true, embedding_fallback: false }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + fs.writeFileSync(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + + await writeContractRegistry(groupDir, MOCK_REGISTRY); + }); + + afterEach(() => { + if (originalHome !== undefined) { + process.env.GITNEXUS_HOME = originalHome; + } else { + delete process.env.GITNEXUS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function makeMockPort(): GroupToolPort { + return { + resolveRepo: async (nameOrPath?: string): Promise => { + if (nameOrPath === 'shared-utils') { + return { id: 'shared-utils', name: 'shared-utils', repoPath: '/mock/shared', storagePath: '/mock/shared/.gitnexus' }; + } + if (nameOrPath === 'web-app') { + return { id: 'web-app', name: 'web-app', repoPath: '/mock/web', storagePath: '/mock/web/.gitnexus' }; + } + throw new Error(`Repo not found: ${nameOrPath}`); + }, + impact: async () => ({}), + query: async (_repo, params) => { + // Return mock processes matching the query + const queryText = (params as { query: string }).query; + return { + processes: [ + { name: `process-${queryText}`, summary: `Mock process for ${queryText}` }, + ], + }; + }, + impactByUid: async () => null, + }; + } + + it('finds cross-repo connections for a symbol', async () => { + const service = new GroupService(makeMockPort()); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'formatDate', + repo: 'shared-utils', + })) as { + sourceRepo: string; + crossConnections: Array<{ + direction: string; + remoteRepo: string; + contractId: string; + contractType: string; + confidence: number; + }>; + totalCrossLinks: number; + }; + + expect(result.sourceRepo).toBe('shared-utils'); + expect(result.totalCrossLinks).toBeGreaterThanOrEqual(1); + + // shared-utils is the provider; apps/web is the consumer + // Direction from shared-utils perspective: incoming (apps/web imports from us) + const conn = result.crossConnections.find((c) => + c.contractId.includes('formatDate'), + ); + expect(conn).toBeDefined(); + expect(conn!.contractType).toBe('lib'); + expect(conn!.confidence).toBe(1.0); + }); + + it('searches all repos when no repo specified', async () => { + const service = new GroupService(makeMockPort()); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'formatDate', + })) as { + sourceRepo: string; + totalCrossLinks: number; + }; + + // Should find it in one of the repos + expect(result.sourceRepo).toBeDefined(); + expect(typeof result.totalCrossLinks).toBe('number'); + }); + + it('returns error when no contracts.json exists', async () => { + // Remove contracts.json + fs.unlinkSync(path.join(groupDir, 'contracts.json')); + + const service = new GroupService(makeMockPort()); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'formatDate', + })) as { error: string }; + + expect(result.error).toContain('No contracts.json'); + }); + + it('returns error when symbol and name are missing', async () => { + const service = new GroupService(makeMockPort()); + const result = (await service.groupGraph({})) as { error: string }; + expect(result.error).toBe('name and symbol are required'); + }); + + it('returns error for unknown repo', async () => { + const service = new GroupService(makeMockPort()); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'formatDate', + repo: 'nonexistent-repo', + })) as { error: string }; + + expect(result.error).toContain('Cannot resolve repo'); + }); + + it('includes remote context in connections', async () => { + const service = new GroupService(makeMockPort()); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'formatDate', + repo: 'shared-utils', + })) as { + crossConnections: Array<{ + remoteContext: unknown; + }>; + }; + + // The mock query returns processes, so remoteContext should not be null + for (const conn of result.crossConnections) { + if (conn.remoteContext) { + expect(conn.remoteContext).toHaveProperty('processes'); + } + } + }); +});