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.
This commit is contained in:
wuhongteng 2026-05-18 19:23:47 +08:00
parent baf57bec88
commit 4d90962a0a
8 changed files with 2778 additions and 3 deletions

View file

@ -373,4 +373,239 @@ export function registerGroupCommands(program: Command): void {
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,
},
};
}

View file

@ -308,6 +308,77 @@ export class GroupService {
return runGroupImpact({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params);
}
async groupTrace(params: Record<string, unknown>): Promise<unknown> {
const { runGroupTrace } = await import('./trace.js');
const traceParams = {
name: String(params.name ?? ''),
repo: String(params.repo ?? ''),
target: String(params.target ?? ''),
direction: (params.direction as 'downstream' | 'upstream' | undefined) ?? 'downstream',
maxDepth:
typeof params.maxDepth === 'number' && !Number.isNaN(params.maxDepth)
? Math.max(0, params.maxDepth)
: 0,
maxCrossDepth:
typeof params.maxCrossDepth === 'number' && !Number.isNaN(params.maxCrossDepth)
? Math.min(50, Math.max(0, params.maxCrossDepth))
: 10,
relationTypes: Array.isArray(params.relationTypes)
? (params.relationTypes as string[])
: ['CALLS'],
includeTests: Boolean(params.includeTests),
minConfidence:
typeof params.minConfidence === 'number' && !Number.isNaN(params.minConfidence)
? Math.min(1, Math.max(0, params.minConfidence))
: 0,
};
const result = await runGroupTrace(
{ port: this.port, gitnexusDir: getDefaultGitnexusDir() },
traceParams,
);
if ('error' in (result as object)) return result;
if (params.verbose) return result;
// Default: slim response — strip nodes, deduplicate crossHops by contractId.
// Avoids returning 40MB+ full trace data over MCP.
const full = result as import('./trace.js').TraceResult;
const seen = new Set<string>();
const dedupHops: unknown[] = [];
for (const seg of full.segments ?? []) {
for (const hop of seg.crossHops ?? []) {
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 ?? '' },
});
}
}
}
return {
group: full.group,
entryRepo: full.entryRepo,
entryTarget: full.entryTarget,
direction: full.direction,
truncated: full.truncated,
skippedRepos: full.skippedRepos,
crossHops: dedupHops,
stats: {
totalRepos: new Set(full.segments?.map((s) => s.repoPath) ?? []).size,
totalSegments: full.segments?.length ?? 0,
totalNodes: full.segments?.reduce((sum, s) => sum + (s.nodes?.length ?? 0), 0) ?? 0,
rawCrossHops: full.segments?.reduce((sum, s) => sum + (s.crossHops?.length ?? 0), 0) ?? 0,
dedupCrossHops: dedupHops.length,
},
};
}
async groupContext(params: Record<string, unknown>): Promise<GroupContextResult> {
const name = String(params.name ?? '').trim();
const target = typeof params.target === 'string' ? params.target.trim() : '';

View file

@ -0,0 +1,331 @@
/**
* Default symbol resolver for cross-repo trace.
*
* This module contains framework-aware logic (RPC / MQ consumer /
* service-naming conventions) that would otherwise be hard-coded inside
* trace.ts. trace.ts itself remains framework-agnostic: it calls the
* SymbolResolver interface and knows nothing about concrete framework patterns.
*
* DefaultSymbolResolver is a reference implementation. To support a different
* RPC framework or naming convention, implement SymbolResolver and pass it to
* runGroupTraceWithResolver — no changes to the BFS engine needed.
*/
import { executeParameterized } from '../lbug/pool-adapter.js';
import { logger } from '../logger.js';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export type SymbolCandidate = { id: string; name: string; type: string; filePath: string };
export type ResolvedSymbol = { id: string; name: string; type: string; filePath: string };
/**
* Context passed to resolveSymbolByName.
* Carries hints extracted from contracts.json so the resolver can skip
* heuristic scoring when precise data is available.
*/
export interface ResolveContext {
/** contracts.json symbolRef.filePath for the target endpoint (may be client-side) */
hintFilePath?: string;
/** true when the hop originates from a topic/MQ crossLink */
isTopic?: boolean;
}
/**
* Pluggable symbol-resolution strategy.
*
* All methods are optional. When a method is absent the BFS engine falls back
* to a minimal built-in behaviour described in each comment.
*/
export interface SymbolResolver {
/**
* Return true when symbolName is a synthetic, non-Java identifier that will
* never exist in LadybugDB (e.g. "mqConsumer(some-topic)", "scheduler.task.foo").
* Fallback: always return false (attempt resolution for every name).
*/
isUnresolvableSymbolName?: (symbolName: string) => boolean;
/**
* Score a candidate symbol node for BFS entry quality.
* Higher score = better entry point.
* Fallback: all candidates score 0 (first candidate in DB order is used).
*/
scoreCandidate?: (
candidate: SymbolCandidate,
classVariants?: string[],
targetName?: string,
) => number;
/**
* Resolve a cross-repo hop symbolName to a concrete lbug Method node.
* Called by processOneSegment for each non-entry hop.
*
* Return null to skip this segment (treated as unresolvable).
* Fallback: exact n.name match with LIMIT 1.
*/
resolveSymbolByName?: (
repoId: string,
symbolName: string,
context: ResolveContext,
) => Promise<ResolvedSymbol | null>;
/**
* Given a class/interface node, return the best Method node in the same file
* for BFS seeding. Called from resolveEntrySymbols when a candidate is a
* non-Method node.
* Fallback: return the node unchanged.
*/
drillDownToMethod?: (
repoId: string,
node: SymbolCandidate,
preferredMethodName?: string,
) => Promise<ResolvedSymbol>;
}
// ---------------------------------------------------------------------------
// DefaultSymbolResolver — generic reference implementation
// ---------------------------------------------------------------------------
/**
* Generic symbol resolver with no framework-specific knowledge.
*
* Covers the common case: symbol names follow `ClassName.methodName` or
* plain `methodName` conventions, and implementation classes live in
* `-service/` or `-impl/` modules.
*
* For framework-specific resolution (e.g. custom RPC frameworks, MQ
* consumers, proprietary service-naming conventions), extend this class
* and override the methods you need, then pass your resolver to
* `runGroupTraceWithResolver`.
*/
export class DefaultSymbolResolver implements SymbolResolver {
// ---- scoreCandidate ----
scoreCandidate(c: SymbolCandidate, classVariants?: string[], target?: string): number {
let s = 0;
const idAndPath = `${c.id}|${c.filePath}`;
if (classVariants) {
for (const variant of classVariants) {
if (idAndPath.includes(variant)) {
s += 100;
break;
}
}
}
// Method nodes preferred over Class/Interface
const nodeType = c.type || (c.id.indexOf(':') > 0 ? c.id.slice(0, c.id.indexOf(':')) : '');
if (nodeType === 'Method') s += 10;
if (c.filePath && (c.filePath.includes('-server/') || c.filePath.includes('-service/')))
s += 50;
if (c.filePath && (c.filePath.toLowerCase().includes('impl') || c.filePath.includes('-impl/')))
s += 20;
if (/gateway|delegate|adapter|proxy|wrapper/i.test(c.id)) s -= 15;
// Semantic relevance via PascalCase target name
if (target && target.length > 0) {
const pascalTarget = target[0].toUpperCase() + target.slice(1);
if (idAndPath.includes(pascalTarget)) s += 40;
}
if (c.filePath && (c.filePath.includes('-client/') || c.filePath.includes('-client-'))) s -= 60;
// Utility / DTO penalty
const combined = `${c.id.toLowerCase()}|${(c.filePath || '').toLowerCase()}`;
if (
/utils?[./|]/.test(combined) ||
/enum[./|]/.test(combined) ||
/validate[./|]/.test(combined) ||
/convert[./|]/.test(combined) ||
/dto[./|]/.test(combined) ||
/entity[./|]/.test(combined) ||
/\.set[A-Z]/.test(c.id) ||
/\.get[A-Z]/.test(c.id) ||
/\.is[A-Z]/.test(c.id)
)
s -= 80;
// Test file penalty
const lower = (c.filePath || '').toLowerCase();
if (
lower.includes('/test/') ||
lower.includes('/tests/') ||
lower.includes('/__tests__/') ||
lower.includes('.test.') ||
lower.includes('.spec.') ||
lower.includes('_test.')
)
s -= 50;
return s;
}
// ---- drillDownToMethod ----
async drillDownToMethod(
repoId: string,
node: SymbolCandidate,
preferredMethodName?: string,
): Promise<ResolvedSymbol> {
if (node.id.startsWith('Method:')) return node;
if (!node.filePath) return node;
const methods = await executeParameterized(
repoId,
`MATCH (m:Method) WHERE m.filePath = $fp
RETURN m.id AS id, m.name AS name, labels(m)[0] AS type, m.filePath AS filePath`,
{ fp: node.filePath },
);
if (methods.length === 0) return node;
let bestMethod: Record<string, unknown> | null = null;
let bestScore = -Infinity;
for (const m of methods) {
const mName = (m.name ?? m[1]) as string;
const mId = (m.id ?? m[0]) as string;
let score = 0;
if (preferredMethodName && mName === preferredMethodName) score += 200;
if (mId.includes('Impl') || mId.includes('Server')) score += 20;
if (/^(get|set|is|toString|hashCode|equals)/.test(mName)) score -= 50;
if (mName === '<init>' || mName === '<clinit>') score -= 100;
if (score > bestScore) {
bestScore = score;
bestMethod = m;
}
}
if (!bestMethod) bestMethod = methods[0];
const result: ResolvedSymbol = {
id: (bestMethod.id ?? bestMethod[0]) as string,
name: (bestMethod.name ?? bestMethod[1]) as string,
type: (bestMethod.type ?? bestMethod[2]) as string,
filePath: (bestMethod.filePath ?? bestMethod[3]) as string,
};
logger.info(
`[trace] drillDownToMethod: "${node.id}" → "${result.id}" (${methods.length} methods in file)`,
);
return result;
}
// ---- resolveSymbolByName ----
/**
* Returns true when a file path belongs to a client/IDL module that should
* not be used as a BFS seed. Override in subclasses to add framework-specific
* dead-end patterns (e.g. generated client stubs, IDL output directories).
*/
protected isClientPath(fp: string): boolean {
if (!fp) return false;
const f = fp.toLowerCase();
return (
f.includes('-client/') ||
f.includes('-client-') ||
f.includes('_client/') ||
f.includes('/idl/') ||
f.endsWith('.thrift') ||
/-api\//.test(f) ||
/[_-]client\d/.test(f)
);
}
async resolveSymbolByName(
repoId: string,
symbolName: string,
context: ResolveContext,
): Promise<ResolvedSymbol | null> {
const { hintFilePath } = context;
const isClientPath = (fp: string) => this.isClientPath(fp);
// Exact full-name match
const rows = await executeParameterized(
repoId,
`MATCH (n) WHERE n.name = $name
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 1`,
{ name: symbolName },
);
if (rows.length > 0) {
const r = rows[0];
const matched: SymbolCandidate = {
id: (r.id ?? r[0]) as string,
name: (r.name ?? r[1]) as string,
type: (r.type ?? r[2]) as string,
filePath: (r.filePath ?? r[3]) as string,
};
if (matched.id.startsWith('Method:')) return matched;
return this.drillDownToMethod(repoId, matched, symbolName);
}
const lastDot = symbolName.lastIndexOf('.');
if (lastDot < 0) return null;
const classPrefix = symbolName.slice(0, lastDot);
const shortName = symbolName.slice(lastDot + 1);
const rows2 = await executeParameterized(
repoId,
`MATCH (n) WHERE n.name = $name
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath`,
{ name: shortName },
);
if (rows2.length === 0) return null;
const candidates: SymbolCandidate[] = rows2.map((r: Record<string, unknown>) => ({
id: (r.id ?? r[0]) as string,
name: (r.name ?? r[1]) as string,
type: (r.type ?? r[2]) as string,
filePath: (r.filePath ?? r[3]) as string,
}));
// Fast path: hintFilePath pins the exact implementation file
if (hintFilePath && !isClientPath(hintFilePath)) {
const pinned = candidates.find((c) => c.filePath === hintFilePath);
if (pinned) {
logger.info(`[trace] resolveSymbolByName "${symbolName}": pinned via hintFilePath`);
return this.drillDownToMethod(repoId, pinned, shortName);
}
}
// Build class-name variants: Service ↔ Server ↔ Impl
const classVariants = [classPrefix];
if (classPrefix.endsWith('Service')) {
classVariants.push(
classPrefix.replace(/Service$/, 'Server'),
classPrefix.replace(/Service$/, 'Impl'),
classPrefix.replace(/Service$/, ''),
);
} else if (classPrefix.endsWith('Server')) {
classVariants.push(
classPrefix.replace(/Server$/, 'Service'),
classPrefix.replace(/Server$/, 'Impl'),
classPrefix.replace(/Server$/, ''),
);
} else if (classPrefix.endsWith('Impl')) {
classVariants.push(
classPrefix.replace(/Impl$/, 'Service'),
classPrefix.replace(/Impl$/, 'Server'),
classPrefix.replace(/Impl$/, ''),
);
}
candidates.sort(
(a, b) => this.scoreCandidate(b, classVariants) - this.scoreCandidate(a, classVariants),
);
const best = candidates[0];
if (candidates.length > 1) {
logger.info(
`[trace] resolveSymbolByName "${symbolName}": ${candidates.length} candidates, ` +
`selected "${best.id}" (score=${this.scoreCandidate(best, classVariants)})`,
);
}
return this.drillDownToMethod(repoId, best, shortName);
}
}

View file

@ -0,0 +1,983 @@
/**
* Cross-repo call trace — BFS within each repo's LadybugDB, jumping across
* repos via contracts.json crossLinks.
*
* Core module, decoupled from CLI. Consumed by CLI and (future) MCP tool.
*
* Algorithm:
* 1. Load contracts.json for the group (contains crossLinks with
* from/to symbolRef.filePath that can be matched against BFS-visited files).
* 2. In the entry repo, resolve the entry symbol → BFS downstream via CALLS edges.
* 3. Collect all visited file paths and match against crossLinks where
* from.repo == currentRepo and from.symbolRef.filePath is in the visited set.
* 4. Open the target repo's lbug, resolve the target symbol by name, seed BFS, repeat.
* 5. Recurse until maxCrossDepth is exhausted or no more hops are found.
*/
import type { ContractType, CrossLink, GroupConfig, MatchType } from './types.js';
import type { GroupRepoHandle, GroupToolPort } from './service.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import { getGroupDir, readContractRegistry } from './storage.js';
import { initLbug, executeParameterized, closeLbug } from '../lbug/pool-adapter.js';
import { logger } from '../logger.js';
import type { SymbolResolver, SymbolCandidate, ResolvedSymbol } from './trace-resolver.js';
import { DefaultSymbolResolver } from './trace-resolver.js';
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
// ---------------------------------------------------------------------------
// Module-level mtime-based caches (invalidated when file changes on disk)
// ---------------------------------------------------------------------------
interface CacheEntry<T> {
value: T;
mtime: number;
}
const _groupConfigCache = new Map<string, CacheEntry<GroupConfig>>();
const _contractRegistryCache = new Map<
string,
CacheEntry<Awaited<ReturnType<typeof readContractRegistry>>>
>();
async function cachedLoadGroupConfig(groupDir: string): Promise<GroupConfig> {
const filePath = join(groupDir, 'group.yaml');
try {
const { mtimeMs } = await stat(filePath);
const cached = _groupConfigCache.get(groupDir);
if (cached && cached.mtime === mtimeMs) return cached.value;
const value = await loadGroupConfig(groupDir);
_groupConfigCache.set(groupDir, { value, mtime: mtimeMs });
return value;
} catch {
return loadGroupConfig(groupDir);
}
}
async function cachedReadContractRegistry(groupDir: string) {
const filePath = join(groupDir, 'contracts.json');
try {
const { mtimeMs } = await stat(filePath);
const cached = _contractRegistryCache.get(groupDir);
if (cached && cached.mtime === mtimeMs) return cached.value;
const value = await readContractRegistry(groupDir);
_contractRegistryCache.set(groupDir, { value, mtime: mtimeMs });
return value;
} catch {
return readContractRegistry(groupDir);
}
}
export type { SymbolResolver, SymbolCandidate, ResolvedSymbol } from './trace-resolver.js';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** A single node visited during intra-repo BFS. */
export interface TraceNode {
id: string;
name: string;
type: string;
filePath: string;
depth: number;
relationType?: string;
confidence?: number;
}
/** A cross-repo hop discovered during the trace. */
export interface TraceCrossHop {
contractId: string;
contractType: ContractType;
matchType: MatchType;
linkConfidence: number;
from: {
repo: string;
symbolUid: string;
symbolName: string;
symbolFilePath?: string;
};
to: {
repo: string;
symbolUid: string;
symbolName: string;
symbolFilePath?: string;
};
}
/** One repo's BFS result within the trace. */
export interface TraceRepoSegment {
repo: string;
repoPath: string;
entrySymbolUid: string;
nodes: TraceNode[];
crossHops: TraceCrossHop[];
}
/** Full trace result returned to callers. */
export interface TraceResult {
group: string;
entryRepo: string;
entryTarget: string;
direction: 'downstream' | 'upstream';
segments: TraceRepoSegment[];
/** Repos that could not be opened / traversed. */
skippedRepos: string[];
/** True when maxCrossDepth was hit before the trace naturally terminated. */
truncated: boolean;
}
// ---------------------------------------------------------------------------
// Internal options (not part of public API)
// ---------------------------------------------------------------------------
interface SegmentOptions {
config: GroupConfig;
deps: TraceDeps;
crossLinksIndex: CrossLinksIndex;
direction: 'downstream' | 'upstream';
maxDepth: number;
relationTypes: string[];
includeTests: boolean;
minConfidence: number;
openedRepoIds: Set<string>;
resolver: SymbolResolver;
}
// ---------------------------------------------------------------------------
// Parameters
// ---------------------------------------------------------------------------
export interface TraceParams {
/** Group name. */
name: string;
/** Group repo path (key in group.yaml repos map, e.g. "app/backend"). */
repo: string;
/** Symbol name or file path to start the trace from. */
target: string;
/** Trace direction — defaults to 'downstream'. */
direction?: 'downstream' | 'upstream';
/** Max BFS depth within each repo. 0 = unlimited (BFS runs until frontier is empty). Default: 0. */
maxDepth?: number;
/** Max cross-repo hops. 0 = unlimited. Default: 10. */
maxCrossDepth?: number;
/** Relation types for BFS edges (default: CALLS). */
relationTypes?: string[];
/** Include test files in traversal (default false). */
includeTests?: boolean;
/** Minimum edge confidence (0–1, default 0). */
minConfidence?: number;
}
// ---------------------------------------------------------------------------
// Deps injection (keeps module free of LocalBackend)
// ---------------------------------------------------------------------------
export interface TraceDeps {
port: GroupToolPort;
gitnexusDir: string;
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_MAX_DEPTH = 0; // 0 = unlimited (BFS terminates when frontier is empty)
const DEFAULT_MAX_CROSS_DEPTH = 10;
const DEFAULT_RELATION_TYPES = ['CALLS'];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** @internal exported for testing only */
export function isTestFilePath(fp: string): boolean {
const lower = fp.toLowerCase();
return (
lower.includes('/test/') ||
lower.includes('/tests/') ||
lower.includes('/__tests__/') ||
lower.includes('.test.') ||
lower.includes('.spec.') ||
lower.includes('_test.')
);
}
// ---------------------------------------------------------------------------
// Generic candidate helpers (framework-agnostic)
// ---------------------------------------------------------------------------
/** @internal exported for testing only */
export function isUtilOrDto(c: SymbolCandidate): boolean {
const idLower = c.id.toLowerCase();
const fpLower = (c.filePath || '').toLowerCase();
const combined = `${idLower}|${fpLower}`;
return (
/utils?[./|]/.test(combined) ||
/enum[./|]/.test(combined) ||
/validate[./|]/.test(combined) ||
/convert[./|]/.test(combined) ||
/dto[./|]/.test(combined) ||
/entity[./|]/.test(combined) ||
/\.set[A-Z]/.test(c.id) ||
/\.get[A-Z]/.test(c.id) ||
/\.is[A-Z]/.test(c.id)
);
}
/**
* Check if a file path belongs to a client/IDL module that is a dead-end for BFS
* (interface definitions with no CALLS edges).
* @internal exported for testing only
*/
export function isClientModulePath(filePath: string): boolean {
if (!filePath) return false;
const fp = filePath.toLowerCase();
if (
fp.includes('-client/') ||
fp.includes('-client-') ||
fp.includes('_client/') ||
fp.includes('/idl/') ||
fp.endsWith('.thrift')
)
return true;
if (/-api\//.test(fp)) return true;
if (/[_-]client\d/.test(fp)) return true;
return false;
}
/**
* Resolve ALL viable entry symbols for multi-seed BFS.
* Uses resolver.scoreCandidate and resolver.drillDownToMethod for framework-aware selection.
*/
async function resolveEntrySymbols(
repoId: string,
target: string,
resolver: SymbolResolver,
): Promise<ResolvedSymbol[]> {
const MAX_SEEDS = 5;
const SCORE_THRESHOLD_OFFSET = 100;
const lastDot = target.lastIndexOf('.');
const preferredMethod = lastDot >= 0 ? target.slice(lastDot + 1) : target;
const drillDown = (node: SymbolCandidate) =>
resolver.drillDownToMethod
? resolver.drillDownToMethod(repoId, node, preferredMethod)
: Promise.resolve(node);
const score = (c: SymbolCandidate, variants?: string[]) =>
resolver.scoreCandidate ? resolver.scoreCandidate(c, variants, target) : 0;
// Exact id match — skip scoring
const exactRows = await executeParameterized(
repoId,
`MATCH (n) WHERE n.id = $target
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 1`,
{ target },
);
if (exactRows.length > 0) {
const r = exactRows[0];
const matched: SymbolCandidate = {
id: r.id ?? r[0],
name: r.name ?? r[1],
type: r.type ?? r[2],
filePath: r.filePath ?? r[3],
};
return [await drillDown(matched)];
}
const nameRows = await executeParameterized(
repoId,
`MATCH (n) WHERE n.name = $target
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath`,
{ target },
);
if (nameRows.length === 0) return [];
const candidates: SymbolCandidate[] = nameRows.map((r: Record<string, unknown>) => ({
id: (r.id ?? r[0]) as string,
name: (r.name ?? r[1]) as string,
type: (r.type ?? r[2]) as string,
filePath: (r.filePath ?? r[3]) as string,
}));
if (candidates.length === 1) return [await drillDown(candidates[0])];
candidates.sort((a, b) => score(b) - score(a));
const topScore = score(candidates[0]);
const scoreThreshold = topScore - SCORE_THRESHOLD_OFFSET;
const viable = candidates.filter((c) => {
const s = score(c);
if (s < scoreThreshold || s < 0) return false;
if (isTestFilePath(c.filePath)) return false;
if (isClientModulePath(c.filePath)) return false;
if (isUtilOrDto(c)) return false;
return true;
});
const pool = viable.length > 0 ? viable : [candidates[0]];
const results: ResolvedSymbol[] = [];
const seenIds = new Set<string>();
for (const c of pool.slice(0, MAX_SEEDS)) {
const drilled = await drillDown(c);
if (!seenIds.has(drilled.id)) {
seenIds.add(drilled.id);
results.push(drilled);
}
}
if (results.length > 1) {
logger.info(
`[trace] resolveEntrySymbols "${target}": ${candidates.length} total, ` +
`${results.length} seeds: ${results.map((r) => `"${r.id}"`).join(', ')} (threshold=${scoreThreshold})`,
);
} else {
logger.info(
`[trace] resolveEntrySymbols "${target}": ${candidates.length} candidates, selected "${results[0]?.id}" (score=${topScore})`,
);
}
return results;
}
/**
* Run BFS within a single repo's lbug graph.
* Seeds are included in nodes at depth=0. Returns visited nodes and file paths.
*/
async function intraRepoBFS(
repoId: string,
seeds: ResolvedSymbol[],
direction: 'downstream' | 'upstream',
opts: {
maxDepth: number;
relationTypes: string[];
includeTests: boolean;
minConfidence: number;
},
): Promise<{ nodes: TraceNode[]; visitedIds: string[]; visitedFilePaths: Set<string> }> {
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
const visited = new Set<string>(seeds.map((s) => s.id));
const visitedFilePaths = new Set<string>(seeds.filter((s) => s.filePath).map((s) => s.filePath));
let frontier = seeds.map((s) => s.id);
// Include seed nodes themselves at depth=0
const nodes: TraceNode[] = seeds.map((s) => ({ ...s, depth: 0 }));
for (let depth = 1; (maxDepth === 0 || depth <= maxDepth) && frontier.length > 0; depth++) {
// Use parameterized query to avoid isWriteQuery false positives when
// node IDs contain keywords like CREATE, SET, DELETE, etc.
const query =
direction === 'downstream'
? `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $idList AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
: `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $idList AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
let related: any[];
try {
related = await executeParameterized(repoId, query, { idList: frontier });
} catch (e) {
logger.warn(`[trace] BFS query failed at depth ${depth}: ${e}`);
break;
}
const nextFrontier: string[] = [];
for (const rel of related) {
const relId = rel.id ?? rel[1];
const filePath = rel.filePath ?? rel[4] ?? '';
if (!includeTests && isTestFilePath(filePath)) continue;
if (visited.has(relId)) continue;
visited.add(relId);
if (filePath) visitedFilePaths.add(filePath);
nextFrontier.push(relId);
const relationType = rel.relType ?? rel[5];
const storedConf = rel.confidence ?? rel[6];
const effectiveConf = typeof storedConf === 'number' && storedConf > 0 ? storedConf : 1;
nodes.push({
id: relId,
name: rel.name ?? rel[2],
type: rel.type ?? rel[3],
filePath,
depth,
relationType,
confidence: effectiveConf,
});
}
frontier = nextFrontier;
}
return { nodes, visitedIds: [...visited], visitedFilePaths };
}
// ---------------------------------------------------------------------------
// CrossLinks index — pre-built once per trace for O(1) repo lookup
// ---------------------------------------------------------------------------
/**
* Pre-indexed crossLinks grouped by (repo, direction-role).
* For each repo, stores the subset of crossLinks where that repo is the
* "local endpoint" (the side that matches during hop discovery).
*/
/** @internal exported for testing only */
export interface CrossLinksIndex {
/** downstream RPC: from.repo → links[] */
downstreamRpc: Map<string, CrossLink[]>;
/** upstream RPC: to.repo → links[] */
upstreamRpc: Map<string, CrossLink[]>;
/** downstream topic: to.repo → links[] (producer side) */
downstreamTopic: Map<string, CrossLink[]>;
/** upstream topic: from.repo → links[] (consumer side) */
upstreamTopic: Map<string, CrossLink[]>;
}
/** @internal exported for testing only */
export function buildCrossLinksIndex(crossLinks: CrossLink[]): CrossLinksIndex {
const idx: CrossLinksIndex = {
downstreamRpc: new Map(),
upstreamRpc: new Map(),
downstreamTopic: new Map(),
upstreamTopic: new Map(),
};
for (const link of crossLinks) {
if (link.type === 'topic') {
// downstream topic: producer(to) is local → consumer(from) is remote
const dtKey = link.to.repo;
if (!idx.downstreamTopic.has(dtKey)) idx.downstreamTopic.set(dtKey, []);
idx.downstreamTopic.get(dtKey)!.push(link);
// upstream topic: consumer(from) is local → producer(to) is remote
const utKey = link.from.repo;
if (!idx.upstreamTopic.has(utKey)) idx.upstreamTopic.set(utKey, []);
idx.upstreamTopic.get(utKey)!.push(link);
} else {
// downstream RPC: consumer(from) is local → provider(to) is remote
const drKey = link.from.repo;
if (!idx.downstreamRpc.has(drKey)) idx.downstreamRpc.set(drKey, []);
idx.downstreamRpc.get(drKey)!.push(link);
// upstream RPC: provider(to) is local → consumer(from) is remote
const urKey = link.to.repo;
if (!idx.upstreamRpc.has(urKey)) idx.upstreamRpc.set(urKey, []);
idx.upstreamRpc.get(urKey)!.push(link);
}
}
return idx;
}
/**
* Find cross-repo hops by matching BFS-visited file paths against
* contracts.json crossLinks (using pre-built index).
*
* For RPC (thrift/http/grpc):
* - downstream: from.repo == currentRepo (consumer calls provider)
* - upstream: to.repo == currentRepo (provider is called by consumer)
*
* For MQ (topic):
* - Data flows from producer (to) → consumer (from), opposite to RPC.
* - downstream: to.repo == currentRepo (producer sends to consumer)
* - upstream: from.repo == currentRepo (consumer receives from producer)
*
* Topic hop deduplication: For topic-type crossLinks, multiple consumers in the
* same target repo (e.g. different consumer groups on the same topic) produce
* duplicate hops. We dedup by (contractType, targetRepo) for topics, keeping
* only the first hop per target repo per topic contractId.
*/
/** @internal exported for testing only */
export function findCrossRepoHopsFromRegistry(
crossLinksIndex: CrossLinksIndex,
repoPath: string,
visitedFilePaths: Set<string>,
direction: 'downstream' | 'upstream',
): TraceCrossHop[] {
const hops: TraceCrossHop[] = [];
const seen = new Set<string>();
// Gather only the links relevant to this repo+direction from the pre-built index
const rpcLinks =
direction === 'downstream'
? (crossLinksIndex.downstreamRpc.get(repoPath) ?? [])
: (crossLinksIndex.upstreamRpc.get(repoPath) ?? []);
const topicLinks =
direction === 'downstream'
? (crossLinksIndex.downstreamTopic.get(repoPath) ?? [])
: (crossLinksIndex.upstreamTopic.get(repoPath) ?? []);
// Process RPC links
for (const link of rpcLinks) {
const localEndpoint = direction === 'downstream' ? link.from : link.to;
const remoteEndpoint = direction === 'downstream' ? link.to : link.from;
if (remoteEndpoint.repo === repoPath) continue; // skip self-links
if (!visitedFilePaths.has(localEndpoint.symbolRef.filePath)) continue;
const key = `${link.contractId}::${repoPath}->${remoteEndpoint.repo}`;
if (seen.has(key)) continue;
seen.add(key);
hops.push({
contractId: link.contractId,
contractType: link.type,
matchType: link.matchType,
linkConfidence: link.confidence,
from: {
repo: link.from.repo,
symbolUid: link.from.symbolUid,
symbolName: link.from.symbolRef.name,
symbolFilePath: link.from.symbolRef.filePath,
},
to: {
repo: link.to.repo,
symbolUid: link.to.symbolUid,
symbolName: link.to.symbolRef.name,
symbolFilePath: link.to.symbolRef.filePath,
},
});
}
// Process topic/MQ links (match at repo level, no file path check)
for (const link of topicLinks) {
const remoteEndpoint = direction === 'downstream' ? link.from : link.to;
if (remoteEndpoint.repo === repoPath) continue; // skip self-links
const key = `topic::${link.contractId}::${remoteEndpoint.repo}`;
if (seen.has(key)) continue;
seen.add(key);
hops.push({
contractId: link.contractId,
contractType: link.type,
matchType: link.matchType,
linkConfidence: link.confidence,
from: {
repo: link.from.repo,
symbolUid: link.from.symbolUid,
symbolName: link.from.symbolRef.name,
symbolFilePath: link.from.symbolRef.filePath,
},
to: {
repo: link.to.repo,
symbolUid: link.to.symbolUid,
symbolName: link.to.symbolRef.name,
symbolFilePath: link.to.symbolRef.filePath,
},
});
}
return hops;
}
// ---------------------------------------------------------------------------
// Segment processing (extracted for parallel execution)
// ---------------------------------------------------------------------------
type QueueItem = {
repoPath: string;
symbolName: string;
crossDepth: number;
isTopic?: boolean;
hintFilePath?: string;
};
interface SegmentResult {
repoPath: string;
skipped: boolean;
segment?: TraceRepoSegment;
}
/**
* Process a single cross-repo segment: resolve repo → init lbug → resolve
* symbol → BFS → find crossHops. Does NOT close lbug — caller manages
* pool lifecycle via openedRepoIds.
*/
async function processOneSegment(item: QueueItem, opts: SegmentOptions): Promise<SegmentResult> {
const {
config,
deps,
crossLinksIndex,
direction,
maxDepth,
relationTypes,
includeTests,
minConfidence,
openedRepoIds,
resolver,
} = opts;
const regName = config.repos[item.repoPath];
if (!regName) {
return { repoPath: item.repoPath, skipped: true };
}
let repoHandle: GroupRepoHandle;
try {
repoHandle = await deps.port.resolveRepo(regName);
} catch {
return { repoPath: item.repoPath, skipped: true };
}
// Init lbug
const dbPath = `${repoHandle.storagePath}/lbug`;
try {
await initLbug(repoHandle.id, dbPath);
openedRepoIds.add(repoHandle.id);
} catch {
return { repoPath: item.repoPath, skipped: true };
}
try {
const bfsOpts = { maxDepth, relationTypes, includeTests, minConfidence };
let nodes: TraceNode[] = [];
let visitedFilePaths: Set<string> = new Set();
let entrySymbolUid = item.symbolName;
const isUnresolvable = resolver.isUnresolvableSymbolName?.bind(resolver) ?? (() => false);
const resolveCtx = { hintFilePath: item.hintFilePath, isTopic: item.isTopic };
if (item.isTopic && !isUnresolvable(item.symbolName)) {
// Topic hop with resolvable symbolName — attempt BFS, fall back to empty segment.
const targetSym = resolver.resolveSymbolByName
? await resolver.resolveSymbolByName(repoHandle.id, item.symbolName, resolveCtx)
: null;
if (targetSym) {
logger.info(`[trace] topic hop resolved "${item.symbolName}" → BFS from ${targetSym.id}`);
entrySymbolUid = targetSym.id;
const bfsResult = await intraRepoBFS(repoHandle.id, [targetSym], direction, bfsOpts);
nodes = bfsResult.nodes;
visitedFilePaths = bfsResult.visitedFilePaths;
} else {
logger.info(
`[trace] topic hop to "${item.repoPath}" — "${item.symbolName}" not found, empty segment`,
);
if (item.hintFilePath) visitedFilePaths.add(item.hintFilePath);
}
} else if (item.isTopic) {
// Unresolvable topic symbolName — seed visitedFilePaths so RPC out-links are discoverable.
logger.info(
`[trace] topic hop to "${item.repoPath}" — skipping resolve for "${item.symbolName}"`,
);
if (item.hintFilePath) visitedFilePaths.add(item.hintFilePath);
} else {
const targetSym = resolver.resolveSymbolByName
? await resolver.resolveSymbolByName(repoHandle.id, item.symbolName, resolveCtx)
: null;
if (!targetSym) {
logger.warn(
`[trace] symbol "${item.symbolName}" not found in "${item.repoPath}", skipping`,
);
return { repoPath: item.repoPath, skipped: true };
}
entrySymbolUid = targetSym.id;
const bfsResult = await intraRepoBFS(repoHandle.id, [targetSym], direction, bfsOpts);
nodes = bfsResult.nodes;
visitedFilePaths = bfsResult.visitedFilePaths;
}
// Find cross-repo hops via pre-built index
const crossHops = findCrossRepoHopsFromRegistry(
crossLinksIndex,
item.repoPath,
visitedFilePaths,
direction,
);
return {
repoPath: item.repoPath,
skipped: false,
segment: {
repo: regName,
repoPath: item.repoPath,
entrySymbolUid,
nodes,
crossHops,
},
};
} finally {
// Don't close here — trace maintains all opened repos until runGroupTrace
// completes, then closes them all. LRU eviction handles pool pressure
// (MAX_POOL_SIZE=5). Some segments may lose their pool entry mid-BFS,
// causing partial traversal (warn + break) which is acceptable.
}
}
// ---------------------------------------------------------------------------
// Main entry points
// ---------------------------------------------------------------------------
/**
* Run a cross-repo call trace with the default symbol resolver.
* Drop-in replacement — existing callers need no changes.
*/
export async function runGroupTrace(
deps: TraceDeps,
params: TraceParams,
): Promise<TraceResult | { error: string }> {
return runGroupTraceWithResolver(deps, params, new DefaultSymbolResolver());
}
/**
* Run a cross-repo call trace with a custom SymbolResolver.
* Use this to inject a different framework-awareness strategy without
* modifying the BFS engine.
*/
export async function runGroupTraceWithResolver(
deps: TraceDeps,
params: TraceParams,
resolver: SymbolResolver,
): Promise<TraceResult | { error: string }> {
const {
name,
repo: entryRepoPath,
target,
direction = 'downstream',
maxDepth = DEFAULT_MAX_DEPTH,
maxCrossDepth = DEFAULT_MAX_CROSS_DEPTH,
relationTypes = DEFAULT_RELATION_TYPES,
includeTests = false,
minConfidence = 0,
} = params;
if (!name) return { error: 'name is required' };
if (!entryRepoPath) return { error: 'repo is required' };
if (!target) return { error: 'target is required' };
if (direction !== 'downstream' && direction !== 'upstream') {
return { error: 'direction must be downstream or upstream' };
}
// Load group config (mtime-cached)
const groupDir = getGroupDir(deps.gitnexusDir, name);
let config: GroupConfig;
try {
config = await cachedLoadGroupConfig(groupDir);
} catch (e) {
if (e instanceof GroupNotFoundError) {
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
}
return { error: e instanceof Error ? e.message : String(e) };
}
// Load contracts.json for cross-repo lookups and build index (mtime-cached)
const registry = await cachedReadContractRegistry(groupDir);
const crossLinks = registry?.crossLinks ?? [];
if (crossLinks.length === 0) {
logger.warn(
`[trace] No crossLinks in contracts.json for group "${name}". Cross-repo hops disabled.`,
);
}
const crossLinksIndex = buildCrossLinksIndex(crossLinks);
// Resolve entry repo
const entryRegistryName = config.repos[entryRepoPath];
if (!entryRegistryName) {
return { error: `Unknown repo path "${entryRepoPath}" in group "${name}".` };
}
let entryRepo: GroupRepoHandle;
try {
entryRepo = await deps.port.resolveRepo(entryRegistryName);
} catch (e) {
return { error: `Cannot resolve entry repo: ${e instanceof Error ? e.message : String(e)}` };
}
// State
const segments: TraceRepoSegment[] = [];
const skippedRepos: string[] = [];
const visitedRepos = new Set<string>(); // repo + symbolName to avoid cycles
let truncated = false;
// Queue: each item is a (repoPath, symbolName) to trace into
const queue: QueueItem[] = [];
// Track all opened repos for cleanup at end of trace
const openedRepoIds = new Set<string>();
try {
// --- Phase 1: entry repo ---
const entryDbPath = `${entryRepo.storagePath}/lbug`;
await initLbug(entryRepo.id, entryDbPath);
openedRepoIds.add(entryRepo.id);
// Resolve entry symbols (multi-seed: all viable implementations)
const entrySyms = await resolveEntrySymbols(entryRepo.id, target, resolver);
if (entrySyms.length === 0) {
return { error: `Symbol "${target}" not found in repo "${entryRepoPath}".` };
}
// BFS within entry repo — seed from ALL resolved entry symbols
const bfsResult = await intraRepoBFS(entryRepo.id, entrySyms, direction, {
maxDepth,
relationTypes,
includeTests,
minConfidence,
});
const entryNodes = bfsResult.nodes;
const entryVisitedFilePaths = bfsResult.visitedFilePaths;
// Find cross-repo hops via pre-built index
const entryCrossHops = findCrossRepoHopsFromRegistry(
crossLinksIndex,
entryRepoPath,
entryVisitedFilePaths,
direction,
);
segments.push({
repo: entryRegistryName,
repoPath: entryRepoPath,
entrySymbolUid: entrySyms[0].id,
nodes: entryNodes,
crossHops: entryCrossHops,
});
// Enqueue cross-repo targets
for (const hop of entryCrossHops) {
const isTopic = hop.contractType === 'topic';
const targetEndpoint = isTopic
? direction === 'downstream'
? hop.from
: hop.to
: direction === 'downstream'
? hop.to
: hop.from;
const key = isTopic
? `topic::${hop.contractId}::${targetEndpoint.repo}`
: `${targetEndpoint.repo}::${targetEndpoint.symbolName}`;
if (!visitedRepos.has(key)) {
visitedRepos.add(key);
queue.push({
repoPath: targetEndpoint.repo,
symbolName: targetEndpoint.symbolName,
crossDepth: 1,
isTopic,
hintFilePath: targetEndpoint.symbolFilePath,
});
}
}
// --- Phase 2+: cross-repo BFS (layer-parallel) ---
// Process segments in parallel batches. PARALLEL_LIMIT=4 leaves 1 pool
// slot for the entry repo (still in pool from Phase 1). LRU eviction
// may close idle repos mid-BFS (causing partial traversal at deeper
// depths) but this is tolerable — the main speedup comes from:
// 1. CrossLinks index: O(1) repo lookup vs O(N=2354) full scan
// 2. Parallelism: 4 segments BFS concurrently
const PARALLEL_LIMIT = 4;
while (queue.length > 0) {
// Drain current layer (all items at the same crossDepth)
const currentDepth = queue[0].crossDepth;
const layer: QueueItem[] = [];
while (queue.length > 0 && queue[0].crossDepth === currentDepth) {
layer.push(queue.shift()!);
}
if (maxCrossDepth > 0 && currentDepth > maxCrossDepth) {
truncated = true;
continue; // skip entire layer
}
// Group layer items by repoPath to batch-process same-repo items together.
// This avoids redundant initLbug/eviction cycles: all items for a given
// repo share one init, and different repo-groups run in parallel batches.
const repoGroups = new Map<string, QueueItem[]>();
for (const item of layer) {
const existing = repoGroups.get(item.repoPath);
if (existing) existing.push(item);
else repoGroups.set(item.repoPath, [item]);
}
const groupKeys = [...repoGroups.keys()];
// Process repo-groups in parallel batches of PARALLEL_LIMIT.
// Each group may contain multiple items for the same repo — they are
// processed sequentially within the group (single init, multiple BFS).
for (let batchStart = 0; batchStart < groupKeys.length; batchStart += PARALLEL_LIMIT) {
const batchKeys = groupKeys.slice(batchStart, batchStart + PARALLEL_LIMIT);
const batchResults = await Promise.all(
batchKeys.map(async (repoPath) => {
const items = repoGroups.get(repoPath)!;
const results: SegmentResult[] = [];
for (const item of items) {
results.push(
await processOneSegment(item, {
config,
deps,
crossLinksIndex,
direction,
maxDepth,
relationTypes,
includeTests,
minConfidence,
openedRepoIds,
resolver,
}),
);
}
return results;
}),
);
// Collect results and enqueue next-layer items
for (const groupResults of batchResults) {
for (const result of groupResults) {
if (result.skipped) {
skippedRepos.push(result.repoPath);
continue;
}
if (result.segment) {
segments.push(result.segment);
}
// Enqueue further cross-repo targets for the NEXT layer
for (const hop of result.segment?.crossHops ?? []) {
const isTopicHop = hop.contractType === 'topic';
const nextEndpoint = isTopicHop
? direction === 'downstream'
? hop.from
: hop.to
: direction === 'downstream'
? hop.to
: hop.from;
const key = isTopicHop
? `topic::${hop.contractId}::${nextEndpoint.repo}`
: `${nextEndpoint.repo}::${nextEndpoint.symbolName}`;
if (!visitedRepos.has(key)) {
visitedRepos.add(key);
queue.push({
repoPath: nextEndpoint.repo,
symbolName: nextEndpoint.symbolName,
crossDepth: currentDepth + 1,
isTopic: isTopicHop,
hintFilePath: nextEndpoint.symbolFilePath,
});
}
}
}
}
}
}
} finally {
// Close all lbug connections opened during this trace
for (const rid of openedRepoIds) {
await closeLbug(rid).catch(() => {});
}
}
// Filter out repos that actually produced segments (avoid false "skipped" reports
// when a repo is entered multiple times with different symbols — some succeed, some fail).
const reposWithSegments = new Set(segments.map((s) => s.repoPath));
const actuallySkipped = [...new Set(skippedRepos)].filter((r) => !reposWithSegments.has(r));
return {
group: name,
entryRepo: entryRepoPath,
entryTarget: target,
direction,
segments,
skippedRepos: actuallySkipped,
truncated,
};
}

View file

@ -3447,6 +3447,8 @@ export class LocalBackend {
return this.groupList(params);
case 'group_sync':
return this.groupSync(params);
case 'group_trace':
return this.getGroupService().groupTrace(params);
default:
throw new Error(
`Unknown group tool: ${method}. Removed tools: use repo "@<groupName>" on impact, query, or context (optional "/<memberPath>"), or MCP resources.`,

View file

@ -559,4 +559,75 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
required: ['name'],
},
},
{
name: 'group_trace',
description: `Trace cross-repo call chains starting from a symbol or file path.
Performs BFS within each repo's call graph (CALLS edges), then follows cross-repo
links from contracts.json to continue tracing into dependent services.
WHEN TO USE: Understanding end-to-end execution flows that span multiple microservices.
For example: "what does this API handler ultimately call across all repos?"
Returns: segments (per-repo BFS nodes), cross-repo hops, skipped repos, and truncation flag.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Group name (e.g. "flight-all")' },
repo: {
type: 'string',
description: 'Entry repo path key from group.yaml (e.g. "app/backend")',
},
target: {
type: 'string',
description: 'Symbol name or file path to start tracing from',
},
direction: {
type: 'string',
enum: ['downstream', 'upstream'],
description: 'Trace direction. Default: "downstream"',
default: 'downstream',
},
maxDepth: {
type: 'number',
description: 'Max BFS depth within each repo. 0 = unlimited. Default: 0',
default: 0,
minimum: 0,
},
maxCrossDepth: {
type: 'number',
description: 'Max cross-repo hops. 0 = unlimited. Default: 10. Capped at 50.',
default: 10,
minimum: 0,
maximum: 50,
},
relationTypes: {
type: 'array',
items: { type: 'string' },
description: 'Relation types for BFS edges. Default: ["CALLS"]',
default: ['CALLS'],
},
includeTests: {
type: 'boolean',
description: 'Include test files in traversal. Default: false',
default: false,
},
minConfidence: {
type: 'number',
description: 'Minimum edge confidence (0–1). Default: 0',
default: 0,
minimum: 0,
maximum: 1,
},
verbose: {
type: 'boolean',
description:
'Return full trace data including all nodes. Default: false returns slim summary with deduplicated cross-hops and stats only.',
default: false,
},
},
required: ['name', 'repo', 'target'],
},
},
];

File diff suppressed because it is too large Load diff

View file

@ -10,15 +10,15 @@
import { describe, it, expect } from 'vitest';
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
const GROUP_TOOLS = new Set(['group_list', 'group_sync']);
const GROUP_TOOLS = new Set(['group_list', 'group_sync', 'group_trace']);
const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
// Read-only tools that legitimately reach external systems. Add a tool name
// here when introducing a read-only tool that needs openWorldHint: true.
const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']);
describe('GITNEXUS_TOOLS', () => {
it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(13);
it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 2 group + 1 group_trace)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(14);
});
it('contains all expected tool names', () => {
@ -152,6 +152,15 @@ describe('GITNEXUS_TOOLS', () => {
}
});
it('group_trace requires name, repo, and target', () => {
const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_trace')!;
expect(tool.inputSchema.required).toContain('name');
expect(tool.inputSchema.required).toContain('repo');
expect(tool.inputSchema.required).toContain('target');
expect(tool.inputSchema.properties.repo).toBeDefined();
expect(tool.inputSchema.properties.target).toBeDefined();
});
it('impact, query, and context expose optional service with minLength', () => {
for (const n of ['impact', 'query', 'context'] as const) {
const tool = GITNEXUS_TOOLS.find((t) => t.name === n)!;