From 03e0abf0227a902ffcfb0e92a989005520513034 Mon Sep 17 00:00:00 2001 From: sahalterion Date: Fri, 3 Apr 2026 15:30:36 -0700 Subject: [PATCH] feat(group): add auto-discover CLI command and MCP tool Add `gitnexus group auto-discover [directory]` that scans a parent directory for indexed repos, reads their package manifests, builds package mappings, and creates a group with code-level dependency detection enabled. Also adds `group_discover` MCP tool for programmatic access and `groupDiscover()` method on GroupService. Includes 5 integration tests covering discovery, error handling, and non-indexed directory filtering. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/cli/group.ts | 78 +++++++ gitnexus/src/core/group/service.ts | 152 +++++++++++++- gitnexus/src/mcp/local/local-backend.ts | 13 ++ gitnexus/src/mcp/tools.ts | 61 ++++++ .../integration/group/auto-discover.test.ts | 198 ++++++++++++++++++ 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/integration/group/auto-discover.test.ts diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index eb0dffc3d..0ebb1fc38 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -21,6 +21,84 @@ export function registerGroupCommands(program: Command): void { console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name); }); + group + .command('auto-discover [directory]') + .description( + 'Auto-discover indexed repos in a directory and create a group with code-level dependency detection', + ) + .option('--name ', 'Group name', 'workspace') + .option('--force', 'Overwrite existing group') + .option('--skip-sync', 'Create group without running sync') + .option('--json', 'JSON output') + .action( + async ( + directory: string | undefined, + opts: { name: string; force?: boolean; skipSync?: boolean; json?: boolean }, + ) => { + const pathMod = await import('node:path'); + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const resolvedDir = pathMod.resolve(directory || process.cwd()); + const backend = new LocalBackend(); + try { + await backend.init(); + console.log( + `Discovering indexed repos in ${resolvedDir}...\n`, + ); + + const raw = await backend.getGroupService().groupDiscover({ + directory: resolvedDir, + name: opts.name, + force: Boolean(opts.force), + skipSync: Boolean(opts.skipSync), + }); + + const result = raw as { + error?: string; + group?: string; + groupDir?: string; + repos?: Array<{ name: string; packageName: string | null }>; + repoCount?: number; + packageMappings?: Record; + synced?: boolean; + contracts?: number; + crossLinks?: 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(`Group "${result.group}" created at ${result.groupDir}\n`); + console.log(`Discovered repos (${result.repoCount}):`); + for (const repo of result.repos || []) { + const pkg = repo.packageName ? ` (${repo.packageName})` : ''; + console.log(` ${repo.name}${pkg}`); + } + const mappings = result.packageMappings || {}; + if (Object.keys(mappings).length > 0) { + console.log(`\nPackage mappings:`); + for (const [pkg, groupPath] of Object.entries(mappings)) { + console.log(` ${pkg} → ${groupPath}`); + } + } + if (result.synced) { + console.log( + `\nSync: ${result.contracts} contracts, ${result.crossLinks} cross-links`, + ); + } + } + } finally { + await backend.dispose().catch(() => {}); + } + }, + ); + group .command('add ') .description( diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index a412ceaa8..1b8d5690d 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -7,12 +7,19 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; import { checkStaleness } from '../git-staleness.js'; import { GroupNotFoundError, loadGroupConfig } from './config-parser.js'; +import { readNpmManifest } from './extractors/manifest-reader.js'; import { fileMatchesServicePrefix, normalizeServicePrefix, repoInSubgroup, } from './group-path-utils.js'; -import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js'; +import { + createGroupDir, + getDefaultGitnexusDir, + getGroupDir, + listGroups, + readContractRegistry, +} from './storage.js'; import { syncGroup } from './sync.js'; import type { ContractRegistry, @@ -513,4 +520,147 @@ export class GroupService { repos: repoStatuses, }; } + + /** + * Auto-discover indexed repos in a directory and create a group with code-level dependency detection. + */ + async groupDiscover(params: Record): Promise { + const directory = typeof params.directory === 'string' ? params.directory.trim() : ''; + const groupName = typeof params.name === 'string' ? params.name.trim() : 'workspace'; + const force = Boolean(params.force); + const skipSync = Boolean(params.skipSync); + + if (!directory) return { error: 'directory is required' }; + + // List subdirectories and find indexed repos + let entries: import('node:fs').Dirent[]; + try { + entries = await fsp.readdir(directory, { withFileTypes: true }); + } catch { + return { error: `Cannot read directory: ${directory}` }; + } + + const repos: Record = {}; + const packages: Record> = {}; + const discoveredRepos: Array<{ + name: string; + path: string; + packageName: string | null; + }> = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const repoPath = path.join(directory, entry.name); + const metaPath = path.join(repoPath, '.gitnexus', 'meta.json'); + + let metaExists = false; + try { + await fsp.access(metaPath); + metaExists = true; + } catch { + // Not indexed + } + if (!metaExists) continue; + + // Find the registry name for this repo + let registryName: string | null = null; + try { + const repoHandle = await this.port.resolveRepo(entry.name); + registryName = repoHandle.name; + } catch { + // Try resolving by path + try { + const repoHandle = await this.port.resolveRepo(repoPath); + registryName = repoHandle.name; + } catch { + // Use directory name as fallback + registryName = entry.name; + } + } + + const groupPath = entry.name; + repos[groupPath] = registryName; + + // Read package manifest for auto-discovery + const manifest = readNpmManifest(repoPath); + discoveredRepos.push({ + name: registryName, + path: repoPath, + packageName: manifest?.packageName ?? null, + }); + } + + if (Object.keys(repos).length === 0) { + return { error: `No indexed repos found in ${directory}. Run 'gitnexus analyze' in each repo first.` }; + } + + // Build packages mapping from discovered manifests + const pkgToGroupPath = new Map(); + for (const repo of discoveredRepos) { + if (repo.packageName) { + const groupPath = Object.entries(repos).find(([, name]) => name === repo.name)?.[0]; + if (groupPath) pkgToGroupPath.set(repo.packageName, groupPath); + } + } + + // Create the group + const gitnexusDir = getDefaultGitnexusDir(); + const groupDir = await createGroupDir(gitnexusDir, groupName, force); + + // Write a populated group.yaml + 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: groupName, + description: `Auto-discovered from ${directory}`, + repos, + links: [], + packages, + detect: { + http: true, + grpc: true, + topics: true, + shared_libs: true, + embedding_fallback: false, + }, + matching: { + bm25_threshold: 0.7, + embedding_threshold: 0.65, + max_candidates_per_step: 3, + }, + }; + + await fsp.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + + // Optionally run sync + let syncResult = null; + if (!skipSync) { + syncResult = await syncGroup(config, { + groupDir, + exactOnly: true, + }); + } + + return { + group: groupName, + groupDir, + repos: discoveredRepos.map((r) => ({ + name: r.name, + packageName: r.packageName, + })), + repoCount: Object.keys(repos).length, + packageMappings: Object.fromEntries(pkgToGroupPath), + synced: !skipSync, + ...(syncResult + ? { + contracts: syncResult.contracts.length, + crossLinks: syncResult.crossLinks.length, + } + : {}), + }; + } } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index e99f81aa4..88e9a6a3e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -3040,6 +3040,14 @@ export class LocalBackend { return this.groupList(params); case 'group_sync': return this.groupSync(params); + case 'group_contracts': + return this.groupContracts(params); + case 'group_query': + return this.groupQuery(params); + case 'group_status': + return this.groupStatus(params); + case 'group_discover': + return this.groupDiscover(params); default: throw new Error( `Unknown group tool: ${method}. Removed tools: use repo "@" on impact, query, or context (optional "/"), or MCP resources.`, @@ -3186,6 +3194,11 @@ export class LocalBackend { return JSON.stringify(raw, null, 2); } + private async groupDiscover(params: Record): Promise { + await this.refreshRepos(); + return this.getGroupService().groupDiscover(params); + } + /** * Fetch Route nodes with their consumers in a single query. * Shared by routeMap and shapeCheck to avoid N+1 query patterns. diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 491c24557..2211d3089 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -508,4 +508,65 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`, required: ['name'], }, }, + { + name: 'group_contracts', + description: `Inspect contracts and cross-links from the group's contracts.json. + +WHEN TO USE: Debug cross-repo links after group_sync.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + type: { type: 'string', description: 'Filter by contract type (http, topic, …)' }, + repo: { type: 'string', description: 'Filter by group repo path (e.g. app/backend)' }, + unmatchedOnly: { type: 'boolean', description: 'Only contracts with no cross-link' }, + }, + required: ['name'], + }, + }, + { + name: 'group_query', + description: `Run the query tool across all repos in a group and merge process results via reciprocal rank fusion. + +WHEN TO USE: Semantic / hybrid search across a whole product group.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + query: { type: 'string', description: 'Search query' }, + subgroup: { type: 'string', description: 'Limit to repo paths under this prefix' }, + limit: { type: 'number', description: 'Max merged results (default 5)' }, + }, + required: ['name', 'query'], + }, + }, + { + name: 'group_status', + description: `Report index staleness (commit vs HEAD) and Contract Registry staleness (indexedAt) for each repo in a group. + +WHEN TO USE: Before group_sync or when agents should refresh indexes.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + }, + required: ['name'], + }, + }, + { + name: 'group_discover', + description: `Auto-discover indexed repos in a directory and create a group with code-level dependency detection. + +WHEN TO USE: When a user wants to see a combined knowledge graph from a parent directory containing multiple indexed repos. Scans for .gitnexus/ in subdirectories, reads package manifests, builds package mappings, creates a group, and optionally runs sync.`, + inputSchema: { + type: 'object', + properties: { + directory: { type: 'string', description: 'Parent directory containing repos' }, + name: { type: 'string', description: 'Group name (default: workspace)' }, + force: { type: 'boolean', description: 'Overwrite existing group' }, + skipSync: { type: 'boolean', description: 'Skip running sync after creation' }, + }, + required: ['directory'], + }, + }, ]; diff --git a/gitnexus/test/integration/group/auto-discover.test.ts b/gitnexus/test/integration/group/auto-discover.test.ts new file mode 100644 index 000000000..93e735fb8 --- /dev/null +++ b/gitnexus/test/integration/group/auto-discover.test.ts @@ -0,0 +1,198 @@ +/** + * Integration test for group auto-discover. + * + * Tests scanning a parent directory to find indexed repos and create a group. + * Uses mock GroupToolPort to avoid needing a real LocalBackend. + */ +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 { listGroups, readContractRegistry } from '../../../src/core/group/storage.js'; + +describe('Group auto-discover integration', () => { + let tmpDir: string; + let gitnexusHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `gitnexus-discover-${Date.now()}`); + gitnexusHome = path.join(tmpDir, '.gitnexus-home'); + fs.mkdirSync(gitnexusHome, { recursive: true }); + + // Override GITNEXUS_HOME so we don't pollute the real home dir + originalHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = gitnexusHome; + + // Create two mock repos with .gitnexus/meta.json + const repoA = path.join(tmpDir, 'repos', 'shared-utils'); + const repoB = path.join(tmpDir, 'repos', 'web-app'); + + fs.mkdirSync(path.join(repoA, '.gitnexus'), { recursive: true }); + fs.writeFileSync( + path.join(repoA, '.gitnexus', 'meta.json'), + JSON.stringify({ indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'abc123' }), + ); + fs.writeFileSync( + path.join(repoA, 'package.json'), + JSON.stringify({ name: '@test/shared-utils', version: '1.0.0', dependencies: {} }), + ); + fs.mkdirSync(path.join(repoA, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(repoA, 'src', 'index.ts'), + 'export function formatDate(d: Date): string { return d.toISOString(); }', + ); + + fs.mkdirSync(path.join(repoB, '.gitnexus'), { recursive: true }); + fs.writeFileSync( + path.join(repoB, '.gitnexus', 'meta.json'), + JSON.stringify({ indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'def456' }), + ); + fs.writeFileSync( + path.join(repoB, 'package.json'), + JSON.stringify({ + name: '@test/web-app', + version: '2.0.0', + dependencies: { '@test/shared-utils': '^1.0.0' }, + }), + ); + fs.mkdirSync(path.join(repoB, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(repoB, 'src', 'app.ts'), + `import { formatDate } from '@test/shared-utils'; +console.log(formatDate(new Date()));`, + ); + }); + + 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 { + const repoHandles: Record = { + 'shared-utils': { + id: 'shared-utils', + name: 'shared-utils', + repoPath: path.join(tmpDir, 'repos', 'shared-utils'), + storagePath: path.join(tmpDir, 'repos', 'shared-utils', '.gitnexus'), + }, + 'web-app': { + id: 'web-app', + name: 'web-app', + repoPath: path.join(tmpDir, 'repos', 'web-app'), + storagePath: path.join(tmpDir, 'repos', 'web-app', '.gitnexus'), + }, + }; + + return { + resolveRepo: async (nameOrPath?: string): Promise => { + if (nameOrPath && repoHandles[nameOrPath]) { + return repoHandles[nameOrPath]; + } + // Try matching by path + for (const handle of Object.values(repoHandles)) { + if (nameOrPath && (handle.repoPath === nameOrPath || handle.repoPath.includes(nameOrPath))) { + return handle; + } + } + throw new Error(`Repo not found: ${nameOrPath}`); + }, + impact: async () => ({}), + query: async () => ({ processes: [] }), + impactByUid: async () => null, + }; + } + + it('discovers repos and creates a group', async () => { + const service = new GroupService(makeMockPort()); + const reposDir = path.join(tmpDir, 'repos'); + + const result = (await service.groupDiscover({ + directory: reposDir, + name: 'test-discover', + skipSync: true, + })) as { + group: string; + groupDir: string; + repos: Array<{ name: string; packageName: string | null }>; + repoCount: number; + packageMappings: Record; + }; + + expect(result.group).toBe('test-discover'); + expect(result.repoCount).toBe(2); + + // Check repos were discovered + const repoNames = result.repos.map((r) => r.name).sort(); + expect(repoNames).toEqual(['shared-utils', 'web-app']); + + // Check package names detected + const sharedRepo = result.repos.find((r) => r.name === 'shared-utils'); + expect(sharedRepo?.packageName).toBe('@test/shared-utils'); + + const webRepo = result.repos.find((r) => r.name === 'web-app'); + expect(webRepo?.packageName).toBe('@test/web-app'); + + // Check package mappings + expect(result.packageMappings['@test/shared-utils']).toBeDefined(); + expect(result.packageMappings['@test/web-app']).toBeDefined(); + + // Check group.yaml was created + const groups = await listGroups(gitnexusHome); + expect(groups).toContain('test-discover'); + }); + + it('returns error for empty directory', async () => { + const emptyDir = path.join(tmpDir, 'empty'); + fs.mkdirSync(emptyDir, { recursive: true }); + + const service = new GroupService(makeMockPort()); + const result = (await service.groupDiscover({ + directory: emptyDir, + name: 'test-empty', + skipSync: true, + })) as { error: string }; + + expect(result.error).toContain('No indexed repos found'); + }); + + it('returns error for non-existent directory', async () => { + const service = new GroupService(makeMockPort()); + const result = (await service.groupDiscover({ + directory: '/nonexistent/path', + name: 'test-missing', + })) as { error: string }; + + expect(result.error).toContain('Cannot read directory'); + }); + + it('returns error when directory param is missing', async () => { + const service = new GroupService(makeMockPort()); + const result = (await service.groupDiscover({})) as { error: string }; + expect(result.error).toBe('directory is required'); + }); + + it('skips non-indexed subdirectories', async () => { + // Add a non-indexed directory + const nonIndexed = path.join(tmpDir, 'repos', 'not-indexed'); + fs.mkdirSync(nonIndexed, { recursive: true }); + fs.writeFileSync(path.join(nonIndexed, 'package.json'), JSON.stringify({ name: 'not-indexed' })); + // No .gitnexus/meta.json + + const service = new GroupService(makeMockPort()); + const result = (await service.groupDiscover({ + directory: path.join(tmpDir, 'repos'), + name: 'test-skip', + skipSync: true, + })) as { repoCount: number }; + + // Should only find 2 (shared-utils and web-app), not 3 + expect(result.repoCount).toBe(2); + }); +});