test(group,cli): cover depth traversal, repoPaths, and CLI commands

Integration:
- group-graph.test.ts: chain apps/web -> libs/shared -> libs/deep
  exercises findConnections recursion at depth 1/2/5, plus A -> B -> A
  cycle guard via the visited set.
- auto-discover.test.ts: explicit repoPaths mode (happy path, unindexed-
  path error, basename fallback when resolveRepo throws).

Unit:
- cli-group.test.ts: 25 tests mocking LocalBackend/storage/sync across
  create, auto-discover, repos, add, remove, list, status, sync, graph,
  query, contracts.
- cli-status.test.ts: 6 tests covering git-repo guard, stale-Kuzu prompt,
  up-to-date vs stale report.
- cli-tool.test.ts: 16 tests covering query/context/impact/cypher arg
  validation, optional-param branches, backend-init failure, EPIPE/EACCES
  fallbacks in output().

Coverage delta vs main: +1.33% statements, +0.77% branches, +1.22%
functions, +1.46% lines — clears the +0.5% review bar on all metrics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sahalterion 2026-04-17 19:53:22 -07:00
parent f450a3d084
commit 1cc6888e19
5 changed files with 1204 additions and 0 deletions

View file

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

View file

@ -267,4 +267,204 @@ describe('Group graph traversal integration', () => {
}
}
});
describe('depth traversal', () => {
async function writeFixture(
repos: Record<string, string>,
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<string, GroupRepoHandle>): 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<string, GroupRepoHandle> = {
'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);
});
});
});

View file

@ -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<void> {
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<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
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',
}),
);
});
});
});

View file

@ -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<typeof vi.spyOn>;
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'));
});
});

View file

@ -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<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('__exit__');
}) as ReturnType<typeof vi.spyOn>;
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 <search_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);
});
});
});