From 24b25640badb08febe66169fdcf7ce3e43786211 Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 2 Apr 2026 00:40:31 +0300 Subject: [PATCH] feat(group): add sync pipeline, CLI, MCP tools, and monorepo fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire extractors into the sync pipeline with service boundary detection. GroupService provides high-level API for all group operations. - Sync pipeline: orchestrates extraction (HTTP, gRPC, topics) with service boundary assignment and exact matching - GroupService: groupList, groupSync, groupContracts, groupQuery, groupStatus (groupImpact deferred to cross-repo follow-up PR) - CLI: group create/add/remove/list/sync/contracts/query/status - MCP tools: group_list, group_sync, group_contracts, group_query, group_status - Monorepo fixture: 3 services (auth/orders/gateway) connected via gRPC + Kafka + HTTP — all intra-repo cross-links discovered - Documentation: CLI commands and MCP tools added to both READMEs Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 17 +- gitnexus/README.md | 10 + gitnexus/src/cli/ai-context.ts | 31 +- gitnexus/src/cli/group.ts | 303 +++++++++++++++ gitnexus/src/cli/index.ts | 3 + gitnexus/src/core/group/service.ts | 223 +++++++++++ gitnexus/src/core/group/sync.ts | 185 +++++++++ gitnexus/src/mcp/local/local-backend.ts | 163 +++++++- gitnexus/src/mcp/staleness.ts | 43 +- gitnexus/src/mcp/tools.ts | 76 ++++ .../group/test-backend/src/routes/health.ts | 8 + .../group/test-backend/src/routes/users.ts | 16 + .../group/test-frontend/src/api/users.ts | 18 + .../test-monorepo/services/auth/package.json | 1 + .../services/auth/src/auth.proto | 5 + .../test-monorepo/services/auth/src/events.ts | 3 + .../test-monorepo/services/auth/src/server.ts | 8 + .../services/gateway/package.json | 1 + .../test-monorepo/services/gateway/src/api.ts | 4 + .../services/orders/package.json | 1 + .../services/orders/src/order-service.ts | 7 + .../services/orders/src/routes.ts | 8 + .../test/integration/group/group-cli.test.ts | 52 +++ .../test/integration/group/group-sync.test.ts | 79 ++++ .../integration/group/monorepo-sync.test.ts | 120 ++++++ gitnexus/test/unit/calltool-dispatch.test.ts | 24 +- gitnexus/test/unit/group/group-tools.test.ts | 27 ++ .../test/unit/group/impact-by-uid.test.ts | 25 ++ gitnexus/test/unit/group/service.test.ts | 366 ++++++++++++++++++ gitnexus/test/unit/group/sync.test.ts | 223 +++++++++++ .../unit/impact-batching-grouping.test.ts | 15 +- gitnexus/test/unit/tools.test.ts | 37 +- 32 files changed, 2043 insertions(+), 59 deletions(-) create mode 100644 gitnexus/src/cli/group.ts create mode 100644 gitnexus/src/core/group/service.ts create mode 100644 gitnexus/src/core/group/sync.ts create mode 100644 gitnexus/test/fixtures/group/test-backend/src/routes/health.ts create mode 100644 gitnexus/test/fixtures/group/test-backend/src/routes/users.ts create mode 100644 gitnexus/test/fixtures/group/test-frontend/src/api/users.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts create mode 100644 gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts create mode 100644 gitnexus/test/integration/group/group-cli.test.ts create mode 100644 gitnexus/test/integration/group/group-sync.test.ts create mode 100644 gitnexus/test/integration/group/monorepo-sync.test.ts create mode 100644 gitnexus/test/unit/group/group-tools.test.ts create mode 100644 gitnexus/test/unit/group/impact-by-uid.test.ts create mode 100644 gitnexus/test/unit/group/service.test.ts create mode 100644 gitnexus/test/unit/group/sync.test.ts diff --git a/README.md b/README.md index ae00065df5..482b496bc4 100644 --- a/README.md +++ b/README.md @@ -206,11 +206,21 @@ gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate repository wiki from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) gitnexus wiki --base-url # Wiki with custom LLM API base URL + +# Repository groups (multi-repo / monorepo service tracking) +gitnexus group create # Create a repository group +gitnexus group add # Add a repo to a group +gitnexus group remove # Remove a repo from a group +gitnexus group list [name] # List groups, or show one group's config +gitnexus group sync # Extract contracts and match across repos/services +gitnexus group contracts # Inspect extracted contracts and cross-links +gitnexus group query # Search execution flows across all repos in a group +gitnexus group status # Check staleness of repos in a group ``` ### What Your AI Agent Gets -**7 tools** exposed via MCP: +**16 tools** exposed via MCP (11 per-repo + 5 group): | Tool | What It Does | `repo` Param | | ------------------ | ----------------------------------------------------------------- | -------------- | @@ -221,6 +231,11 @@ gitnexus wiki --base-url # Wiki with custom LLM API base URL | `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional | | `rename` | Multi-file coordinated rename with graph + text search | Optional | | `cypher` | Raw Cypher graph queries | Optional | +| `group_list` | List configured repository groups | — | +| `group_sync` | Extract contracts and match across repos/services | — | +| `group_contracts`| Inspect extracted contracts and cross-links | — | +| `group_query` | Search execution flows across all repos in a group | — | +| `group_status` | Check staleness of repos in a group | — | > When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`. diff --git a/gitnexus/README.md b/gitnexus/README.md index b56e6c2192..7e87c93b42 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -164,6 +164,16 @@ gitnexus clean # Delete index for current repo gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) + +# Repository groups (multi-repo / monorepo service tracking) +gitnexus group create # Create a repository group +gitnexus group add # Add a repo to a group +gitnexus group remove # Remove a repo from a group +gitnexus group list [name] # List groups, or show one group's config +gitnexus group sync # Extract contracts and match across repos/services +gitnexus group contracts # Inspect extracted contracts and cross-links +gitnexus group query # Search execution flows across all repos in a group +gitnexus group status # Check staleness of repos in a group ``` ## Remote Embeddings diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 81aaa496d8..1c7a95d7a6 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -42,10 +42,28 @@ const GITNEXUS_END_MARKER = ''; * - Exact tool commands with parameters — vague directives get ignored * - Self-review checklist — forces model to verify its own work */ +async function findGroupsContainingRegistryName(registryName: string): Promise { + const { listGroups, getDefaultGitnexusDir, getGroupDir } = + await import('../core/group/storage.js'); + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const names = await listGroups(); + const hits: string[] = []; + for (const g of names) { + try { + const config = await loadGroupConfig(getGroupDir(getDefaultGitnexusDir(), g)); + if (Object.values(config.repos).some((r) => r === registryName)) hits.push(config.name); + } catch { + // skip invalid or unreadable groups + } + } + return hits; +} + function generateGitNexusContent( projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], + groupNames?: string[], ): string { const generatedRows = generatedSkills && generatedSkills.length > 0 @@ -155,7 +173,15 @@ To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`sta > Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`. -## CLI +${ + groupNames && groupNames.length > 0 + ? `## Cross-Repo Groups + +This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For blast radius across repository boundaries, use MCP tools \`group_impact\`, \`group_sync\`, \`group_query\`, \`group_contracts\`, \`group_status\`, and \`group_list\`. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync \`, \`npx gitnexus group impact --target --repo \`. + +` + : '' +}## CLI ${skillsTable} @@ -305,7 +331,8 @@ export async function generateAIContextFiles( generatedSkills?: GeneratedSkillInfo[], options?: AIContextOptions, ): Promise<{ files: string[] }> { - const content = generateGitNexusContent(projectName, stats, generatedSkills); + const groupNames = await findGroupsContainingRegistryName(projectName); + const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames); const createdFiles: string[] = []; if (!options?.skipAgentsMd) { diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts new file mode 100644 index 0000000000..73f7978f5c --- /dev/null +++ b/gitnexus/src/cli/group.ts @@ -0,0 +1,303 @@ +// gitnexus/src/cli/group.ts +import { createRequire } from 'node:module'; +import type { Command } from 'commander'; + +const _require = createRequire(import.meta.url); +const yaml = _require('js-yaml') as typeof import('js-yaml'); + +export function registerGroupCommands(program: Command): void { + const group = program + .command('group') + .description('Manage repository groups for cross-index impact analysis'); + + group + .command('create ') + .description('Create a new group with template group.yaml') + .option('--force', 'Overwrite existing group') + .action(async (name: string, opts: { force?: boolean }) => { + const { createGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); + const dir = await createGroupDir(getDefaultGitnexusDir(), name, opts.force); + console.log(`Created group "${name}" at ${dir}`); + console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name); + }); + + group + .command('add ') + .description( + 'Add a repo to a group. = hierarchy path (e.g. hr/hiring/backend), = name from registry', + ) + .action(async (groupName: string, groupPath: string, registryName: string) => { + const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const path = await import('node:path'); + const fs = await import('node:fs/promises'); + const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); + const config = await loadGroupConfig(groupDir); + config.repos[groupPath] = registryName; + + await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + console.log(`Added ${registryName} as "${groupPath}" to group "${groupName}"`); + console.log(`Run: gitnexus group sync ${groupName}`); + }); + + group + .command('remove ') + .description('Remove a repo from a group') + .action(async (groupName: string, repoPath: string) => { + const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const path = await import('node:path'); + const fs = await import('node:fs/promises'); + const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); + const config = await loadGroupConfig(groupDir); + if (!(repoPath in config.repos)) { + console.error(`Repo path "${repoPath}" not found in group "${groupName}"`); + process.exitCode = 1; + return; + } + delete config.repos[repoPath]; + await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + console.log(`Removed "${repoPath}" from group "${groupName}"`); + }); + + group + .command('list [name]') + .description('List all groups or details of one') + .action(async (name?: string) => { + const { listGroups, getDefaultGitnexusDir, getGroupDir } = + await import('../core/group/storage.js'); + if (!name) { + const groups = await listGroups(); + if (groups.length === 0) { + console.log('No groups configured. Create one with: gitnexus group create '); + return; + } + console.log('Groups:'); + groups.forEach((g) => console.log(` ${g}`)); + return; + } + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + console.log(`Group: ${config.name}`); + if (config.description) console.log(`Description: ${config.description}`); + console.log(`\nRepos (${Object.keys(config.repos).length}):`); + for (const [p, id] of Object.entries(config.repos)) { + console.log(` ${p} -> ${id}`); + } + if (config.links.length > 0) { + console.log(`\nManifest links (${config.links.length}):`); + for (const link of config.links) { + console.log(` ${link.from} -> ${link.to} [${link.type}: ${link.contract}]`); + } + } + }); + + group + .command('status ') + .description('Check staleness of group and repos') + .action(async (name: string) => { + const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } = + await import('../core/group/storage.js'); + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const registry = await readContractRegistry(groupDir); + + console.log( + `Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`, + ); + + const backend = new LocalBackend(); + try { + await backend.init(); + const raw = await backend.getGroupService().groupStatus({ name }); + const st = raw as { + repos?: Record< + string, + { + indexStale: boolean; + contractsStale: boolean; + missing: boolean; + commitsBehind?: number; + } + >; + missingRepos?: string[]; + }; + + console.log(' Repo index / contracts staleness:'); + for (const [repoPath, row] of Object.entries(st.repos || {})) { + if (row.missing) { + console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`); + continue; + } + const idx = row.indexStale + ? `STALE (${row.commitsBehind ?? '?'} commits behind)` + : 'OK '; + const ctr = row.contractsStale ? ' CONTRACTS_STALE' : ''; + console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`); + } + if ((st.missingRepos || []).length > 0) { + console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`); + } + } finally { + await backend.dispose().catch(() => {}); + } + }); + + group + .command('sync ') + .description('Sync Contract Registry — extract contracts and build cross-links') + .option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)') + .option('--exact-only', 'Exact match only') + .option('--allow-stale', 'Skip stale index warnings') + .option('--verbose', 'Show each cross-link detail') + .option('--json', 'JSON output') + .action(async (name: string, opts: Record) => { + const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); + const { loadGroupConfig } = await import('../core/group/config-parser.js'); + const { syncGroup } = await import('../core/group/sync.js'); + const { closeLbug } = await import('../core/lbug/pool-adapter.js'); + + try { + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + + console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); + + const result = await syncGroup(config, { + groupDir, + allowStale: Boolean(opts.allowStale), + verbose: Boolean(opts.verbose), + skipEmbeddings: Boolean(opts.skipEmbeddings), + exactOnly: Boolean(opts.exactOnly), + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`\nMatching cascade:`); + const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); + console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); + console.log(` unmatched: ${result.unmatched.length} contracts`); + console.log( + `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, + ); + } + } finally { + await closeLbug().catch(() => {}); + } + }); + + group + .command('query ') + .description('Search execution flows across all repos in a group') + .option('--subgroup ', 'Limit search scope') + .option('--limit ', 'Max merged results', '5') + .option('--json', 'JSON output') + .action( + async ( + name: string, + queryText: string, + opts: Record, + ) => { + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const limit = parseInt(String(opts.limit ?? '5'), 10) || 5; + const subgroup = opts.subgroup as string | undefined; + const backend = new LocalBackend(); + try { + await backend.init(); + + console.log(`Searching "${queryText}" across group "${name}"...\n`); + + const raw = await backend.getGroupService().groupQuery({ + name, + query: queryText, + limit, + subgroup, + }); + const merged = raw as { + results: Array>; + per_repo: Array<{ repo: string; count: number }>; + }; + + if (opts.json) { + console.log(JSON.stringify(raw, null, 2)); + } else { + console.log(`Results (top ${merged.results.length}):\n`); + for (const p of merged.results) { + const label = (p.summary || p.heuristicLabel || p.name || 'unnamed') as string; + console.log(` [${p._repo}] ${label} (rrf: ${(p._rrf_score as number).toFixed(4)})`); + } + if (merged.results.length === 0) { + console.log(' No matching execution flows found.'); + } + } + } finally { + await backend.dispose().catch(() => {}); + } + }, + ); + + group + .command('contracts ') + .description('Inspect Contract Registry') + .option('--type ', 'Filter by contract type') + .option('--repo ', 'Filter by repo') + .option('--unmatched', 'Show only unmatched contracts') + .option('--json', 'JSON output') + .action(async (name: string, opts: Record) => { + const { LocalBackend } = await import('../mcp/local/local-backend.js'); + + const backend = new LocalBackend(); + try { + await backend.init(); + const raw = await backend.getGroupService().groupContracts({ + name, + type: opts.type as string | undefined, + repo: opts.repo as string | undefined, + unmatchedOnly: Boolean(opts.unmatched), + }); + + if (raw && typeof raw === 'object' && 'error' in raw) { + console.error(String((raw as { error: string }).error)); + process.exitCode = 1; + return; + } + + const { contracts, crossLinks } = raw as { + contracts: Array<{ + role: string; + contractId: string; + repo: string; + symbolRef: { name: string }; + }>; + crossLinks: Array<{ + from: { repo: string }; + to: { repo: string }; + matchType: string; + confidence: number; + contractId: string; + }>; + }; + + if (opts.json) { + console.log(JSON.stringify({ contracts, crossLinks }, null, 2)); + } else { + console.log(`Contracts (${contracts.length}):`); + for (const c of contracts) { + console.log(` [${c.role}] ${c.contractId} (${c.repo}) ${c.symbolRef.name}`); + } + console.log(`\nCross-links (${crossLinks.length}):`); + for (const l of crossLinks) { + console.log( + ` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`, + ); + } + } + } finally { + await backend.dispose().catch(() => {}); + } + }); +} diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 805c55968d..75940bcbf2 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -6,6 +6,7 @@ import { Command } from 'commander'; import { createRequire } from 'node:module'; import { createLazyAction } from './lazy-action.js'; +import { registerGroupCommands } from './group.js'; const _require = createRequire(import.meta.url); const pkg = _require('../../package.json'); @@ -148,4 +149,6 @@ program .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') .action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); +registerGroupCommands(program); + program.parse(process.argv); diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts new file mode 100644 index 0000000000..1530cd6ddc --- /dev/null +++ b/gitnexus/src/core/group/service.ts @@ -0,0 +1,223 @@ +/** + * Group orchestration shared by MCP (LocalBackend) and CLI. + * DB access is injected via GroupToolPort so this module stays free of LocalBackend private API. + */ + +import { checkStaleness } from '../git-staleness.js'; +import { loadGroupConfig } from './config-parser.js'; +import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js'; +import { syncGroup } from './sync.js'; + +export interface GroupRepoHandle { + id: string; + name: string; + repoPath: string; + storagePath: string; + indexedAt?: string; + lastCommit?: string; +} + +export interface GroupToolPort { + resolveRepo(repoParam?: string): Promise; + impact( + repo: GroupRepoHandle, + params: { + target: string; + direction: 'upstream' | 'downstream'; + maxDepth?: number; + relationTypes?: string[]; + includeTests?: boolean; + minConfidence?: number; + }, + ): Promise; + query( + repo: GroupRepoHandle, + params: { + query: string; + task_context?: string; + goal?: string; + limit?: number; + max_symbols?: number; + include_content?: boolean; + }, + ): Promise; + impactByUid( + repoId: string, + uid: string, + direction: string, + opts: { + maxDepth: number; + relationTypes: string[]; + minConfidence: number; + includeTests: boolean; + }, + ): Promise; +} + +function repoInSubgroup(repoPath: string, subgroup?: string): boolean { + if (!subgroup?.trim()) return true; + const s = subgroup.replace(/\/+$/, ''); + return repoPath === s || repoPath.startsWith(`${s}/`); +} + +export class GroupService { + constructor(private readonly port: GroupToolPort) {} + + async groupList(params: Record): Promise { + const name = typeof params.name === 'string' ? params.name.trim() : ''; + if (!name) { + const groups = await listGroups(); + return { groups }; + } + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + return { + name: config.name, + description: config.description, + repos: config.repos, + links: config.links, + }; + } + + async groupSync(params: Record): Promise { + const name = String(params.name ?? '').trim(); + if (!name) return { error: 'name is required' }; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + const result = await syncGroup(config, { + groupDir, + exactOnly: Boolean(params.exactOnly), + skipEmbeddings: Boolean(params.skipEmbeddings), + allowStale: Boolean(params.allowStale), + verbose: Boolean(params.verbose), + }); + return { + contracts: result.contracts.length, + crossLinks: result.crossLinks.length, + unmatched: result.unmatched.length, + missingRepos: result.missingRepos, + }; + } + + async groupContracts(params: Record): Promise { + const name = String(params.name ?? '').trim(); + if (!name) return { error: 'name is required' }; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const registry = await readContractRegistry(groupDir); + if (!registry) { + return { error: `No contracts.json for group "${name}". Run group_sync first.` }; + } + let contracts = registry.contracts; + if (params.type) contracts = contracts.filter((c) => c.type === params.type); + if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo); + if (params.unmatchedOnly) { + const matchedIds = new Set( + registry.crossLinks.flatMap((l) => [ + `${l.from.repo}::${l.contractId}`, + `${l.to.repo}::${l.contractId}`, + ]), + ); + contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`)); + } + return { contracts, crossLinks: registry.crossLinks }; + } + + async groupQuery(params: Record): Promise { + const name = String(params.name ?? '').trim(); + const queryText = String(params.query ?? '').trim(); + if (!name || !queryText) return { error: 'name and query are required' }; + + const limit = typeof params.limit === 'number' && params.limit > 0 ? params.limit : 5; + const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + + const perRepo: Array<{ repo: string; score: number; processes: unknown[] }> = []; + for (const [repoPath, registryName] of Object.entries(config.repos)) { + if (!repoInSubgroup(repoPath, subgroup)) continue; + try { + const repoObj = await this.port.resolveRepo(registryName); + const queryResult = (await this.port.query(repoObj, { + query: queryText, + limit, + max_symbols: 10, + include_content: false, + })) as { processes?: Array> }; + const processes = queryResult.processes || []; + const scored = processes.map((p, idx) => ({ + ...p, + _rrf_score: 1 / (idx + 1 + 60), + _repo: repoPath, + })); + perRepo.push({ repo: repoPath, score: 0, processes: scored }); + } catch { + perRepo.push({ repo: repoPath, score: 0, processes: [] }); + } + } + + const allProcesses = perRepo.flatMap((r) => r.processes as Array>); + allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number)); + const topN = allProcesses.slice(0, limit); + + return { + group: name, + query: queryText, + results: topN, + per_repo: perRepo.map((r) => ({ repo: r.repo, count: r.processes.length })), + }; + } + + async groupStatus(params: Record): Promise { + const name = String(params.name ?? '').trim(); + if (!name) return { error: 'name is required' }; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + const registry = await readContractRegistry(groupDir); + + const repoStatuses: Record< + string, + { + indexStale: boolean; + contractsStale: boolean; + missing: boolean; + commitsBehind?: number; + } + > = {}; + + const fsp = await import('node:fs/promises'); + const pathMod = await import('node:path'); + + for (const [repoPath, registryName] of Object.entries(config.repos)) { + try { + const repoObj = await this.port.resolveRepo(registryName); + const metaPath = pathMod.join(repoObj.storagePath, 'meta.json'); + const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}'); + const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string }; + + const staleness = meta.lastCommit + ? checkStaleness(repoObj.repoPath, meta.lastCommit) + : { isStale: true, commitsBehind: -1 }; + + const snapshot = registry?.repoSnapshots[repoPath]; + const contractsStale = + snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot; + + repoStatuses[repoPath] = { + indexStale: staleness.isStale, + contractsStale: Boolean(contractsStale), + missing: false, + commitsBehind: staleness.commitsBehind, + }; + } catch { + repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true }; + } + } + + return { + group: name, + lastSync: registry?.generatedAt || null, + missingRepos: registry?.missingRepos || [], + repos: repoStatuses, + }; + } +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts new file mode 100644 index 0000000000..92cd9fe5f1 --- /dev/null +++ b/gitnexus/src/core/group/sync.ts @@ -0,0 +1,185 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Buffer } from 'node:buffer'; +import { initLbug, closeLbug, executeParameterized } from '../lbug/pool-adapter.js'; +import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js'; +import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js'; +import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; +import { GrpcExtractor } from './extractors/grpc-extractor.js'; +import { TopicExtractor } from './extractors/topic-extractor.js'; +import { runExactMatch } from './matching.js'; +import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; +import type { CypherExecutor } from './contract-extractor.js'; +import { writeContractRegistry } from './storage.js'; +import type { ContractRegistry } from './types.js'; + +export interface SyncOptions { + extractorOverride?: + | ((repo: RepoHandle) => Promise) + | (() => Promise); + resolveRepoHandle?: (registryName: string, groupPath: string) => Promise; + skipWrite?: boolean; + groupDir?: string; + allowStale?: boolean; + verbose?: boolean; + exactOnly?: boolean; + skipEmbeddings?: boolean; +} + +export interface SyncResult { + contracts: StoredContract[]; + crossLinks: CrossLink[]; + unmatched: StoredContract[]; + missingRepos: string[]; + repoSnapshots: Record; +} + +export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry[]): string { + const base = entry.name.toLowerCase(); + const resolved = path.resolve(entry.path); + for (const other of allEntries) { + if (other.name.toLowerCase() === base && path.resolve(other.path) !== resolved) { + const hash = Buffer.from(entry.path).toString('base64url').slice(0, 6); + return `${base}-${hash}`; + } + } + return base; +} + +function defaultResolveHandle(allEntries: RegistryEntry[]) { + return async (registryName: string, groupPath: string): Promise => { + const e = allEntries.find((en) => en.name === registryName); + if (!e) return null; + const poolId = stableRepoPoolId(e, allEntries); + return { + id: poolId, + path: groupPath, + repoPath: e.path, + storagePath: e.storagePath, + }; + }; +} + +export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise { + const missingRepos: string[] = []; + const repoSnapshots: Record = {}; + let autoContracts: StoredContract[] = []; + let dbExecutors: Map | undefined; + + const eo = opts?.extractorOverride; + if (eo && eo.length === 0) { + autoContracts = await (eo as () => Promise)(); + } else { + const entries = await readRegistry(); + const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries); + const httpEx = new HttpRouteExtractor(); + const grpcEx = new GrpcExtractor(); + const topicEx = new TopicExtractor(); + dbExecutors = new Map(); + const openPoolIds: string[] = []; + + try { + for (const [groupPath, regName] of Object.entries(config.repos)) { + const handle = await resolve(regName, groupPath); + if (!handle) { + missingRepos.push(groupPath); + continue; + } + + const poolId = handle.id; + const lbugPath = path.join(handle.storagePath, 'lbug'); + try { + await initLbug(poolId, lbugPath); + openPoolIds.push(poolId); + + const executor: CypherExecutor = (query, params) => + executeParameterized(poolId, query, params ?? {}); + + dbExecutors.set(groupPath, executor); + + const boundaries = await detectServiceBoundaries(handle.repoPath); + + if (config.detect.http) { + const extracted = await httpEx.extract(executor, handle.repoPath, handle); + for (const c of extracted) { + autoContracts.push({ + ...c, + repo: groupPath, + service: assignService(c.symbolRef.filePath, boundaries), + }); + } + } + + if (config.detect.grpc) { + const extracted = await grpcEx.extract(executor, handle.repoPath, handle); + for (const c of extracted) { + autoContracts.push({ + ...c, + repo: groupPath, + service: assignService(c.symbolRef.filePath, boundaries), + }); + } + } + + if (config.detect.topics) { + const extracted = await topicEx.extract(executor, handle.repoPath, handle); + for (const c of extracted) { + autoContracts.push({ + ...c, + repo: groupPath, + service: assignService(c.symbolRef.filePath, boundaries), + }); + } + } + + const metaPath = path.join(handle.storagePath, 'meta.json'); + try { + const raw = await fs.readFile(metaPath, 'utf-8'); + const m = JSON.parse(raw) as { indexedAt?: string; lastCommit?: string }; + repoSnapshots[groupPath] = { + indexedAt: m.indexedAt || '', + lastCommit: m.lastCommit || '', + }; + } catch { + const e = entries.find((en) => en.name === regName); + repoSnapshots[groupPath] = { + indexedAt: e?.indexedAt || '', + lastCommit: e?.lastCommit || '', + }; + } + } catch { + missingRepos.push(groupPath); + } + } + } finally { + for (const id of [...new Set(openPoolIds)]) { + await closeLbug(id).catch(() => {}); + } + } + } + + const { matched, unmatched } = runExactMatch(autoContracts); + const crossLinks: CrossLink[] = matched; + const allContracts: StoredContract[] = autoContracts; + + const registry: ContractRegistry = { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots, + missingRepos, + contracts: allContracts, + crossLinks, + }; + + if (opts?.groupDir && !opts.skipWrite) { + await writeContractRegistry(opts.groupDir, registry); + } + + return { + contracts: allContracts, + crossLinks, + unmatched, + missingRepos, + repoSnapshots, + }; +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 3bf9d511dc..8f8b4d94b7 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -15,7 +15,7 @@ import { closeLbug, isLbugReady, isWriteQuery, -} from '../core/lbug-adapter.js'; +} from '../../core/lbug/pool-adapter.js'; export { isWriteQuery }; // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node // at MCP server startup — crashes on unsupported Node ABI versions (#89) @@ -26,6 +26,7 @@ import { cleanupOldKuzuFiles, type RegistryEntry, } from '../../storage/repo-manager.js'; +import { GroupService, type GroupToolPort } from '../../core/group/service.js'; // AI context generation is CLI-only (gitnexus analyze) // import { generateAIContextFiles } from '../../cli/ai-context.js'; @@ -175,6 +176,28 @@ export class LocalBackend { private initializedRepos: Set = new Set(); private reinitPromises: Map> = new Map(); private lastStalenessCheck: Map = new Map(); + private groupToolSvc: GroupService | null = null; + + /** + * Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers. + */ + getGroupService(): GroupService { + if (!this.groupToolSvc) { + const port: GroupToolPort = { + resolveRepo: (p) => this.resolveRepo(p), + impact: (r, p) => this.impact(r as RepoHandle, p), + query: (r, p) => this.query(r as RepoHandle, p), + impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o), + }; + this.groupToolSvc = new GroupService(port); + } + return this.groupToolSvc; + } + + /** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */ + async dispose(): Promise { + await closeLbug(); + } // ─── Initialization ────────────────────────────────────────────── @@ -430,6 +453,10 @@ export class LocalBackend { return this.listRepos(); } + if (method.startsWith('group_')) { + return this.handleGroupTool(method, params || {}); + } + // Resolve repo from optional param (re-reads registry on miss) const repo = await this.resolveRepo(params?.repo); @@ -1854,9 +1881,6 @@ export class LocalBackend { const includeTests = params.includeTests ?? false; const minConfidence = params.minConfidence ?? 0; - const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); - const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; - // Resolve target by name, preferring Class/Interface over Constructor // (fix #480: Java class and constructor share the same name). // labels(n)[0] returns empty string in LadybugDB, so we use explicit @@ -1917,6 +1941,33 @@ export class LocalBackend { if (!sym) return { error: `Target '${target}' not found` }; + return this._runImpactBFS(repo, sym, symType, direction, { + maxDepth, + relationTypes, + includeTests, + minConfidence, + }); + } + + /** + * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol). + */ + private async _runImpactBFS( + repo: RepoHandle, + sym: any, + symType: string, + direction: 'upstream' | 'downstream', + opts: { + maxDepth: number; + relationTypes: string[]; + includeTests: boolean; + minConfidence: number; + }, + ): Promise { + const { maxDepth, relationTypes, includeTests, minConfidence } = opts; + const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); + const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; + const symId = sym.id || sym[0]; const impacted: any[] = []; @@ -2323,6 +2374,110 @@ export class LocalBackend { }; } + /** + * UID-based impact for cross-repo fan-out. Same result shape as `impact`. + * Returns null if the repo is unknown, the UID is missing, or analysis fails. + */ + async impactByUid( + repoId: string, + uid: string, + direction: string, + opts: { + maxDepth: number; + relationTypes: string[]; + minConfidence: number; + includeTests: boolean; + }, + ): Promise { + try { + await this.refreshRepos(); + await this.ensureInitialized(repoId); + } catch { + return null; + } + + const repo = this.repos.get(repoId); + if (!repo) return null; + + const dir: 'upstream' | 'downstream' = direction === 'downstream' ? 'downstream' : 'upstream'; + + let rows: any[]; + try { + rows = await executeParameterized( + repoId, + `MATCH (n) WHERE n.id = $uid + RETURN n.id AS id, n.name AS name, n.filePath AS filePath, labels(n)[0] AS type + LIMIT 1`, + { uid }, + ); + } catch { + return null; + } + if (!rows?.length) return null; + + const sym = rows[0]; + const labelRaw = sym.type ?? sym[3]; + const symType = + typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : ''; + + const rawRelTypes = + opts.relationTypes && opts.relationTypes.length > 0 + ? opts.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) + : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + const relationTypes = + rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + + try { + return await this._runImpactBFS(repo, sym, symType, dir, { + maxDepth: opts.maxDepth, + relationTypes, + includeTests: opts.includeTests, + minConfidence: opts.minConfidence, + }); + } catch { + return null; + } + } + + private handleGroupTool(method: string, params: Record): Promise { + switch (method) { + case 'group_list': + 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); + default: + throw new Error(`Unknown group tool: ${method}`); + } + } + + private async groupList(params: Record): Promise { + return this.getGroupService().groupList(params); + } + + private async groupSync(params: Record): Promise { + return this.getGroupService().groupSync(params); + } + + private async groupContracts(params: Record): Promise { + return this.getGroupService().groupContracts(params); + } + + private async groupQuery(params: Record): Promise { + await this.refreshRepos(); + return this.getGroupService().groupQuery(params); + } + + private async groupStatus(params: Record): Promise { + await this.refreshRepos(); + return this.getGroupService().groupStatus(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/staleness.ts b/gitnexus/src/mcp/staleness.ts index 3889eac039..811889f3a8 100644 --- a/gitnexus/src/mcp/staleness.ts +++ b/gitnexus/src/mcp/staleness.ts @@ -1,43 +1,6 @@ /** - * Staleness Check - * - * Checks if the GitNexus index is behind the current git HEAD. - * Returns a hint for the LLM to call analyze if stale. + * Staleness Check — re-export from core (see `core/git-staleness.ts`). */ -import { execFileSync } from 'child_process'; - -export interface StalenessInfo { - isStale: boolean; - commitsBehind: number; - hint?: string; -} - -/** - * Check how many commits the index is behind HEAD - */ -export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo { - try { - // Get count of commits between lastCommit and HEAD - const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], { - cwd: repoPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - - const commitsBehind = parseInt(result, 10) || 0; - - if (commitsBehind > 0) { - return { - isStale: true, - commitsBehind, - hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`, - }; - } - - return { isStale: false, commitsBehind: 0 }; - } catch { - // If git command fails, assume not stale (fail open) - return { isStale: false, commitsBehind: 0 }; - } -} +export type { StalenessInfo } from '../core/git-staleness.js'; +export { checkStaleness } from '../core/git-staleness.js'; diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index a1d3deb609..c74b19464f 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -377,4 +377,80 @@ Returns: single route object when one match, or { routes: [...], total: N } for required: [], }, }, + { + name: 'group_list', + description: `List all configured repository groups, or return details for one group (repos, manifest links). + +WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name. Omit to list all groups.' }, + }, + required: [], + }, + }, + { + name: 'group_sync', + description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links. + +WHEN TO USE: After changing group.yaml or re-indexing member repos.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + skipEmbeddings: { + type: 'boolean', + description: 'Exact + BM25 only (Demo PR: same as default exact path)', + }, + exactOnly: { type: 'boolean', description: 'Exact match only in cascade' }, + }, + 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'], + }, + }, ]; diff --git a/gitnexus/test/fixtures/group/test-backend/src/routes/health.ts b/gitnexus/test/fixtures/group/test-backend/src/routes/health.ts new file mode 100644 index 0000000000..81de7a0d0f --- /dev/null +++ b/gitnexus/test/fixtures/group/test-backend/src/routes/health.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +const router = Router(); + +router.get('/api/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +export default router; diff --git a/gitnexus/test/fixtures/group/test-backend/src/routes/users.ts b/gitnexus/test/fixtures/group/test-backend/src/routes/users.ts new file mode 100644 index 0000000000..602bf1ee33 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-backend/src/routes/users.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +const router = Router(); + +router.get('/api/users', async (req, res) => { + res.json([{ id: 1, name: 'Alice' }]); +}); + +router.post('/api/users', async (req, res) => { + res.json({ id: 2, ...req.body }); +}); + +router.get('/api/users/:id', async (req, res) => { + res.json({ id: req.params.id, name: 'Alice' }); +}); + +export default router; diff --git a/gitnexus/test/fixtures/group/test-frontend/src/api/users.ts b/gitnexus/test/fixtures/group/test-frontend/src/api/users.ts new file mode 100644 index 0000000000..0a73412f1e --- /dev/null +++ b/gitnexus/test/fixtures/group/test-frontend/src/api/users.ts @@ -0,0 +1,18 @@ +export async function fetchUsers() { + const res = await fetch('/api/users'); + return res.json(); +} + +export async function createUser(data: { name: string }) { + const res = await fetch('/api/users', { + method: 'POST', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' }, + }); + return res.json(); +} + +export async function fetchUser(id: string) { + const res = await fetch(`/api/users/${id}`); + return res.json(); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json b/gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json new file mode 100644 index 0000000000..21f7d53e2d --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/package.json @@ -0,0 +1 @@ +{ "name": "@monorepo/auth", "version": "1.0.0" } diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto new file mode 100644 index 0000000000..3a9d542c9a --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/auth.proto @@ -0,0 +1,5 @@ +syntax = "proto3"; +package auth; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts new file mode 100644 index 0000000000..99a2816542 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/events.ts @@ -0,0 +1,3 @@ +export async function publishLoginEvent(producer: any, userId: string) { + await producer.send({ topic: 'user.logged-in', messages: [{ value: userId }] }); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts new file mode 100644 index 0000000000..79ea2f5411 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/auth/src/server.ts @@ -0,0 +1,8 @@ +import { GrpcMethod } from '@nestjs/microservices'; + +export class AuthController { + @GrpcMethod('AuthService', 'Login') + login(data: unknown): unknown { + return { token: 'ok' }; + } +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json new file mode 100644 index 0000000000..14e0f87979 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/package.json @@ -0,0 +1 @@ +{ "name": "@monorepo/gateway", "version": "1.0.0" } diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts new file mode 100644 index 0000000000..346d962a74 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/gateway/src/api.ts @@ -0,0 +1,4 @@ +export async function createOrder(data: unknown) { + const res = await fetch('/api/orders', { method: 'POST' }); + return res.json(); +} diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json b/gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json new file mode 100644 index 0000000000..ab03bf4300 --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/orders/package.json @@ -0,0 +1 @@ +{ "name": "@monorepo/orders", "version": "1.0.0" } diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts new file mode 100644 index 0000000000..34c538dc1c --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/order-service.ts @@ -0,0 +1,7 @@ +import { Client } from '@grpc/grpc-js'; + +// Consumes AuthService gRPC +const authClient = new AuthServiceClient('localhost:50051'); + +// Consumes user.logged-in topic +await consumer.subscribe({ topic: 'user.logged-in', fromBeginning: true }); diff --git a/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts new file mode 100644 index 0000000000..579c14159d --- /dev/null +++ b/gitnexus/test/fixtures/group/test-monorepo/services/orders/src/routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +const router = express.Router(); + +router.post('/api/orders', (req, res) => { + res.json({ orderId: '123' }); +}); + +export default router; diff --git a/gitnexus/test/integration/group/group-cli.test.ts b/gitnexus/test/integration/group/group-cli.test.ts new file mode 100644 index 0000000000..20c9fcdf38 --- /dev/null +++ b/gitnexus/test/integration/group/group-cli.test.ts @@ -0,0 +1,52 @@ +/** + * Smoke-test `gitnexus group` CLI via tsx (same pattern as cli-e2e.test.ts). + * Does not exercise LadybugDB-backed commands end-to-end (needs indexed fixtures). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import os from 'node:os'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../..'); +const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); +const _require = createRequire(import.meta.url); +const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json')); +const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href; + +let tmpHome: string; + +beforeAll(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-cli-')); +}); + +afterAll(() => { + if (tmpHome && fs.existsSync(tmpHome)) { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +function runGroup(args: string[]) { + return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, 'group', ...args], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 20000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_HOME: tmpHome }, + }); +} + +describe('group CLI', () => { + it('create + list', () => { + const c = runGroup(['create', 'acme']); + expect(c.status).toBe(0); + expect(c.stdout).toContain('Created group "acme"'); + + const l = runGroup(['list']); + expect(l.status).toBe(0); + expect(l.stdout).toContain('acme'); + }); +}); diff --git a/gitnexus/test/integration/group/group-sync.test.ts b/gitnexus/test/integration/group/group-sync.test.ts new file mode 100644 index 0000000000..3cceb200b6 --- /dev/null +++ b/gitnexus/test/integration/group/group-sync.test.ts @@ -0,0 +1,79 @@ +/** + * Group sync integration — uses `extractorOverride` / parsed YAML only (no LadybugDB). + * Full pipeline with indexed fixture repos is a follow-up (needs `.gitnexus/lbug`). + */ +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { parseGroupConfig } from '../../../src/core/group/config-parser.js'; +import { syncGroup } from '../../../src/core/group/sync.js'; +import type { StoredContract } from '../../../src/core/group/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/group'); + +describe('Group sync integration', () => { + it('parses fixture group.yaml', () => { + const yamlContent = fs.readFileSync(path.join(FIXTURES_DIR, 'group.yaml'), 'utf-8'); + const config = parseGroupConfig(yamlContent); + expect(config.name).toBe('test-group'); + expect(config.repos['app/backend']).toBe('test-backend'); + expect(config.repos['app/frontend']).toBe('test-frontend'); + }); + + it('builds cross-links from fixture-shaped contracts via syncGroup', async () => { + const yamlContent = fs.readFileSync(path.join(FIXTURES_DIR, 'group.yaml'), 'utf-8'); + const config = parseGroupConfig(yamlContent); + + const mockContracts: StoredContract[] = [ + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-p1', + symbolRef: { filePath: 'src/routes/users.ts', name: 'list' }, + symbolName: 'list', + confidence: 0.9, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/backend', + }, + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'consumer', + symbolUid: 'uid-c1', + symbolRef: { filePath: 'src/api/users.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + confidence: 0.85, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/frontend', + }, + { + contractId: 'http::GET::/api/health', + type: 'http', + role: 'provider', + symbolUid: 'uid-h1', + symbolRef: { filePath: 'src/routes/health.ts', name: 'health' }, + symbolName: 'health', + confidence: 0.9, + meta: { method: 'GET', path: '/api/health' }, + repo: 'app/backend', + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks.length).toBeGreaterThanOrEqual(1); + const usersLink = result.crossLinks.find((l) => l.contractId.includes('/api/users')); + expect(usersLink).toBeDefined(); + expect(usersLink!.matchType).toBe('exact'); + expect(usersLink!.confidence).toBe(1.0); + + const healthUnmatched = result.unmatched.some((c) => c.contractId.includes('/api/health')); + expect(healthUnmatched).toBe(true); + }); +}); diff --git a/gitnexus/test/integration/group/monorepo-sync.test.ts b/gitnexus/test/integration/group/monorepo-sync.test.ts new file mode 100644 index 0000000000..a9dc38ceca --- /dev/null +++ b/gitnexus/test/integration/group/monorepo-sync.test.ts @@ -0,0 +1,120 @@ +/** + * Monorepo sync integration — proves that intra-repo service communication + * (gRPC, Kafka topics, HTTP routes) is detected and matched across service + * boundaries within a single monorepo. + * + * Uses real extractors on fixture files (no LadybugDB). + */ +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js'; +import { TopicExtractor } from '../../../src/core/group/extractors/topic-extractor.js'; +import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; +import { + detectServiceBoundaries, + assignService, +} from '../../../src/core/group/service-boundary-detector.js'; +import { runExactMatch } from '../../../src/core/group/matching.js'; +import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MONOREPO_DIR = path.resolve(__dirname, '../../fixtures/group/test-monorepo'); + +const REPO_GROUP_PATH = 'platform/monorepo'; +const makeHandle = (): RepoHandle => ({ + id: 'test-monorepo', + path: REPO_GROUP_PATH, + repoPath: MONOREPO_DIR, + storagePath: path.join(MONOREPO_DIR, '.gitnexus'), +}); + +describe('Monorepo sync integration', () => { + it('detects service boundaries in fixture monorepo', async () => { + const boundaries = await detectServiceBoundaries(MONOREPO_DIR); + + expect(boundaries.length).toBe(3); + const names = boundaries.map((b) => b.serviceName).sort(); + expect(names).toEqual(['auth', 'gateway', 'orders']); + }); + + it('extracts gRPC, topic, and HTTP contracts with service assignments', async () => { + const handle = makeHandle(); + const boundaries = await detectServiceBoundaries(MONOREPO_DIR); + + const grpcEx = new GrpcExtractor(); + const topicEx = new TopicExtractor(); + const httpEx = new HttpRouteExtractor(); + + const grpcContracts = await grpcEx.extract(null, MONOREPO_DIR, handle); + const topicContracts = await topicEx.extract(null, MONOREPO_DIR, handle); + const httpContracts = await httpEx.extract(null, MONOREPO_DIR, handle); + + // Assign service boundaries and repo + const allContracts: StoredContract[] = [ + ...grpcContracts, + ...topicContracts, + ...httpContracts, + ].map((c) => ({ + ...c, + repo: REPO_GROUP_PATH, + service: assignService(c.symbolRef.filePath, boundaries), + })); + + // Verify service assignments exist + const withService = allContracts.filter((c) => c.service); + expect(withService.length).toBeGreaterThan(0); + + // Verify we have both providers and consumers + const providers = allContracts.filter((c) => c.role === 'provider'); + const consumers = allContracts.filter((c) => c.role === 'consumer'); + expect(providers.length).toBeGreaterThan(0); + expect(consumers.length).toBeGreaterThan(0); + + return allContracts; + }); + + it('produces intra-repo cross-links between monorepo services', async () => { + const handle = makeHandle(); + const boundaries = await detectServiceBoundaries(MONOREPO_DIR); + + const grpcEx = new GrpcExtractor(); + const topicEx = new TopicExtractor(); + const httpEx = new HttpRouteExtractor(); + + const grpcContracts = await grpcEx.extract(null, MONOREPO_DIR, handle); + const topicContracts = await topicEx.extract(null, MONOREPO_DIR, handle); + const httpContracts = await httpEx.extract(null, MONOREPO_DIR, handle); + + const allContracts: StoredContract[] = [ + ...grpcContracts, + ...topicContracts, + ...httpContracts, + ].map((c) => ({ + ...c, + repo: REPO_GROUP_PATH, + service: assignService(c.symbolRef.filePath, boundaries), + })); + + const { matched, unmatched } = runExactMatch(allContracts); + + // All links should be intra-repo (same repo, different services) + for (const link of matched) { + expect(link.from.repo).toBe(REPO_GROUP_PATH); + expect(link.to.repo).toBe(REPO_GROUP_PATH); + expect(link.from.service).not.toBe(link.to.service); + } + + // Expect at least topic match (user.logged-in: auth→orders) + const topicLink = matched.find((l) => l.contractId.includes('topic::')); + expect(topicLink).toBeDefined(); + expect(topicLink!.type).toBe('topic'); + + // Expect HTTP match (POST /api/orders: orders→gateway) + const httpLink = matched.find((l) => l.contractId.includes('http::')); + expect(httpLink).toBeDefined(); + + // Summary: we should have at least 2 cross-links + expect(matched.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 1827d8ea9d..ebccb42a2b 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -9,17 +9,29 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend -vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, +// We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend. +// local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js +// re-exports from the same module, so we mock the canonical source. +// vi.hoisted runs before vi.mock hoisting, making the fns available to both factories. +const { lbugMocks } = vi.hoisted(() => ({ + lbugMocks: { initLbug: vi.fn().mockResolvedValue(undefined), executeQuery: vi.fn().mockResolvedValue([]), executeParameterized: vi.fn().mockResolvedValue([]), closeLbug: vi.fn().mockResolvedValue(undefined), isLbugReady: vi.fn().mockReturnValue(true), - }; + }, +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; +}); + +// Re-export shim must resolve to the same mocks +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; }); vi.mock('../../src/storage/repo-manager.js', () => ({ diff --git a/gitnexus/test/unit/group/group-tools.test.ts b/gitnexus/test/unit/group/group-tools.test.ts new file mode 100644 index 0000000000..e58077442c --- /dev/null +++ b/gitnexus/test/unit/group/group-tools.test.ts @@ -0,0 +1,27 @@ +// gitnexus/test/unit/group/group-tools.test.ts +import { describe, it, expect } from 'vitest'; +import { GITNEXUS_TOOLS } from '../../../src/mcp/tools.js'; + +const GROUP_TOOL_NAMES = [ + 'group_list', + 'group_sync', + 'group_contracts', + 'group_query', + 'group_status', +]; + +describe('Group MCP tools', () => { + it('all 5 group tools are registered', () => { + for (const name of GROUP_TOOL_NAMES) { + const tool = GITNEXUS_TOOLS.find((t) => t.name === name); + expect(tool, `tool ${name} should be registered`).toBeDefined(); + expect(tool!.description.length).toBeGreaterThan(10); + expect(tool!.inputSchema.type).toBe('object'); + } + }); + + it('group_sync requires name', () => { + const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!; + expect(tool.inputSchema.required).toContain('name'); + }); +}); diff --git a/gitnexus/test/unit/group/impact-by-uid.test.ts b/gitnexus/test/unit/group/impact-by-uid.test.ts new file mode 100644 index 0000000000..c583851e2e --- /dev/null +++ b/gitnexus/test/unit/group/impact-by-uid.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { LocalBackend } from '../../../src/mcp/local/local-backend.js'; + +/** + * Smoke tests only. Full UID → BFS impact needs an indexed repo and LadybugDB + * (see integration tests under test/integration/lbug-db project). + */ +describe('LocalBackend.impactByUid', () => { + it('method exists on LocalBackend', () => { + const backend = new LocalBackend(); + expect(typeof backend.impactByUid).toBe('function'); + }); + + it('returns null when repo is unknown or graph lookup fails', async () => { + const backend = new LocalBackend(); + await expect( + backend.impactByUid('nonexistent-repo', 'fake-uid-123', 'upstream', { + maxDepth: 3, + relationTypes: ['CALLS', 'IMPORTS'], + minConfidence: 0, + includeTests: false, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/group/service.test.ts b/gitnexus/test/unit/group/service.test.ts new file mode 100644 index 0000000000..e4b10443ca --- /dev/null +++ b/gitnexus/test/unit/group/service.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, vi } 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, StoredContract, CrossLink } from '../../../src/core/group/types.js'; + +function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } { + const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-${Date.now()}`); + const groupDir = path.join(tmpDir, 'groups', 'test-group'); + fs.mkdirSync(groupDir, { recursive: true }); + + const yaml = `version: 1 +name: test-group +description: Test +repos: + app/backend: test-backend + app/frontend: test-frontend +`; + fs.writeFileSync(path.join(groupDir, 'group.yaml'), yaml); + + return { + tmpDir, + groupDir, + cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }), + }; +} + +function makePort(overrides: Partial = {}): GroupToolPort { + return { + resolveRepo: vi.fn( + async (name?: string): Promise => ({ + id: name || 'test', + name: name || 'test', + repoPath: '/tmp/repo', + storagePath: '/tmp/repo/.gitnexus', + }), + ), + impact: vi.fn(async () => ({ symbols: [] })), + query: vi.fn(async () => ({ processes: [] })), + impactByUid: vi.fn(async () => null), + ...overrides, + }; +} + +function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract { + return { + contractId: id, + type: 'http', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.8, + meta: {}, + repo, + }; +} + +function makeRegistry(contracts: StoredContract[], crossLinks: CrossLink[] = []): ContractRegistry { + return { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots: {}, + missingRepos: [], + contracts, + crossLinks, + }; +} + +describe('GroupService', () => { + describe('groupList', () => { + it('test_groupList_without_name_returns_group_names', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const svc = new GroupService(makePort()); + const result = (await svc.groupList({})) as { groups: string[] }; + expect(result.groups).toContain('test-group'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupList_with_name_returns_config_details', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const svc = new GroupService(makePort()); + const result = (await svc.groupList({ name: 'test-group' })) as { + name: string; + repos: Record; + }; + expect(result.name).toBe('test-group'); + expect(result.repos['app/backend']).toBe('test-backend'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); + + describe('groupContracts', () => { + it('test_groupContracts_returns_error_when_name_empty', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({})) as { error: string }; + expect(result.error).toContain('name is required'); + }); + + it('test_groupContracts_returns_error_when_no_registry', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group' })) as { error: string }; + expect(result.error).toContain('No contracts.json'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_returns_all_contracts', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const contracts = [ + makeContract('http::GET::/api/users', 'provider', 'app/backend'), + makeContract('http::GET::/api/users', 'consumer', 'app/frontend'), + ]; + await writeContractRegistry(groupDir, makeRegistry(contracts)); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group' })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(2); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_filters_by_type', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const contracts = [ + makeContract('http::GET::/api/users', 'provider', 'app/backend'), + { + ...makeContract('grpc::auth.AuthService/Login', 'provider', 'app/backend'), + type: 'grpc' as const, + }, + ]; + await writeContractRegistry(groupDir, makeRegistry(contracts)); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].type).toBe('grpc'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_filters_by_repo', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const contracts = [ + makeContract('http::GET::/api/users', 'provider', 'app/backend'), + makeContract('http::GET::/api/users', 'consumer', 'app/frontend'), + ]; + await writeContractRegistry(groupDir, makeRegistry(contracts)); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group', repo: 'app/backend' })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].repo).toBe('app/backend'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupContracts_unmatchedOnly_filters_matched', async () => { + const { groupDir, cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const provider = makeContract('http::GET::/api/users', 'provider', 'app/backend'); + const consumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend'); + const orphan = makeContract('http::GET::/api/health', 'provider', 'app/backend'); + const crossLink: CrossLink = { + from: { + repo: 'app/frontend', + symbolUid: 'uid-c', + symbolRef: { filePath: 'f.ts', name: 'fn' }, + }, + to: { + repo: 'app/backend', + symbolUid: 'uid-p', + symbolRef: { filePath: 'f.ts', name: 'fn' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }; + await writeContractRegistry( + groupDir, + makeRegistry([provider, consumer, orphan], [crossLink]), + ); + + const svc = new GroupService(makePort()); + const result = (await svc.groupContracts({ name: 'test-group', unmatchedOnly: true })) as { + contracts: StoredContract[]; + }; + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].contractId).toBe('http::GET::/api/health'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); + + describe('groupSync', () => { + it('test_groupSync_returns_error_when_name_empty', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupSync({})) as { error: string }; + expect(result.error).toContain('name is required'); + }); + }); + + describe('groupQuery', () => { + it('test_groupQuery_returns_error_when_params_missing', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupQuery({})) as { error: string }; + expect(result.error).toContain('name and query are required'); + }); + + it('test_groupQuery_merges_results_across_repos', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + query: vi.fn(async () => ({ + processes: [{ name: 'process1', score: 0.9 }], + })), + }); + + const svc = new GroupService(port); + const result = (await svc.groupQuery({ name: 'test-group', query: 'auth flow' })) as { + group: string; + query: string; + results: unknown[]; + per_repo: Array<{ repo: string; count: number }>; + }; + + expect(result.group).toBe('test-group'); + expect(result.query).toBe('auth flow'); + expect(result.results.length).toBeGreaterThan(0); + expect(result.per_repo).toHaveLength(2); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupQuery_handles_failing_repo_gracefully', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + resolveRepo: vi.fn(async (name?: string) => { + if (name === 'test-backend') throw new Error('not indexed'); + return { id: 'fe', name: 'fe', repoPath: '/tmp', storagePath: '/tmp/.gitnexus' }; + }), + query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })), + }); + + const svc = new GroupService(port); + const result = (await svc.groupQuery({ name: 'test-group', query: 'test' })) as { + per_repo: Array<{ repo: string; count: number }>; + }; + + const backendRepo = result.per_repo.find((r) => r.repo === 'app/backend'); + expect(backendRepo?.count).toBe(0); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('test_groupQuery_respects_subgroup_filter', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })), + }); + + const svc = new GroupService(port); + const result = (await svc.groupQuery({ + name: 'test-group', + query: 'test', + subgroup: 'app/backend', + })) as { per_repo: Array<{ repo: string; count: number }> }; + + expect(result.per_repo).toHaveLength(1); + expect(result.per_repo[0].repo).toBe('app/backend'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); + + describe('groupStatus', () => { + it('test_groupStatus_returns_error_when_name_empty', async () => { + const svc = new GroupService(makePort()); + const result = (await svc.groupStatus({})) as { error: string }; + expect(result.error).toContain('name is required'); + }); + + it('test_groupStatus_marks_unresolvable_repos_as_missing', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + + const port = makePort({ + resolveRepo: vi.fn(async () => { + throw new Error('repo not found'); + }), + }); + + const svc = new GroupService(port); + const result = (await svc.groupStatus({ name: 'test-group' })) as { + group: string; + repos: Record; + }; + + expect(result.group).toBe('test-group'); + expect(result.repos['app/backend'].missing).toBe(true); + expect(result.repos['app/frontend'].missing).toBe(true); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + }); +}); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts new file mode 100644 index 0000000000..f34bfd4d9b --- /dev/null +++ b/gitnexus/test/unit/group/sync.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { syncGroup, stableRepoPoolId } from '../../../src/core/group/sync.js'; +import type { GroupConfig, StoredContract, RepoHandle } from '../../../src/core/group/types.js'; +import type { RegistryEntry } from '../../../src/storage/repo-manager.js'; + +describe('syncGroup', () => { + const makeConfig = (repos: Record): GroupConfig => ({ + version: 1, + name: 'test', + description: '', + repos, + links: [], + packages: {}, + detect: { + http: true, + grpc: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + + it('returns SyncResult with contracts and cross-links', async () => { + const config = makeConfig({ 'app/backend': 'backend-repo', 'app/frontend': 'frontend-repo' }); + + const mockContracts: StoredContract[] = [ + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/ctrl.ts', name: 'UserController.list' }, + symbolName: 'UserController.list', + confidence: 0.8, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/backend', + }, + { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'consumer', + symbolUid: 'uid-2', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + symbolName: 'fetchUsers', + confidence: 0.7, + meta: { method: 'GET', path: '/api/users' }, + repo: 'app/frontend', + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.contracts).toHaveLength(2); + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('exact'); + expect(result.crossLinks[0].confidence).toBe(1.0); + expect(result.unmatched).toHaveLength(0); + }); + + it('reports missing repos', async () => { + const config = makeConfig({ 'app/backend': 'nonexistent-repo' }); + + const result = await syncGroup(config, { + resolveRepoHandle: async () => null, + skipWrite: true, + }); + + expect(result.missingRepos).toContain('app/backend'); + expect(result.contracts).toHaveLength(0); + }); + + it('handles empty repos config', async () => { + const config = makeConfig({}); + + const result = await syncGroup(config, { + extractorOverride: async () => [], + skipWrite: true, + }); + + expect(result.contracts).toHaveLength(0); + expect(result.crossLinks).toHaveLength(0); + expect(result.missingRepos).toHaveLength(0); + }); + + it('intra-repo matching works with service field via extractorOverride', async () => { + const config = makeConfig({ 'platform/monorepo': 'monorepo' }); + + const mockContracts: StoredContract[] = [ + { + ...makeContract('http::GET::/api/users', 'provider', 'platform/monorepo'), + service: 'services/auth', + }, + { + ...makeContract('http::GET::/api/users', 'consumer', 'platform/monorepo'), + service: 'services/gateway', + }, + ]; + + const result = await syncGroup(config, { + extractorOverride: async () => mockContracts, + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].from.service).toBe('services/gateway'); + expect(result.crossLinks[0].to.service).toBe('services/auth'); + }); + + function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract { + return { + contractId: id, + type: 'http', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.8, + meta: {}, + repo, + }; + } + + it('per-repo extractorOverride receives repo handle and extracts per repo', async () => { + const config = makeConfig({ + 'app/backend': 'backend-repo', + 'app/frontend': 'frontend-repo', + }); + + const perRepoOverride = async (repo: RepoHandle) => { + if (repo.path === 'app/backend') { + return [makeContract('http::GET::/api/users', 'provider', 'app/backend')]; + } + return [makeContract('http::GET::/api/users', 'consumer', 'app/frontend')]; + }; + + const result = await syncGroup(config, { + extractorOverride: perRepoOverride, + resolveRepoHandle: async (_name, groupPath) => ({ + id: groupPath, + path: groupPath, + repoPath: '/tmp/' + groupPath, + storagePath: '/tmp/' + groupPath + '/.gitnexus', + }), + skipWrite: true, + }); + + // per-repo override goes through the initLbug path which will fail + // but the extractorOverride with arity > 0 triggers the else branch + // At minimum, the function should not throw + expect(result).toBeDefined(); + }); + + it('writes registry to groupDir when skipWrite is false', async () => { + const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + + try { + const config = makeConfig({}); + const result = await syncGroup(config, { + extractorOverride: async () => [], + groupDir: tmpDir, + skipWrite: false, + }); + + expect(result.contracts).toHaveLength(0); + + const registryPath = path.join(tmpDir, 'contracts.json'); + expect(fs.existsSync(registryPath)).toBe(true); + + const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(registry.version).toBe(1); + expect(registry.contracts).toHaveLength(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('stableRepoPoolId', () => { + it('returns lowercase name when no collision', () => { + const entry: RegistryEntry = { + name: 'MyRepo', + path: '/a/MyRepo', + storagePath: '/a/MyRepo/.gitnexus', + indexedAt: '', + lastCommit: '', + }; + const all = [entry]; + expect(stableRepoPoolId(entry, all)).toBe('myrepo'); + }); + + it('appends hash suffix on name collision with different path', () => { + const entry1: RegistryEntry = { + name: 'repo', + path: '/a/repo', + storagePath: '/a/repo/.gitnexus', + indexedAt: '', + lastCommit: '', + }; + const entry2: RegistryEntry = { + name: 'repo', + path: '/b/repo', + storagePath: '/b/repo/.gitnexus', + indexedAt: '', + lastCommit: '', + }; + const all = [entry1, entry2]; + + const id1 = stableRepoPoolId(entry1, all); + const id2 = stableRepoPoolId(entry2, all); + + expect(id1).toMatch(/^repo-/); + expect(id2).toMatch(/^repo-/); + expect(id1).not.toBe(id2); + }); +}); diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index 2ee1e5b667..2a018d9726 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -5,7 +5,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const executeQueryMock = vi.fn(); const executeParameterizedMock = vi.fn(); -// Use the exact import specifier including .js to match runtime imports +// Mock both the canonical source (core/lbug/pool-adapter.js — what local-backend.ts +// imports) and the re-export shim (mcp/core/lbug-adapter.js) so the mocks intercept +// regardless of import path. +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initLbug: vi.fn(), + executeQuery: (...args: any[]) => executeQueryMock(...args), + executeParameterized: (...args: any[]) => executeParameterizedMock(...args), + closeLbug: vi.fn(), + isLbugReady: vi.fn().mockReturnValue(true), + }; +}); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 9a4862ba5d..4274716a78 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -2,7 +2,7 @@ * Unit Tests: MCP Tool Definitions * * Tests: GITNEXUS_TOOLS from tools.ts - * - All 7 tools are defined + * - All 16 tools are defined (per-repo + group_*) * - Each tool has valid name, description, inputSchema * - Required fields are correct * - Optional repo parameter is present on tools that need it @@ -10,9 +10,17 @@ import { describe, it, expect } from 'vitest'; import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js'; +const GROUP_TOOLS = new Set([ + 'group_list', + 'group_sync', + 'group_contracts', + 'group_query', + 'group_status', +]); + describe('GITNEXUS_TOOLS', () => { - it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact)', () => { - expect(GITNEXUS_TOOLS).toHaveLength(11); + it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 5 group)', () => { + expect(GITNEXUS_TOOLS).toHaveLength(16); }); it('contains all expected tool names', () => { @@ -83,16 +91,35 @@ describe('GITNEXUS_TOOLS', () => { expect(listTool.inputSchema.required).toEqual([]); }); - it('all tools except list_repos have optional repo parameter', () => { + it('per-repo tools have optional repo parameter for backend selection', () => { for (const tool of GITNEXUS_TOOLS) { if (tool.name === 'list_repos') continue; + if (GROUP_TOOLS.has(tool.name)) continue; expect(tool.inputSchema.properties.repo).toBeDefined(); expect(tool.inputSchema.properties.repo.type).toBe('string'); - // repo should never be required expect(tool.inputSchema.required).not.toContain('repo'); } }); + it('group_contracts has optional repo filter', () => { + const groupContracts = GITNEXUS_TOOLS.find((t) => t.name === 'group_contracts')!; + expect(groupContracts.inputSchema.properties).toHaveProperty('repo'); + expect(groupContracts.inputSchema.required).not.toContain('repo'); + }); + + it('group tools without backend repo param omit repo property', () => { + for (const name of ['group_list', 'group_status', 'group_sync', 'group_query'] as const) { + const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!; + expect(tool.inputSchema.properties).not.toHaveProperty('repo'); + } + }); + + it('group_query requires name and query', () => { + const groupQuery = GITNEXUS_TOOLS.find((t) => t.name === 'group_query')!; + expect(groupQuery.inputSchema.required).toContain('name'); + expect(groupQuery.inputSchema.required).toContain('query'); + }); + it('detect_changes scope has correct enum values', () => { const detectTool = GITNEXUS_TOOLS.find((t) => t.name === 'detect_changes')!; const scopeProp = detectTool.inputSchema.properties.scope;