gitnexus 1.6.3-rc.22 → 1.6.3-rc.24
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/ai-context.js +1 -1
- package/dist/cli/group.js +73 -0
- package/dist/core/embeddings/ast-utils.js +22 -22
- package/dist/core/group/cross-impact.d.ts +41 -0
- package/dist/core/group/cross-impact.js +454 -0
- package/dist/core/group/group-path-utils.d.ts +17 -0
- package/dist/core/group/group-path-utils.js +40 -0
- package/dist/core/group/resolve-at-member.d.ts +10 -0
- package/dist/core/group/resolve-at-member.js +31 -0
- package/dist/core/group/service.d.ts +9 -0
- package/dist/core/group/service.js +219 -20
- package/dist/core/group/types.d.ts +30 -0
- package/dist/core/lbug/lbug-adapter.d.ts +12 -0
- package/dist/core/lbug/lbug-adapter.js +30 -0
- package/dist/core/run-analyze.js +7 -12
- package/dist/core/search/bm25-index.d.ts +6 -0
- package/dist/core/search/bm25-index.js +54 -2
- package/dist/mcp/local/local-backend.d.ts +18 -3
- package/dist/mcp/local/local-backend.js +141 -15
- package/dist/mcp/resources.d.ts +31 -0
- package/dist/mcp/resources.js +100 -17
- package/dist/mcp/tools.d.ts +4 -1
- package/dist/mcp/tools.js +75 -54
- package/package.json +1 -1
package/dist/cli/ai-context.js
CHANGED
|
@@ -89,7 +89,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
|
|
|
89
89
|
${groupNames && groupNames.length > 0
|
|
90
90
|
? `## Cross-Repo Groups
|
|
91
91
|
|
|
92
|
-
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For
|
|
92
|
+
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For cross-repo analysis, use MCP tools \`impact\`, \`query\`, and \`context\` with \`repo\` set to \`@<groupName>\` or \`@<groupName>/<memberPath>\` (paths match keys in that group’s \`group.yaml\`). Use \`group_list\` / \`group_sync\` for membership and sync. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
|
|
93
93
|
|
|
94
94
|
`
|
|
95
95
|
: ''}## CLI
|
package/dist/cli/group.js
CHANGED
|
@@ -149,6 +149,79 @@ export function registerGroupCommands(program) {
|
|
|
149
149
|
console.log(`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`);
|
|
150
150
|
}
|
|
151
151
|
});
|
|
152
|
+
group
|
|
153
|
+
.command('impact <name>')
|
|
154
|
+
.description('Cross-repo impact for a symbol in one member repo of a group')
|
|
155
|
+
.requiredOption('--target <symbol>', 'Symbol or file name to analyze')
|
|
156
|
+
.requiredOption('--repo <groupPath>', 'Member path from group.yaml (e.g. app/backend), not the indexed repo name')
|
|
157
|
+
.option('--direction <dir>', 'upstream or downstream', 'upstream')
|
|
158
|
+
.option('--service <path>', 'Optional monorepo service directory prefix (path filter)')
|
|
159
|
+
.option('--subgroup <path>', 'Optional prefix limiting which group repos participate in cross fan-out')
|
|
160
|
+
.option('--max-depth <n>', 'Max graph traversal depth')
|
|
161
|
+
.option('--cross-depth <n>', 'Cross-repository hop depth')
|
|
162
|
+
.option('--min-confidence <n>', 'Minimum relation confidence (0–1)')
|
|
163
|
+
.option('--include-tests', 'Include test files in traversal', false)
|
|
164
|
+
.option('--timeout-ms <n>', 'Phase-1 local impact wall time in milliseconds')
|
|
165
|
+
.option('--json', 'JSON output')
|
|
166
|
+
.action(async (name, opts) => {
|
|
167
|
+
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
|
168
|
+
const backend = new LocalBackend();
|
|
169
|
+
try {
|
|
170
|
+
await backend.init();
|
|
171
|
+
const payload = {
|
|
172
|
+
name,
|
|
173
|
+
repo: opts.repo,
|
|
174
|
+
target: opts.target,
|
|
175
|
+
direction: opts.direction || 'upstream',
|
|
176
|
+
};
|
|
177
|
+
if (opts.service)
|
|
178
|
+
payload.service = opts.service;
|
|
179
|
+
if (opts.subgroup)
|
|
180
|
+
payload.subgroup = opts.subgroup;
|
|
181
|
+
if (opts.maxDepth !== undefined && opts.maxDepth !== '') {
|
|
182
|
+
const n = parseInt(String(opts.maxDepth), 10);
|
|
183
|
+
if (!Number.isNaN(n))
|
|
184
|
+
payload.maxDepth = n;
|
|
185
|
+
}
|
|
186
|
+
if (opts.crossDepth !== undefined && opts.crossDepth !== '') {
|
|
187
|
+
const n = parseInt(String(opts.crossDepth), 10);
|
|
188
|
+
if (!Number.isNaN(n))
|
|
189
|
+
payload.crossDepth = n;
|
|
190
|
+
}
|
|
191
|
+
if (opts.minConfidence !== undefined && opts.minConfidence !== '') {
|
|
192
|
+
const n = parseFloat(String(opts.minConfidence));
|
|
193
|
+
if (!Number.isNaN(n))
|
|
194
|
+
payload.minConfidence = n;
|
|
195
|
+
}
|
|
196
|
+
if (opts.timeoutMs !== undefined && opts.timeoutMs !== '') {
|
|
197
|
+
const n = parseInt(String(opts.timeoutMs), 10);
|
|
198
|
+
if (!Number.isNaN(n))
|
|
199
|
+
payload.timeoutMs = n;
|
|
200
|
+
}
|
|
201
|
+
if (opts.includeTests)
|
|
202
|
+
payload.includeTests = true;
|
|
203
|
+
const raw = await backend.getGroupService().groupImpact(payload);
|
|
204
|
+
if (raw && typeof raw === 'object' && 'error' in raw) {
|
|
205
|
+
console.error(String(raw.error));
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (opts.json) {
|
|
210
|
+
console.log(JSON.stringify(raw, null, 2));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const summary = raw?.summary;
|
|
214
|
+
const risk = raw?.risk;
|
|
215
|
+
console.log(`Group impact for "${name}" (${String(opts.repo)}): risk=${risk ?? '?'}`);
|
|
216
|
+
if (summary) {
|
|
217
|
+
console.log(` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
await backend.dispose().catch(() => { });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
152
225
|
group
|
|
153
226
|
.command('query <name> <query>')
|
|
154
227
|
.description('Search execution flows across all repos in a group')
|
|
@@ -55,17 +55,17 @@ const FUNCTION_LIKE_TYPES = new Set([
|
|
|
55
55
|
* numbers don't apply.
|
|
56
56
|
*/
|
|
57
57
|
export const findFunctionNode = (root) => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
// Iterative DFS — avoids stack overflow on deeply nested ASTs.
|
|
59
|
+
const stack = [root];
|
|
60
|
+
while (stack.length > 0) {
|
|
61
|
+
const node = stack.pop();
|
|
62
|
+
if (FUNCTION_LIKE_TYPES.has(node.type))
|
|
63
|
+
return node;
|
|
64
|
+
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
|
65
|
+
const child = node.namedChild(i);
|
|
66
|
+
if (child)
|
|
67
|
+
stack.push(child);
|
|
68
|
+
}
|
|
69
69
|
}
|
|
70
70
|
return null;
|
|
71
71
|
};
|
|
@@ -89,17 +89,17 @@ export const findDeclarationNode = (root) => {
|
|
|
89
89
|
'object_declaration', // Kotlin: object
|
|
90
90
|
'impl_item', // Rust: impl
|
|
91
91
|
]);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
92
|
+
// Iterative DFS — avoids stack overflow on deeply nested ASTs.
|
|
93
|
+
const stack = [root];
|
|
94
|
+
while (stack.length > 0) {
|
|
95
|
+
const node = stack.pop();
|
|
96
|
+
if (CLASS_LIKE_TYPES.has(node.type))
|
|
97
|
+
return node;
|
|
98
|
+
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
|
99
|
+
const child = node.namedChild(i);
|
|
100
|
+
if (child)
|
|
101
|
+
stack.push(child);
|
|
102
|
+
}
|
|
103
103
|
}
|
|
104
104
|
return null;
|
|
105
105
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-repo impact (Phase 1 local walk + Phase 2 bridge fan-out).
|
|
3
|
+
* All bridge Cypher for this feature lives in this module.
|
|
4
|
+
*/
|
|
5
|
+
import type { GroupImpactResult } from './types.js';
|
|
6
|
+
import type { GroupToolPort } from './service.js';
|
|
7
|
+
/** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */
|
|
8
|
+
export declare const MAX_SUPPORTED_CROSS_DEPTH = 1;
|
|
9
|
+
/** Default wall-clock budget for the Phase 1 `impact` leg when callers omit `timeoutMs`. */
|
|
10
|
+
export declare const DEFAULT_LOCAL_IMPACT_TIMEOUT_MS = 30000;
|
|
11
|
+
export interface RunGroupImpactDeps {
|
|
12
|
+
port: GroupToolPort;
|
|
13
|
+
gitnexusDir: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function validateGroupImpactParams(params: Record<string, unknown>): {
|
|
16
|
+
ok: true;
|
|
17
|
+
name: string;
|
|
18
|
+
repoPath: string;
|
|
19
|
+
target: string;
|
|
20
|
+
direction: 'upstream' | 'downstream';
|
|
21
|
+
maxDepth: number;
|
|
22
|
+
crossDepth: number;
|
|
23
|
+
crossDepthWarning?: string;
|
|
24
|
+
relationTypes?: string[];
|
|
25
|
+
includeTests: boolean;
|
|
26
|
+
minConfidence: number;
|
|
27
|
+
service?: string;
|
|
28
|
+
subgroup?: string;
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function collectImpactSymbolUids(local: unknown, servicePrefix: string | undefined): {
|
|
35
|
+
uids: string[];
|
|
36
|
+
targetFilePath?: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function runGroupImpact(deps: RunGroupImpactDeps, params: Record<string, unknown>): Promise<GroupImpactResult | {
|
|
39
|
+
error: string;
|
|
40
|
+
}>;
|
|
41
|
+
export { normalizeServicePrefix, fileMatchesServicePrefix } from './group-path-utils.js';
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-repo impact (Phase 1 local walk + Phase 2 bridge fan-out).
|
|
3
|
+
* All bridge Cypher for this feature lives in this module.
|
|
4
|
+
*/
|
|
5
|
+
import fsp from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { loadGroupConfig } from './config-parser.js';
|
|
8
|
+
import { fileMatchesServicePrefix, normalizeServicePrefix, repoInSubgroup, } from './group-path-utils.js';
|
|
9
|
+
import { getGroupDir } from './storage.js';
|
|
10
|
+
import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js';
|
|
11
|
+
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
|
12
|
+
/** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */
|
|
13
|
+
export const MAX_SUPPORTED_CROSS_DEPTH = 1;
|
|
14
|
+
/** Default wall-clock budget for the Phase 1 `impact` leg when callers omit `timeoutMs`. */
|
|
15
|
+
export const DEFAULT_LOCAL_IMPACT_TIMEOUT_MS = 30_000;
|
|
16
|
+
const CY_NEIGHBORS_UPSTREAM = `
|
|
17
|
+
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
|
|
18
|
+
WHERE provider.repo = $localRepo
|
|
19
|
+
AND provider.symbolUid IN $uids
|
|
20
|
+
AND provider.role = 'provider'
|
|
21
|
+
RETURN consumer.repo AS neighborRepo,
|
|
22
|
+
consumer.symbolUid AS neighborUid,
|
|
23
|
+
consumer.filePath AS neighborFilePath,
|
|
24
|
+
l.matchType AS matchType,
|
|
25
|
+
l.confidence AS confidence,
|
|
26
|
+
l.contractId AS contractId,
|
|
27
|
+
consumer.type AS contractType
|
|
28
|
+
`;
|
|
29
|
+
const CY_NEIGHBORS_DOWNSTREAM = `
|
|
30
|
+
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
|
|
31
|
+
WHERE consumer.repo = $localRepo
|
|
32
|
+
AND consumer.symbolUid IN $uids
|
|
33
|
+
AND consumer.role = 'consumer'
|
|
34
|
+
RETURN provider.repo AS neighborRepo,
|
|
35
|
+
provider.symbolUid AS neighborUid,
|
|
36
|
+
provider.filePath AS neighborFilePath,
|
|
37
|
+
l.matchType AS matchType,
|
|
38
|
+
l.confidence AS confidence,
|
|
39
|
+
l.contractId AS contractId,
|
|
40
|
+
provider.type AS contractType
|
|
41
|
+
`;
|
|
42
|
+
function parseDirection(raw) {
|
|
43
|
+
if (raw === 'upstream' || raw === 'downstream')
|
|
44
|
+
return raw;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function clampCrossDepth(raw) {
|
|
48
|
+
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 1;
|
|
49
|
+
const d = n < 1 ? 1 : n;
|
|
50
|
+
if (d > MAX_SUPPORTED_CROSS_DEPTH) {
|
|
51
|
+
return {
|
|
52
|
+
depth: MAX_SUPPORTED_CROSS_DEPTH,
|
|
53
|
+
warning: `crossDepth was ${d}; multi-hop cross-boundary traversal beyond ${MAX_SUPPORTED_CROSS_DEPTH} is not implemented yet. Using crossDepth ${MAX_SUPPORTED_CROSS_DEPTH}.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { depth: d };
|
|
57
|
+
}
|
|
58
|
+
export function validateGroupImpactParams(params) {
|
|
59
|
+
const name = String(params.name ?? '').trim();
|
|
60
|
+
const repoPath = String(params.repo ?? '').trim();
|
|
61
|
+
const target = String(params.target ?? '').trim();
|
|
62
|
+
if (!name)
|
|
63
|
+
return { ok: false, error: 'name is required' };
|
|
64
|
+
if (!repoPath)
|
|
65
|
+
return { ok: false, error: 'repo is required (group repo path, e.g. app/backend)' };
|
|
66
|
+
if (!target)
|
|
67
|
+
return { ok: false, error: 'target is required' };
|
|
68
|
+
if (params.service !== undefined &&
|
|
69
|
+
params.service !== null &&
|
|
70
|
+
String(params.service).trim() === '') {
|
|
71
|
+
return { ok: false, error: 'service must not be an empty string' };
|
|
72
|
+
}
|
|
73
|
+
const direction = parseDirection(params.direction);
|
|
74
|
+
if (!direction)
|
|
75
|
+
return { ok: false, error: 'direction must be upstream or downstream' };
|
|
76
|
+
let maxDepth = typeof params.maxDepth === 'number' && params.maxDepth > 0 ? params.maxDepth : 3;
|
|
77
|
+
if (maxDepth > 32)
|
|
78
|
+
maxDepth = 32;
|
|
79
|
+
const { depth: crossDepth, warning: crossDepthWarning } = clampCrossDepth(params.crossDepth);
|
|
80
|
+
const relationTypes = Array.isArray(params.relationTypes)
|
|
81
|
+
? params.relationTypes.filter((t) => typeof t === 'string')
|
|
82
|
+
: undefined;
|
|
83
|
+
const includeTests = Boolean(params.includeTests);
|
|
84
|
+
let minConfidence = typeof params.minConfidence === 'number' ? params.minConfidence : 0;
|
|
85
|
+
if (minConfidence < 0)
|
|
86
|
+
minConfidence = 0;
|
|
87
|
+
if (minConfidence > 1)
|
|
88
|
+
minConfidence = 1;
|
|
89
|
+
const service = normalizeServicePrefix(params.service);
|
|
90
|
+
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
|
|
91
|
+
let timeoutMs = typeof params.timeoutMs === 'number' && params.timeoutMs > 0
|
|
92
|
+
? params.timeoutMs
|
|
93
|
+
: typeof params.timeout === 'number' && params.timeout > 0
|
|
94
|
+
? params.timeout
|
|
95
|
+
: DEFAULT_LOCAL_IMPACT_TIMEOUT_MS;
|
|
96
|
+
if (timeoutMs > 3_600_000)
|
|
97
|
+
timeoutMs = 3_600_000;
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
name,
|
|
101
|
+
repoPath,
|
|
102
|
+
target,
|
|
103
|
+
direction,
|
|
104
|
+
maxDepth,
|
|
105
|
+
crossDepth,
|
|
106
|
+
crossDepthWarning,
|
|
107
|
+
relationTypes,
|
|
108
|
+
includeTests,
|
|
109
|
+
minConfidence,
|
|
110
|
+
service,
|
|
111
|
+
subgroup,
|
|
112
|
+
timeoutMs,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function resolveGroupRepo(port, config, repoPath) {
|
|
116
|
+
const registryName = config.repos[repoPath];
|
|
117
|
+
if (!registryName) {
|
|
118
|
+
return { error: `Unknown repo path "${repoPath}" in this group.` };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return await port.resolveRepo(registryName);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function safeLocalImpact(port, repo, impactParams, timeoutMs) {
|
|
128
|
+
let timer;
|
|
129
|
+
const impactP = port.impact(repo, impactParams).catch((err) => ({
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
}));
|
|
132
|
+
const timeoutP = new Promise((resolve) => {
|
|
133
|
+
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
134
|
+
});
|
|
135
|
+
const won = await Promise.race([
|
|
136
|
+
impactP.then((v) => ({ tag: 'impact', v })),
|
|
137
|
+
timeoutP.then(() => ({ tag: 'timeout' })),
|
|
138
|
+
]);
|
|
139
|
+
if (timer !== undefined)
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
if (won.tag === 'timeout') {
|
|
142
|
+
return {
|
|
143
|
+
value: { error: 'Local impact timed out', partial: true },
|
|
144
|
+
timedOut: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { value: won.v, timedOut: false };
|
|
148
|
+
}
|
|
149
|
+
export function collectImpactSymbolUids(local, servicePrefix) {
|
|
150
|
+
const uids = new Set();
|
|
151
|
+
let targetFilePath;
|
|
152
|
+
const obj = local;
|
|
153
|
+
if (!obj || typeof obj !== 'object')
|
|
154
|
+
return { uids: [], targetFilePath };
|
|
155
|
+
const target = obj.target;
|
|
156
|
+
if (target?.id) {
|
|
157
|
+
targetFilePath = typeof target.filePath === 'string' ? target.filePath : undefined;
|
|
158
|
+
if (fileMatchesServicePrefix(targetFilePath, servicePrefix)) {
|
|
159
|
+
uids.add(String(target.id));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const byDepth = obj.byDepth;
|
|
163
|
+
if (byDepth && typeof byDepth === 'object') {
|
|
164
|
+
for (const items of Object.values(byDepth)) {
|
|
165
|
+
if (!Array.isArray(items))
|
|
166
|
+
continue;
|
|
167
|
+
for (const it of items) {
|
|
168
|
+
const row = it;
|
|
169
|
+
if (row?.id && fileMatchesServicePrefix(row.filePath, servicePrefix)) {
|
|
170
|
+
uids.add(String(row.id));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { uids: [...uids], targetFilePath };
|
|
176
|
+
}
|
|
177
|
+
function extractProcessNames(impact) {
|
|
178
|
+
const o = impact;
|
|
179
|
+
if (!o?.affected_processes)
|
|
180
|
+
return [];
|
|
181
|
+
return o.affected_processes.map((p) => String(p.name ?? '')).filter(Boolean);
|
|
182
|
+
}
|
|
183
|
+
function mergeRisk(localRisk, cross) {
|
|
184
|
+
const highConf = cross.some((c) => c.contract.confidence >= 0.85);
|
|
185
|
+
if (localRisk === 'CRITICAL')
|
|
186
|
+
return 'CRITICAL';
|
|
187
|
+
if (cross.length >= 3)
|
|
188
|
+
return 'CRITICAL';
|
|
189
|
+
if (highConf)
|
|
190
|
+
return 'HIGH';
|
|
191
|
+
if (cross.length > 0 && (localRisk === 'LOW' || localRisk === 'UNKNOWN'))
|
|
192
|
+
return 'MEDIUM';
|
|
193
|
+
return localRisk;
|
|
194
|
+
}
|
|
195
|
+
async function ensureBridgeReady(groupDir) {
|
|
196
|
+
const meta = await readBridgeMeta(groupDir);
|
|
197
|
+
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
|
198
|
+
return {
|
|
199
|
+
error: `Bridge schema version mismatch (meta.json has ${meta.version}, expected ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync for this group.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const dbPath = path.join(groupDir, 'bridge.lbug');
|
|
203
|
+
try {
|
|
204
|
+
await fsp.access(dbPath);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return {
|
|
208
|
+
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const handle = await openBridgeDbReadOnly(groupDir);
|
|
212
|
+
if (!handle) {
|
|
213
|
+
return {
|
|
214
|
+
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { handle };
|
|
218
|
+
}
|
|
219
|
+
function rowToNeighbor(r) {
|
|
220
|
+
const neighborRepo = String(r.neighborRepo ?? r[0] ?? '');
|
|
221
|
+
const neighborUid = String(r.neighborUid ?? r[1] ?? '');
|
|
222
|
+
if (!neighborRepo || !neighborUid)
|
|
223
|
+
return null;
|
|
224
|
+
return {
|
|
225
|
+
neighborRepo,
|
|
226
|
+
neighborUid,
|
|
227
|
+
neighborFilePath: r.neighborFilePath !== undefined ? String(r.neighborFilePath) : String(r[2] ?? ''),
|
|
228
|
+
matchType: String(r.matchType ?? r[3] ?? 'exact'),
|
|
229
|
+
confidence: Number(r.confidence ?? r[4] ?? 0),
|
|
230
|
+
contractId: String(r.contractId ?? r[5] ?? ''),
|
|
231
|
+
contractType: String(r.contractType ?? r[6] ?? 'custom'),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export async function runGroupImpact(deps, params) {
|
|
235
|
+
const parsed = validateGroupImpactParams(params);
|
|
236
|
+
if (parsed.ok === false)
|
|
237
|
+
return { error: parsed.error };
|
|
238
|
+
const { name, repoPath, target, direction, maxDepth, crossDepth: _crossDepth, crossDepthWarning, relationTypes, includeTests, minConfidence, service: servicePrefix, subgroup, timeoutMs, } = parsed;
|
|
239
|
+
const groupDir = getGroupDir(deps.gitnexusDir, name);
|
|
240
|
+
let config;
|
|
241
|
+
try {
|
|
242
|
+
config = await loadGroupConfig(groupDir);
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
246
|
+
}
|
|
247
|
+
const resolved = await resolveGroupRepo(deps.port, config, repoPath);
|
|
248
|
+
if ('error' in resolved)
|
|
249
|
+
return { error: resolved.error };
|
|
250
|
+
const impactParams = {
|
|
251
|
+
target,
|
|
252
|
+
direction,
|
|
253
|
+
maxDepth,
|
|
254
|
+
relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined,
|
|
255
|
+
includeTests,
|
|
256
|
+
minConfidence,
|
|
257
|
+
};
|
|
258
|
+
// Single shared deadline for Phase 1 (local walk) + Phase 2 (bridge fan-out).
|
|
259
|
+
// Phase 1 still gets the full budget; Phase 2 only uses whatever wall-clock
|
|
260
|
+
// time is left, so total work cannot exceed `timeoutMs`.
|
|
261
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
262
|
+
const { value: local, timedOut: localTimedOut } = await safeLocalImpact(deps.port, resolved, impactParams, timeoutMs);
|
|
263
|
+
if (localTimedOut) {
|
|
264
|
+
const base = local;
|
|
265
|
+
return {
|
|
266
|
+
local,
|
|
267
|
+
group: name,
|
|
268
|
+
cross: [],
|
|
269
|
+
outOfScope: [],
|
|
270
|
+
truncated: true,
|
|
271
|
+
truncatedRepos: [],
|
|
272
|
+
summary: {
|
|
273
|
+
direct: 0,
|
|
274
|
+
processes_affected: 0,
|
|
275
|
+
modules_affected: 0,
|
|
276
|
+
cross_repo_hits: 0,
|
|
277
|
+
},
|
|
278
|
+
risk: 'UNKNOWN',
|
|
279
|
+
timeoutMs,
|
|
280
|
+
truncationReason: 'timeout',
|
|
281
|
+
crossDepthWarning,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const localObj = local;
|
|
285
|
+
if (localObj?.error && typeof localObj.error === 'string') {
|
|
286
|
+
const empty = {
|
|
287
|
+
local,
|
|
288
|
+
group: name,
|
|
289
|
+
cross: [],
|
|
290
|
+
outOfScope: [],
|
|
291
|
+
truncated: false,
|
|
292
|
+
truncatedRepos: [],
|
|
293
|
+
summary: {
|
|
294
|
+
direct: 0,
|
|
295
|
+
processes_affected: 0,
|
|
296
|
+
modules_affected: 0,
|
|
297
|
+
cross_repo_hits: 0,
|
|
298
|
+
},
|
|
299
|
+
risk: 'UNKNOWN',
|
|
300
|
+
timeoutMs,
|
|
301
|
+
crossDepthWarning,
|
|
302
|
+
};
|
|
303
|
+
return empty;
|
|
304
|
+
}
|
|
305
|
+
if (servicePrefix) {
|
|
306
|
+
const tf = localObj?.target?.filePath;
|
|
307
|
+
if (!fileMatchesServicePrefix(tf, servicePrefix)) {
|
|
308
|
+
return {
|
|
309
|
+
local: {},
|
|
310
|
+
group: name,
|
|
311
|
+
cross: [],
|
|
312
|
+
outOfScope: [],
|
|
313
|
+
truncated: false,
|
|
314
|
+
truncatedRepos: [],
|
|
315
|
+
summary: {
|
|
316
|
+
direct: 0,
|
|
317
|
+
processes_affected: 0,
|
|
318
|
+
modules_affected: 0,
|
|
319
|
+
cross_repo_hits: 0,
|
|
320
|
+
},
|
|
321
|
+
risk: 'LOW',
|
|
322
|
+
timeoutMs,
|
|
323
|
+
crossDepthWarning,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const { uids } = collectImpactSymbolUids(local, servicePrefix);
|
|
328
|
+
if (uids.length === 0) {
|
|
329
|
+
const s = local?.summary || {};
|
|
330
|
+
return {
|
|
331
|
+
local,
|
|
332
|
+
group: name,
|
|
333
|
+
cross: [],
|
|
334
|
+
outOfScope: [],
|
|
335
|
+
truncated: Boolean(local.partial),
|
|
336
|
+
truncatedRepos: [],
|
|
337
|
+
summary: {
|
|
338
|
+
direct: s.direct ?? 0,
|
|
339
|
+
processes_affected: s.processes_affected ?? 0,
|
|
340
|
+
modules_affected: s.modules_affected ?? 0,
|
|
341
|
+
cross_repo_hits: 0,
|
|
342
|
+
},
|
|
343
|
+
risk: String(local.risk ?? 'LOW'),
|
|
344
|
+
timeoutMs,
|
|
345
|
+
truncationReason: local.partial ? 'partial' : undefined,
|
|
346
|
+
crossDepthWarning,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const bridgePrep = await ensureBridgeReady(groupDir);
|
|
350
|
+
if ('error' in bridgePrep)
|
|
351
|
+
return { error: bridgePrep.error };
|
|
352
|
+
const handle = bridgePrep.handle;
|
|
353
|
+
const cross = [];
|
|
354
|
+
const outOfScope = [];
|
|
355
|
+
const truncatedRepos = [];
|
|
356
|
+
try {
|
|
357
|
+
const cypher = direction === 'upstream' ? CY_NEIGHBORS_UPSTREAM : CY_NEIGHBORS_DOWNSTREAM;
|
|
358
|
+
const rows = await queryBridge(handle, cypher, {
|
|
359
|
+
localRepo: repoPath,
|
|
360
|
+
uids,
|
|
361
|
+
});
|
|
362
|
+
const neighbors = [];
|
|
363
|
+
for (const raw of rows) {
|
|
364
|
+
const n = rowToNeighbor(raw);
|
|
365
|
+
if (n)
|
|
366
|
+
neighbors.push(n);
|
|
367
|
+
}
|
|
368
|
+
neighbors.sort((a, b) => b.confidence - a.confidence);
|
|
369
|
+
const seen = new Set();
|
|
370
|
+
for (const n of neighbors) {
|
|
371
|
+
if (servicePrefix && !fileMatchesServicePrefix(n.neighborFilePath, servicePrefix)) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (!repoInSubgroup(n.neighborRepo, subgroup)) {
|
|
375
|
+
// CrossLink convention: consumer -> provider
|
|
376
|
+
outOfScope.push({
|
|
377
|
+
from: direction === 'upstream' ? n.neighborRepo : repoPath,
|
|
378
|
+
to: direction === 'upstream' ? repoPath : n.neighborRepo,
|
|
379
|
+
contractId: n.contractId,
|
|
380
|
+
confidence: n.confidence,
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const key = `${n.neighborRepo}\0${n.neighborUid}\0${n.contractId}`;
|
|
385
|
+
if (seen.has(key))
|
|
386
|
+
continue;
|
|
387
|
+
seen.add(key);
|
|
388
|
+
if (Date.now() > deadline) {
|
|
389
|
+
truncatedRepos.push(n.neighborRepo);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const regName = config.repos[n.neighborRepo];
|
|
393
|
+
if (!regName)
|
|
394
|
+
continue;
|
|
395
|
+
let neighborHandle;
|
|
396
|
+
try {
|
|
397
|
+
neighborHandle = await deps.port.resolveRepo(regName);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
truncatedRepos.push(n.neighborRepo);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const fan = await deps.port.impactByUid(neighborHandle.id, n.neighborUid, direction, {
|
|
404
|
+
maxDepth,
|
|
405
|
+
relationTypes: relationTypes ?? [],
|
|
406
|
+
minConfidence,
|
|
407
|
+
includeTests,
|
|
408
|
+
});
|
|
409
|
+
if (fan == null) {
|
|
410
|
+
truncatedRepos.push(n.neighborRepo);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
cross.push({
|
|
414
|
+
repo: regName,
|
|
415
|
+
repo_path: n.neighborRepo,
|
|
416
|
+
contract: {
|
|
417
|
+
id: n.contractId,
|
|
418
|
+
type: n.contractType,
|
|
419
|
+
match_type: n.matchType || 'exact',
|
|
420
|
+
confidence: n.confidence,
|
|
421
|
+
},
|
|
422
|
+
by_depth: (fan.byDepth ?? {}),
|
|
423
|
+
affected_processes: extractProcessNames(fan),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
await closeBridgeDb(handle);
|
|
429
|
+
}
|
|
430
|
+
const localSum = local?.summary || {};
|
|
431
|
+
const localRisk = String(local.risk ?? 'LOW');
|
|
432
|
+
const localPartial = Boolean(local.partial);
|
|
433
|
+
const truncated = truncatedRepos.length > 0 || localPartial;
|
|
434
|
+
const result = {
|
|
435
|
+
local,
|
|
436
|
+
group: name,
|
|
437
|
+
cross,
|
|
438
|
+
outOfScope,
|
|
439
|
+
truncated,
|
|
440
|
+
truncatedRepos: [...new Set(truncatedRepos)],
|
|
441
|
+
summary: {
|
|
442
|
+
direct: localSum.direct ?? 0,
|
|
443
|
+
processes_affected: localSum.processes_affected ?? 0,
|
|
444
|
+
modules_affected: localSum.modules_affected ?? 0,
|
|
445
|
+
cross_repo_hits: cross.length,
|
|
446
|
+
},
|
|
447
|
+
risk: mergeRisk(localRisk, cross),
|
|
448
|
+
timeoutMs,
|
|
449
|
+
truncationReason: truncated ? 'partial' : undefined,
|
|
450
|
+
crossDepthWarning,
|
|
451
|
+
};
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
export { normalizeServicePrefix, fileMatchesServicePrefix } from './group-path-utils.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared service-path normalization for group tools (`service` monorepo filter)
|
|
3
|
+
* and subgroup membership checks.
|
|
4
|
+
*
|
|
5
|
+
* Inputs may originate from tree-sitter, the OS file API, or user-supplied
|
|
6
|
+
* MCP arguments, so both `\` and `/` separators are accepted. Internally we
|
|
7
|
+
* normalize to POSIX-style `/` for case-sensitive segment comparisons.
|
|
8
|
+
*/
|
|
9
|
+
export declare function normalizeServicePrefix(service: unknown): string | undefined;
|
|
10
|
+
export declare function fileMatchesServicePrefix(filePath: string | undefined, prefix: string | undefined): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* True if `repoPath` is at or beneath `subgroup` (member-path prefix in
|
|
13
|
+
* `group.yaml`). Empty / missing `subgroup` matches every repo.
|
|
14
|
+
*
|
|
15
|
+
* @param exact When set, requires an exact equality match (no descendant repos).
|
|
16
|
+
*/
|
|
17
|
+
export declare function repoInSubgroup(repoPath: string, subgroup?: string, exact?: boolean): boolean;
|