mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
- 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.
611 lines
23 KiB
TypeScript
611 lines
23 KiB
TypeScript
// 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,
|
||
},
|
||
};
|
||
}
|