GitNexus/gitnexus/src/cli/group.ts
wuhongteng 4d90962a0a feat(group): add cross-repo call trace with pluggable SymbolResolver
- trace.ts: BFS engine across repos via CALLS edges + contracts.json crossLinks.
  Pluggable SymbolResolver interface; DefaultSymbolResolver as reference implementation.
  runGroupTrace uses DefaultSymbolResolver; runGroupTraceWithResolver accepts any resolver.
- trace-resolver.ts: SymbolResolver interface + DefaultSymbolResolver (generic, no
  framework-specific logic). Subclass to add custom symbol resolution.
- service.ts: groupTrace() with typed TraceParams construction, NaN guards, depth clamping
  (maxCrossDepth cap 50). Slim MCP response by default; verbose flag for full data.
- cli/group.ts: group trace subcommand with NaN-safe parseInt, negative-value guards.
- tools.ts: group_trace MCP tool with complete schema (relationTypes, maxCrossDepth max 50).
- local-backend.ts: route group_trace to GroupService.
2026-05-25 21:27:03 +08:00

611 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
import { logger } from '../core/logger.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
export function registerGroupCommands(program: Command): void {
const group = program
.command('group')
.description('Manage repository groups for cross-index impact analysis');
group
.command('create <name>')
.description('Create a new group with template group.yaml')
.option('--force', 'Overwrite existing group')
.action(async (name: string, opts: { force?: boolean }) => {
const { createGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const dir = await createGroupDir(getDefaultGitnexusDir(), name, opts.force);
console.log(`Created group "${name}" at ${dir}`);
console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name);
});
group
.command('add <group> <groupPath> <registryName>')
.description(
'Add a repo to a group. <groupPath> = hierarchy path (e.g. hr/hiring/backend), <registryName> = name from registry',
)
.action(async (groupName: string, groupPath: string, registryName: string) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const path = await import('node:path');
const fs = await import('node:fs/promises');
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
config.repos[groupPath] = registryName;
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
console.log(`Added ${registryName} as "${groupPath}" to group "${groupName}"`);
console.log(`Run: gitnexus group sync ${groupName}`);
});
group
.command('remove <group> <path>')
.description('Remove a repo from a group')
.action(async (groupName: string, repoPath: string) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const path = await import('node:path');
const fs = await import('node:fs/promises');
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
if (!(repoPath in config.repos)) {
logger.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
process.exitCode = 1;
return;
}
delete config.repos[repoPath];
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
console.log(`Removed "${repoPath}" from group "${groupName}"`);
});
group
.command('list [name]')
.description('List all groups or details of one')
.action(async (name?: string) => {
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
await import('../core/group/storage.js');
if (!name) {
const groups = await listGroups();
if (groups.length === 0) {
console.log('No groups configured. Create one with: gitnexus group create <name>');
return;
}
console.log('Groups:');
groups.forEach((g) => console.log(` ${g}`));
return;
}
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Group: ${config.name}`);
if (config.description) console.log(`Description: ${config.description}`);
console.log(`\nRepos (${Object.keys(config.repos).length}):`);
for (const [p, id] of Object.entries(config.repos)) {
console.log(` ${p} -> ${id}`);
}
if (config.links.length > 0) {
console.log(`\nManifest links (${config.links.length}):`);
for (const link of config.links) {
console.log(` ${link.from} -> ${link.to} [${link.type}: ${link.contract}]`);
}
}
});
group
.command('status <name>')
.description('Check staleness of group and repos')
.action(async (name: string) => {
const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } =
await import('../core/group/storage.js');
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const registry = await readContractRegistry(groupDir);
console.log(
`Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`,
);
const backend = new LocalBackend();
try {
await backend.init();
const raw = await backend.getGroupService().groupStatus({ name });
const st = raw as {
repos?: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
>;
missingRepos?: string[];
};
console.log(' Repo index / contracts staleness:');
for (const [repoPath, row] of Object.entries(st.repos || {})) {
if (row.missing) {
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
continue;
}
const idx = row.indexStale
? `STALE (${row.commitsBehind ?? '?'} commits behind)`
: 'OK ';
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
}
if ((st.missingRepos || []).length > 0) {
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
}
} finally {
await backend.dispose().catch(() => {});
}
});
group
.command('sync <name>')
.description('Sync Contract Registry — extract contracts and build cross-links')
.option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)')
.option('--exact-only', 'Exact match only')
.option('--allow-stale', 'Skip stale index warnings')
.option('--verbose', 'Show each cross-link detail')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
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 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)`,
);
}
});
group
.command('impact <name>')
.description('Cross-repo impact for a symbol in one member repo of a group')
.requiredOption('--target <symbol>', 'Symbol or file name to analyze')
.requiredOption(
'--repo <groupPath>',
'Member path from group.yaml (e.g. app/backend), not the indexed repo name',
)
.option('--direction <dir>', 'upstream or downstream', 'upstream')
.option('--service <path>', 'Optional monorepo service directory prefix (path filter)')
.option(
'--subgroup <path>',
'Optional prefix limiting which group repos participate in cross fan-out',
)
.option('--max-depth <n>', 'Max graph traversal depth')
.option('--cross-depth <n>', 'Cross-repository hop depth')
.option('--min-confidence <n>', 'Minimum relation confidence (0–1)')
.option('--include-tests', 'Include test files in traversal', false)
.option('--timeout-ms <n>', 'Phase-1 local impact wall time in milliseconds')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const backend = new LocalBackend();
try {
await backend.init();
const payload: Record<string, unknown> = {
name,
repo: opts.repo,
target: opts.target,
direction: (opts.direction as string) || 'upstream',
};
if (opts.service) payload.service = opts.service;
if (opts.subgroup) payload.subgroup = opts.subgroup;
if (opts.maxDepth !== undefined && opts.maxDepth !== '') {
const n = parseInt(String(opts.maxDepth), 10);
if (!Number.isNaN(n)) payload.maxDepth = n;
}
if (opts.crossDepth !== undefined && opts.crossDepth !== '') {
const n = parseInt(String(opts.crossDepth), 10);
if (!Number.isNaN(n)) payload.crossDepth = n;
}
if (opts.minConfidence !== undefined && opts.minConfidence !== '') {
const n = parseFloat(String(opts.minConfidence));
if (!Number.isNaN(n)) payload.minConfidence = n;
}
if (opts.timeoutMs !== undefined && opts.timeoutMs !== '') {
const n = parseInt(String(opts.timeoutMs), 10);
if (!Number.isNaN(n)) payload.timeoutMs = n;
}
if (opts.includeTests) payload.includeTests = true;
const raw = await backend.getGroupService().groupImpact(payload);
if (raw && typeof raw === 'object' && 'error' in raw) {
logger.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(raw, null, 2));
} else {
const summary = (raw as { summary?: Record<string, number> })?.summary;
const risk = (raw as { risk?: string })?.risk;
console.log(`Group impact for "${name}" (${String(opts.repo)}): risk=${risk ?? '?'}`);
if (summary) {
console.log(
` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}`,
);
}
}
} finally {
await backend.dispose().catch(() => {});
}
});
group
.command('query <name> <query>')
.description('Search execution flows across all repos in a group')
.option('--subgroup <path>', 'Limit search scope')
.option('--limit <n>', 'Max merged results', '5')
.option('--json', 'JSON output')
.action(
async (
name: string,
queryText: string,
opts: Record<string, string | boolean | undefined>,
) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const limit = parseInt(String(opts.limit ?? '5'), 10) || 5;
const subgroup = opts.subgroup as string | undefined;
const backend = new LocalBackend();
try {
await backend.init();
console.log(`Searching "${queryText}" across group "${name}"...\n`);
const raw = await backend.getGroupService().groupQuery({
name,
query: queryText,
limit,
subgroup,
});
const merged = raw as {
results: Array<Record<string, unknown>>;
per_repo: Array<{ repo: string; count: number }>;
};
if (opts.json) {
console.log(JSON.stringify(raw, null, 2));
} else {
console.log(`Results (top ${merged.results.length}):\n`);
for (const p of merged.results) {
const label = (p.summary || p.heuristicLabel || p.name || 'unnamed') as string;
console.log(` [${p._repo}] ${label} (rrf: ${(p._rrf_score as number).toFixed(4)})`);
}
if (merged.results.length === 0) {
console.log(' No matching execution flows found.');
}
}
} finally {
await backend.dispose().catch(() => {});
}
},
);
group
.command('contracts <name>')
.description('Inspect Contract Registry')
.option('--type <type>', 'Filter by contract type')
.option('--repo <repo>', 'Filter by repo')
.option('--unmatched', 'Show only unmatched contracts')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const backend = new LocalBackend();
try {
await backend.init();
const raw = await backend.getGroupService().groupContracts({
name,
type: opts.type as string | undefined,
repo: opts.repo as string | undefined,
unmatchedOnly: Boolean(opts.unmatched),
});
if (raw && typeof raw === 'object' && 'error' in raw) {
logger.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}
const { contracts, crossLinks } = raw as {
contracts: Array<{
role: string;
contractId: string;
repo: string;
symbolRef: { name: string };
}>;
crossLinks: Array<{
from: { repo: string };
to: { repo: string };
matchType: string;
confidence: number;
contractId: string;
}>;
};
if (opts.json) {
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
} else {
console.log(`Contracts (${contracts.length}):`);
for (const c of contracts) {
console.log(` [${c.role}] ${c.contractId} (${c.repo}) ${c.symbolRef.name}`);
}
console.log(`\nCross-links (${crossLinks.length}):`);
for (const l of crossLinks) {
console.log(
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
);
}
}
} finally {
await backend.dispose().catch(() => {});
}
});
group
.command('trace <name>')
.description('Cross-repo call trace — follow CALLS edges across repos via CrossLinks')
.requiredOption('--target <symbol>', 'Symbol name, file path, or node id to start from')
.requiredOption('--repo <groupPath>', 'Member path from group.yaml (e.g. app/backend)')
.option('--direction <dir>', 'downstream or upstream', 'downstream')
.option('--max-depth <n>', 'Max BFS depth within each repo (0=unlimited)', '0')
.option('--max-cross-depth <n>', 'Max cross-repo hops (0=unlimited)', '10')
.option('--relation-types <types>', 'Comma-separated relation types (default: CALLS)', 'CALLS')
.option('--min-confidence <n>', 'Minimum edge confidence (0–1)', '0')
.option('--include-tests', 'Include test files in traversal', false)
.option(
'--json',
'JSON output (summary by default — no nodes, crossHops deduplicated by contractId)',
)
.option(
'--verbose',
'Full output with all nodes and raw crossHops (use with --json for complete data)',
)
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const backend = new LocalBackend();
try {
await backend.init();
const maxDepthRaw = parseInt(String(opts.maxDepth ?? '0'), 10);
const maxDepth = Number.isNaN(maxDepthRaw) ? 0 : Math.max(0, maxDepthRaw);
const maxCrossDepthRaw = parseInt(String(opts.maxCrossDepth ?? '10'), 10);
const maxCrossDepth = Number.isNaN(maxCrossDepthRaw)
? 10
: Math.min(50, Math.max(0, maxCrossDepthRaw));
const minConfidence = parseFloat(String(opts.minConfidence ?? '0')) || 0;
const relationTypes = String(opts.relationTypes ?? 'CALLS')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const raw = await backend.getGroupService().groupTrace({
name,
repo: String(opts.repo),
target: String(opts.target),
direction: (opts.direction as string) || 'downstream',
maxDepth,
maxCrossDepth,
relationTypes,
includeTests: Boolean(opts.includeTests),
minConfidence,
verbose: true, // CLI always requests full data; summary is built locally
});
if (raw && typeof raw === 'object' && 'error' in raw) {
logger.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}
const result = raw as {
entryTarget: string;
entryRepo: string;
direction: string;
segments: Array<{
repo: string;
repoPath: string;
entrySymbolUid: string;
nodes: unknown[];
crossHops: Array<{
from: { repo: string };
to: { repo: string };
contractType: string;
contractId: string;
linkConfidence: number;
}>;
}>;
skippedRepos: string[];
truncated: boolean;
};
const verbose = Boolean(opts.verbose);
if (opts.json) {
if (verbose) {
// --verbose: full output with all nodes and raw crossHops
console.log(JSON.stringify(result, null, 2));
} else {
// Default summary: no nodes, crossHops deduplicated by contractId
const summary = buildTraceSummary(result);
console.log(JSON.stringify(summary, null, 2));
}
} else {
if (verbose) {
// Text verbose: show node counts
console.log(
`Trace: ${result.entryTarget} (${result.entryRepo}) [${result.direction}]\n`,
);
for (const seg of result.segments) {
console.log(` Repo: ${seg.repoPath} (${seg.repo})`);
console.log(` Entry: ${seg.entrySymbolUid}`);
console.log(` Nodes: ${seg.nodes.length}`);
if (seg.crossHops.length > 0) {
console.log(` Cross-hops:`);
for (const hop of seg.crossHops) {
console.log(
` ${hop.from.repo} -> ${hop.to.repo} [${hop.contractType}] ${hop.contractId} (conf=${hop.linkConfidence})`,
);
}
}
}
if (result.skippedRepos.length > 0) {
console.log(`\n Skipped repos: ${result.skippedRepos.join(', ')}`);
}
if (result.truncated) {
console.log(`\n (truncated — maxCrossDepth reached)`);
}
console.log(
`\n Total: ${result.segments.length} repo segments, ${result.segments.reduce((s, seg) => s + seg.nodes.length, 0)} nodes`,
);
} else {
// Text summary
const summary = buildTraceSummary(result);
console.log(
`Trace: ${summary.entryTarget} (${summary.entryRepo}) [${summary.direction}]\n`,
);
console.log(` Cross-hops (${summary.crossHops.length} unique by contractId):`);
for (const hop of summary.crossHops) {
console.log(
` ${hop.from.repo} -> ${hop.to.repo} [${hop.contractType}] ${hop.contractId} (${hop.from.symbolName} -> ${hop.to.symbolName})`,
);
}
console.log(
`\n Stats: ${summary.stats.totalRepos} repos, ${summary.stats.totalSegments} segments, ${summary.crossHops.length} cross-hops (${summary.stats.rawCrossHops} raw)`,
);
console.log(` (use --verbose for full output with ${summary.stats.totalNodes} nodes)`);
}
}
} finally {
await backend.dispose().catch(() => {});
}
});
}
// ---------------------------------------------------------------------------
// Summary builder — deduplicates crossHops by contractId level and strips nodes
// ---------------------------------------------------------------------------
interface TraceSummaryHop {
contractId: string;
contractType: string;
from: { repo: string; symbolName: string };
to: { repo: string; symbolName: string };
}
interface TraceSummary {
group: string;
entryRepo: string;
entryTarget: string;
direction: string;
crossHops: TraceSummaryHop[];
stats: {
totalRepos: number;
totalSegments: number;
totalNodes: number;
rawCrossHops: number;
dedupCrossHops: number;
};
}
function buildTraceSummary(result: {
group?: string;
entryRepo: string;
entryTarget: string;
direction: string;
segments: Array<{
repo: string;
repoPath: string;
entrySymbolUid: string;
nodes: unknown[];
crossHops: Array<{
from: { repo: string; symbolUid?: string; symbolName?: string };
to: { repo: string; symbolUid?: string; symbolName?: string };
contractType: string;
contractId: string;
linkConfidence: number;
}>;
}>;
skippedRepos: string[];
truncated: boolean;
}): TraceSummary {
// Deduplicate crossHops by (from.repo + to.repo + contractId + contractType)
// Keep first occurrence's symbolName as representative
const seen = new Set<string>();
const dedupHops: TraceSummaryHop[] = [];
let rawCount = 0;
for (const seg of result.segments) {
for (const hop of seg.crossHops) {
rawCount++;
const key = `${hop.from.repo}\0${hop.to.repo}\0${hop.contractId}\0${hop.contractType}`;
if (!seen.has(key)) {
seen.add(key);
dedupHops.push({
contractId: hop.contractId,
contractType: hop.contractType,
from: {
repo: hop.from.repo,
symbolName: hop.from.symbolName ?? hop.from.symbolUid ?? '',
},
to: { repo: hop.to.repo, symbolName: hop.to.symbolName ?? hop.to.symbolUid ?? '' },
});
}
}
}
// Collect unique repos from crossHops
const repoSet = new Set<string>();
for (const hop of dedupHops) {
repoSet.add(hop.from.repo);
repoSet.add(hop.to.repo);
}
return {
group: result.group ?? '',
entryRepo: result.entryRepo,
entryTarget: result.entryTarget,
direction: result.direction,
crossHops: dedupHops,
stats: {
totalRepos: repoSet.size,
totalSegments: result.segments.length,
totalNodes: result.segments.reduce((sum, seg) => sum + seg.nodes.length, 0),
rawCrossHops: rawCount,
dedupCrossHops: dedupHops.length,
},
};
}