From 4dc8a0c9f9a67e53b50df35d7ffdfb65b584dab2 Mon Sep 17 00:00:00 2001 From: Sam Fakhreddine Date: Sun, 19 Apr 2026 18:35:29 -0600 Subject: [PATCH] fix: expose detect-changes in direct CLI Squashed commits: - test: fix risk_level mock case and prettier formatting in tool-direct-cli.test - test: add edge-case coverage for detectChangesCommand formatter --- gitnexus/src/cli/index.ts | 9 ++ gitnexus/src/cli/tool.ts | 52 ++++++++++ gitnexus/test/unit/cli-index-help.test.ts | 10 ++ gitnexus/test/unit/tool-direct-cli.test.ts | 110 +++++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 gitnexus/test/unit/tool-direct-cli.test.ts diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index dca5983e0..2b54f04f3 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -151,6 +151,15 @@ program .option('-r, --repo ', 'Target repository') .action(createLazyAction(() => import('./tool.js'), 'cypherCommand')); +program + .command('detect-changes') + .alias('detect_changes') + .description('Map git diff hunks to indexed symbols and affected execution flows') + .option('-s, --scope ', 'What to analyze: unstaged, staged, all, or compare', 'unstaged') + .option('-b, --base-ref ', 'Branch/commit for compare scope (e.g. main)') + .option('-r, --repo ', 'Target repository') + .action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand')); + // ─── Eval Server (persistent daemon for SWE-bench) ───────────────── program diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index e5219d2d7..443f12f4c 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -164,3 +164,55 @@ export async function cypherCommand( }); output(result); } + +function formatDetectChangesResult(result: any): string { + if (result?.error) return `Error: ${result.error}`; + + const summary = result?.summary || {}; + if ((summary.changed_count || 0) === 0) { + return 'No changes detected.'; + } + + const lines: string[] = []; + lines.push(`Changes: ${summary.changed_files || 0} files, ${summary.changed_count || 0} symbols`); + lines.push(`Affected processes: ${summary.affected_count || 0}`); + lines.push(`Risk level: ${summary.risk_level || 'unknown'}`); + lines.push(''); + + const changed = result?.changed_symbols || []; + if (changed.length > 0) { + lines.push('Changed symbols:'); + for (const symbol of changed.slice(0, 15)) { + lines.push(` ${symbol.type} ${symbol.name} → ${symbol.filePath}`); + } + if (changed.length > 15) { + lines.push(` ... and ${changed.length - 15} more`); + } + lines.push(''); + } + + const affected = result?.affected_processes || []; + if (affected.length > 0) { + lines.push('Affected execution flows:'); + for (const processInfo of affected.slice(0, 10)) { + const steps = (processInfo.changed_steps || []).map((s: any) => s.symbol).join(', '); + lines.push(` • ${processInfo.name} (${processInfo.step_count} steps) — changed: ${steps}`); + } + } + + return lines.join('\n').trim(); +} + +export async function detectChangesCommand(options?: { + scope?: string; + baseRef?: string; + repo?: string; +}): Promise { + const backend = await getBackend(); + const result = await backend.callTool('detect_changes', { + scope: options?.scope || 'unstaged', + base_ref: options?.baseRef, + repo: options?.repo, + }); + output(formatDetectChangesResult(result)); +} diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 96e3eab81..59109c8d9 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -43,6 +43,16 @@ describe('CLI help surface', () => { expect(result.stdout).toContain('--repo '); }); + it('detect-changes help exposes compare scope and base-ref flags', () => { + const result = runHelp('detect-changes'); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('gitnexus detect-changes|detect_changes [options]'); + expect(result.stdout).toContain('--scope '); + expect(result.stdout).toContain('--base-ref '); + expect(result.stdout).toContain('--repo '); + }); + it('wiki help shows provider, review, and verbose flags', () => { const result = runHelp('wiki'); diff --git a/gitnexus/test/unit/tool-direct-cli.test.ts b/gitnexus/test/unit/tool-direct-cli.test.ts new file mode 100644 index 000000000..9ede6225b --- /dev/null +++ b/gitnexus/test/unit/tool-direct-cli.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const initMock = vi.fn(); +const callToolMock = vi.fn(); +const writeSyncMock = vi.fn(); + +vi.mock('../../src/mcp/local/local-backend.js', () => ({ + LocalBackend: class { + init = initMock; + callTool = callToolMock; + }, +})); + +vi.mock('node:fs', () => ({ + writeSync: writeSyncMock, +})); + +describe('direct CLI tool commands', () => { + beforeEach(() => { + vi.resetModules(); + initMock.mockReset(); + callToolMock.mockReset(); + writeSyncMock.mockReset(); + initMock.mockResolvedValue(true); + }); + + it('dispatches detect_changes with CLI-shaped arguments', async () => { + callToolMock.mockResolvedValue({ + summary: { + changed_files: 1, + changed_count: 2, + affected_count: 1, + risk_level: 'low', + }, + }); + const { detectChangesCommand } = await import('../../src/cli/tool.js'); + + await detectChangesCommand({ + scope: 'compare', + baseRef: 'main', + repo: 'gitnexus', + }); + + expect(callToolMock).toHaveBeenCalledWith('detect_changes', { + scope: 'compare', + base_ref: 'main', + repo: 'gitnexus', + }); + expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('Risk level: low')); + }); + + it('prints "No changes detected." when changed_count is 0', async () => { + callToolMock.mockResolvedValue({ + summary: { changed_files: 0, changed_count: 0, affected_count: 0, risk_level: 'low' }, + }); + const { detectChangesCommand } = await import('../../src/cli/tool.js'); + + await detectChangesCommand({}); + + expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('No changes detected.')); + }); + + it('prints error message when result contains an error', async () => { + callToolMock.mockResolvedValue({ error: 'index is stale' }); + const { detectChangesCommand } = await import('../../src/cli/tool.js'); + + await detectChangesCommand({}); + + expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('Error: index is stale')); + }); + + it('truncates changed_symbols list beyond 15 and shows overflow count', async () => { + const symbols = Array.from({ length: 17 }, (_, i) => ({ + type: 'function', + name: `fn${i}`, + filePath: `src/file${i}.ts`, + })); + callToolMock.mockResolvedValue({ + summary: { changed_files: 17, changed_count: 17, affected_count: 0, risk_level: 'low' }, + changed_symbols: symbols, + }); + const { detectChangesCommand } = await import('../../src/cli/tool.js'); + + await detectChangesCommand({}); + + const output: string = writeSyncMock.mock.calls[0][1]; + expect(output).toContain('function fn14 → src/file14.ts'); + expect(output).not.toContain('fn15'); + expect(output).toContain('... and 2 more'); + }); + + it('truncates affected_processes list beyond 10', async () => { + const processes = Array.from({ length: 12 }, (_, i) => ({ + name: `proc${i}`, + step_count: 3, + changed_steps: [{ symbol: `sym${i}` }], + })); + callToolMock.mockResolvedValue({ + summary: { changed_files: 1, changed_count: 1, affected_count: 12, risk_level: 'low' }, + affected_processes: processes, + }); + const { detectChangesCommand } = await import('../../src/cli/tool.js'); + + await detectChangesCommand({}); + + const output: string = writeSyncMock.mock.calls[0][1]; + expect(output).toContain('proc9'); + expect(output).not.toContain('proc10'); + }); +});