feat(group): cross-index impact analysis via repository groups

Cross-repo blast radius analysis through Contract Registry:
- group_impact MCP tool and CLI command
- ManifestExtractor for explicit cross-repo links
- HTTP route extractor for auto-detected contracts
- closeLbug() resource cleanup in CLI sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ivkond 2026-04-04 23:28:29 +03:00
parent 5c4fca21c3
commit 613a0d9db2
14 changed files with 2025 additions and 41 deletions

File diff suppressed because it is too large Load diff

View file

@ -157,31 +157,143 @@ export function registerGroupCommands(program: Command): void {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const { syncGroup } = await import('../core/group/sync.js');
const { closeLbug } = await import('../core/lbug/pool-adapter.js');
try {
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
} finally {
await closeLbug().catch(() => {});
}
});
group
.command('impact <name>')
.description('Cross-index blast radius analysis')
.requiredOption('--target <symbol>', 'Symbol name to analyze')
.requiredOption('--repo <repo>', 'Repo group path (e.g. hr/hiring/backend)')
.option('--direction <dir>', 'upstream or downstream', 'upstream')
.option('--cross-depth <n>', 'Hops through boundaries (MVP: capped at 1)', '1')
.option('--max-depth <n>', 'Max depth within each repo', '3')
.option('--min-confidence <n>', 'Min confidence for cross-links', '0.5')
.option('--subgroup <path>', 'Limit fan-out scope')
.option('--timeout <ms>', 'Total wall time budget in ms', '30000')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
const { getGroupDir, getDefaultGitnexusDir, readContractRegistry } =
await import('../core/group/storage.js');
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const regFile = await readContractRegistry(groupDir);
if (!regFile) {
console.error(`No contracts.json found. Run: gitnexus group sync ${name}`);
process.exitCode = 1;
return;
}
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const repoGroupPath = opts.repo as string;
const targetSymbol = opts.target as string;
if (!repoGroupPath || !targetSymbol) {
console.error('Both --target and --repo are required.');
process.exitCode = 1;
return;
}
const direction = (opts.direction as string) ?? 'upstream';
const maxDepth = opts.maxDepth != null ? parseInt(String(opts.maxDepth), 10) : 3;
const minConfidence =
opts.minConfidence != null ? parseFloat(String(opts.minConfidence)) : 0.5;
const timeout = opts.timeout != null ? parseInt(String(opts.timeout), 10) : 30000;
const subgroup = opts.subgroup as string | undefined;
const requestedCrossDepth =
opts.crossDepth != null ? parseInt(String(opts.crossDepth), 10) : 1;
const crossDepth = Math.min(requestedCrossDepth, 1);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
const crossDepthWarning =
requestedCrossDepth > 1
? `Multi-hop cross-boundary traversal is not yet implemented. Using --cross-depth 1 (requested: ${requestedCrossDepth}).`
: undefined;
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
if (crossDepthWarning && !opts.json) {
console.log(`WARNING: ${crossDepthWarning}\n`);
}
if (!opts.json) {
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
`Analyzing impact of "${targetSymbol}" in ${repoGroupPath} (group: ${name})...\n`,
);
}
const backend = new LocalBackend();
try {
await backend.init();
const result = (await backend.getGroupService().groupImpact({
name,
target: targetSymbol,
repo: repoGroupPath,
direction,
crossDepth,
maxDepth,
minConfidence,
subgroup,
timeout,
})) as import('../core/group/types.js').GroupImpactResult & { crossDepthWarning?: string };
if (opts.json) {
const jsonOutput = crossDepthWarning ? { ...result, crossDepthWarning } : result;
console.log(JSON.stringify(jsonOutput, null, 2));
} else {
console.log(`Target: ${targetSymbol} (${repoGroupPath})`);
console.log(`Risk: ${result.risk}`);
console.log(`\nLocal impact: ${result.summary.direct} direct callers`);
if (result.cross.length > 0) {
console.log(`\nCross-repo impact (${result.cross.length} repos):`);
for (const cr of result.cross) {
console.log(
` ${cr.repo_path} (via ${cr.contract.id}, ${cr.contract.match_type}, conf=${cr.contract.confidence}):`,
);
for (const [depth, symbols] of Object.entries(cr.by_depth)) {
console.log(` d=${depth}: ${(symbols as unknown[]).length} symbols`);
}
}
}
if (result.outOfScope.length > 0) {
console.log(`\nOut of scope (${result.outOfScope.length} cross-links not followed):`);
for (const oos of result.outOfScope) {
console.log(` ${oos.from} -> ${oos.to} [${oos.contractId}]`);
}
}
if (result.truncated) {
console.log(
`\nWARNING: Timeout reached. Repos not analyzed: ${result.truncatedRepos.join(', ')}`,
);
}
}
} finally {
await backend.dispose().catch(() => {});
}
});
group

