mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
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:
parent
03e0abf022
commit
b59493932d
5 changed files with 529 additions and 0 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
273
gitnexus/test/integration/group/group-graph.test.ts
Normal file
273
gitnexus/test/integration/group/group-graph.test.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue