mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
Merge branch 'main' into feature/ci-setup-wizard
This commit is contained in:
commit
594fb830b1
13 changed files with 399 additions and 3 deletions
|
|
@ -36,6 +36,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `check` | Check graph invariants such as circular imports |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const COMMAND_DESCRIPTION_KEYS = {
|
|||
impact: 'help.command.impact.description',
|
||||
cypher: 'help.command.cypher.description',
|
||||
'detect-changes': 'help.command.detectChanges.description',
|
||||
check: 'help.command.check.description',
|
||||
'eval-server': 'help.command.evalServer.description',
|
||||
group: 'help.command.group.description',
|
||||
'group create': 'help.command.group.create.description',
|
||||
|
|
@ -131,6 +132,10 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
|
||||
'detect-changes|-r, --repo <name>': 'help.option.repo.target',
|
||||
'detect-changes|--branch <name>': 'help.option.branch',
|
||||
'check|--cycles': 'help.option.check.cycles',
|
||||
'check|--json': 'help.option.json',
|
||||
'check|-r, --repo <name>': 'help.option.repo.target',
|
||||
'check|--branch <name>': 'help.option.branch',
|
||||
'eval-server|-p, --port <port>': 'help.option.port',
|
||||
'eval-server|--host <host>': 'help.option.evalServer.host',
|
||||
'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ export const en = {
|
|||
'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph',
|
||||
'help.command.detectChanges.description':
|
||||
'Map git diff hunks to indexed symbols and affected execution flows',
|
||||
'help.command.check.description': 'Run structural checks against the indexed graph',
|
||||
'help.command.evalServer.description':
|
||||
'Start lightweight HTTP server for fast tool calls during evaluation',
|
||||
'help.command.group.description': 'Manage repository groups for cross-index impact analysis',
|
||||
|
|
@ -253,6 +254,7 @@ export const en = {
|
|||
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
|
||||
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
|
||||
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
|
||||
'help.option.check.cycles': 'Detect circular imports and fail when any are found',
|
||||
'help.option.evalServer.host':
|
||||
'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
|
||||
'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)',
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ export const zhCN = {
|
|||
'help.command.impact.description': '影响面分析:修改符号会影响什么',
|
||||
'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询',
|
||||
'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程',
|
||||
'help.command.check.description': '对已索引图谱运行结构检查',
|
||||
'help.command.evalServer.description': '启动轻量 HTTP 服务器,用于评测期间的快速工具调用',
|
||||
'help.command.group.description': '管理仓库组,用于跨索引影响分析',
|
||||
'help.command.group.create.description': '使用模板 group.yaml 创建新仓库组',
|
||||
|
|
@ -237,6 +238,7 @@ export const zhCN = {
|
|||
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
|
||||
'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
|
||||
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
|
||||
'help.option.check.cycles': '检测循环导入,并在发现循环时失败',
|
||||
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)',
|
||||
'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)',
|
||||
'help.option.group.create.force': '覆盖现有仓库组',
|
||||
|
|
|
|||
|
|
@ -297,6 +297,15 @@ program
|
|||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
|
||||
program
|
||||
.command('check')
|
||||
.description('Run structural checks against the indexed graph')
|
||||
.option('--cycles', 'Detect circular imports and fail when any are found')
|
||||
.option('--json', 'Emit machine-readable JSON')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'checkCommand'));
|
||||
|
||||
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
||||
|
||||
program
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Direct CLI Tool Commands
|
||||
*
|
||||
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
|
||||
* Exposes GitNexus tools (query, context, impact, cypher, check) as direct CLI commands.
|
||||
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
|
||||
*
|
||||
* Usage:
|
||||
|
|
@ -226,3 +226,43 @@ export async function detectChangesCommand(options?: {
|
|||
});
|
||||
output(formatDetectChangesResult(result));
|
||||
}
|
||||
|
||||
export async function checkCommand(options?: {
|
||||
cycles?: boolean;
|
||||
json?: boolean;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
}): Promise<void> {
|
||||
if (!options?.cycles) {
|
||||
process.stderr.write('Usage: gitnexus check --cycles [--json]\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('check', {
|
||||
cycles: true,
|
||||
repo: options.repo,
|
||||
branch: options.branch,
|
||||
});
|
||||
if (result?.error) {
|
||||
output(result);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (options.json) {
|
||||
output(result);
|
||||
} else if (result.cycleCount === 0) {
|
||||
output('No circular imports found.');
|
||||
} else {
|
||||
output(
|
||||
result.cycles.map((cycle: { files: string[] }) => cycle.files.join(' -> ')).join('\n'),
|
||||
);
|
||||
}
|
||||
if (result.cycleCount > 0) process.exitCode = 1;
|
||||
} catch (error) {
|
||||
output({ error: error instanceof Error ? error.message : String(error) });
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
gitnexus/src/core/graph/import-cycles.ts
Normal file
110
gitnexus/src/core/graph/import-cycles.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
interface ImportEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
function findCyclePath(component: string[], adjacency: Map<string, string[]>): string[] {
|
||||
const allowed = new Set(component);
|
||||
const start = component[0];
|
||||
const parents = new Map<string, string | null>([[start, null]]);
|
||||
const queue = [start];
|
||||
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const node = queue[index];
|
||||
for (const next of adjacency.get(node) ?? []) {
|
||||
if (!allowed.has(next)) continue;
|
||||
if (next === start) {
|
||||
const path: string[] = [];
|
||||
let cursor: string | null = node;
|
||||
while (cursor !== null) {
|
||||
path.push(cursor);
|
||||
cursor = parents.get(cursor) ?? null;
|
||||
}
|
||||
path.reverse();
|
||||
return [...path, start];
|
||||
}
|
||||
if (parents.has(next)) continue;
|
||||
parents.set(next, node);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invariant violation: no cycle found through SCC root.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one deterministic concrete cycle for every cyclic strongly connected
|
||||
* component in the file import graph.
|
||||
*/
|
||||
export function findImportCycles(edges: ImportEdge[]): string[][] {
|
||||
const adjacency = new Map<string, Set<string>>();
|
||||
for (const { source, target } of edges) {
|
||||
if (!source || !target) continue;
|
||||
const targets = adjacency.get(source) ?? new Set<string>();
|
||||
targets.add(target);
|
||||
adjacency.set(source, targets);
|
||||
if (!adjacency.has(target)) adjacency.set(target, new Set());
|
||||
}
|
||||
|
||||
const sortedAdjacency = new Map(
|
||||
[...adjacency].map(([node, targets]) => [node, [...targets].sort()] as const),
|
||||
);
|
||||
const reverseAdjacency = new Map<string, string[]>();
|
||||
for (const node of sortedAdjacency.keys()) reverseAdjacency.set(node, []);
|
||||
for (const [source, targets] of sortedAdjacency) {
|
||||
for (const target of targets) reverseAdjacency.get(target)!.push(source);
|
||||
}
|
||||
for (const sources of reverseAdjacency.values()) sources.sort();
|
||||
|
||||
const visited = new Set<string>();
|
||||
const finishOrder: string[] = [];
|
||||
const components: string[][] = [];
|
||||
|
||||
for (const start of [...sortedAdjacency.keys()].sort()) {
|
||||
if (visited.has(start)) continue;
|
||||
visited.add(start);
|
||||
const stack = [{ node: start, nextIndex: 0 }];
|
||||
while (stack.length > 0) {
|
||||
const frame = stack[stack.length - 1];
|
||||
const neighbors = sortedAdjacency.get(frame.node) ?? [];
|
||||
if (frame.nextIndex < neighbors.length) {
|
||||
const next = neighbors[frame.nextIndex++];
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
stack.push({ node: next, nextIndex: 0 });
|
||||
}
|
||||
} else {
|
||||
finishOrder.push(frame.node);
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visited.clear();
|
||||
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
||||
const start = finishOrder[index];
|
||||
if (visited.has(start)) continue;
|
||||
const component: string[] = [];
|
||||
const stack = [start];
|
||||
visited.add(start);
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
component.push(node);
|
||||
for (const next of reverseAdjacency.get(node) ?? []) {
|
||||
if (visited.has(next)) continue;
|
||||
visited.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
component.sort();
|
||||
components.push(component);
|
||||
}
|
||||
|
||||
return components
|
||||
.filter(
|
||||
(component) =>
|
||||
component.length > 1 || (sortedAdjacency.get(component[0]) ?? []).includes(component[0]),
|
||||
)
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map((component) => findCyclePath(component, sortedAdjacency));
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ import { PhaseTimer } from '../../core/search/phase-timer.js';
|
|||
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
|
||||
import { logger } from '../../core/logger.js';
|
||||
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
|
||||
import { findImportCycles } from '../../core/graph/import-cycles.js';
|
||||
// AI context generation is CLI-only (gitnexus analyze)
|
||||
// import { generateAIContextFiles } from '../../cli/ai-context.js';
|
||||
|
||||
|
|
@ -1246,6 +1247,8 @@ export class LocalBackend {
|
|||
return this.impact(repo, params);
|
||||
case 'detect_changes':
|
||||
return this.detectChanges(repo, params);
|
||||
case 'check':
|
||||
return this.check(repo, params);
|
||||
case 'rename':
|
||||
return this.rename(repo, params);
|
||||
// Legacy aliases for backwards compatibility
|
||||
|
|
@ -1270,6 +1273,44 @@ export class LocalBackend {
|
|||
|
||||
// ─── Tool Implementations ────────────────────────────────────────
|
||||
|
||||
/** Check repository graph invariants that are suitable for CI gating. */
|
||||
private async check(repo: RepoHandle, params?: { cycles?: boolean }): Promise<any> {
|
||||
if (params?.cycles === false) {
|
||||
return { error: 'No checks selected. Set "cycles" to true.' };
|
||||
}
|
||||
await this.ensureInitialized(repo);
|
||||
const rowLimit = 100_001;
|
||||
const rows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`MATCH (source:File)-[r:CodeRelation]->(target:File)
|
||||
WHERE r.type = 'IMPORTS'
|
||||
AND (r.reason IS NULL OR (
|
||||
r.reason <> 'swift-scope: implicit module visibility'
|
||||
AND r.reason <> 'markdown-link'
|
||||
))
|
||||
RETURN source.filePath AS source, target.filePath AS target
|
||||
LIMIT ${rowLimit}`,
|
||||
{},
|
||||
);
|
||||
if (rows.length === rowLimit) {
|
||||
return {
|
||||
error: `Import graph exceeds the ${rowLimit - 1} edge safety limit.`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
const cycles = findImportCycles(
|
||||
rows.map((row: any) => ({
|
||||
source: String(row.source ?? row[0] ?? ''),
|
||||
target: String(row.target ?? row[1] ?? ''),
|
||||
})),
|
||||
);
|
||||
return {
|
||||
status: cycles.length === 0 ? 'clean' : 'cycles_found',
|
||||
cycleCount: cycles.length,
|
||||
cycles: cycles.map((files) => ({ files })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query tool — process-grouped search.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -316,6 +316,29 @@ Returns: changed symbols, affected processes, and a risk summary.`,
|
|||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'check',
|
||||
description: `Run read-only structural checks against the indexed graph.
|
||||
|
||||
Currently detects directed cycles between File nodes connected by IMPORTS edges.
|
||||
Returns deterministic cycle paths and a cycle count suitable for CI automation.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cycles: {
|
||||
type: 'boolean',
|
||||
description: 'Detect circular file imports (default: true).',
|
||||
default: true,
|
||||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'rename',
|
||||
description: `Multi-file coordinated rename using the knowledge graph + text search.
|
||||
|
|
@ -624,6 +647,7 @@ const BRANCH_SCOPED_TOOLS = new Set([
|
|||
'cypher',
|
||||
'context',
|
||||
'detect_changes',
|
||||
'check',
|
||||
'impact',
|
||||
'rename',
|
||||
'route_map',
|
||||
|
|
|
|||
|
|
@ -307,6 +307,44 @@ describe('LocalBackend.callTool', () => {
|
|||
expect(result).toHaveProperty('definitions');
|
||||
});
|
||||
|
||||
it('checks cycles using only non-synthetic import edges', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{ source: 'src/a.ts', target: 'src/b.ts' },
|
||||
{ source: 'src/b.ts', target: 'src/a.ts' },
|
||||
]);
|
||||
|
||||
const result = await backend.callTool('check', { cycles: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'cycles_found',
|
||||
cycleCount: 1,
|
||||
cycles: [{ files: ['src/a.ts', 'src/b.ts', 'src/a.ts'] }],
|
||||
});
|
||||
const query = (executeParameterized as any).mock.calls.at(-1)[1] as string;
|
||||
expect(query).toContain("r.reason <> 'swift-scope: implicit module visibility'");
|
||||
expect(query).toContain("r.reason <> 'markdown-link'");
|
||||
expect(query).toContain('LIMIT 100001');
|
||||
});
|
||||
|
||||
it('uses the advertised cycles default when check arguments are omitted', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
await expect(backend.callTool('check', undefined)).resolves.toEqual({
|
||||
status: 'clean',
|
||||
cycleCount: 0,
|
||||
cycles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the import-edge safety limit is reached', async () => {
|
||||
(executeParameterized as any).mockResolvedValue({ length: 100_001 });
|
||||
|
||||
await expect(backend.callTool('check', { cycles: true })).resolves.toEqual({
|
||||
error: 'Import graph exceeds the 100000 edge safety limit.',
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes FTS-unavailable warning when ftsAvailable is false (#1403)', async () => {
|
||||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||||
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ results: [], ftsAvailable: false });
|
||||
|
|
|
|||
68
gitnexus/test/unit/import-cycles.test.ts
Normal file
68
gitnexus/test/unit/import-cycles.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { findImportCycles } from '../../src/core/graph/import-cycles.js';
|
||||
|
||||
describe('findImportCycles', () => {
|
||||
it('returns no cycles for an acyclic graph', () => {
|
||||
expect(
|
||||
findImportCycles([
|
||||
{ source: 'src/a.ts', target: 'src/b.ts' },
|
||||
{ source: 'src/b.ts', target: 'src/c.ts' },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns deterministic concrete paths for cyclic components', () => {
|
||||
expect(
|
||||
findImportCycles([
|
||||
{ source: 'src/b.ts', target: 'src/a.ts' },
|
||||
{ source: 'src/a.ts', target: 'src/b.ts' },
|
||||
{ source: 'src/y.ts', target: 'src/z.ts' },
|
||||
{ source: 'src/z.ts', target: 'src/y.ts' },
|
||||
]),
|
||||
).toEqual([
|
||||
['src/a.ts', 'src/b.ts', 'src/a.ts'],
|
||||
['src/y.ts', 'src/z.ts', 'src/y.ts'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates edges and reports self-imports', () => {
|
||||
expect(
|
||||
findImportCycles([
|
||||
{ source: 'src/a.ts', target: 'src/a.ts' },
|
||||
{ source: 'src/a.ts', target: 'src/a.ts' },
|
||||
]),
|
||||
).toEqual([['src/a.ts', 'src/a.ts']]);
|
||||
});
|
||||
|
||||
it('returns the shortest deterministic path through the component root', () => {
|
||||
expect(
|
||||
findImportCycles([
|
||||
{ source: 'src/a.ts', target: 'src/b.ts' },
|
||||
{ source: 'src/b.ts', target: 'src/c.ts' },
|
||||
{ source: 'src/c.ts', target: 'src/d.ts' },
|
||||
{ source: 'src/d.ts', target: 'src/a.ts' },
|
||||
{ source: 'src/a.ts', target: 'src/z.ts' },
|
||||
{ source: 'src/z.ts', target: 'src/a.ts' },
|
||||
]),
|
||||
).toEqual([['src/a.ts', 'src/z.ts', 'src/a.ts']]);
|
||||
});
|
||||
|
||||
it('finds an edge-connected path when component sort order is not a path', () => {
|
||||
expect(
|
||||
findImportCycles([
|
||||
{ source: 'src/a.ts', target: 'src/c.ts' },
|
||||
{ source: 'src/c.ts', target: 'src/b.ts' },
|
||||
{ source: 'src/b.ts', target: 'src/a.ts' },
|
||||
]),
|
||||
).toEqual([['src/a.ts', 'src/c.ts', 'src/b.ts', 'src/a.ts']]);
|
||||
});
|
||||
|
||||
it('handles deep import graphs without recursive traversal', () => {
|
||||
const size = 20_000;
|
||||
const edges = Array.from({ length: size - 1 }, (_, index) => ({
|
||||
source: `src/${index}.ts`,
|
||||
target: `src/${index + 1}.ts`,
|
||||
}));
|
||||
expect(findImportCycles(edges)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,9 +23,64 @@ describe('direct CLI tool commands', () => {
|
|||
initMock.mockReset();
|
||||
callToolMock.mockReset();
|
||||
writeSyncMock.mockReset();
|
||||
process.exitCode = undefined;
|
||||
initMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
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('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('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('dispatches detect_changes with CLI-shaped arguments', async () => {
|
||||
callToolMock.mockResolvedValue({
|
||||
summary: {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
|
|||
const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']);
|
||||
|
||||
describe('GITNEXUS_TOOLS', () => {
|
||||
it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => {
|
||||
expect(GITNEXUS_TOOLS).toHaveLength(13);
|
||||
it('exports all tools (8 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => {
|
||||
expect(GITNEXUS_TOOLS).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('contains all expected tool names', () => {
|
||||
|
|
@ -34,6 +34,7 @@ describe('GITNEXUS_TOOLS', () => {
|
|||
'cypher',
|
||||
'context',
|
||||
'detect_changes',
|
||||
'check',
|
||||
'rename',
|
||||
'impact',
|
||||
'api_impact',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue