diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 0053099b7..26c61f757 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -373,4 +373,239 @@ export function registerGroupCommands(program: Command): void { await backend.dispose().catch(() => {}); } }); + + group + .command('trace ') + .description('Cross-repo call trace — follow CALLS edges across repos via CrossLinks') + .requiredOption('--target ', 'Symbol name, file path, or node id to start from') + .requiredOption('--repo ', 'Member path from group.yaml (e.g. app/backend)') + .option('--direction ', 'downstream or upstream', 'downstream') + .option('--max-depth ', 'Max BFS depth within each repo (0=unlimited)', '0') + .option('--max-cross-depth ', 'Max cross-repo hops (0=unlimited)', '10') + .option('--relation-types ', 'Comma-separated relation types (default: CALLS)', 'CALLS') + .option('--min-confidence ', '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) => { + 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(); + 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(); + 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, + }, + }; } diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index d0473048f..3435f1017 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -308,6 +308,77 @@ export class GroupService { return runGroupImpact({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params); } + async groupTrace(params: Record): Promise { + 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(); + 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): Promise { const name = String(params.name ?? '').trim(); const target = typeof params.target === 'string' ? params.target.trim() : ''; diff --git a/gitnexus/src/core/group/trace-resolver.ts b/gitnexus/src/core/group/trace-resolver.ts new file mode 100644 index 000000000..d4e405a4f --- /dev/null +++ b/gitnexus/src/core/group/trace-resolver.ts @@ -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; + + /** + * 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; +} + +// --------------------------------------------------------------------------- +// 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 { + 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 | 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 === '' || mName === '') 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 { + 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) => ({ + 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); + } +} diff --git a/gitnexus/src/core/group/trace.ts b/gitnexus/src/core/group/trace.ts new file mode 100644 index 000000000..5807b6fae --- /dev/null +++ b/gitnexus/src/core/group/trace.ts @@ -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 { + value: T; + mtime: number; +} + +const _groupConfigCache = new Map>(); +const _contractRegistryCache = new Map< + string, + CacheEntry>> +>(); + +async function cachedLoadGroupConfig(groupDir: string): Promise { + 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; + 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 { + 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) => ({ + 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(); + 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 }> { + 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(seeds.map((s) => s.id)); + const visitedFilePaths = new Set(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; + /** upstream RPC: to.repo → links[] */ + upstreamRpc: Map; + /** downstream topic: to.repo → links[] (producer side) */ + downstreamTopic: Map; + /** upstream topic: from.repo → links[] (consumer side) */ + upstreamTopic: Map; +} + +/** @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, + direction: 'downstream' | 'upstream', +): TraceCrossHop[] { + const hops: TraceCrossHop[] = []; + const seen = new Set(); + + // 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 { + 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 = 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 { + 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 { + 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(); // 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(); + + 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(); + 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, + }; +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 83c3f023f..2d003038d 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 "@" on impact, query, or context (optional "/"), or MCP resources.`, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 9300f5ae5..bdaeadf7e 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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'], + }, + }, ]; diff --git a/gitnexus/test/unit/group/trace.test.ts b/gitnexus/test/unit/group/trace.test.ts new file mode 100644 index 000000000..6a7eb0839 --- /dev/null +++ b/gitnexus/test/unit/group/trace.test.ts @@ -0,0 +1,1073 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + runGroupTrace, + runGroupTraceWithResolver, + isTestFilePath, + isClientModulePath, + isUtilOrDto, + buildCrossLinksIndex, + findCrossRepoHopsFromRegistry, + type CrossLinksIndex, +} from '../../../src/core/group/trace.js'; +import type { TraceResult, TraceDeps } from '../../../src/core/group/trace.js'; +import type { GroupToolPort, GroupRepoHandle } from '../../../src/core/group/service.js'; +import { DefaultSymbolResolver } from '../../../src/core/group/trace-resolver.js'; +import type { SymbolCandidate } from '../../../src/core/group/trace-resolver.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tmpGroup(opts?: { repos?: Record }): { + tmpDir: string; + groupDir: string; + cleanup: () => void; +} { + const tmpDir = path.join(os.tmpdir(), `gitnexus-trace-${Date.now()}-${Math.random()}`); + const groupDir = path.join(tmpDir, 'groups', 'g1'); + fs.mkdirSync(groupDir, { recursive: true }); + + const repos = opts?.repos ?? { 'app/backend': 'reg-be', 'app/frontend': 'reg-fe' }; + const reposYaml = Object.entries(repos) + .map(([k, v]) => ` ${k}: ${v}`) + .join('\n'); + + fs.writeFileSync( + path.join(groupDir, 'group.yaml'), + `version: 1 +name: g1 +description: "" +repos: +${reposYaml} +links: [] +packages: {} +detect: + http: true + grpc: true + topics: true + shared_libs: true + embedding_fallback: true +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`, + ); + + return { + tmpDir, + groupDir, + cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }), + }; +} + +/** Write a contracts.json file into the group directory. */ +function writeContractsJson(groupDir: string, crossLinks: any[] = [], contracts: any[] = []): void { + fs.writeFileSync( + path.join(groupDir, 'contracts.json'), + JSON.stringify({ + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots: {}, + missingRepos: [], + contracts, + crossLinks, + }), + ); +} + +function makePort(overrides: Partial = {}): GroupToolPort { + return { + resolveRepo: vi.fn( + async (name?: string): Promise => ({ + id: name ?? 'unknown', + name: name ?? 'unknown', + repoPath: `/tmp/repos/${name}`, + storagePath: `/tmp/storage/${name}`, + }), + ), + impact: vi.fn(async () => ({})), + query: vi.fn(async () => ({})), + impactByUid: vi.fn(async () => null), + context: vi.fn(async () => ({})), + ...overrides, + }; +} + +function makeDeps(port: GroupToolPort, gitnexusDir: string): TraceDeps { + return { port, gitnexusDir }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// Mock lbug pool-adapter so we don't need a real LadybugDB +vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({ + initLbug: vi.fn(async () => {}), + closeLbug: vi.fn(async () => {}), + executeParameterized: vi.fn(async () => []), + executeQuery: vi.fn(async () => []), + setMaxPoolSize: vi.fn(() => () => {}), // returns a no-op restore function +})); + +describe('runGroupTrace', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns error when name is missing', async () => { + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, '/tmp'), { + name: '', + repo: 'app/backend', + target: 'foo', + }); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('name'); + }); + + it('returns error when repo is missing', async () => { + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, '/tmp'), { + name: 'g1', + repo: '', + target: 'foo', + }); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('repo'); + }); + + it('returns error when target is missing', async () => { + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, '/tmp'), { + name: 'g1', + repo: 'app/backend', + target: '', + }); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('target'); + }); + + it('returns error when group not found', async () => { + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, '/tmp/nonexistent'), { + name: 'g1', + repo: 'app/backend', + target: 'foo', + }); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('not found'); + }); + + it('returns error when repo path not in group', async () => { + const { tmpDir, cleanup } = tmpGroup(); + try { + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/nonexistent', + target: 'foo', + }); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('Unknown repo path'); + } finally { + cleanup(); + } + }); + + it('returns error when entry symbol not found in lbug', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + writeContractsJson(groupDir); + // executeParameterized returns [] for all queries → symbol not found + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/backend', + target: 'nonExistentSymbol', + }); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('not found'); + } finally { + cleanup(); + } + }); + + it('returns single-repo trace when no crossLinks exist', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + writeContractsJson(groupDir); // empty crossLinks + + const { executeParameterized, executeQuery } = + await import('../../../src/core/lbug/pool-adapter.js'); + + // Resolve entry symbol by id + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'sym-1', name: 'myFunc', type: 'Function', filePath: 'src/main.ts' }, + ]); + + // BFS query returns one neighbor (now uses executeParameterized) + (executeParameterized as any).mockResolvedValueOnce([ + { + sourceId: 'sym-1', + id: 'sym-2', + name: 'helperFunc', + type: 'Function', + filePath: 'src/helper.ts', + relType: 'CALLS', + confidence: 1, + }, + ]); + // Next depth: no more neighbors + (executeParameterized as any).mockResolvedValueOnce([]); + + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/backend', + target: 'sym-1', + maxDepth: 2, + }); + + expect(result).not.toHaveProperty('error'); + const trace = result as TraceResult; + expect(trace.segments).toHaveLength(1); + expect(trace.segments[0].repo).toBe('reg-be'); + expect(trace.segments[0].nodes).toHaveLength(1); + expect(trace.segments[0].nodes[0].name).toBe('helperFunc'); + expect(trace.segments[0].crossHops).toHaveLength(0); + expect(trace.truncated).toBe(false); + } finally { + cleanup(); + } + }); + + it('follows cross-repo hop via contracts.json crossLinks', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + // Write contracts.json with a crossLink from app/backend → app/frontend + writeContractsJson(groupDir, [ + { + from: { + repo: 'app/backend', + symbolUid: 'source-scan::thrift::consumer::FrontendService/handleRequest', + symbolRef: { filePath: 'src/client.ts', name: 'FrontendService.handleRequest' }, + }, + to: { + repo: 'app/frontend', + symbolUid: 'source-scan::thrift::provider::FrontendService/handleRequest', + symbolRef: { filePath: 'src/api.ts', name: 'FrontendService.handleRequest' }, + }, + type: 'thrift', + contractId: 'thrift::FrontendService/handleRequest', + matchType: 'exact', + confidence: 1, + }, + ]); + + const { executeParameterized, executeQuery } = + await import('../../../src/core/lbug/pool-adapter.js'); + + // Entry repo: resolve entry symbol (exact id match → LIMIT 1 query) + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'be-sym-1', name: 'callFrontend', type: 'Function', filePath: 'src/client.ts' }, + ]); + + // Entry repo BFS depth 1: no CALLS neighbors + (executeParameterized as any).mockResolvedValueOnce([]); + + // Target repo: DefaultSymbolResolver.resolveSymbolByName: + // 1. exact full-name match query (rows) → empty + (executeParameterized as any).mockResolvedValueOnce([]); + // 2. shortName 'handleRequest' query (rows2) → one hit + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'fe-sym-1', name: 'handleRequest', type: 'Method', filePath: 'src/api.ts' }, + ]); + // drillDownToMethod: fe-sym-1 is already Method: → no extra query + + // Target repo BFS depth 1: one neighbor + (executeParameterized as any).mockResolvedValueOnce([ + { + sourceId: 'fe-sym-1', + id: 'fe-sym-2', + name: 'processData', + type: 'Function', + filePath: 'src/processor.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + // Target repo BFS depth 2: no more + (executeParameterized as any).mockResolvedValueOnce([]); + + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/backend', + target: 'be-sym-1', + maxDepth: 3, + maxCrossDepth: 2, + }); + + expect(result).not.toHaveProperty('error'); + const trace = result as TraceResult; + expect(trace.segments).toHaveLength(2); + + // First segment: entry repo + expect(trace.segments[0].repoPath).toBe('app/backend'); + expect(trace.segments[0].crossHops).toHaveLength(1); + expect(trace.segments[0].crossHops[0].contractId).toBe( + 'thrift::FrontendService/handleRequest', + ); + + // Second segment: target repo + expect(trace.segments[1].repoPath).toBe('app/frontend'); + expect(trace.segments[1].nodes).toHaveLength(1); + expect(trace.segments[1].nodes[0].name).toBe('processData'); + } finally { + cleanup(); + } + }); + + it('follows cross-repo hop via topic crossLink (MQ direction reversed)', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + // For MQ/topic crossLinks: from=consumer, to=producer. + // When tracing downstream from the producer (app/backend), + // the fix should match link.to.repo === currentRepo and jump to link.from.repo. + // symbolRef.name uses MQ consumer/producer format: "mqConsumer(...)" / "mqProducer(...)" + // which won't exist in LadybugDB — the isTopic flag skips resolveByName. + writeContractsJson(groupDir, [ + { + from: { + repo: 'app/frontend', // consumer + symbolUid: 'source-scan::topic::consumer::order_created', + symbolRef: { filePath: 'messaging.properties', name: 'mqConsumer(order_created)' }, + }, + to: { + repo: 'app/backend', // producer + symbolUid: 'source-scan::topic::provider::order_created', + symbolRef: { filePath: 'messaging.properties', name: 'mqProducer(order_created)' }, + }, + type: 'topic', + contractId: 'topic::order_created', + matchType: 'exact', + confidence: 1, + }, + ]); + + const { executeParameterized } = await import('../../../src/core/lbug/pool-adapter.js'); + + // Entry repo (app/backend): resolve entry symbol + (executeParameterized as any).mockResolvedValueOnce([ + { + id: 'be-producer', + name: 'OrderCreatedProducer', + type: 'Class', + filePath: 'src/producer.ts', + }, + ]); + + // Entry repo BFS depth 1: no CALLS neighbors + (executeParameterized as any).mockResolvedValueOnce([]); + + // Consumer repo (app/frontend): topic hop — resolveByName is SKIPPED. + // No mock needed for resolveByName. Only need empty BFS results won't be called either. + // The segment will have empty nodes but still be added. + + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/backend', + target: 'be-producer', + maxDepth: 3, + maxCrossDepth: 2, + }); + + expect(result).not.toHaveProperty('error'); + const trace = result as TraceResult; + expect(trace.segments).toHaveLength(2); + + // First segment: producer repo (app/backend) + expect(trace.segments[0].repoPath).toBe('app/backend'); + expect(trace.segments[0].crossHops).toHaveLength(1); + expect(trace.segments[0].crossHops[0].contractId).toBe('topic::order_created'); + expect(trace.segments[0].crossHops[0].contractType).toBe('topic'); + + // Second segment: consumer repo (app/frontend) — added via topic hop, no BFS + expect(trace.segments[1].repoPath).toBe('app/frontend'); + expect(trace.segments[1].nodes).toHaveLength(0); // no BFS for topic hops + // Crucially: NOT in skippedRepos + expect(trace.skippedRepos).not.toContain('app/frontend'); + } finally { + cleanup(); + } + }); + + it('deduplicates multiple topic hops to the same target repo', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + // Two topic crossLinks from app/backend → app/frontend (different topics) + // Should produce only ONE segment for app/frontend (deduped by repo). + writeContractsJson(groupDir, [ + { + from: { + repo: 'app/frontend', + symbolUid: 'source-scan::topic::consumer::topic_a', + symbolRef: { filePath: 'messaging.properties', name: 'mqConsumer(topic_a)' }, + }, + to: { + repo: 'app/backend', + symbolUid: 'source-scan::topic::provider::topic_a', + symbolRef: { filePath: 'messaging.properties', name: 'mqProducer(topic_a)' }, + }, + type: 'topic', + contractId: 'topic::topic_a', + matchType: 'exact', + confidence: 1, + }, + { + from: { + repo: 'app/frontend', + symbolUid: 'source-scan::topic::consumer::topic_b', + symbolRef: { filePath: 'messaging.properties', name: 'mqConsumer(topic_b)' }, + }, + to: { + repo: 'app/backend', + symbolUid: 'source-scan::topic::provider::topic_b', + symbolRef: { filePath: 'messaging.properties', name: 'mqProducer(topic_b)' }, + }, + type: 'topic', + contractId: 'topic::topic_b', + matchType: 'exact', + confidence: 1, + }, + ]); + + const { executeParameterized } = await import('../../../src/core/lbug/pool-adapter.js'); + + // Entry repo: resolve entry symbol + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'be-1', name: 'ProducerService', type: 'Class', filePath: 'src/producer.ts' }, + ]); + // Entry repo BFS: no neighbors + (executeParameterized as any).mockResolvedValueOnce([]); + + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/backend', + target: 'be-1', + maxDepth: 2, + maxCrossDepth: 2, + }); + + expect(result).not.toHaveProperty('error'); + const trace = result as TraceResult; + // Two topic crossLinks with different contractIds → two hops, two consumer segments. + // Dedup key is topic::contractId::repo, so different contractIds each enqueue once. + expect(trace.segments).toHaveLength(3); + const frontendSegments = trace.segments.filter((s) => s.repoPath === 'app/frontend'); + expect(frontendSegments).toHaveLength(2); + // Each hop should appear in entry crossHops + expect(trace.segments[0].crossHops).toHaveLength(2); + } finally { + cleanup(); + } + }); + + it('skips resolveByName for unresolvable synthetic symbol names', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + // RPC crossLink with a cache-style symbolName that won't exist in lbug + writeContractsJson(groupDir, [ + { + from: { + repo: 'app/backend', + symbolUid: 'cache::consumer::fare.fd', + symbolRef: { filePath: 'src/cache.ts', name: 'cache.fare.fd.category.name' }, + }, + to: { + repo: 'app/frontend', + symbolUid: 'cache::provider::fare.fd', + symbolRef: { filePath: 'cache.properties', name: 'cache.fare.fd.category.name' }, + }, + type: 'custom', + contractId: 'custom::cache::fare.fd', + matchType: 'exact', + confidence: 0.8, + }, + ]); + + const { executeParameterized } = await import('../../../src/core/lbug/pool-adapter.js'); + + // Entry repo: resolve entry symbol + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'be-1', name: 'CacheService', type: 'Class', filePath: 'src/cache.ts' }, + ]); + // Entry repo BFS depth 1: returns the file that matches crossLink + (executeParameterized as any).mockResolvedValueOnce([ + { + sourceId: 'be-1', + id: 'be-2', + name: 'readCache', + type: 'Method', + filePath: 'src/cache.ts', + relType: 'CALLS', + confidence: 1, + }, + ]); + // Entry repo BFS depth 2: no more + (executeParameterized as any).mockResolvedValueOnce([]); + + // Target repo: since symbolName is "cache.fare.fd.category.name", + // isUnresolvableSymbolName should return true → no lbug query fired → + // repo is skipped (returns null from resolveByName → skipped). + + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/backend', + target: 'be-1', + maxDepth: 3, + maxCrossDepth: 2, + }); + + expect(result).not.toHaveProperty('error'); + const trace = result as TraceResult; + // Entry segment should have the crossHop + expect(trace.segments[0].crossHops).toHaveLength(1); + // Target repo should be SKIPPED (resolveByName returned null for unresolvable name) + expect(trace.skippedRepos).toContain('app/frontend'); + // Only 1 segment (the entry repo) + expect(trace.segments).toHaveLength(1); + } finally { + cleanup(); + } + }); + + it('returns error for invalid direction', async () => { + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, '/tmp'), { + name: 'g1', + repo: 'app/backend', + target: 'foo', + direction: 'sideways' as any, + }); + expect(result).toHaveProperty('error'); + expect((result as { error: string }).error).toContain('direction'); + }); + + it('skips test files when includeTests is false', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + writeContractsJson(groupDir); + + const { executeParameterized, executeQuery } = + await import('../../../src/core/lbug/pool-adapter.js'); + + // Resolve entry symbol (exact id match) — id has Method: prefix so drillDown is skipped + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'Method:sym-1', name: 'myFunc', type: 'Method', filePath: 'src/main.ts' }, + ]); + + // BFS depth 1: returns a test-file neighbor + (executeParameterized as any).mockResolvedValueOnce([ + { + sourceId: 'sym-1', + id: 'test-sym', + name: 'testMyFunc', + type: 'Function', + filePath: 'src/__tests__/main.test.ts', + relType: 'CALLS', + confidence: 1, + }, + ]); + // BFS depth 2 would not run (test-sym filtered → empty frontier) + + const port = makePort(); + const result = await runGroupTrace(makeDeps(port, tmpDir), { + name: 'g1', + repo: 'app/backend', + target: 'sym-1', + includeTests: false, + maxDepth: 2, + }); + + expect(result).not.toHaveProperty('error'); + const trace = result as TraceResult; + // Seed sym-1 (non-test) appears at depth=0; test-file neighbor filtered out. + expect(trace.segments[0].nodes).toHaveLength(1); + expect(trace.segments[0].nodes[0].id).toBe('Method:sym-1'); + expect(trace.segments[0].nodes.every((n) => !n.filePath.includes('__tests__'))).toBe(true); + } finally { + cleanup(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Pure-function unit tests (no lbug, no I/O) +// --------------------------------------------------------------------------- + +describe('isTestFilePath', () => { + it.each([ + ['src/__tests__/foo.ts', true], + ['src/foo.test.ts', true], + ['src/foo.spec.ts', true], + ['src/test/foo.ts', true], + ['src/tests/foo.ts', true], + ['src/foo_test.ts', true], + ['src/main.ts', false], + ['src/contest/winner.ts', false], + ['src/TestHelper.ts', false], + ])('%s → %s', (fp, expected) => { + expect(isTestFilePath(fp)).toBe(expected); + }); +}); + +describe('isClientModulePath', () => { + it.each([ + ['', false], + ['services/order-service/src/main.ts', false], + ['services/order-client/src/api.ts', true], + ['services/order-client-v2/src/api.ts', true], + ['services/order_client/src/api.ts', true], + ['idl/order.thrift', true], + ['src/services/order.thrift', true], + ['services/order-api/src/routes.ts', true], + ['services/order-client3/src/api.ts', true], + ])('%s → %s', (fp, expected) => { + expect(isClientModulePath(fp)).toBe(expected); + }); +}); + +describe('isUtilOrDto', () => { + const c = (id: string, fp = ''): SymbolCandidate => ({ + id, + name: id, + type: 'Class', + filePath: fp, + }); + + it('flags util classes', () => { + expect(isUtilOrDto(c('utils/StringUtils'))).toBe(true); + expect(isUtilOrDto(c('util/DateUtil'))).toBe(true); + }); + + it('flags enum / dto / entity', () => { + expect(isUtilOrDto(c('enum/Status'))).toBe(true); + expect(isUtilOrDto(c('dto/OrderDto'))).toBe(true); + expect(isUtilOrDto(c('entity/UserEntity'))).toBe(true); + }); + + it('flags getter / setter / is-check methods', () => { + expect(isUtilOrDto(c('Order.getName'))).toBe(true); + expect(isUtilOrDto(c('Order.setName'))).toBe(true); + expect(isUtilOrDto(c('Order.isActive'))).toBe(true); + }); + + it('does not flag normal service classes', () => { + expect(isUtilOrDto(c('OrderService', 'src/service/OrderService.java'))).toBe(false); + expect(isUtilOrDto(c('PaymentHandler', 'src/handler/PaymentHandler.java'))).toBe(false); + }); +}); + +describe('buildCrossLinksIndex', () => { + it('indexes RPC links by from.repo (downstream) and to.repo (upstream)', () => { + const links: any[] = [ + { + from: { repo: 'svc-a', symbolRef: { filePath: 'a.ts', name: 'foo' }, symbolUid: 'u1' }, + to: { repo: 'svc-b', symbolRef: { filePath: 'b.ts', name: 'bar' }, symbolUid: 'u2' }, + type: 'http', + contractId: 'c1', + matchType: 'exact', + confidence: 1, + }, + ]; + const idx = buildCrossLinksIndex(links); + expect(idx.downstreamRpc.get('svc-a')).toHaveLength(1); + expect(idx.upstreamRpc.get('svc-b')).toHaveLength(1); + expect(idx.downstreamTopic.size).toBe(0); + }); + + it('indexes topic links by to.repo (downstream) and from.repo (upstream)', () => { + const links: any[] = [ + { + from: { repo: 'consumer', symbolRef: { filePath: 'c.ts', name: 'recv' }, symbolUid: 'u3' }, + to: { repo: 'producer', symbolRef: { filePath: 'p.ts', name: 'send' }, symbolUid: 'u4' }, + type: 'topic', + contractId: 'topic::orders', + matchType: 'exact', + confidence: 1, + }, + ]; + const idx = buildCrossLinksIndex(links); + expect(idx.downstreamTopic.get('producer')).toHaveLength(1); + expect(idx.upstreamTopic.get('consumer')).toHaveLength(1); + expect(idx.downstreamRpc.size).toBe(0); + }); + + it('returns empty index for no links', () => { + const idx = buildCrossLinksIndex([]); + expect(idx.downstreamRpc.size).toBe(0); + expect(idx.upstreamRpc.size).toBe(0); + expect(idx.downstreamTopic.size).toBe(0); + expect(idx.upstreamTopic.size).toBe(0); + }); +}); + +describe('findCrossRepoHopsFromRegistry', () => { + function makeIdx(overrides: Partial = {}): CrossLinksIndex { + return { + downstreamRpc: new Map(), + upstreamRpc: new Map(), + downstreamTopic: new Map(), + upstreamTopic: new Map(), + ...overrides, + }; + } + + it('returns empty when no links for this repo', () => { + const idx = makeIdx(); + const hops = findCrossRepoHopsFromRegistry(idx, 'svc-a', new Set(['a.ts']), 'downstream'); + expect(hops).toHaveLength(0); + }); + + it('returns hop when visited file matches RPC from.symbolRef.filePath (downstream)', () => { + const link: any = { + from: { + repo: 'svc-a', + symbolRef: { filePath: 'src/client.ts', name: 'callB' }, + symbolUid: 'u1', + }, + to: { + repo: 'svc-b', + symbolRef: { filePath: 'src/handler.ts', name: 'handle' }, + symbolUid: 'u2', + }, + type: 'http', + contractId: 'http::c1', + matchType: 'exact', + confidence: 0.9, + }; + const idx = makeIdx({ downstreamRpc: new Map([['svc-a', [link]]]) }); + const visited = new Set(['src/client.ts']); + const hops = findCrossRepoHopsFromRegistry(idx, 'svc-a', visited, 'downstream'); + expect(hops).toHaveLength(1); + expect(hops[0].contractId).toBe('http::c1'); + expect(hops[0].from.repo).toBe('svc-a'); + expect(hops[0].to.repo).toBe('svc-b'); + }); + + it('skips hop when visited file does NOT match', () => { + const link: any = { + from: { + repo: 'svc-a', + symbolRef: { filePath: 'src/client.ts', name: 'callB' }, + symbolUid: 'u1', + }, + to: { + repo: 'svc-b', + symbolRef: { filePath: 'src/handler.ts', name: 'handle' }, + symbolUid: 'u2', + }, + type: 'http', + contractId: 'http::c1', + matchType: 'exact', + confidence: 0.9, + }; + const idx = makeIdx({ downstreamRpc: new Map([['svc-a', [link]]]) }); + const visited = new Set(['src/other.ts']); // doesn't match + const hops = findCrossRepoHopsFromRegistry(idx, 'svc-a', visited, 'downstream'); + expect(hops).toHaveLength(0); + }); + + it('deduplicates same contractId+repo combination', () => { + const link: any = { + from: { + repo: 'svc-a', + symbolRef: { filePath: 'src/client.ts', name: 'callB' }, + symbolUid: 'u1', + }, + to: { + repo: 'svc-b', + symbolRef: { filePath: 'src/handler.ts', name: 'handle' }, + symbolUid: 'u2', + }, + type: 'http', + contractId: 'http::c1', + matchType: 'exact', + confidence: 0.9, + }; + // Same link duplicated in index + const idx = makeIdx({ downstreamRpc: new Map([['svc-a', [link, link]]]) }); + const visited = new Set(['src/client.ts']); + const hops = findCrossRepoHopsFromRegistry(idx, 'svc-a', visited, 'downstream'); + expect(hops).toHaveLength(1); + }); + + it('skips self-links (remote.repo === currentRepo)', () => { + const link: any = { + from: { + repo: 'svc-a', + symbolRef: { filePath: 'src/client.ts', name: 'callSelf' }, + symbolUid: 'u1', + }, + to: { + repo: 'svc-a', + symbolRef: { filePath: 'src/handler.ts', name: 'handle' }, + symbolUid: 'u2', + }, + type: 'http', + contractId: 'http::c-self', + matchType: 'exact', + confidence: 1, + }; + const idx = makeIdx({ downstreamRpc: new Map([['svc-a', [link]]]) }); + const hops = findCrossRepoHopsFromRegistry( + idx, + 'svc-a', + new Set(['src/client.ts']), + 'downstream', + ); + expect(hops).toHaveLength(0); + }); +}); + +describe('DefaultSymbolResolver.scoreCandidate', () => { + const resolver = new DefaultSymbolResolver(); + const c = (id: string, fp = '', type = 'Method'): SymbolCandidate => ({ + id, + name: id, + type, + filePath: fp, + }); + + it('prefers Method over Class', () => { + const method = c('Method:Foo.bar', 'src/Foo.java', 'Method'); + const cls = c('Class:Foo', 'src/Foo.java', 'Class'); + expect(resolver.scoreCandidate(method)).toBeGreaterThan(resolver.scoreCandidate(cls)); + }); + + it('boosts -service/ and -impl/ paths', () => { + const impl = c('Class:FooImpl', 'services/order-service/FooImpl.java'); + const other = c('Class:Foo', 'services/order-common/Foo.java'); + expect(resolver.scoreCandidate(impl)).toBeGreaterThan(resolver.scoreCandidate(other)); + }); + + it('penalizes -client/ paths', () => { + const client = c('Class:FooClient', 'services/order-client/FooClient.java'); + expect(resolver.scoreCandidate(client)).toBeLessThan(0); + }); + + it('penalizes util/dto/entity patterns', () => { + const util = c('utils/StringUtils'); + const dto = c('dto/OrderDto'); + const normal = c('Method:OrderService.create', 'src/service/OrderService.java'); + expect(resolver.scoreCandidate(util)).toBeLessThan(resolver.scoreCandidate(normal)); + expect(resolver.scoreCandidate(dto)).toBeLessThan(resolver.scoreCandidate(normal)); + }); + + it('boosts classVariant match', () => { + const impl = c('Class:OrderServiceImpl', 'src/OrderServiceImpl.java'); + const other = c('Class:OrderController', 'src/OrderController.java'); + expect(resolver.scoreCandidate(impl, ['OrderServiceImpl'])).toBeGreaterThan( + resolver.scoreCandidate(other, ['OrderServiceImpl']), + ); + }); + + it('boosts PascalCase target name match', () => { + const matching = c('Method:OrderService.create', 'src/OrderService.java'); + const unrelated = c('Method:PayService.create', 'src/PayService.java'); + expect(resolver.scoreCandidate(matching, [], 'orderService')).toBeGreaterThan( + resolver.scoreCandidate(unrelated, [], 'orderService'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCache: same symbol resolved twice in one trace should only call +// resolver.resolveSymbolByName once +// --------------------------------------------------------------------------- + +describe('resolveCache', () => { + it('deduplicates resolveSymbolByName calls for same repoId+symbolName', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + writeContractsJson(groupDir, [ + { + from: { + repo: 'app/backend', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/client.ts', name: 'FooService.bar' }, + }, + to: { + repo: 'app/frontend', + symbolUid: 'uid-2', + symbolRef: { filePath: 'src/handler.ts', name: 'FooService.bar' }, + }, + type: 'thrift', + contractId: 'thrift::FooService/bar', + matchType: 'exact', + confidence: 1, + }, + // Second crossLink pointing to the SAME target symbol + { + from: { + repo: 'app/backend', + symbolUid: 'uid-3', + symbolRef: { filePath: 'src/client2.ts', name: 'FooService.bar' }, + }, + to: { + repo: 'app/frontend', + symbolUid: 'uid-2', + symbolRef: { filePath: 'src/handler.ts', name: 'FooService.bar' }, + }, + type: 'thrift', + contractId: 'thrift::FooService/bar2', + matchType: 'exact', + confidence: 1, + }, + ]); + + const { executeParameterized } = await import('../../../src/core/lbug/pool-adapter.js'); + + // Entry repo: resolve entry symbol + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'Method:be-1', name: 'callFoo', type: 'Method', filePath: 'src/client.ts' }, + ]); + // Entry repo BFS: no neighbors (both crossLinks fire from same visited file) + (executeParameterized as any).mockResolvedValueOnce([]); + + // Target repo: resolveSymbolByName — exact match query (should be called ONCE) + (executeParameterized as any).mockResolvedValue([ + { id: 'Method:fe-1', name: 'bar', type: 'Method', filePath: 'src/handler.ts' }, + ]); + + const resolveCallCount = { n: 0 }; + const countingResolver = { + resolveSymbolByName: async (...args: any[]) => { + resolveCallCount.n++; + const { DefaultSymbolResolver } = + await import('../../../src/core/group/trace-resolver.js'); + return new DefaultSymbolResolver().resolveSymbolByName(...(args as [any, any, any])); + }, + }; + + const port = makePort(); + await runGroupTraceWithResolver( + makeDeps(port, tmpDir), + { + name: 'g1', + repo: 'app/backend', + target: 'Method:be-1', + maxCrossDepth: 2, + }, + countingResolver, + ); + + // Two hops point to the same symbolName in the same repo. + // resolveCache should deduplicate — resolver called at most once per unique key. + expect(resolveCallCount.n).toBeLessThanOrEqual(1); + } finally { + cleanup(); + } + }); +}); + +// --------------------------------------------------------------------------- +// mtime cache: second trace call with unchanged files returns same result +// without error (behavioral smoke test — file reads are internal to Node fs) +// --------------------------------------------------------------------------- + +describe('mtime cache', () => { + it('returns consistent results on repeated calls with unchanged group files', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + writeContractsJson(groupDir); + + const { executeParameterized } = await import('../../../src/core/lbug/pool-adapter.js'); + + // Two sequential trace calls need two sets of mocks + for (let i = 0; i < 2; i++) { + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'Method:sym-1', name: 'myFunc', type: 'Method', filePath: 'src/main.ts' }, + ]); + (executeParameterized as any).mockResolvedValueOnce([]); + } + + const port = makePort(); + const params = { name: 'g1', repo: 'app/backend', target: 'Method:sym-1' }; + + const r1 = (await runGroupTrace(makeDeps(port, tmpDir), params)) as any; + const r2 = (await runGroupTrace(makeDeps(port, tmpDir), params)) as any; + + // Both calls succeed (no error) and return same structural shape + expect(r1.error).toBeUndefined(); + expect(r2.error).toBeUndefined(); + expect(r1.group).toBe(r2.group); + expect(r1.entryRepo).toBe(r2.entryRepo); + } finally { + cleanup(); + } + }); + + it('re-reads contracts.json when file content changes between calls', async () => { + const { tmpDir, groupDir, cleanup } = tmpGroup(); + try { + writeContractsJson(groupDir); // initially empty + + const { executeParameterized } = await import('../../../src/core/lbug/pool-adapter.js'); + + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'Method:sym-1', name: 'myFunc', type: 'Method', filePath: 'src/main.ts' }, + ]); + (executeParameterized as any).mockResolvedValueOnce([]); + + const port = makePort(); + const params = { name: 'g1', repo: 'app/backend', target: 'Method:sym-1' }; + + const r1 = (await runGroupTrace(makeDeps(port, tmpDir), params)) as any; + expect(r1.error).toBeUndefined(); + expect(r1.segments[0].crossHops).toHaveLength(0); // no crossLinks initially + + // Wait 5ms to ensure different mtime, then rewrite with a crossLink + await new Promise((r) => setTimeout(r, 10)); + writeContractsJson(groupDir, [ + { + from: { + repo: 'app/backend', + symbolUid: 'u1', + symbolRef: { filePath: 'src/main.ts', name: 'foo' }, + }, + to: { + repo: 'app/frontend', + symbolUid: 'u2', + symbolRef: { filePath: 'src/api.ts', name: 'foo' }, + }, + type: 'http', + contractId: 'http::c1', + matchType: 'exact', + confidence: 1, + }, + ]); + + (executeParameterized as any).mockResolvedValueOnce([ + { id: 'Method:sym-1', name: 'myFunc', type: 'Method', filePath: 'src/main.ts' }, + ]); + (executeParameterized as any).mockResolvedValueOnce([]); + + const r2 = (await runGroupTrace(makeDeps(port, tmpDir), params)) as any; + expect(r2.error).toBeUndefined(); + // After file change, the new crossLink should be picked up + expect(r2.segments[0].crossHops).toHaveLength(1); + } finally { + cleanup(); + } + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 7bfdded45..724aac049 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -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)!;