gitnexus 1.6.8-rc.17 → 1.6.8-rc.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/help-i18n.js +5 -0
- package/dist/cli/i18n/en.d.ts +2 -0
- package/dist/cli/i18n/en.js +2 -0
- package/dist/cli/i18n/resources.d.ts +4 -0
- package/dist/cli/i18n/zh-CN.d.ts +2 -0
- package/dist/cli/i18n/zh-CN.js +2 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/tool.d.ts +7 -1
- package/dist/cli/tool.js +36 -1
- package/dist/core/graph/import-cycles.d.ts +10 -0
- package/dist/core/graph/import-cycles.js +103 -0
- package/dist/mcp/local/local-backend.d.ts +2 -0
- package/dist/mcp/local/local-backend.js +34 -0
- package/dist/mcp/tools.js +24 -0
- package/hooks/antigravity/gitnexus-antigravity-hook.cjs +21 -7
- package/hooks/claude/gitnexus-hook.cjs +22 -9
- package/hooks/claude/hook-db-lock-probe.cjs +123 -2
- package/package.json +1 -1
package/dist/cli/help-i18n.js
CHANGED
|
@@ -27,6 +27,7 @@ const COMMAND_DESCRIPTION_KEYS = {
|
|
|
27
27
|
impact: 'help.command.impact.description',
|
|
28
28
|
cypher: 'help.command.cypher.description',
|
|
29
29
|
'detect-changes': 'help.command.detectChanges.description',
|
|
30
|
+
check: 'help.command.check.description',
|
|
30
31
|
'eval-server': 'help.command.evalServer.description',
|
|
31
32
|
group: 'help.command.group.description',
|
|
32
33
|
'group create': 'help.command.group.create.description',
|
|
@@ -117,6 +118,10 @@ const OPTION_DESCRIPTION_KEYS = {
|
|
|
117
118
|
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
|
|
118
119
|
'detect-changes|-r, --repo <name>': 'help.option.repo.target',
|
|
119
120
|
'detect-changes|--branch <name>': 'help.option.branch',
|
|
121
|
+
'check|--cycles': 'help.option.check.cycles',
|
|
122
|
+
'check|--json': 'help.option.json',
|
|
123
|
+
'check|-r, --repo <name>': 'help.option.repo.target',
|
|
124
|
+
'check|--branch <name>': 'help.option.branch',
|
|
120
125
|
'eval-server|-p, --port <port>': 'help.option.port',
|
|
121
126
|
'eval-server|--host <host>': 'help.option.evalServer.host',
|
|
122
127
|
'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -126,6 +126,7 @@ export declare const en: {
|
|
|
126
126
|
readonly 'help.command.impact.description': "Blast radius analysis: what breaks if you change a symbol";
|
|
127
127
|
readonly 'help.command.cypher.description': "Execute raw Cypher query against the knowledge graph";
|
|
128
128
|
readonly 'help.command.detectChanges.description': "Map git diff hunks to indexed symbols and affected execution flows";
|
|
129
|
+
readonly 'help.command.check.description': "Run structural checks against the indexed graph";
|
|
129
130
|
readonly 'help.command.evalServer.description': "Start lightweight HTTP server for fast tool calls during evaluation";
|
|
130
131
|
readonly 'help.command.group.description': "Manage repository groups for cross-index impact analysis";
|
|
131
132
|
readonly 'help.command.group.create.description': "Create a new group with template group.yaml";
|
|
@@ -200,6 +201,7 @@ export declare const en: {
|
|
|
200
201
|
readonly 'help.option.impact.summaryOnly': "Return counts and risk only, omit symbol list";
|
|
201
202
|
readonly 'help.option.detectChanges.scope': "What to analyze: unstaged, staged, all, or compare";
|
|
202
203
|
readonly 'help.option.detectChanges.baseRef': "Branch/commit for compare scope (e.g. main)";
|
|
204
|
+
readonly 'help.option.check.cycles': "Detect circular imports and fail when any are found";
|
|
203
205
|
readonly 'help.option.evalServer.host': "Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)";
|
|
204
206
|
readonly 'help.option.evalServer.idleTimeout': "Auto-shutdown after N seconds idle (0 = disabled)";
|
|
205
207
|
readonly 'help.option.group.create.force': "Overwrite existing group";
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -126,6 +126,7 @@ export const en = {
|
|
|
126
126
|
'help.command.impact.description': 'Blast radius analysis: what breaks if you change a symbol',
|
|
127
127
|
'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph',
|
|
128
128
|
'help.command.detectChanges.description': 'Map git diff hunks to indexed symbols and affected execution flows',
|
|
129
|
+
'help.command.check.description': 'Run structural checks against the indexed graph',
|
|
129
130
|
'help.command.evalServer.description': 'Start lightweight HTTP server for fast tool calls during evaluation',
|
|
130
131
|
'help.command.group.description': 'Manage repository groups for cross-index impact analysis',
|
|
131
132
|
'help.command.group.create.description': 'Create a new group with template group.yaml',
|
|
@@ -200,6 +201,7 @@ export const en = {
|
|
|
200
201
|
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
|
|
201
202
|
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
|
|
202
203
|
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
|
|
204
|
+
'help.option.check.cycles': 'Detect circular imports and fail when any are found',
|
|
203
205
|
'help.option.evalServer.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
|
|
204
206
|
'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)',
|
|
205
207
|
'help.option.group.create.force': 'Overwrite existing group',
|
|
@@ -127,6 +127,7 @@ export declare const cliResources: {
|
|
|
127
127
|
readonly 'help.command.impact.description': "Blast radius analysis: what breaks if you change a symbol";
|
|
128
128
|
readonly 'help.command.cypher.description': "Execute raw Cypher query against the knowledge graph";
|
|
129
129
|
readonly 'help.command.detectChanges.description': "Map git diff hunks to indexed symbols and affected execution flows";
|
|
130
|
+
readonly 'help.command.check.description': "Run structural checks against the indexed graph";
|
|
130
131
|
readonly 'help.command.evalServer.description': "Start lightweight HTTP server for fast tool calls during evaluation";
|
|
131
132
|
readonly 'help.command.group.description': "Manage repository groups for cross-index impact analysis";
|
|
132
133
|
readonly 'help.command.group.create.description': "Create a new group with template group.yaml";
|
|
@@ -201,6 +202,7 @@ export declare const cliResources: {
|
|
|
201
202
|
readonly 'help.option.impact.summaryOnly': "Return counts and risk only, omit symbol list";
|
|
202
203
|
readonly 'help.option.detectChanges.scope': "What to analyze: unstaged, staged, all, or compare";
|
|
203
204
|
readonly 'help.option.detectChanges.baseRef': "Branch/commit for compare scope (e.g. main)";
|
|
205
|
+
readonly 'help.option.check.cycles': "Detect circular imports and fail when any are found";
|
|
204
206
|
readonly 'help.option.evalServer.host': "Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)";
|
|
205
207
|
readonly 'help.option.evalServer.idleTimeout': "Auto-shutdown after N seconds idle (0 = disabled)";
|
|
206
208
|
readonly 'help.option.group.create.force': "Overwrite existing group";
|
|
@@ -351,6 +353,7 @@ export declare const cliResources: {
|
|
|
351
353
|
'help.command.impact.description': string;
|
|
352
354
|
'help.command.cypher.description': string;
|
|
353
355
|
'help.command.detectChanges.description': string;
|
|
356
|
+
'help.command.check.description': string;
|
|
354
357
|
'help.command.evalServer.description': string;
|
|
355
358
|
'help.command.group.description': string;
|
|
356
359
|
'help.command.group.create.description': string;
|
|
@@ -425,6 +428,7 @@ export declare const cliResources: {
|
|
|
425
428
|
'help.option.impact.summaryOnly': string;
|
|
426
429
|
'help.option.detectChanges.scope': string;
|
|
427
430
|
'help.option.detectChanges.baseRef': string;
|
|
431
|
+
'help.option.check.cycles': string;
|
|
428
432
|
'help.option.evalServer.host': string;
|
|
429
433
|
'help.option.evalServer.idleTimeout': string;
|
|
430
434
|
'help.option.group.create.force': string;
|
package/dist/cli/i18n/zh-CN.d.ts
CHANGED
|
@@ -126,6 +126,7 @@ export declare const zhCN: {
|
|
|
126
126
|
'help.command.impact.description': string;
|
|
127
127
|
'help.command.cypher.description': string;
|
|
128
128
|
'help.command.detectChanges.description': string;
|
|
129
|
+
'help.command.check.description': string;
|
|
129
130
|
'help.command.evalServer.description': string;
|
|
130
131
|
'help.command.group.description': string;
|
|
131
132
|
'help.command.group.create.description': string;
|
|
@@ -200,6 +201,7 @@ export declare const zhCN: {
|
|
|
200
201
|
'help.option.impact.summaryOnly': string;
|
|
201
202
|
'help.option.detectChanges.scope': string;
|
|
202
203
|
'help.option.detectChanges.baseRef': string;
|
|
204
|
+
'help.option.check.cycles': string;
|
|
203
205
|
'help.option.evalServer.host': string;
|
|
204
206
|
'help.option.evalServer.idleTimeout': string;
|
|
205
207
|
'help.option.group.create.force': string;
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -126,6 +126,7 @@ export const zhCN = {
|
|
|
126
126
|
'help.command.impact.description': '影响面分析:修改符号会影响什么',
|
|
127
127
|
'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询',
|
|
128
128
|
'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程',
|
|
129
|
+
'help.command.check.description': '对已索引图谱运行结构检查',
|
|
129
130
|
'help.command.evalServer.description': '启动轻量 HTTP 服务器,用于评测期间的快速工具调用',
|
|
130
131
|
'help.command.group.description': '管理仓库组,用于跨索引影响分析',
|
|
131
132
|
'help.command.group.create.description': '使用模板 group.yaml 创建新仓库组',
|
|
@@ -200,6 +201,7 @@ export const zhCN = {
|
|
|
200
201
|
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
|
|
201
202
|
'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
|
|
202
203
|
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
|
|
204
|
+
'help.option.check.cycles': '检测循环导入,并在发现循环时失败',
|
|
203
205
|
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)',
|
|
204
206
|
'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)',
|
|
205
207
|
'help.option.group.create.force': '覆盖现有仓库组',
|
package/dist/cli/index.js
CHANGED
|
@@ -186,6 +186,14 @@ program
|
|
|
186
186
|
.option('-r, --repo <name>', 'Target repository')
|
|
187
187
|
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
|
188
188
|
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
|
189
|
+
program
|
|
190
|
+
.command('check')
|
|
191
|
+
.description('Run structural checks against the indexed graph')
|
|
192
|
+
.option('--cycles', 'Detect circular imports and fail when any are found')
|
|
193
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
194
|
+
.option('-r, --repo <name>', 'Target repository')
|
|
195
|
+
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
|
196
|
+
.action(createLbugLazyAction(() => import('./tool.js'), 'checkCommand'));
|
|
189
197
|
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
|
190
198
|
program
|
|
191
199
|
.command('eval-server')
|
package/dist/cli/tool.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Direct CLI Tool Commands
|
|
3
3
|
*
|
|
4
|
-
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
|
|
4
|
+
* Exposes GitNexus tools (query, context, impact, cypher, check) as direct CLI commands.
|
|
5
5
|
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
@@ -52,3 +52,9 @@ export declare function detectChangesCommand(options?: {
|
|
|
52
52
|
repo?: string;
|
|
53
53
|
branch?: string;
|
|
54
54
|
}): Promise<void>;
|
|
55
|
+
export declare function checkCommand(options?: {
|
|
56
|
+
cycles?: boolean;
|
|
57
|
+
json?: boolean;
|
|
58
|
+
repo?: string;
|
|
59
|
+
branch?: string;
|
|
60
|
+
}): Promise<void>;
|
package/dist/cli/tool.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Direct CLI Tool Commands
|
|
3
3
|
*
|
|
4
|
-
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
|
|
4
|
+
* Exposes GitNexus tools (query, context, impact, cypher, check) as direct CLI commands.
|
|
5
5
|
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
@@ -170,3 +170,38 @@ export async function detectChangesCommand(options) {
|
|
|
170
170
|
});
|
|
171
171
|
output(formatDetectChangesResult(result));
|
|
172
172
|
}
|
|
173
|
+
export async function checkCommand(options) {
|
|
174
|
+
if (!options?.cycles) {
|
|
175
|
+
process.stderr.write('Usage: gitnexus check --cycles [--json]\n');
|
|
176
|
+
process.exitCode = 1;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const backend = await getBackend();
|
|
181
|
+
const result = await backend.callTool('check', {
|
|
182
|
+
cycles: true,
|
|
183
|
+
repo: options.repo,
|
|
184
|
+
branch: options.branch,
|
|
185
|
+
});
|
|
186
|
+
if (result?.error) {
|
|
187
|
+
output(result);
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (options.json) {
|
|
192
|
+
output(result);
|
|
193
|
+
}
|
|
194
|
+
else if (result.cycleCount === 0) {
|
|
195
|
+
output('No circular imports found.');
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
output(result.cycles.map((cycle) => cycle.files.join(' -> ')).join('\n'));
|
|
199
|
+
}
|
|
200
|
+
if (result.cycleCount > 0)
|
|
201
|
+
process.exitCode = 1;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
output({ error: error instanceof Error ? error.message : String(error) });
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface ImportEdge {
|
|
2
|
+
source: string;
|
|
3
|
+
target: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Return one deterministic concrete cycle for every cyclic strongly connected
|
|
7
|
+
* component in the file import graph.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findImportCycles(edges: ImportEdge[]): string[][];
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
function findCyclePath(component, adjacency) {
|
|
2
|
+
const allowed = new Set(component);
|
|
3
|
+
const start = component[0];
|
|
4
|
+
const parents = new Map([[start, null]]);
|
|
5
|
+
const queue = [start];
|
|
6
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
7
|
+
const node = queue[index];
|
|
8
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
9
|
+
if (!allowed.has(next))
|
|
10
|
+
continue;
|
|
11
|
+
if (next === start) {
|
|
12
|
+
const path = [];
|
|
13
|
+
let cursor = node;
|
|
14
|
+
while (cursor !== null) {
|
|
15
|
+
path.push(cursor);
|
|
16
|
+
cursor = parents.get(cursor) ?? null;
|
|
17
|
+
}
|
|
18
|
+
path.reverse();
|
|
19
|
+
return [...path, start];
|
|
20
|
+
}
|
|
21
|
+
if (parents.has(next))
|
|
22
|
+
continue;
|
|
23
|
+
parents.set(next, node);
|
|
24
|
+
queue.push(next);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error('Invariant violation: no cycle found through SCC root.');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Return one deterministic concrete cycle for every cyclic strongly connected
|
|
31
|
+
* component in the file import graph.
|
|
32
|
+
*/
|
|
33
|
+
export function findImportCycles(edges) {
|
|
34
|
+
const adjacency = new Map();
|
|
35
|
+
for (const { source, target } of edges) {
|
|
36
|
+
if (!source || !target)
|
|
37
|
+
continue;
|
|
38
|
+
const targets = adjacency.get(source) ?? new Set();
|
|
39
|
+
targets.add(target);
|
|
40
|
+
adjacency.set(source, targets);
|
|
41
|
+
if (!adjacency.has(target))
|
|
42
|
+
adjacency.set(target, new Set());
|
|
43
|
+
}
|
|
44
|
+
const sortedAdjacency = new Map([...adjacency].map(([node, targets]) => [node, [...targets].sort()]));
|
|
45
|
+
const reverseAdjacency = new Map();
|
|
46
|
+
for (const node of sortedAdjacency.keys())
|
|
47
|
+
reverseAdjacency.set(node, []);
|
|
48
|
+
for (const [source, targets] of sortedAdjacency) {
|
|
49
|
+
for (const target of targets)
|
|
50
|
+
reverseAdjacency.get(target).push(source);
|
|
51
|
+
}
|
|
52
|
+
for (const sources of reverseAdjacency.values())
|
|
53
|
+
sources.sort();
|
|
54
|
+
const visited = new Set();
|
|
55
|
+
const finishOrder = [];
|
|
56
|
+
const components = [];
|
|
57
|
+
for (const start of [...sortedAdjacency.keys()].sort()) {
|
|
58
|
+
if (visited.has(start))
|
|
59
|
+
continue;
|
|
60
|
+
visited.add(start);
|
|
61
|
+
const stack = [{ node: start, nextIndex: 0 }];
|
|
62
|
+
while (stack.length > 0) {
|
|
63
|
+
const frame = stack[stack.length - 1];
|
|
64
|
+
const neighbors = sortedAdjacency.get(frame.node) ?? [];
|
|
65
|
+
if (frame.nextIndex < neighbors.length) {
|
|
66
|
+
const next = neighbors[frame.nextIndex++];
|
|
67
|
+
if (!visited.has(next)) {
|
|
68
|
+
visited.add(next);
|
|
69
|
+
stack.push({ node: next, nextIndex: 0 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
finishOrder.push(frame.node);
|
|
74
|
+
stack.pop();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
visited.clear();
|
|
79
|
+
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
|
80
|
+
const start = finishOrder[index];
|
|
81
|
+
if (visited.has(start))
|
|
82
|
+
continue;
|
|
83
|
+
const component = [];
|
|
84
|
+
const stack = [start];
|
|
85
|
+
visited.add(start);
|
|
86
|
+
while (stack.length > 0) {
|
|
87
|
+
const node = stack.pop();
|
|
88
|
+
component.push(node);
|
|
89
|
+
for (const next of reverseAdjacency.get(node) ?? []) {
|
|
90
|
+
if (visited.has(next))
|
|
91
|
+
continue;
|
|
92
|
+
visited.add(next);
|
|
93
|
+
stack.push(next);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
component.sort();
|
|
97
|
+
components.push(component);
|
|
98
|
+
}
|
|
99
|
+
return components
|
|
100
|
+
.filter((component) => component.length > 1 || (sortedAdjacency.get(component[0]) ?? []).includes(component[0]))
|
|
101
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
102
|
+
.map((component) => findCyclePath(component, sortedAdjacency));
|
|
103
|
+
}
|
|
@@ -337,6 +337,8 @@ export declare class LocalBackend {
|
|
|
337
337
|
*/
|
|
338
338
|
private maybeWarnSiblingDrift;
|
|
339
339
|
callTool(method: string, params: any): Promise<any>;
|
|
340
|
+
/** Check repository graph invariants that are suitable for CI gating. */
|
|
341
|
+
private check;
|
|
340
342
|
/**
|
|
341
343
|
* Query tool — process-grouped search.
|
|
342
344
|
*
|
|
@@ -28,6 +28,7 @@ import { PhaseTimer } from '../../core/search/phase-timer.js';
|
|
|
28
28
|
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
|
|
29
29
|
import { logger } from '../../core/logger.js';
|
|
30
30
|
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
|
|
31
|
+
import { findImportCycles } from '../../core/graph/import-cycles.js';
|
|
31
32
|
// AI context generation is CLI-only (gitnexus analyze)
|
|
32
33
|
// import { generateAIContextFiles } from '../../cli/ai-context.js';
|
|
33
34
|
/**
|
|
@@ -1037,6 +1038,8 @@ export class LocalBackend {
|
|
|
1037
1038
|
return this.impact(repo, params);
|
|
1038
1039
|
case 'detect_changes':
|
|
1039
1040
|
return this.detectChanges(repo, params);
|
|
1041
|
+
case 'check':
|
|
1042
|
+
return this.check(repo, params);
|
|
1040
1043
|
case 'rename':
|
|
1041
1044
|
return this.rename(repo, params);
|
|
1042
1045
|
// Legacy aliases for backwards compatibility
|
|
@@ -1059,6 +1062,37 @@ export class LocalBackend {
|
|
|
1059
1062
|
}
|
|
1060
1063
|
}
|
|
1061
1064
|
// ─── Tool Implementations ────────────────────────────────────────
|
|
1065
|
+
/** Check repository graph invariants that are suitable for CI gating. */
|
|
1066
|
+
async check(repo, params) {
|
|
1067
|
+
if (params?.cycles === false) {
|
|
1068
|
+
return { error: 'No checks selected. Set "cycles" to true.' };
|
|
1069
|
+
}
|
|
1070
|
+
await this.ensureInitialized(repo);
|
|
1071
|
+
const rowLimit = 100_001;
|
|
1072
|
+
const rows = await executeParameterized(repo.lbugPath, `MATCH (source:File)-[r:CodeRelation]->(target:File)
|
|
1073
|
+
WHERE r.type = 'IMPORTS'
|
|
1074
|
+
AND (r.reason IS NULL OR (
|
|
1075
|
+
r.reason <> 'swift-scope: implicit module visibility'
|
|
1076
|
+
AND r.reason <> 'markdown-link'
|
|
1077
|
+
))
|
|
1078
|
+
RETURN source.filePath AS source, target.filePath AS target
|
|
1079
|
+
LIMIT ${rowLimit}`, {});
|
|
1080
|
+
if (rows.length === rowLimit) {
|
|
1081
|
+
return {
|
|
1082
|
+
error: `Import graph exceeds the ${rowLimit - 1} edge safety limit.`,
|
|
1083
|
+
truncated: true,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const cycles = findImportCycles(rows.map((row) => ({
|
|
1087
|
+
source: String(row.source ?? row[0] ?? ''),
|
|
1088
|
+
target: String(row.target ?? row[1] ?? ''),
|
|
1089
|
+
})));
|
|
1090
|
+
return {
|
|
1091
|
+
status: cycles.length === 0 ? 'clean' : 'cycles_found',
|
|
1092
|
+
cycleCount: cycles.length,
|
|
1093
|
+
cycles: cycles.map((files) => ({ files })),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1062
1096
|
/**
|
|
1063
1097
|
* Query tool — process-grouped search.
|
|
1064
1098
|
*
|
package/dist/mcp/tools.js
CHANGED
|
@@ -277,6 +277,29 @@ Returns: changed symbols, affected processes, and a risk summary.`,
|
|
|
277
277
|
required: [],
|
|
278
278
|
},
|
|
279
279
|
},
|
|
280
|
+
{
|
|
281
|
+
name: 'check',
|
|
282
|
+
description: `Run read-only structural checks against the indexed graph.
|
|
283
|
+
|
|
284
|
+
Currently detects directed cycles between File nodes connected by IMPORTS edges.
|
|
285
|
+
Returns deterministic cycle paths and a cycle count suitable for CI automation.`,
|
|
286
|
+
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
287
|
+
inputSchema: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
cycles: {
|
|
291
|
+
type: 'boolean',
|
|
292
|
+
description: 'Detect circular file imports (default: true).',
|
|
293
|
+
default: true,
|
|
294
|
+
},
|
|
295
|
+
repo: {
|
|
296
|
+
type: 'string',
|
|
297
|
+
description: 'Repository name or path. Omit if only one repo is indexed.',
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
required: [],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
280
303
|
{
|
|
281
304
|
name: 'rename',
|
|
282
305
|
description: `Multi-file coordinated rename using the knowledge graph + text search.
|
|
@@ -572,6 +595,7 @@ const BRANCH_SCOPED_TOOLS = new Set([
|
|
|
572
595
|
'cypher',
|
|
573
596
|
'context',
|
|
574
597
|
'detect_changes',
|
|
598
|
+
'check',
|
|
575
599
|
'impact',
|
|
576
600
|
'rename',
|
|
577
601
|
'route_map',
|
|
@@ -284,18 +284,32 @@ function buildAfterToolContext(input) {
|
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
function runAugment(gitNexusDir, cwd, pattern) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
287
|
+
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
|
288
|
+
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
|
289
|
+
// as the augment, or concurrent sessions fan out unbounded probe
|
|
290
|
+
// subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
|
|
291
|
+
// buildAfterToolContext before this — moving the acquire any earlier would
|
|
292
|
+
// churn slot files on tool calls that never probe.
|
|
293
|
+
const release = acquireHookSlot(gitNexusDir);
|
|
294
|
+
if (!release) {
|
|
295
|
+
// Normal skip path: all per-repo hook slots are held by concurrent
|
|
296
|
+
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
|
297
|
+
// the reason only under GITNEXUS_DEBUG.
|
|
290
298
|
if (isDebugEnabled()) {
|
|
291
|
-
process.stderr.write('[GitNexus] augment skipped:
|
|
299
|
+
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
|
292
300
|
}
|
|
293
301
|
return '';
|
|
294
302
|
}
|
|
295
|
-
const release = acquireHookSlot(gitNexusDir);
|
|
296
|
-
if (!release) return '';
|
|
297
|
-
const cliPath = resolveCliPath();
|
|
298
303
|
try {
|
|
304
|
+
if (hasGitNexusServerOwner(gitNexusDir)) {
|
|
305
|
+
// Normal skip path: the MCP server owns the DB. Stay silent for strict
|
|
306
|
+
// hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
|
|
307
|
+
if (isDebugEnabled()) {
|
|
308
|
+
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
|
309
|
+
}
|
|
310
|
+
return '';
|
|
311
|
+
}
|
|
312
|
+
const cliPath = resolveCliPath();
|
|
299
313
|
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
|
300
314
|
if (!child.error && child.status === 0) {
|
|
301
315
|
return extractAugmentContext(child.stderr || '');
|
|
@@ -259,22 +259,35 @@ function handlePreToolUse(input) {
|
|
|
259
259
|
|
|
260
260
|
const pattern = extractPattern(toolName, toolInput);
|
|
261
261
|
if (!pattern || pattern.length < 3) return;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
|
|
263
|
+
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
|
264
|
+
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
|
265
|
+
// as the augment, or concurrent sessions fan out unbounded probe
|
|
266
|
+
// subprocesses. Keep the acquire right after the cheap guards above —
|
|
267
|
+
// moving it earlier would churn slot files on tool calls that never probe.
|
|
268
|
+
const release = acquireHookSlot(gitNexusDir);
|
|
269
|
+
if (!release) {
|
|
270
|
+
// Normal skip path: all per-repo hook slots are held by concurrent
|
|
271
|
+
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
|
272
|
+
// the reason only when diagnostics are explicitly requested.
|
|
266
273
|
if (isDebugEnabled()) {
|
|
267
|
-
process.stderr.write('[GitNexus] augment skipped:
|
|
274
|
+
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
|
268
275
|
}
|
|
269
276
|
return;
|
|
270
277
|
}
|
|
271
278
|
|
|
272
|
-
const release = acquireHookSlot(gitNexusDir);
|
|
273
|
-
if (!release) return;
|
|
274
|
-
|
|
275
|
-
const cliPath = resolveCliPath();
|
|
276
279
|
let result = '';
|
|
277
280
|
try {
|
|
281
|
+
if (hasGitNexusServerOwner(gitNexusDir)) {
|
|
282
|
+
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
|
283
|
+
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
|
284
|
+
// surface the reason only when diagnostics are explicitly requested.
|
|
285
|
+
if (isDebugEnabled()) {
|
|
286
|
+
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const cliPath = resolveCliPath();
|
|
278
291
|
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
|
279
292
|
if (!child.error && child.status === 0) {
|
|
280
293
|
result = extractAugmentContext(child.stderr || '');
|
|
@@ -11,6 +11,22 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
|
13
13
|
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
|
14
|
+
*
|
|
15
|
+
* Unix subprocess containment contract (#2163):
|
|
16
|
+
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
|
|
17
|
+
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
|
|
18
|
+
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
|
|
19
|
+
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
|
|
20
|
+
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
|
|
21
|
+
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
|
|
22
|
+
* wrapper off deterministically; any other value is adopted only when it
|
|
23
|
+
* exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
|
|
24
|
+
* THROUGH to the built-in candidate list (first self-test pass wins), so
|
|
25
|
+
* no malformed value of any shape can silently disable orphan containment.
|
|
26
|
+
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
|
|
27
|
+
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
|
|
28
|
+
* A probe before that first query is therefore always false — a known,
|
|
29
|
+
* pre-existing race, not a bug in this probe.
|
|
14
30
|
*/
|
|
15
31
|
|
|
16
32
|
const fs = require('fs');
|
|
@@ -46,6 +62,80 @@ function resolveHookBinary(tool) {
|
|
|
46
62
|
return tool;
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
// Sentinel:
|
|
66
|
+
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
|
|
67
|
+
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
|
|
68
|
+
// null = no usable wrapper (disabled, none found, or self-test failed)
|
|
69
|
+
let unixGuardTimeoutCache;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
|
|
73
|
+
* (#2163). Dead code on Windows (the win32 dispatch returns earlier).
|
|
74
|
+
*
|
|
75
|
+
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
|
|
76
|
+
* wrapper off; any other value is only a CANDIDATE — an existing file path
|
|
77
|
+
* is tried first, but it must pass the `-k` self-test to be adopted. On any
|
|
78
|
+
* failure (non-existent path, directory, non-executable file, wrapper
|
|
79
|
+
* without `-k` support, …) resolution falls through to the built-in
|
|
80
|
+
* candidates below, tried in order, first self-test pass wins. This is
|
|
81
|
+
* strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
|
|
82
|
+
* GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
|
|
83
|
+
* value of ANY shape can silently disable orphan containment.
|
|
84
|
+
*
|
|
85
|
+
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
|
|
86
|
+
* first reached, and the result is memoized. A candidate is adopted only
|
|
87
|
+
* when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
|
|
88
|
+
* not support the coreutils `-k` flag — busybox <1.34, toybox, broken
|
|
89
|
+
* symlinks — which would otherwise exit with a usage error without ever
|
|
90
|
+
* running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
|
|
91
|
+
* into fail-open (#1492 regression). Only when EVERY candidate fails does
|
|
92
|
+
* the probe fall back to the unwrapped status quo (memoized null).
|
|
93
|
+
* busybox ≥1.34 passes the test and is fully usable (capability, not
|
|
94
|
+
* identity, decides).
|
|
95
|
+
*/
|
|
96
|
+
function passesGuardSelfTest(guard) {
|
|
97
|
+
try {
|
|
98
|
+
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
|
|
99
|
+
encoding: 'utf-8',
|
|
100
|
+
timeout: 3000,
|
|
101
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
102
|
+
windowsHide: true,
|
|
103
|
+
});
|
|
104
|
+
return !selfTest.error && selfTest.status === 0;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveUnixGuardTimeout() {
|
|
111
|
+
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
|
|
112
|
+
unixGuardTimeoutCache = null;
|
|
113
|
+
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
|
114
|
+
const trimmed = fromEnv ? String(fromEnv).trim() : '';
|
|
115
|
+
if (trimmed === 'disabled') return unixGuardTimeoutCache;
|
|
116
|
+
const candidates = [];
|
|
117
|
+
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
|
|
118
|
+
for (const builtin of [
|
|
119
|
+
'/usr/bin/timeout',
|
|
120
|
+
'/bin/timeout',
|
|
121
|
+
'/opt/homebrew/bin/gtimeout',
|
|
122
|
+
'/usr/local/bin/gtimeout',
|
|
123
|
+
]) {
|
|
124
|
+
try {
|
|
125
|
+
if (fs.existsSync(builtin)) candidates.push(builtin);
|
|
126
|
+
} catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
if (passesGuardSelfTest(candidate)) {
|
|
132
|
+
unixGuardTimeoutCache = candidate;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return unixGuardTimeoutCache;
|
|
137
|
+
}
|
|
138
|
+
|
|
49
139
|
function resolveWindowsPowerShellPath() {
|
|
50
140
|
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
|
51
141
|
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
|
@@ -188,20 +278,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
|
|
188
278
|
}
|
|
189
279
|
|
|
190
280
|
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
|
281
|
+
const guard = resolveUnixGuardTimeout();
|
|
191
282
|
const lsofPath = resolveHookBinary('lsof');
|
|
192
|
-
|
|
283
|
+
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
|
|
284
|
+
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
|
|
285
|
+
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
|
|
286
|
+
// fail-closed contract are untouched. The wrapper only matters once this
|
|
287
|
+
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
|
|
288
|
+
const [lsofCmd, lsofArgs] = guard
|
|
289
|
+
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
|
|
290
|
+
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
|
|
291
|
+
const lsof = spawnSync(lsofCmd, lsofArgs, {
|
|
193
292
|
encoding: 'utf-8',
|
|
194
293
|
timeout: 1000,
|
|
195
294
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
196
295
|
windowsHide: true,
|
|
197
296
|
});
|
|
198
297
|
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
|
298
|
+
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
|
|
299
|
+
// result shapes, verified against coreutils 9.1:
|
|
300
|
+
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
|
|
301
|
+
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
|
|
302
|
+
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
|
|
303
|
+
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
|
|
304
|
+
// suspend) and the guard expires while it sleeps. By construction, a
|
|
305
|
+
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
|
|
306
|
+
// is a budget/kill outcome.
|
|
307
|
+
// - 124: budget expired and the child exited after the plain SIGTERM.
|
|
308
|
+
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
|
|
309
|
+
// or a child SIGKILLed externally (e.g. the OOM killer).
|
|
310
|
+
if (guard && lsof.status === null && lsof.signal) return true;
|
|
311
|
+
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
|
|
199
312
|
|
|
200
313
|
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
|
|
201
314
|
const psPath = resolveHookBinary('ps');
|
|
202
315
|
for (const pid of pids) {
|
|
203
316
|
if (Number(pid) === myPid) continue;
|
|
204
|
-
const
|
|
317
|
+
const [psCmd, psArgs] = guard
|
|
318
|
+
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
|
|
319
|
+
: [psPath, ['-p', pid, '-o', 'command=']];
|
|
320
|
+
const ps = spawnSync(psCmd, psArgs, {
|
|
205
321
|
encoding: 'utf-8',
|
|
206
322
|
timeout: 500,
|
|
207
323
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
@@ -211,6 +327,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
|
|
211
327
|
if (ps.error.code === 'ETIMEDOUT') return true;
|
|
212
328
|
continue;
|
|
213
329
|
}
|
|
330
|
+
// Same guard-mediated-death mapping as the lsof call above (signal-death
|
|
331
|
+
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
|
|
332
|
+
// for exit-code-propagating wrappers / external SIGKILL).
|
|
333
|
+
if (guard && ps.status === null && ps.signal) return true;
|
|
334
|
+
if (guard && (ps.status === 124 || ps.status === 137)) return true;
|
|
214
335
|
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
|
215
336
|
}
|
|
216
337
|
return false;
|
package/package.json
CHANGED