View file

@ -0,0 +1,225 @@
import type {
ContractRegistry,
CrossLink,
GroupImpactResult,
CrossRepoImpact,
OutOfScopeLink,
} from './types.js';
export interface GroupImpactOptions {
groupName: string;
target: string;
repoPath: string;
direction: 'upstream' | 'downstream';
registry: ContractRegistry;
localImpactFn: (target: string, direction: string) => Promise<unknown>;
crossImpactFn: (
targetGroupPath: string,
symbolUid: string,
direction: string,
) => Promise<unknown | null>;
maxDepth?: number;
minConfidence?: number;
subgroup?: string;
timeout?: number;
crossDepth?: number;
}
function collectPhase1Uids(local: Record<string, unknown>): Set<string> {
const uids = new Set<string>();
const target = local.target as { id?: string } | undefined;
if (target?.id) uids.add(String(target.id));
const byDepth = (local.byDepth || {}) as Record<string, { id?: string }[]>;
for (const arr of Object.values(byDepth)) {
for (const item of arr || []) {
if (item?.id) uids.add(String(item.id));
}
}
return uids;
}
function refKey(filePath: string, name: string): string {
return `${filePath}::${name}`;
}
function collectPhase1Refs(local: Record<string, unknown>): Set<string> {
const refs = new Set<string>();
const t = local.target as { filePath?: string; name?: string } | undefined;
if (t?.filePath?.length && t?.name?.length) refs.add(refKey(t.filePath, t.name));
const byDepth = (local.byDepth || {}) as Record<string, { filePath?: string; name?: string }[]>;
for (const arr of Object.values(byDepth)) {
for (const item of arr || []) {
if (item.filePath?.length && item.name?.length) refs.add(refKey(item.filePath, item.name));
}
}
return refs;
}
function linkMatchesRefs(
link: CrossLink,
refs: Set<string>,
direction: 'upstream' | 'downstream',
): boolean {
if (direction === 'upstream') {
const r = link.to.symbolRef;
if (!r.filePath?.length || !r.name?.length) return false;
return refs.has(refKey(r.filePath, r.name));
}
const r = link.from.symbolRef;
if (!r.filePath?.length || !r.name?.length) return false;
return refs.has(refKey(r.filePath, r.name));
}
function inSubgroup(repoPath: string, subgroup?: string): boolean {
if (!subgroup?.trim()) return true;
const s = subgroup.replace(/\/+$/, '');
return repoPath === s || repoPath.startsWith(`${s}/`);
}
function mergeRisk(
base: string,
crossHits: number,
maxCrossConf: number,
distinctCrossRepos: number,
): string {
const order = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
let idx = Math.max(0, order.indexOf(base));
if (crossHits > 0 && maxCrossConf >= 0.85) {
idx = Math.max(idx, order.indexOf('HIGH'));
} else if (crossHits > 0 && maxCrossConf > 0) {
idx = Math.max(idx, order.indexOf('MEDIUM'));
}
if (distinctCrossRepos >= 3) {
idx = Math.max(idx, order.indexOf('CRITICAL'));
}
return order[idx] ?? base;
}
export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImpactResult> {
const timeout = opts.timeout ?? 30000;
const minConfidence = opts.minConfidence ?? 0.5;
const crossDepth = Math.min(1, opts.crossDepth ?? 1);
const tStart = Date.now();
const wallDeadline = tStart + timeout;
const phase1Timeout = Math.min(5000, timeout);
const localResult = await Promise.race([
opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })),
new Promise<{ ok: false }>((resolve) =>
setTimeout(() => resolve({ ok: false }), phase1Timeout),
),
]);
let truncated = !localResult.ok;
const local = localResult.ok
? (localResult.v as Record<string, unknown>)
: ({
target: { id: '', name: opts.target, filePath: '' },
direction: opts.direction,
impactedCount: 0,
risk: 'LOW',
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {},
} as Record<string, unknown>);
const uids = collectPhase1Uids(local);
const phase1Refs = collectPhase1Refs(local);
const cross: CrossRepoImpact[] = [];
const outOfScope: OutOfScopeLink[] = [];
const truncatedRepos: string[] = [];
const links = [...opts.registry.crossLinks]
.filter((l) => l.confidence >= minConfidence)
.sort((a, b) => b.confidence - a.confidence);
const applicable: CrossLink[] = [];
for (const link of links) {
const uidMatch =
opts.direction === 'upstream'
? Boolean(link.to.symbolUid && uids.has(link.to.symbolUid))
: Boolean(link.from.symbolUid && uids.has(link.from.symbolUid));
const refMatch = !uidMatch && linkMatchesRefs(link, phase1Refs, opts.direction);
if (!uidMatch && !refMatch) continue;
applicable.push(link);
}
let maxCrossConf = 0;
const distinctRepos = new Set<string>();
for (const link of applicable) {
if (Date.now() > wallDeadline) {
truncated = true;
break;
}
const fanOutRepo = opts.direction === 'upstream' ? link.from.repo : link.to.repo;
const symbolUid = opts.direction === 'upstream' ? link.from.symbolUid : link.to.symbolUid;
if (!inSubgroup(fanOutRepo, opts.subgroup)) {
outOfScope.push({
from: link.from.repo,
to: link.to.repo,
contractId: link.contractId,
confidence: link.confidence,
});
continue;
}
if (crossDepth < 1) break;
const remote = await opts.crossImpactFn(fanOutRepo, symbolUid, opts.direction);
if (remote) {
maxCrossConf = Math.max(maxCrossConf, link.confidence);
distinctRepos.add(fanOutRepo);
const r = remote as Record<string, unknown>;
cross.push({
repo: fanOutRepo,
repo_path: fanOutRepo,
contract: {
id: link.contractId,
type: link.type,
match_type: link.matchType,
confidence: link.confidence,
},
by_depth: (r.byDepth || {}) as Record<string, unknown[]>,
affected_processes: (r.affected_processes || []) as string[],
});
}
if (Date.now() > wallDeadline) {
truncated = true;
truncatedRepos.push(fanOutRepo);
break;
}
}
const summaryLocal = (local.summary || {}) as {
direct?: number;
processes_affected?: number;
modules_affected?: number;
};
const baseRisk = String(local.risk || 'LOW');
const risk = mergeRisk(baseRisk, cross.length, maxCrossConf, distinctRepos.size);
return {
local,
group: opts.groupName,
cross,
outOfScope,
truncated,
truncatedRepos,
summary: {
direct: summaryLocal.direct ?? 0,
processes_affected: summaryLocal.processes_affected ?? 0,
modules_affected: summaryLocal.modules_affected ?? 0,
cross_repo_hits: cross.length,
},
risk,
};
}

