import { afterEach, 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.unstubAllEnvs(); vi.stubEnv('GITNEXUS_LANG', 'en'); vi.resetModules(); initMock.mockReset(); callToolMock.mockReset(); writeSyncMock.mockReset(); process.exitCode = undefined; initMock.mockResolvedValue(true); }); // These commands set `process.exitCode` on the real process. Clearing it after // each test keeps a deliberate failure here from failing the whole run. afterEach(() => { process.exitCode = undefined; }); it('dispatches circular-import checks and fails CI when cycles exist', async () => { callToolMock.mockResolvedValue({ status: 'cycles_found', cycleCount: 1, cycles: [{ files: ['src/a.ts', 'src/b.ts', 'src/a.ts'] }], }); const { checkCommand } = await import('../../src/cli/tool.js'); await checkCommand({ cycles: true, repo: 'gitnexus' }); expect(callToolMock).toHaveBeenCalledWith('check', { cycles: true, repo: 'gitnexus', }); expect(writeSyncMock).toHaveBeenCalledWith( 1, expect.stringContaining('src/a.ts -> src/b.ts -> src/a.ts'), ); expect(process.exitCode).toBe(1); }); it('still fails CI when the enumeration was capped and there is no cycle count', async () => { // Past the cap the backend reports one representative cycle per component // and `cycleCount: null` — deliberately not a number, so a partial count // cannot be read as a real one. Keying the exit code off the count made // `null > 0` false and exited 0 on exactly the repositories with the most // cycles; this pins that `status` is what decides. callToolMock.mockResolvedValue({ status: 'cycles_found', enumeration: 'component-representatives', truncated: true, cycleCount: null, componentCount: 2, cycles: [ { files: ['src/a.ts', 'src/b.ts', 'src/a.ts'] }, { files: ['src/y.ts', 'src/z.ts', 'src/y.ts'] }, ], }); const { checkCommand } = await import('../../src/cli/tool.js'); await checkCommand({ cycles: true }); expect(process.exitCode).toBe(1); // and the operator is told the list is representative, not exhaustive expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('representative')); }); it('emits JSON and succeeds for a clean import graph', async () => { callToolMock.mockResolvedValue({ status: 'clean', cycleCount: 0, cycles: [] }); const { checkCommand } = await import('../../src/cli/tool.js'); await checkCommand({ cycles: true, json: true }); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('"status": "clean"')); expect(process.exitCode).toBeUndefined(); }); it('fails closed for backend error payloads in JSON mode', async () => { callToolMock.mockResolvedValue({ error: 'Import graph exceeds the safety limit.' }); const { checkCommand } = await import('../../src/cli/tool.js'); await checkCommand({ cycles: true, json: true }); expect(writeSyncMock).toHaveBeenCalledWith( 1, expect.stringContaining('Import graph exceeds the safety limit.'), ); expect(process.exitCode).toBe(1); }); it('prints the safety-limit error in PROSE mode instead of throwing on the missing cycle list', async () => { // The `enumeration: 'none'` response — a run that died inside the component // decomposition — carries `{ error, truncated }` and deliberately no // `status` and no `cycles`. The prose branch reads `result.cycles.map(...)`, // so without the error guard ahead of it this shape surfaces as a TypeError // instead of the limit it is trying to report. JSON mode was covered; this // is the path that renders. callToolMock.mockResolvedValue({ error: 'Import cycle enumeration exceeded its 10000000 step safety limit.', truncated: true, }); const { checkCommand } = await import('../../src/cli/tool.js'); await checkCommand({ cycles: true }); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('step safety limit')); expect(writeSyncMock).not.toHaveBeenCalledWith(1, expect.stringContaining('TypeError')); expect(process.exitCode).toBe(1); }); it('fails closed when the backend throws', async () => { callToolMock.mockRejectedValue(new Error('unknown branch')); const { checkCommand } = await import('../../src/cli/tool.js'); await checkCommand({ cycles: true }); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('unknown branch')); expect(process.exitCode).toBe(1); }); it('fails closed when cypher returns a backend error payload', async () => { callToolMock.mockResolvedValue({ error: 'Binder exception: missing relationship property' }); const { cypherCommand } = await import('../../src/cli/tool.js'); await cypherCommand('MATCH ()-[r:CodeRelation]->() RETURN r.missing'); expect(writeSyncMock).toHaveBeenCalledWith( 1, expect.stringContaining('Binder exception: missing relationship property'), ); expect(process.exitCode).toBe(1); }); it('keeps a successful cypher result at exit zero', async () => { callToolMock.mockResolvedValue({ markdown: '| count |\n| --- |\n| 1 |', row_count: 1 }); const { cypherCommand } = await import('../../src/cli/tool.js'); await cypherCommand('MATCH (n) RETURN count(n) AS count'); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('"row_count": 1')); expect(process.exitCode).toBeUndefined(); }); it('fails closed when query returns a backend error payload', async () => { callToolMock.mockResolvedValue({ error: 'Repository "missing" not found.' }); const { queryCommand } = await import('../../src/cli/tool.js'); await queryCommand('auth flow'); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('not found')); expect(process.exitCode).toBe(1); }); // `partial` is cross-tool vocabulary, not detect_changes' private flag, and the // degraded shape is the dangerous one: it looks like a result. `impact` matters // most — AGENTS.md makes it the gate before every edit, so a truncated traversal // that exits 0 lets `gitnexus impact … && ` proceed on a short caller set. it('fails closed when query degrades to a partial result', async () => { callToolMock.mockResolvedValue({ results: [], partial: true }); const { queryCommand } = await import('../../src/cli/tool.js'); await queryCommand('auth flow'); expect(process.exitCode).toBe(1); }); it('fails closed when impact truncates its traversal', async () => { callToolMock.mockResolvedValue({ byDepth: {}, risk: 'LOW', partial: true }); const { impactCommand } = await import('../../src/cli/tool.js'); await impactCommand('someSymbol', { direction: 'upstream' }); expect(process.exitCode).toBe(1); }); it('fails closed when context returns a backend error payload', async () => { callToolMock.mockResolvedValue({ error: 'Symbol not found: nope' }); const { contextCommand } = await import('../../src/cli/tool.js'); await contextCommand('nope'); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('Symbol not found')); expect(process.exitCode).toBe(1); }); 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 and fails the gate 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')); // `output()` gets the structured result plus its formatter, so the // object-payload check sees the `error` the rendered prose hides. // `gitnexus detect-changes && git commit` must not proceed here. expect(process.exitCode).toBe(1); }); it('fails the gate for a partial run, which reports zeros it did not earn', async () => { // A swallowed graph query leaves the counts at zero. Exit 0 would let // `detect-changes && git commit` treat a run that never completed as clean. callToolMock.mockResolvedValue({ partial: true, summary: { changed_files: 1, 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('PARTIAL RESULT')); expect(process.exitCode).toBe(1); }); it('keeps a truncated listing at exit zero — only the list was capped', async () => { // Deliberately NOT a failure: `changed_count`, `affected_count` and // `risk_level` are computed over every changed symbol, so the gate's verdict // is sound. Failing on `truncated` would fire on every large-but-healthy // diff and teach people to bypass the gate. callToolMock.mockResolvedValue({ truncated: true, summary: { changed_files: 40, changed_count: 500, affected_count: 3, risk_level: 'high' }, changed_symbols: [{ type: 'function', name: 'fn0', filePath: 'src/file0.ts' }], }); const { detectChangesCommand } = await import('../../src/cli/tool.js'); await detectChangesCommand({}); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('LISTING CAPPED')); expect(writeSyncMock).toHaveBeenCalledWith(1, expect.stringContaining('Risk level: high')); expect(process.exitCode).toBeUndefined(); }); 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'); }); it('localizes detect_changes formatter labels for Simplified Chinese', async () => { vi.stubEnv('GITNEXUS_LANG', 'zh-CN'); callToolMock.mockResolvedValue({ summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' }, changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }], affected_processes: [ { name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] }, ], }); const { detectChangesCommand } = await import('../../src/cli/tool.js'); await detectChangesCommand({}); const output: string = writeSyncMock.mock.calls[0][1]; expect(output).toContain('变更:2 个文件,3 个符号'); expect(output).toContain('受影响流程:1'); expect(output).toContain('风险等级:MEDIUM'); expect(output).toContain('已变更符号:'); expect(output).toContain('受影响执行流程:'); expect(output).toContain('Auth Flow (5 步) — 已变更:foo'); }); });