Merge branch 'main' into feature/ci-setup-wizard

This commit is contained in:
Gergő Magyar 2026-06-12 04:53:48 +01:00 • committed by GitHub
commit 594fb830b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 399 additions and 3 deletions

View file

@ -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`) |

View file

@ -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',

View file

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

View file

@ -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': '覆盖现有仓库组',

View file

@ -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

View file

@ -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;
}
}

View 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));
}

View file

@ -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.
*

View file

@ -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',

View file

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

View 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([]);
});
});

View file

@ -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: {

View file

@ -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',