GitNexus/gitnexus/test/unit/tool-direct-cli.test.ts
Gergő Magyar d540b00184
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
fix(check): stop reporting erased and deferred imports as initialization cycles (#2934)
2026-08-12 17:09:32 +00:00

344 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 … && <edit>` 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');
});
});