mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
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:
parent
5c4fca21c3
commit
613a0d9db2
14 changed files with 2025 additions and 41 deletions
1039
docs/specs/2026-03-31-cross-index-impact-design.md
Normal file
1039
docs/specs/2026-03-31-cross-index-impact-design.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
225
gitnexus/src/core/group/cross-impact.ts
Normal file
225
gitnexus/src/core/group/cross-impact.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
124
gitnexus/src/core/group/extractors/manifest-extractor.ts
Normal file
124
gitnexus/src/core/group/extractors/manifest-extractor.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
75
gitnexus/test/integration/group/group-impact.test.ts
Normal file
75
gitnexus/test/integration/group/group-impact.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
191
gitnexus/test/unit/group/cross-impact.test.ts
Normal file
191
gitnexus/test/unit/group/cross-impact.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
68
gitnexus/test/unit/group/manifest-extractor.test.ts
Normal file
68
gitnexus/test/unit/group/manifest-extractor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue