gitnexus 1.6.3-rc.22 → 1.6.3-rc.23

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.
@@ -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 blast radius across repository boundaries, use MCP tools \`group_impact\`, \`group_sync\`, \`group_query\`, \`group_contracts\`, \`group_status\`, and \`group_list\`. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
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')
@@ -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;
@@ -0,0 +1,40 @@
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
+ function toPosix(p) {
10
+ return p.replace(/\\/g, '/');
11
+ }
12
+ export function normalizeServicePrefix(service) {
13
+ if (service === undefined || service === null)
14
+ return undefined;
15
+ const s = toPosix(String(service)).trim().replace(/\/+$/, '');
16
+ return s.length > 0 ? s : undefined;
17
+ }
18
+ export function fileMatchesServicePrefix(filePath, prefix) {
19
+ if (!prefix)
20
+ return true;
21
+ if (!filePath)
22
+ return false;
23
+ const normalized = toPosix(filePath);
24
+ return normalized === prefix || normalized.startsWith(`${prefix}/`);
25
+ }
26
+ /**
27
+ * True if `repoPath` is at or beneath `subgroup` (member-path prefix in
28
+ * `group.yaml`). Empty / missing `subgroup` matches every repo.
29
+ *
30
+ * @param exact When set, requires an exact equality match (no descendant repos).
31
+ */
32
+ export function repoInSubgroup(repoPath, subgroup, exact) {
33
+ if (!subgroup?.trim())
34
+ return true;
35
+ const s = toPosix(subgroup).replace(/\/+$/, '');
36
+ const r = toPosix(repoPath);
37
+ if (exact)
38
+ return r === s;
39
+ return r === s || r.startsWith(`${s}/`);
40
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Map MCP/CLI `@groupName` or `@groupName/memberPath` to a concrete member path in group.yaml.
3
+ */
4
+ export declare function resolveAtGroupMemberRepoPath(groupName: string, explicitMemberPath: string | undefined): Promise<{
5
+ ok: true;
6
+ repoPath: string;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ }>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Map MCP/CLI `@groupName` or `@groupName/memberPath` to a concrete member path in group.yaml.
3
+ */
4
+ import { loadGroupConfig } from './config-parser.js';
5
+ import { getDefaultGitnexusDir, getGroupDir } from './storage.js';
6
+ export async function resolveAtGroupMemberRepoPath(groupName, explicitMemberPath) {
7
+ const trimmed = groupName.trim();
8
+ if (!trimmed)
9
+ return { ok: false, error: 'Group name is empty.' };
10
+ try {
11
+ const groupDir = getGroupDir(getDefaultGitnexusDir(), trimmed);
12
+ const config = await loadGroupConfig(groupDir);
13
+ const keys = Object.keys(config.repos).sort((a, b) => a.localeCompare(b));
14
+ if (keys.length === 0) {
15
+ return { ok: false, error: `Group "${trimmed}" has no repos in group.yaml.` };
16
+ }
17
+ if (explicitMemberPath !== undefined && explicitMemberPath !== '') {
18
+ if (!(explicitMemberPath in config.repos)) {
19
+ return {
20
+ ok: false,
21
+ error: `Unknown member path "${explicitMemberPath}" in group "${trimmed}". Known paths: ${keys.join(', ')}`,
22
+ };
23
+ }
24
+ return { ok: true, repoPath: explicitMemberPath };
25
+ }
26
+ return { ok: true, repoPath: keys[0] };
27
+ }
28
+ catch (e) {
29
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
30
+ }
31
+ }