View file

@ -0,0 +1,124 @@
import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js';
import type { CypherExecutor } from '../contract-extractor.js';
export interface ManifestExtractResult {
contracts: StoredContract[];
crossLinks: CrossLink[];
}
export class ManifestExtractor {
async extractFromManifest(
links: GroupManifestLink[],
dbExecutors?: Map<string, CypherExecutor>,
): Promise<ManifestExtractResult> {
const contracts: StoredContract[] = [];
const crossLinks: CrossLink[] = [];
for (const link of links) {
const contractId = this.buildContractId(link.type, link.contract);
const providerRepo = link.role === 'provider' ? link.from : link.to;
const consumerRepo = link.role === 'provider' ? link.to : link.from;
const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors);
const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors);
const providerRef = providerSymbol || { filePath: '', name: link.contract };
const consumerRef = consumerSymbol || { filePath: '', name: link.contract };
const providerUid = providerSymbol?.uid ?? '';
const consumerUid = consumerSymbol?.uid ?? '';
contracts.push({
contractId,
type: link.type,
role: 'provider',
symbolUid: providerUid,
symbolRef: providerRef,
symbolName: link.contract,
confidence: 1.0,
meta: { source: 'manifest' },
repo: providerRepo,
});
contracts.push({
contractId,
type: link.type,
role: 'consumer',
symbolUid: consumerUid,
symbolRef: consumerRef,
symbolName: link.contract,
confidence: 1.0,
meta: { source: 'manifest' },
repo: consumerRepo,
});
crossLinks.push({
from: { repo: consumerRepo, symbolUid: consumerUid, symbolRef: consumerRef },
to: { repo: providerRepo, symbolUid: providerUid, symbolRef: providerRef },
type: link.type,
contractId,
matchType: 'manifest',
confidence: 1.0,
});
}
return { contracts, crossLinks };
}
private async resolveSymbol(
repoPathKey: string,
link: GroupManifestLink,
dbExecutors?: Map<string, CypherExecutor>,
): Promise<{ filePath: string; name: string; uid: string } | null> {
const executor = dbExecutors?.get(repoPathKey);
if (!executor) return null;
try {
let rows: Record<string, unknown>[];
if (link.type === 'http') {
rows = await executor(
`MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
WHERE route.name CONTAINS $contract
RETURN handler.id AS uid, handler.name AS name, handler.filePath AS filePath
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'topic') {
rows = await executor(
`MATCH (n) WHERE n.name CONTAINS $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 1`,
{ contract: link.contract },
);
} else {
return null;
}
if (rows.length > 0) {
return {
filePath: rows[0].filePath as string,
name: rows[0].name as string,
uid: String(rows[0].uid ?? ''),
};
}
} catch {
/* fall through */
}
return null;
}
private buildContractId(type: ContractType, contract: string): string {
switch (type) {
case 'http': {
if (/^[A-Za-z]+::/.test(contract)) return `http::${contract}`;
return `http::*::${contract}`;
}
case 'grpc':
return `grpc::${contract}`;
case 'topic':
return `topic::${contract}`;
case 'lib':
return `lib::${contract}`;
case 'custom':
return `custom::${contract}`;
}
}
}

View file

@ -1,10 +1,11 @@
/**
* Group orchestration shared by MCP (LocalBackend) and CLI.
* Cross-repo group orchestration shared by MCP (LocalBackend) and CLI.
* DB access is injected via GroupToolPort so this module stays free of LocalBackend private API.
*/
import { checkStaleness } from '../git-staleness.js';
import { loadGroupConfig } from './config-parser.js';
import { runGroupImpact } from './cross-impact.js';
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
import { syncGroup } from './sync.js';
@ -122,6 +123,98 @@ export class GroupService {
return { contracts, crossLinks: registry.crossLinks };
}
async groupImpact(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
const targetSymbol = String(params.target ?? '').trim();
const repoGroupPath = String(params.repo ?? '').trim();
if (!name || !targetSymbol || !repoGroupPath) {
return { error: 'name, target, and repo are required' };
}
const direction = (params.direction as string) === 'downstream' ? 'downstream' : 'upstream';
const maxDepth =
typeof params.maxDepth === 'number' && Number.isFinite(params.maxDepth) ? params.maxDepth : 3;
const minConfidence =
typeof params.minConfidence === 'number' && Number.isFinite(params.minConfidence)
? params.minConfidence
: 0.5;
const timeout =
typeof params.timeout === 'number' && Number.isFinite(params.timeout)
? params.timeout
: 30000;
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const registry = await readContractRegistry(groupDir);
if (!registry) {
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
}
const requestedCrossDepth =
typeof params.crossDepth === 'number' && Number.isFinite(params.crossDepth)
? params.crossDepth
: 1;
const crossDepth = Math.min(requestedCrossDepth, 1);
const crossDepthWarning =
requestedCrossDepth > 1
? `Multi-hop cross-boundary traversal is not yet implemented. Using --cross-depth 1 (requested: ${requestedCrossDepth}).`
: undefined;
const defaultRelTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const resolveGroupRepo = async (groupPath: string): Promise<GroupRepoHandle> => {
const registryName = config.repos[groupPath];
if (!registryName) throw new Error(`Repo "${groupPath}" not found in group "${name}"`);
return this.port.resolveRepo(registryName);
};
const result = await runGroupImpact({
groupName: name,
target: targetSymbol,
repoPath: repoGroupPath,
direction,
registry,
localImpactFn: async (t: string, d: string) => {
const repoObj = await resolveGroupRepo(repoGroupPath);
return this.port.impact(repoObj, {
target: t,
direction: d as 'upstream' | 'downstream',
maxDepth,
relationTypes: defaultRelTypes,
minConfidence: 0,
includeTests: false,
});
},
crossImpactFn: async (targetGroupPath: string, uid: string, d: string) => {
const registryName = config.repos[targetGroupPath];
if (!registryName) return null;
try {
const repoObj = await this.port.resolveRepo(registryName);
return this.port.impactByUid(repoObj.id, uid, d, {
maxDepth,
relationTypes: defaultRelTypes,
minConfidence: 0,
includeTests: false,
});
} catch {
return null;
}
},
maxDepth,
minConfidence,
subgroup,
timeout,
crossDepth,
});
if (crossDepthWarning) {
(result as unknown as Record<string, unknown>).crossDepthWarning = crossDepthWarning;
}
return result;
}
async groupQuery(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
const queryText = String(params.query ?? '').trim();

View file

@ -7,6 +7,7 @@ import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink }
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
@ -65,10 +66,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
const repoSnapshots: Record<string, RepoSnapshot> = {};
let autoContracts: StoredContract[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
let manifestResult: Awaited<ReturnType<ManifestExtractor['extractFromManifest']>>;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
manifestResult = await new ManifestExtractor().extractFromManifest(config.links);
} else {
const entries = await readRegistry();
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
@ -151,6 +154,8 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
missingRepos.push(groupPath);
}
}
manifestResult = await new ManifestExtractor().extractFromManifest(config.links, dbExecutors);
} finally {
for (const id of [...new Set(openPoolIds)]) {
await closeLbug(id).catch(() => {});
@ -159,8 +164,8 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
const { matched, unmatched } = runExactMatch(autoContracts);
const crossLinks: CrossLink[] = matched;
const allContracts: StoredContract[] = autoContracts;
const crossLinks: CrossLink[] = [...manifestResult.crossLinks, ...matched];
const allContracts: StoredContract[] = [...manifestResult.contracts, ...autoContracts];
const registry: ContractRegistry = {
version: 1,

View file

@ -2447,6 +2447,8 @@ export class LocalBackend {
return this.groupSync(params);
case 'group_contracts':
return this.groupContracts(params);
case 'group_impact':
return this.groupImpact(params);
case 'group_query':
return this.groupQuery(params);
case 'group_status':
@ -2468,6 +2470,11 @@ export class LocalBackend {
return this.getGroupService().groupContracts(params);
}
private async groupImpact(params: Record<string, unknown>): Promise<unknown> {
await this.refreshRepos();
return this.getGroupService().groupImpact(params);
}
private async groupQuery(params: Record<string, unknown>): Promise<unknown> {
await this.refreshRepos();
return this.getGroupService().groupQuery(params);

View file

@ -381,7 +381,7 @@ Returns: single route object when one match, or { routes: [...], total: N } for
name: 'group_list',
description: `List all configured repository groups, or return details for one group (repos, manifest links).
WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`,
WHEN TO USE: Discover groups before group_sync or group_impact. Optional "name" returns a single group's config.`,
inputSchema: {
type: 'object',
properties: {
@ -424,6 +424,44 @@ WHEN TO USE: Debug cross-repo links after group_sync.`,
required: ['name'],
},
},
{
name: 'group_impact',
description: `Cross-repository blast radius: local impact in the source repo, then one-hop fan-out via Contract Registry (exact/manifest links).
WHEN TO USE: When a symbol may affect other repos in the same group. Multi-hop cross-boundary is not implemented; crossDepth is capped at 1.`,
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Group name' },
target: { type: 'string', description: 'Symbol name (same as impact tool)' },
repo: {
type: 'string',
description: 'Group path of the source repo (e.g. hr/hiring/backend)',
},
direction: {
type: 'string',
description: 'upstream or downstream',
enum: ['upstream', 'downstream'],
},
crossDepth: {
type: 'number',
description:
'Cross-boundary hops (MVP: capped at 1; values above 1 are ignored with a warning)',
},
maxDepth: { type: 'number', description: 'Max graph depth within each repo (default 3)' },
minConfidence: {
type: 'number',
description: 'Minimum cross-link confidence (default 0.5)',
},
subgroup: {
type: 'string',
description: 'Only fan out into repos under this group path prefix',
},
timeout: { type: 'number', description: 'Wall-clock budget in ms (default 30000)' },
},
required: ['name', 'target', 'repo'],
},
},
{
name: 'group_query',
description: `Run the query tool across all repos in a group and merge process results via reciprocal rank fusion.

View file

@ -49,20 +49,4 @@ describe('group CLI', () => {
expect(l.status).toBe(0);
expect(l.stdout).toContain('acme');
});
it('test_create_with_invalid_name_fails', () => {
const result = runGroup(['create', '../../evil']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Invalid group name');
});
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
const source = fs.readFileSync(cliGroupPath, 'utf-8');
// closeLbug() without arguments (blanket close) must not appear.
// Match closeLbug() but not closeLbug(someArg)
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
expect(source).not.toMatch(blanketClosePattern);
});
});

View file

@ -0,0 +1,75 @@
/**
* Group impact wiring — mocks `localImpactFn` / `crossImpactFn`. E2E with real graphs is a follow-up.
*/
import { describe, it, expect } from 'vitest';
import { runGroupImpact } from '../../../src/core/group/cross-impact.js';
import type { ContractRegistry } from '../../../src/core/group/types.js';
function minimalRegistry(crossLinks: ContractRegistry['crossLinks']): ContractRegistry {
return {
version: 1,
generatedAt: new Date().toISOString(),
repoSnapshots: {},
missingRepos: [],
contracts: [],
crossLinks,
};
}
describe('Group impact integration', () => {
it('runs phase 1 and fan-out when cross-link matches UID', async () => {
const registry = minimalRegistry([
{
from: {
repo: 'app/frontend',
symbolUid: 'remote-1',
symbolRef: { filePath: 'f.ts', name: 'x' },
},
to: {
repo: 'app/backend',
symbolUid: 'local-target',
symbolRef: { filePath: 'b.ts', name: 'y' },
},
type: 'http',
contractId: 'http::GET::/x',
matchType: 'exact',
confidence: 1.0,
},
]);
const localImpactFn = async () => ({
target: { id: 'local-target', name: 'T', filePath: 'b.ts' },
direction: 'upstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: { '1': [{ id: 'local-target', name: 'T', filePath: 'b.ts' }] },
});
let fanOutCalls = 0;
const crossImpactFn = async (groupPath: string, uid: string, _direction: string) => {
fanOutCalls++;
expect(groupPath).toBe('app/frontend');
expect(uid).toBe('remote-1');
return { byDepth: {}, affected_processes: [] };
};
const result = await runGroupImpact({
groupName: 'g',
target: 'T',
repoPath: 'app/backend',
direction: 'upstream',
registry,
localImpactFn,
crossImpactFn,
crossDepth: 1,
timeout: 5000,
});
expect(result.cross.length).toBe(1);
expect(fanOutCalls).toBe(1);
expect(result.summary.cross_repo_hits).toBe(1);
});
});

View file

@ -0,0 +1,191 @@
import { describe, it, expect } from 'vitest';
import { runGroupImpact } from '../../../src/core/group/cross-impact.js';
import type { ContractRegistry } from '../../../src/core/group/types.js';
describe('runGroupImpact', () => {
const mockRegistry: ContractRegistry = {
version: 1,
generatedAt: '2026-03-31T10:00:00Z',
repoSnapshots: {
'app/backend': { indexedAt: '2026-03-31T09:00:00Z', lastCommit: 'abc123' },
'app/frontend': { indexedAt: '2026-03-31T09:00:00Z', lastCommit: 'def456' },
},
missingRepos: [],
contracts: [],
crossLinks: [
{
from: {
repo: 'app/frontend',
symbolUid: 'uid-fetch',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
},
to: {
repo: 'app/backend',
symbolUid: 'uid-ctrl',
symbolRef: { filePath: 'src/ctrl.ts', name: 'UserController.list' },
},
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1.0,
},
],
};
it('returns local impact when no cross-links match', async () => {
const result = await runGroupImpact({
groupName: 'test',
target: 'SomeUnrelatedFn',
repoPath: 'app/backend',
direction: 'upstream',
registry: mockRegistry,
localImpactFn: async () => ({
target: { id: 'uid-x', name: 'SomeUnrelatedFn', filePath: 'src/x.ts' },
direction: 'upstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: { '1': [{ id: 'uid-y', name: 'CallerFn', filePath: 'src/y.ts' }] },
}),
crossImpactFn: async () => null,
});
expect(result.cross).toHaveLength(0);
expect(result.summary.cross_repo_hits).toBe(0);
expect(result.risk).toBe('LOW');
});
it('fans out through cross-links for upstream direction', async () => {
const result = await runGroupImpact({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
direction: 'upstream',
registry: mockRegistry,
localImpactFn: async () => ({
target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' },
direction: 'upstream',
impactedCount: 2,
risk: 'LOW',
summary: { direct: 2, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {
'1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }],
},
}),
crossImpactFn: async () => ({
target: { id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' },
direction: 'upstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {
'1': [
{ id: 'uid-profile', name: 'UserProfile', filePath: 'src/components/UserProfile.tsx' },
],
},
}),
});
expect(result.cross).toHaveLength(1);
expect(result.cross[0].repo_path).toBe('app/frontend');
expect(result.cross[0].contract.match_type).toBe('exact');
expect(result.summary.cross_repo_hits).toBe(1);
expect(['HIGH', 'CRITICAL']).toContain(result.risk);
});
it('fans out for downstream direction (consumer repo → provider repo)', async () => {
const result = await runGroupImpact({
groupName: 'test',
target: 'fetchUsers',
repoPath: 'app/frontend',
direction: 'downstream',
registry: mockRegistry,
localImpactFn: async () => ({
target: { id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' },
direction: 'downstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: { '1': [{ id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' }] },
}),
crossImpactFn: async (groupPath, uid, _direction) => {
expect(groupPath).toBe('app/backend');
expect(uid).toBe('uid-ctrl');
expect(_direction).toBe('downstream');
return {
byDepth: {
'1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }],
},
affected_processes: [],
};
},
});
expect(result.cross).toHaveLength(1);
expect(result.cross[0].repo_path).toBe('app/backend');
expect(result.summary.cross_repo_hits).toBe(1);
});
it('respects subgroup filter', async () => {
const result = await runGroupImpact({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
direction: 'upstream',
registry: mockRegistry,
subgroup: 'other/team',
localImpactFn: async () => ({
target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' },
direction: 'upstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {
'1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }],
},
}),
crossImpactFn: async () => null,
});
expect(result.cross).toHaveLength(0);
expect(result.outOfScope).toHaveLength(1);
expect(result.outOfScope[0].from).toBe('app/frontend');
});
it('respects timeout and returns truncated result', async () => {
const result = await runGroupImpact({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
direction: 'upstream',
timeout: 1,
registry: mockRegistry,
localImpactFn: async () => {
await new Promise((r) => setTimeout(r, 50));
return {
target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' },
direction: 'upstream',
impactedCount: 0,
risk: 'LOW',
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {},
};
},
crossImpactFn: async () => null,
});
expect(result.truncated).toBe(true);
});
});

View file

@ -6,12 +6,13 @@ const GROUP_TOOL_NAMES = [
'group_list',
'group_sync',
'group_contracts',
'group_impact',
'group_query',
'group_status',
];
describe('Group MCP tools', () => {
it('all 5 group tools are registered', () => {
it('all 6 group tools are registered', () => {
for (const name of GROUP_TOOL_NAMES) {
const tool = GITNEXUS_TOOLS.find((t) => t.name === name);
expect(tool, `tool ${name} should be registered`).toBeDefined();
@ -20,8 +21,22 @@ describe('Group MCP tools', () => {
}
});
it('group_impact requires name, target, repo', () => {
const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_impact')!;
expect(tool.inputSchema.required).toContain('name');
expect(tool.inputSchema.required).toContain('target');
expect(tool.inputSchema.required).toContain('repo');
});
it('group_sync requires name', () => {
const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!;
expect(tool.inputSchema.required).toContain('name');
});
it('group_impact has crossDepth param with max 1 note in description', () => {
const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_impact')!;
const crossDepth = tool.inputSchema.properties.crossDepth as { description?: string };
expect(crossDepth).toBeDefined();
expect(crossDepth.description).toContain('capped at 1');
});
});

View file

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { ManifestExtractor } from '../../../src/core/group/extractors/manifest-extractor.js';
import type { GroupManifestLink } from '../../../src/core/group/types.js';
describe('ManifestExtractor', () => {
const extractor = new ManifestExtractor();
it('creates provider + consumer contracts and a cross-link for each manifest link', async () => {
const links: GroupManifestLink[] = [
{
from: 'hr/payroll/backend',
to: 'hr/hiring/backend',
type: 'topic',
contract: 'employee.hired',
role: 'provider',
},
];
const result = await extractor.extractFromManifest(links);
expect(result.contracts).toHaveLength(2);
const provider = result.contracts.find((c) => c.role === 'provider');
expect(provider).toBeDefined();
expect(provider!.contractId).toBe('topic::employee.hired');
expect(provider!.type).toBe('topic');
expect(provider!.confidence).toBe(1.0);
const consumer = result.contracts.find((c) => c.role === 'consumer');
expect(consumer).toBeDefined();
expect(consumer!.contractId).toBe('topic::employee.hired');
expect(result.crossLinks).toHaveLength(1);
expect(result.crossLinks[0].matchType).toBe('manifest');
expect(result.crossLinks[0].confidence).toBe(1.0);
expect(result.crossLinks[0].from.repo).toBe('hr/hiring/backend');
expect(result.crossLinks[0].to.repo).toBe('hr/payroll/backend');
});
it('handles role: consumer (from-repo is consumer)', async () => {
const links: GroupManifestLink[] = [
{
from: 'sales/admin/bff',
to: 'sales/crm/backend',
type: 'http',
contract: '/api/v2/leads/*',
role: 'consumer',
},
];
const result = await extractor.extractFromManifest(links);
const provider = result.contracts.find((c) => c.role === 'provider');
const consumer = result.contracts.find((c) => c.role === 'consumer');
expect(consumer!.contractId).toBe('http::*::/api/v2/leads/*');
expect(provider!.contractId).toBe('http::*::/api/v2/leads/*');
expect(result.crossLinks[0].from.repo).toBe('sales/admin/bff');
expect(result.crossLinks[0].to.repo).toBe('sales/crm/backend');
});
it('returns empty for no links', async () => {
const result = await extractor.extractFromManifest([]);
expect(result.contracts).toHaveLength(0);
expect(result.crossLinks).toHaveLength(0);
});
});

View file

@ -2,7 +2,7 @@
* Unit Tests: MCP Tool Definitions
*
* Tests: GITNEXUS_TOOLS from tools.ts
* - All 16 tools are defined (per-repo + group_*)
* - All 17 tools are defined (per-repo + group_*)
* - Each tool has valid name, description, inputSchema
* - Required fields are correct
* - Optional repo parameter is present on tools that need it
@ -14,13 +14,14 @@ const GROUP_TOOLS = new Set([
'group_list',
'group_sync',
'group_contracts',
'group_impact',
'group_query',
'group_status',
]);
describe('GITNEXUS_TOOLS', () => {
it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 5 group)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(16);
it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 6 group)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(17);
});
it('contains all expected tool names', () => {
@ -101,6 +102,13 @@ describe('GITNEXUS_TOOLS', () => {
}
});
it('group_impact uses repo as required group path', () => {
const groupImpact = GITNEXUS_TOOLS.find((t) => t.name === 'group_impact')!;
expect(groupImpact.inputSchema.required).toContain('repo');
expect(groupImpact.inputSchema.required).toContain('name');
expect(groupImpact.inputSchema.required).toContain('target');
});
it('group_contracts has optional repo filter', () => {
const groupContracts = GITNEXUS_TOOLS.find((t) => t.name === 'group_contracts')!;
expect(groupContracts.inputSchema.properties).toHaveProperty('repo');