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) <noreply@anthropic.com>
This commit is contained in:
sahalterion 2026-04-03 15:30:36 -07:00
parent 05636bf681
commit 03e0abf022
5 changed files with 501 additions and 1 deletions

View file

@ -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 <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<string, string>;
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 <group> <groupPath> <registryName>')
.description(

View file

@ -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<string, unknown>): Promise<unknown> {
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<string, string> = {};
const packages: Record<string, Record<string, string>> = {};
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<string, string>();
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,
}
: {}),
};
}
}

View file

@ -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 "@<groupName>" on impact, query, or context (optional "/<memberPath>"), or MCP resources.`,
@ -3186,6 +3194,11 @@ export class LocalBackend {
return JSON.stringify(raw, null, 2);
}
private async groupDiscover(params: Record<string, unknown>): Promise<unknown> {
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.

View file

@ -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'],
},
},
];

View file

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