diff --git a/gitnexus/test/integration/group/auto-discover.test.ts b/gitnexus/test/integration/group/auto-discover.test.ts index 2161ceb73..2a9cb2c19 100644 --- a/gitnexus/test/integration/group/auto-discover.test.ts +++ b/gitnexus/test/integration/group/auto-discover.test.ts @@ -193,4 +193,73 @@ console.log(formatDate(new Date()));`, // Should only find 2 (shared-utils and web-app), not 3 expect(result.repoCount).toBe(2); }); + + describe('explicit repoPaths mode', () => { + it('discovers and groups repos from an explicit repoPaths list', async () => { + const service = new GroupService(makeMockPort()); + const repoA = path.join(tmpDir, 'repos', 'shared-utils'); + const repoB = path.join(tmpDir, 'repos', 'web-app'); + + const result = (await service.groupDiscover({ + repoPaths: [repoA, repoB], + name: 'test-explicit', + skipSync: true, + })) as { + group: string; + repoCount: number; + repos: Array<{ name: string; packageName: string | null }>; + packageMappings: Record; + }; + + expect(result.group).toBe('test-explicit'); + expect(result.repoCount).toBe(2); + const names = result.repos.map((r) => r.name).sort(); + expect(names).toEqual(['shared-utils', 'web-app']); + expect(result.packageMappings['@test/shared-utils']).toBeDefined(); + expect(result.packageMappings['@test/web-app']).toBeDefined(); + + const groups = await listGroups(gitnexusHome); + expect(groups).toContain('test-explicit'); + }); + + it('returns an error when one of the explicit repoPaths is not indexed', async () => { + const unindexed = path.join(tmpDir, 'repos', 'unindexed'); + fs.mkdirSync(unindexed, { recursive: true }); + fs.writeFileSync( + path.join(unindexed, 'package.json'), + JSON.stringify({ name: 'unindexed' }), + ); + + const service = new GroupService(makeMockPort()); + const result = (await service.groupDiscover({ + repoPaths: [path.join(tmpDir, 'repos', 'shared-utils'), unindexed], + name: 'test-unindexed', + skipSync: true, + })) as { error: string }; + + expect(result.error).toContain('not indexed'); + expect(result.error).toContain(unindexed); + }); + + it('falls back to directory basename when resolveRepo cannot find a handle', async () => { + const port: GroupToolPort = { + resolveRepo: async () => { + throw new Error('not registered'); + }, + query: async () => ({ processes: [] }), + }; + const service = new GroupService(port); + const result = (await service.groupDiscover({ + repoPaths: [path.join(tmpDir, 'repos', 'shared-utils')], + name: 'test-fallback', + skipSync: true, + })) as { + repoCount: number; + repos: Array<{ name: string }>; + }; + + expect(result.repoCount).toBe(1); + expect(result.repos[0].name).toBe('shared-utils'); + }); + }); }); diff --git a/gitnexus/test/integration/group/group-graph.test.ts b/gitnexus/test/integration/group/group-graph.test.ts index dd31310a6..d855626ae 100644 --- a/gitnexus/test/integration/group/group-graph.test.ts +++ b/gitnexus/test/integration/group/group-graph.test.ts @@ -267,4 +267,204 @@ describe('Group graph traversal integration', () => { } } }); + + describe('depth traversal', () => { + async function writeFixture( + repos: Record, + registry: ContractRegistry, + ) { + const { createRequire } = await import('node:module'); + const _require = createRequire(import.meta.url); + const yaml = _require('js-yaml') as typeof import('js-yaml'); + const config = { + version: 1, + name: 'test-workspace', + description: '', + repos, + links: [], + packages: {}, + detect: { + http: false, + grpc: false, + topics: false, + shared_libs: true, + embedding_fallback: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + fs.writeFileSync(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8'); + await writeContractRegistry(groupDir, registry); + } + + function makeHandlePort(handles: Record): GroupToolPort { + return { + resolveRepo: async (name?: string) => { + if (!name || !handles[name]) throw new Error(`Repo not found: ${name}`); + return handles[name]; + }, + query: async (_repo, params) => ({ + processes: [ + { name: `proc-${(params as { query: string }).query}`, summary: 'mock' }, + ], + }), + }; + } + + describe('chain: apps/web → libs/shared → libs/deep', () => { + const CHAIN_REGISTRY: ContractRegistry = { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots: { + 'apps/web': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'a' }, + 'libs/shared': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'b' }, + 'libs/deep': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'c' }, + }, + missingRepos: [], + contracts: [], + crossLinks: [ + { + from: { + repo: 'apps/web', + symbolUid: '', + symbolRef: { filePath: 'src/app.ts', name: 'callShared' }, + }, + to: { + repo: 'libs/shared', + symbolUid: 'uid-shared', + symbolRef: { filePath: 'src/shared.ts', name: 'shared' }, + }, + type: 'lib', + contractId: 'lib::@test/shared::shared', + matchType: 'exact', + confidence: 1.0, + }, + { + from: { + repo: 'libs/shared', + symbolUid: 'uid-shared', + symbolRef: { filePath: 'src/shared.ts', name: 'callDeep' }, + }, + to: { + repo: 'libs/deep', + symbolUid: 'uid-deep', + symbolRef: { filePath: 'src/deep.ts', name: 'deep' }, + }, + type: 'lib', + contractId: 'lib::@test/deep::deep', + matchType: 'exact', + confidence: 1.0, + }, + ], + }; + + const chainRepos = { + 'apps/web': 'web-app', + 'libs/shared': 'shared-utils', + 'libs/deep': 'deep-lib', + }; + + const chainHandles: Record = { + 'web-app': { id: 'web-app', name: 'web-app', repoPath: '/mock/web', storagePath: '/mock/web/.gitnexus' }, + 'shared-utils': { id: 'shared-utils', name: 'shared-utils', repoPath: '/mock/shared', storagePath: '/mock/shared/.gitnexus' }, + 'deep-lib': { id: 'deep-lib', name: 'deep-lib', repoPath: '/mock/deep', storagePath: '/mock/deep/.gitnexus' }, + }; + + beforeEach(async () => { + await writeFixture(chainRepos, CHAIN_REGISTRY); + }); + + it('depth 1 stops at direct cross-links and does not recurse', async () => { + const service = new GroupService(makeHandlePort(chainHandles)); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'callShared', + repo: 'web-app', + depth: 1, + })) as { + totalCrossLinks: number; + crossConnections: Array<{ remoteRepo: string }>; + }; + + expect(result.totalCrossLinks).toBe(1); + expect(result.crossConnections.map((c) => c.remoteRepo)).toEqual(['libs/shared']); + }); + + it('depth 2 recurses through the intermediate repo to reach the transitive target', async () => { + const service = new GroupService(makeHandlePort(chainHandles)); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'callShared', + repo: 'web-app', + depth: 2, + })) as { + totalCrossLinks: number; + crossConnections: Array<{ remoteRepo: string }>; + }; + + expect(result.totalCrossLinks).toBe(2); + const repos = result.crossConnections.map((c) => c.remoteRepo).sort(); + expect(repos).toEqual(['libs/deep', 'libs/shared']); + }); + + it('clamps depth > 2 to 2 (depth 5 behaves like depth 2)', async () => { + const service = new GroupService(makeHandlePort(chainHandles)); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'callShared', + repo: 'web-app', + depth: 5, + })) as { totalCrossLinks: number }; + + expect(result.totalCrossLinks).toBe(2); + }); + }); + + it('visited-set prevents infinite recursion on a cycle (A → B → A)', async () => { + const cycleRegistry: ContractRegistry = { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots: { + 'apps/a': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'a' }, + 'apps/b': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'b' }, + }, + missingRepos: [], + contracts: [], + crossLinks: [ + { + from: { repo: 'apps/a', symbolUid: 'uid-a', symbolRef: { filePath: 'a.ts', name: 'a' } }, + to: { repo: 'apps/b', symbolUid: 'uid-b', symbolRef: { filePath: 'b.ts', name: 'b' } }, + type: 'lib', + contractId: 'lib::a-to-b', + matchType: 'exact', + confidence: 1.0, + }, + { + from: { repo: 'apps/b', symbolUid: 'uid-b', symbolRef: { filePath: 'b.ts', name: 'b' } }, + to: { repo: 'apps/a', symbolUid: 'uid-a', symbolRef: { filePath: 'a.ts', name: 'a' } }, + type: 'lib', + contractId: 'lib::b-to-a', + matchType: 'exact', + confidence: 1.0, + }, + ], + }; + + await writeFixture({ 'apps/a': 'repo-a', 'apps/b': 'repo-b' }, cycleRegistry); + + const cyclePort = makeHandlePort({ + 'repo-a': { id: 'repo-a', name: 'repo-a', repoPath: '/mock/a', storagePath: '/mock/a/.gitnexus' }, + 'repo-b': { id: 'repo-b', name: 'repo-b', repoPath: '/mock/b', storagePath: '/mock/b/.gitnexus' }, + }); + + const service = new GroupService(cyclePort); + const result = (await service.groupGraph({ + name: 'test-workspace', + symbol: 'a', + repo: 'repo-a', + depth: 2, + })) as { totalCrossLinks: number }; + + expect(result.totalCrossLinks).toBe(1); + }); + }); }); diff --git a/gitnexus/test/unit/cli-group.test.ts b/gitnexus/test/unit/cli-group.test.ts new file mode 100644 index 000000000..72814be81 --- /dev/null +++ b/gitnexus/test/unit/cli-group.test.ts @@ -0,0 +1,580 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; + +const mockGroupService = { + groupDiscover: vi.fn(), + groupStatus: vi.fn(), + groupGraph: vi.fn(), + groupQuery: vi.fn(), + groupContracts: vi.fn(), +}; + +const mockBackend = { + init: vi.fn(async () => {}), + dispose: vi.fn(async () => {}), + getGroupService: vi.fn(() => mockGroupService), +}; + +const mockCreateGroupDir = vi.fn(); +const mockGetGroupDir = vi.fn(); +const mockGetDefaultGitnexusDir = vi.fn(); +const mockListGroups = vi.fn(); +const mockReadContractRegistry = vi.fn(); +const mockLoadGroupConfig = vi.fn(); +const mockSyncGroup = vi.fn(); +const mockFsWriteFile = vi.fn(); + +vi.mock('../../src/mcp/local/local-backend.js', () => ({ + LocalBackend: class { + init = mockBackend.init; + dispose = mockBackend.dispose; + getGroupService = mockBackend.getGroupService; + }, +})); + +vi.mock('../../src/core/group/storage.js', () => ({ + createGroupDir: mockCreateGroupDir, + getGroupDir: mockGetGroupDir, + getDefaultGitnexusDir: mockGetDefaultGitnexusDir, + listGroups: mockListGroups, + readContractRegistry: mockReadContractRegistry, +})); + +vi.mock('../../src/core/group/config-parser.js', () => ({ + loadGroupConfig: mockLoadGroupConfig, +})); + +vi.mock('../../src/core/group/sync.js', () => ({ + syncGroup: mockSyncGroup, +})); + +vi.mock('node:fs/promises', () => ({ + writeFile: mockFsWriteFile, + default: { writeFile: mockFsWriteFile }, +})); + +async function runCommand(args: string[]): Promise { + const { registerGroupCommands } = await import('../../src/cli/group.js'); + const program = new Command(); + program.exitOverride(); + registerGroupCommands(program); + await program.parseAsync(['node', 'gitnexus', ...args]); +} + +describe('group CLI', () => { + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + mockGetDefaultGitnexusDir.mockReturnValue('/home/.gitnexus'); + mockGetGroupDir.mockImplementation((home: string, name: string) => `${home}/groups/${name}`); + }); + + describe('create', () => { + it('creates a group dir and prints next-step instructions', async () => { + mockCreateGroupDir.mockResolvedValueOnce('/home/.gitnexus/groups/team-a'); + + await runCommand(['group', 'create', 'team-a']); + + expect(mockCreateGroupDir).toHaveBeenCalledWith('/home/.gitnexus', 'team-a', undefined); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Created group "team-a"'), + ); + }); + + it('forwards --force to createGroupDir', async () => { + mockCreateGroupDir.mockResolvedValueOnce('/home/.gitnexus/groups/team-a'); + + await runCommand(['group', 'create', 'team-a', '--force']); + + expect(mockCreateGroupDir).toHaveBeenCalledWith('/home/.gitnexus', 'team-a', true); + }); + }); + + describe('auto-discover', () => { + it('calls groupDiscover with resolved directory and prints repos + package mappings', async () => { + mockGroupService.groupDiscover.mockResolvedValueOnce({ + group: 'workspace', + groupDir: '/home/.gitnexus/groups/workspace', + repoCount: 2, + repos: [ + { name: 'shared-utils', packageName: '@test/shared' }, + { name: 'web-app', packageName: null }, + ], + packageMappings: { '@test/shared': 'libs/shared' }, + synced: true, + contracts: 5, + crossLinks: 3, + }); + + await runCommand(['group', 'auto-discover', '/repos']); + + expect(mockGroupService.groupDiscover).toHaveBeenCalledWith( + expect.objectContaining({ + directory: expect.stringContaining('repos'), + name: 'workspace', + force: false, + skipSync: false, + }), + ); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Discovering indexed repos')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Repos (2)')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('shared-utils (@test/shared)')); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('5 contracts, 3 cross-links'), + ); + expect(mockBackend.dispose).toHaveBeenCalled(); + }); + + it('prints JSON when --json is set', async () => { + const payload = { group: 'workspace', groupDir: '/g', repoCount: 0, repos: [], packageMappings: {} }; + mockGroupService.groupDiscover.mockResolvedValueOnce(payload); + + await runCommand(['group', 'auto-discover', '/repos', '--json']); + + const jsonCall = logSpy.mock.calls.find((c) => + typeof c[0] === 'string' && c[0].trim().startsWith('{'), + ); + expect(jsonCall).toBeDefined(); + expect(JSON.parse(jsonCall![0] as string)).toEqual(payload); + }); + + it('prints error and sets exitCode=1 on service error', async () => { + mockGroupService.groupDiscover.mockResolvedValueOnce({ error: 'boom' }); + + await runCommand(['group', 'auto-discover', '/repos']); + + expect(errSpy).toHaveBeenCalledWith('boom'); + expect(process.exitCode).toBe(1); + }); + }); + + describe('repos', () => { + it('resolves each repo path and calls groupDiscover with repoPaths', async () => { + mockGroupService.groupDiscover.mockResolvedValueOnce({ + group: 'workspace', + groupDir: '/g', + repoCount: 2, + repos: [], + packageMappings: {}, + }); + + await runCommand(['group', 'repos', '/a', '/b', '--name', 'custom']); + + expect(mockGroupService.groupDiscover).toHaveBeenCalledWith( + expect.objectContaining({ + repoPaths: expect.arrayContaining([ + expect.stringContaining('/a'), + expect.stringContaining('/b'), + ]), + name: 'custom', + force: false, + skipSync: false, + }), + ); + }); + }); + + describe('add', () => { + it('adds a repo to the group yaml and prints sync reminder', async () => { + mockLoadGroupConfig.mockResolvedValueOnce({ + version: 1, + name: 'team-a', + description: '', + repos: {}, + links: [], + packages: {}, + detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + + await runCommand(['group', 'add', 'team-a', 'apps/web', 'web-app']); + + expect(mockFsWriteFile).toHaveBeenCalled(); + const writtenArg = mockFsWriteFile.mock.calls[0][1] as string; + expect(writtenArg).toContain('apps/web'); + expect(writtenArg).toContain('web-app'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Added web-app as "apps/web" to group "team-a"'), + ); + }); + }); + + describe('remove', () => { + it('removes a repo path from the group yaml', async () => { + mockLoadGroupConfig.mockResolvedValueOnce({ + version: 1, + name: 'team-a', + description: '', + repos: { 'apps/web': 'web-app', 'libs/shared': 'shared-utils' }, + links: [], + packages: {}, + detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + + await runCommand(['group', 'remove', 'team-a', 'apps/web']); + + expect(mockFsWriteFile).toHaveBeenCalled(); + const written = mockFsWriteFile.mock.calls[0][1] as string; + expect(written).not.toContain('apps/web'); + expect(written).toContain('libs/shared'); + }); + + it('errors when the repo path is not present', async () => { + mockLoadGroupConfig.mockResolvedValueOnce({ + version: 1, + name: 'team-a', + description: '', + repos: { 'libs/shared': 'shared-utils' }, + links: [], + packages: {}, + detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + + await runCommand(['group', 'remove', 'team-a', 'apps/web']); + + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('not found in group "team-a"'), + ); + expect(process.exitCode).toBe(1); + expect(mockFsWriteFile).not.toHaveBeenCalled(); + }); + }); + + describe('list', () => { + it('without name: prints all groups', async () => { + mockListGroups.mockResolvedValueOnce(['team-a', 'team-b']); + + await runCommand(['group', 'list']); + + expect(logSpy).toHaveBeenCalledWith('Groups:'); + expect(logSpy).toHaveBeenCalledWith(' team-a'); + expect(logSpy).toHaveBeenCalledWith(' team-b'); + }); + + it('without name, empty: prints create instruction', async () => { + mockListGroups.mockResolvedValueOnce([]); + + await runCommand(['group', 'list']); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('No groups configured'), + ); + }); + + it('with name: prints repos and manifest links', async () => { + mockLoadGroupConfig.mockResolvedValueOnce({ + version: 1, + name: 'team-a', + description: 'A team group', + repos: { 'apps/web': 'web-app' }, + links: [ + { from: 'apps/web', to: 'services/api', type: 'http', contract: 'GET::/x', role: 'consumer' }, + ], + packages: {}, + detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + + await runCommand(['group', 'list', 'team-a']); + + expect(logSpy).toHaveBeenCalledWith('Group: team-a'); + expect(logSpy).toHaveBeenCalledWith('Description: A team group'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('apps/web -> web-app')); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('apps/web -> services/api [http: GET::/x]'), + ); + }); + }); + + describe('status', () => { + it('prints OK/STALE/MISSING rows and last-sync timestamp', async () => { + mockReadContractRegistry.mockResolvedValueOnce({ + version: 1, + generatedAt: '2026-04-01T00:00:00Z', + repoSnapshots: {}, + missingRepos: ['legacy/old'], + contracts: [], + crossLinks: [], + }); + mockGroupService.groupStatus.mockResolvedValueOnce({ + repos: { + 'apps/web': { indexStale: false, contractsStale: false, missing: false, commitsBehind: 0 }, + 'libs/shared': { indexStale: true, contractsStale: true, missing: false, commitsBehind: 3 }, + 'legacy/old': { indexStale: false, contractsStale: false, missing: true }, + }, + missingRepos: ['legacy/old'], + }); + + await runCommand(['group', 'status', 'team-a']); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('last sync: 2026-04-01T00:00:00Z'), + ); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('MISSING')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('STALE')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('3 commits behind')); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Last sync missing repos: legacy/old'), + ); + }); + + it('shows "never synced" when no registry exists', async () => { + mockReadContractRegistry.mockResolvedValueOnce(null); + mockGroupService.groupStatus.mockResolvedValueOnce({ repos: {}, missingRepos: [] }); + + await runCommand(['group', 'status', 'team-a']); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('never synced')); + }); + }); + + describe('sync', () => { + it('calls syncGroup and prints cascade summary', async () => { + mockLoadGroupConfig.mockResolvedValueOnce({ + version: 1, + name: 'team-a', + description: '', + repos: { 'apps/web': 'web-app' }, + links: [], + packages: {}, + detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + mockSyncGroup.mockResolvedValueOnce({ + contracts: [{ id: 1 }, { id: 2 }], + crossLinks: [ + { matchType: 'exact' }, + { matchType: 'exact' }, + { matchType: 'manifest' }, + ], + unmatched: [{ id: 3 }], + missingRepos: [], + repoSnapshots: {}, + }); + + await runCommand(['group', 'sync', 'team-a', '--allow-stale', '--verbose']); + + expect(mockSyncGroup).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ allowStale: true, verbose: true }), + ); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('exact: 2 cross-links')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('unmatched: 1 contracts')); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Wrote contracts.json (2 contracts, 3 cross-links)'), + ); + }); + + it('prints JSON when --json is set', async () => { + mockLoadGroupConfig.mockResolvedValueOnce({ + version: 1, + name: 'team-a', + description: '', + repos: {}, + links: [], + packages: {}, + detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + const result = { + contracts: [], + crossLinks: [], + unmatched: [], + missingRepos: [], + repoSnapshots: {}, + }; + mockSyncGroup.mockResolvedValueOnce(result); + + await runCommand(['group', 'sync', 'team-a', '--json']); + + const jsonCall = logSpy.mock.calls.find((c) => + typeof c[0] === 'string' && c[0].trim().startsWith('{'), + ); + expect(jsonCall).toBeDefined(); + }); + }); + + describe('graph', () => { + it('parses --depth and --direction, prints cross connections', async () => { + mockGroupService.groupGraph.mockResolvedValueOnce({ + sourceRepo: 'web-app', + localContext: {}, + crossConnections: [ + { + direction: 'outgoing', + remoteRepo: 'libs/shared', + contractId: 'lib::x', + contractType: 'lib', + confidence: 1.0, + }, + { + direction: 'incoming', + remoteRepo: 'apps/other', + contractId: 'http::GET::/y', + contractType: 'http', + confidence: 0.8, + }, + ], + totalCrossLinks: 2, + }); + + await runCommand([ + 'group', 'graph', 'team-a', 'mySymbol', + '--repo', 'web-app', + '--depth', '2', + '--direction', 'both', + ]); + + expect(mockGroupService.groupGraph).toHaveBeenCalledWith({ + name: 'team-a', + symbol: 'mySymbol', + repo: 'web-app', + depth: 2, + direction: 'both', + }); + expect(logSpy).toHaveBeenCalledWith('Source repo: web-app'); + expect(logSpy).toHaveBeenCalledWith('Cross-repo connections: 2\n'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('→ libs/shared [lib] lib::x')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('← apps/other [http]')); + }); + + it('prints "No cross-repo connections" when empty', async () => { + mockGroupService.groupGraph.mockResolvedValueOnce({ + sourceRepo: 'web-app', + localContext: {}, + crossConnections: [], + totalCrossLinks: 0, + }); + + await runCommand(['group', 'graph', 'team-a', 'mySymbol']); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('No cross-repo connections found'), + ); + }); + + it('sets exitCode=1 on error', async () => { + mockGroupService.groupGraph.mockResolvedValueOnce({ error: 'not found' }); + + await runCommand(['group', 'graph', 'team-a', 'mySymbol']); + + expect(errSpy).toHaveBeenCalledWith('not found'); + expect(process.exitCode).toBe(1); + }); + + it('defaults depth to 1 when --depth is invalid', async () => { + mockGroupService.groupGraph.mockResolvedValueOnce({ + sourceRepo: 'r', + localContext: {}, + crossConnections: [], + totalCrossLinks: 0, + }); + + await runCommand(['group', 'graph', 'team-a', 'x', '--depth', 'not-a-number']); + + expect(mockGroupService.groupGraph).toHaveBeenCalledWith( + expect.objectContaining({ depth: 1 }), + ); + }); + }); + + describe('query', () => { + it('prints merged results with RRF scores', async () => { + mockGroupService.groupQuery.mockResolvedValueOnce({ + results: [ + { summary: 'flow A', _repo: 'apps/web', _rrf_score: 0.0164 }, + { name: 'flow B', _repo: 'libs/shared', _rrf_score: 0.0161 }, + ], + per_repo: [{ repo: 'apps/web', count: 1 }, { repo: 'libs/shared', count: 1 }], + }); + + await runCommand(['group', 'query', 'team-a', 'auth', '--limit', '10', '--subgroup', 'apps']); + + expect(mockGroupService.groupQuery).toHaveBeenCalledWith({ + name: 'team-a', + query: 'auth', + limit: 10, + subgroup: 'apps', + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Results (top 2)')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[apps/web] flow A')); + }); + + it('prints empty-result message when no flows match', async () => { + mockGroupService.groupQuery.mockResolvedValueOnce({ results: [], per_repo: [] }); + + await runCommand(['group', 'query', 'team-a', 'nothing']); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('No matching execution flows'), + ); + }); + }); + + describe('contracts', () => { + it('prints contracts and cross-links', async () => { + mockGroupService.groupContracts.mockResolvedValueOnce({ + contracts: [ + { role: 'provider', contractId: 'lib::x', repo: 'libs/shared', symbolRef: { name: 'x' } }, + { role: 'consumer', contractId: 'lib::x', repo: 'apps/web', symbolRef: { name: 'x' } }, + ], + crossLinks: [ + { + from: { repo: 'apps/web' }, + to: { repo: 'libs/shared' }, + matchType: 'exact', + confidence: 1.0, + contractId: 'lib::x', + }, + ], + }); + + await runCommand(['group', 'contracts', 'team-a', '--type', 'lib']); + + expect(mockGroupService.groupContracts).toHaveBeenCalledWith({ + name: 'team-a', + type: 'lib', + repo: undefined, + unmatchedOnly: false, + }); + expect(logSpy).toHaveBeenCalledWith('Contracts (2):'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[provider] lib::x')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('apps/web -> libs/shared')); + }); + + it('errors and exits 1 when service returns an error', async () => { + mockGroupService.groupContracts.mockResolvedValueOnce({ error: 'bad' }); + + await runCommand(['group', 'contracts', 'team-a']); + + expect(errSpy).toHaveBeenCalledWith('bad'); + expect(process.exitCode).toBe(1); + }); + + it('forwards --unmatched and --repo filters', async () => { + mockGroupService.groupContracts.mockResolvedValueOnce({ contracts: [], crossLinks: [] }); + + await runCommand([ + 'group', 'contracts', 'team-a', + '--unmatched', + '--repo', 'apps/web', + '--json', + ]); + + expect(mockGroupService.groupContracts).toHaveBeenCalledWith( + expect.objectContaining({ + unmatchedOnly: true, + repo: 'apps/web', + }), + ); + }); + }); +}); diff --git a/gitnexus/test/unit/cli-status.test.ts b/gitnexus/test/unit/cli-status.test.ts new file mode 100644 index 000000000..e1a800af3 --- /dev/null +++ b/gitnexus/test/unit/cli-status.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockIsGitRepo = vi.fn(); +const mockGetGitRoot = vi.fn(); +const mockGetCurrentCommit = vi.fn(); +const mockFindRepo = vi.fn(); +const mockGetStoragePaths = vi.fn(); +const mockHasKuzuIndex = vi.fn(); + +vi.mock('../../src/storage/git.js', () => ({ + isGitRepo: mockIsGitRepo, + getGitRoot: mockGetGitRoot, + getCurrentCommit: mockGetCurrentCommit, +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + findRepo: mockFindRepo, + getStoragePaths: mockGetStoragePaths, + hasKuzuIndex: mockHasKuzuIndex, +})); + +describe('statusCommand', () => { + let logSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockGetStoragePaths.mockReturnValue({ storagePath: '/repo/.gitnexus' }); + }); + + it('prints "Not a git repository" when cwd is not a git repo', async () => { + mockIsGitRepo.mockReturnValue(false); + + const { statusCommand } = await import('../../src/cli/status.js'); + await statusCommand(); + + expect(logSpy).toHaveBeenCalledWith('Not a git repository.'); + expect(mockFindRepo).not.toHaveBeenCalled(); + }); + + it('prompts for migration when a stale KuzuDB index exists', async () => { + mockIsGitRepo.mockReturnValue(true); + mockFindRepo.mockResolvedValue(null); + mockGetGitRoot.mockReturnValue('/repo'); + mockHasKuzuIndex.mockResolvedValue(true); + + const { statusCommand } = await import('../../src/cli/status.js'); + await statusCommand(); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('stale KuzuDB index'), + ); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('rebuilds the index')); + }); + + it('prompts to analyze when no index exists at all', async () => { + mockIsGitRepo.mockReturnValue(true); + mockFindRepo.mockResolvedValue(null); + mockGetGitRoot.mockReturnValue('/repo'); + mockHasKuzuIndex.mockResolvedValue(false); + + const { statusCommand } = await import('../../src/cli/status.js'); + await statusCommand(); + + expect(logSpy).toHaveBeenCalledWith('Repository not indexed.'); + expect(logSpy).toHaveBeenCalledWith('Run: gitnexus analyze'); + }); + + it('falls back to cwd when getGitRoot returns null', async () => { + mockIsGitRepo.mockReturnValue(true); + mockFindRepo.mockResolvedValue(null); + mockGetGitRoot.mockReturnValue(null); + mockHasKuzuIndex.mockResolvedValue(false); + + const { statusCommand } = await import('../../src/cli/status.js'); + await statusCommand(); + + expect(mockGetStoragePaths).toHaveBeenCalledWith(process.cwd()); + }); + + it('reports up-to-date when current commit matches indexed commit', async () => { + mockIsGitRepo.mockReturnValue(true); + mockFindRepo.mockResolvedValue({ + repoPath: '/repo', + meta: { + lastCommit: 'abc1234567', + indexedAt: '2026-04-01T00:00:00Z', + }, + }); + mockGetCurrentCommit.mockReturnValue('abc1234567'); + + const { statusCommand } = await import('../../src/cli/status.js'); + await statusCommand(); + + expect(logSpy).toHaveBeenCalledWith('Repository: /repo'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('abc1234')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('up-to-date')); + }); + + it('reports stale when current commit diverges from indexed commit', async () => { + mockIsGitRepo.mockReturnValue(true); + mockFindRepo.mockResolvedValue({ + repoPath: '/repo', + meta: { + lastCommit: 'old12345', + indexedAt: '2026-04-01T00:00:00Z', + }, + }); + mockGetCurrentCommit.mockReturnValue('new67890'); + + const { statusCommand } = await import('../../src/cli/status.js'); + await statusCommand(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('stale')); + }); +}); diff --git a/gitnexus/test/unit/cli-tool.test.ts b/gitnexus/test/unit/cli-tool.test.ts new file mode 100644 index 000000000..de5c0d82e --- /dev/null +++ b/gitnexus/test/unit/cli-tool.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCallTool = vi.fn(); +const mockInit = vi.fn(); + +vi.mock('../../src/mcp/local/local-backend.js', () => ({ + LocalBackend: class { + callTool = mockCallTool; + init = mockInit; + }, +})); + +const writeSyncMock = vi.fn(); +vi.mock('node:fs', () => ({ + writeSync: writeSyncMock, +})); + +describe('CLI tool commands', () => { + let errSpy: ReturnType; + let exitSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit__'); + }) as ReturnType; + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockInit.mockResolvedValue(true); + }); + + describe('queryCommand', () => { + it('errors when queryText is empty', async () => { + const { queryCommand } = await import('../../src/cli/tool.js'); + await expect(queryCommand('')).rejects.toThrow('__exit__'); + expect(errSpy).toHaveBeenCalledWith('Usage: gitnexus query '); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('forwards options to callTool and writes result', async () => { + mockCallTool.mockResolvedValueOnce({ results: ['a', 'b'] }); + const { queryCommand } = await import('../../src/cli/tool.js'); + + await queryCommand('auth flow', { + repo: 'web', + context: 'onboarding', + goal: 'debug', + limit: '5', + content: true, + }); + + expect(mockCallTool).toHaveBeenCalledWith('query', { + query: 'auth flow', + task_context: 'onboarding', + goal: 'debug', + limit: 5, + include_content: true, + repo: 'web', + }); + expect(writeSyncMock).toHaveBeenCalled(); + }); + + it('passes include_content=false and limit=undefined by default', async () => { + mockCallTool.mockResolvedValueOnce({}); + const { queryCommand } = await import('../../src/cli/tool.js'); + + await queryCommand('x'); + + expect(mockCallTool).toHaveBeenCalledWith( + 'query', + expect.objectContaining({ include_content: false, limit: undefined }), + ); + }); + }); + + describe('contextCommand', () => { + it('errors when both name and uid are empty', async () => { + const { contextCommand } = await import('../../src/cli/tool.js'); + await expect(contextCommand('')).rejects.toThrow('__exit__'); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('gitnexus context'), + ); + }); + + it('accepts uid without a name', async () => { + mockCallTool.mockResolvedValueOnce({}); + const { contextCommand } = await import('../../src/cli/tool.js'); + + await contextCommand('', { uid: 'uid-123' }); + + expect(mockCallTool).toHaveBeenCalledWith( + 'context', + expect.objectContaining({ uid: 'uid-123', name: undefined }), + ); + }); + + it('forwards --file and --content', async () => { + mockCallTool.mockResolvedValueOnce({}); + const { contextCommand } = await import('../../src/cli/tool.js'); + + await contextCommand('validateUser', { file: 'src/auth.ts', content: true }); + + expect(mockCallTool).toHaveBeenCalledWith( + 'context', + expect.objectContaining({ + name: 'validateUser', + file_path: 'src/auth.ts', + include_content: true, + }), + ); + }); + }); + + describe('impactCommand', () => { + it('errors when target is empty', async () => { + const { impactCommand } = await import('../../src/cli/tool.js'); + await expect(impactCommand('')).rejects.toThrow('__exit__'); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('gitnexus impact'), + ); + }); + + it('defaults direction to upstream and passes depth as integer', async () => { + mockCallTool.mockResolvedValueOnce({ impacts: [] }); + const { impactCommand } = await import('../../src/cli/tool.js'); + + await impactCommand('AuthService', { depth: '3' }); + + expect(mockCallTool).toHaveBeenCalledWith( + 'impact', + expect.objectContaining({ + target: 'AuthService', + direction: 'upstream', + maxDepth: 3, + includeTests: false, + }), + ); + }); + + it('forwards --direction downstream', async () => { + mockCallTool.mockResolvedValueOnce({}); + const { impactCommand } = await import('../../src/cli/tool.js'); + + await impactCommand('AuthService', { direction: 'downstream' }); + + expect(mockCallTool).toHaveBeenCalledWith( + 'impact', + expect.objectContaining({ direction: 'downstream' }), + ); + }); + + it('emits a structured error object and exits 1 when callTool throws', async () => { + mockCallTool.mockRejectedValueOnce(new Error('transport failed')); + const { impactCommand } = await import('../../src/cli/tool.js'); + + await expect(impactCommand('AuthService')).rejects.toThrow('__exit__'); + expect(writeSyncMock).toHaveBeenCalled(); + const firstWrite = writeSyncMock.mock.calls[0][1] as string; + expect(firstWrite).toContain('transport failed'); + expect(firstWrite).toContain('AuthService'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('cypherCommand', () => { + it('errors when query is empty', async () => { + const { cypherCommand } = await import('../../src/cli/tool.js'); + await expect(cypherCommand('')).rejects.toThrow('__exit__'); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('gitnexus cypher'), + ); + }); + + it('forwards --repo option', async () => { + mockCallTool.mockResolvedValueOnce({ rows: [] }); + const { cypherCommand } = await import('../../src/cli/tool.js'); + + await cypherCommand('MATCH (n) RETURN n', { repo: 'my-repo' }); + + expect(mockCallTool).toHaveBeenCalledWith('cypher', { + query: 'MATCH (n) RETURN n', + repo: 'my-repo', + }); + }); + }); + + describe('getBackend singleton + init failure', () => { + it('errors and exits 1 when backend init returns false', async () => { + mockInit.mockResolvedValueOnce(false); + const { queryCommand } = await import('../../src/cli/tool.js'); + + await expect(queryCommand('x')).rejects.toThrow('__exit__'); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('No indexed repositories found'), + ); + }); + + it('reuses the same backend across calls', async () => { + mockCallTool.mockResolvedValue({}); + const { queryCommand, cypherCommand } = await import('../../src/cli/tool.js'); + + await queryCommand('a'); + await cypherCommand('MATCH (n) RETURN n'); + + expect(mockInit).toHaveBeenCalledTimes(1); + }); + }); + + describe('output() EPIPE fallback', () => { + it('falls back to stderr and does not crash when stdout write throws non-EPIPE', async () => { + writeSyncMock.mockImplementationOnce(() => { + const err = new Error('broken pipe') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + }); + mockCallTool.mockResolvedValueOnce({ ok: true }); + const { queryCommand } = await import('../../src/cli/tool.js'); + + await queryCommand('x'); + + expect(stderrSpy).toHaveBeenCalled(); + }); + + it('exits cleanly (0) on EPIPE write failure', async () => { + writeSyncMock.mockImplementationOnce(() => { + const err = new Error('EPIPE') as NodeJS.ErrnoException; + err.code = 'EPIPE'; + throw err; + }); + mockCallTool.mockResolvedValueOnce({ ok: true }); + const { queryCommand } = await import('../../src/cli/tool.js'); + + await expect(queryCommand('x')).rejects.toThrow('__exit__'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + }); +});