feat(group): add cross-repo graph traversal CLI and MCP tool

Add `gitnexus group graph <name> <symbol>` command and `group_graph`
MCP tool that traverses CrossLinks to find how a symbol connects to
other repos in a group. Returns local context plus remote connections
with their contract metadata.

Searches all repos in the group when --repo is not specified. Supports
--depth (max 2) and --direction (upstream/downstream/both) options.

Includes 6 integration tests with mock GroupToolPort.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sahalterion 2026-04-03 15:33:44 -07:00
parent 03e0abf022
commit b59493932d
5 changed files with 529 additions and 0 deletions

View file

@ -339,6 +339,78 @@ export function registerGroupCommands(program: Command): void {
}
});
group
.command('graph <name> <symbol>')
.description('Traverse the cross-repo knowledge graph for a symbol')
.option('--repo <repo>', 'Repo containing the symbol')
.option('--depth <n>', 'Cross-repo traversal depth (max 2)', '1')
.option('--direction <dir>', '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 <name> <query>')
.description('Search execution flows across all repos in a group')

View file

@ -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<string, unknown>): Promise<unknown> {
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<string>([sourceRepo.name]);
const findConnections = async (
repoName: string,
currentDepth: number,
): Promise<void> => {
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.
*/

View file

@ -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<string, unknown>): Promise<unknown> {
await this.refreshRepos();
return this.getGroupService().groupGraph(params);
}
private async groupDiscover(params: Record<string, unknown>): Promise<unknown> {
await this.refreshRepos();
return this.getGroupService().groupDiscover(params);

View file

@ -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.

View file

@ -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<GroupRepoHandle> => {
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');
}
}
});
});