diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md index 80f9c0ec5..4a33e589a 100644 --- a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -45,6 +45,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | | Recent regression | `detect_changes` to see what your changes affect | +| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools @@ -72,6 +73,17 @@ MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "valid RETURN [n IN nodes(path) | n.name] AS chain ``` +**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: + +``` +trace({ from: "processCheckout", to: "fetchRates" }) +→ status: ok, hopCount: 3 +→ hops: processCheckout → validatePayment → verifyCard → fetchRates +→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) +``` + +When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary). + ## Example: "Payment endpoint returns 500 intermittently" ``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md index 7f90f4e6d..a5df5b665 100644 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -35,6 +35,7 @@ For any task involving code understanding, debugging, impact analysis, or refact | `query` | Process-grouped code intelligence — execution flows related to a concept | | `context` | 360-degree symbol view — categorized refs, processes it participates in | | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `trace` | Shortest path between two symbols — "how does A reach B?" in one call | | `detect_changes` | Git-diff impact — what do your current changes affect | | `rename` | Multi-file coordinated rename with confidence-tagged edits | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | @@ -93,6 +94,16 @@ A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: f A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface. +### Shortest path between two symbols (`trace`) + +`trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 3–8 `context`/`impact` hops by hand. + +- `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols. +- Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates. +- `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols. + +Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`. + ## Resources Reference Lightweight reads (~100-500 tokens) for navigation: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4aa854a88..e936e2dd9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,6 +38,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). | `detect_changes` | Map git diffs to affected symbols and processes | | `rename` | Graph-assisted multi-file rename with `dry_run` preview | | `api_impact` | Pre-change impact report for an API route handler | +| `trace` | Shortest directed path between two symbols (call + class-member edges) | | `route_map` | API route → handler → consumer mappings | | `tool_map` | MCP/RPC tool definitions and handlers | | `shape_check` | Response shape vs consumer property access mismatches | diff --git a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md index 80f9c0ec5..4a33e589a 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md @@ -45,6 +45,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | | Recent regression | `detect_changes` to see what your changes affect | +| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools @@ -72,6 +73,17 @@ MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "valid RETURN [n IN nodes(path) | n.name] AS chain ``` +**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: + +``` +trace({ from: "processCheckout", to: "fetchRates" }) +→ status: ok, hopCount: 3 +→ hops: processCheckout → validatePayment → verifyCard → fetchRates +→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) +``` + +When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary). + ## Example: "Payment endpoint returns 500 intermittently" ``` diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md index 7f90f4e6d..a5df5b665 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md @@ -35,6 +35,7 @@ For any task involving code understanding, debugging, impact analysis, or refact | `query` | Process-grouped code intelligence — execution flows related to a concept | | `context` | 360-degree symbol view — categorized refs, processes it participates in | | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `trace` | Shortest path between two symbols — "how does A reach B?" in one call | | `detect_changes` | Git-diff impact — what do your current changes affect | | `rename` | Multi-file coordinated rename with confidence-tagged edits | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | @@ -93,6 +94,16 @@ A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: f A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface. +### Shortest path between two symbols (`trace`) + +`trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 3–8 `context`/`impact` hops by hand. + +- `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols. +- Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates. +- `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols. + +Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`. + ## Resources Reference Lightweight reads (~100-500 tokens) for navigation: diff --git a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md index cde6a6a3a..6f8944fd4 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md @@ -44,6 +44,7 @@ description: Trace bugs through call chains using knowledge graph | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | | Recent regression | `detect_changes` to see what your changes affect | +| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools @@ -68,6 +69,16 @@ MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "valid RETURN [n IN nodes(path) | n.name] AS chain ``` +**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: +``` +trace({ from: "processCheckout", to: "fetchRates" }) +→ status: ok, hopCount: 3 +→ hops: processCheckout → validatePayment → verifyCard → fetchRates +→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) +``` + +When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary). + ## Example: "Payment endpoint returns 500 intermittently" ``` diff --git a/gitnexus/skills/gitnexus-debugging.md b/gitnexus/skills/gitnexus-debugging.md index 80f9c0ec5..4a33e589a 100644 --- a/gitnexus/skills/gitnexus-debugging.md +++ b/gitnexus/skills/gitnexus-debugging.md @@ -45,6 +45,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | | Recent regression | `detect_changes` to see what your changes affect | +| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools @@ -72,6 +73,17 @@ MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "valid RETURN [n IN nodes(path) | n.name] AS chain ``` +**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: + +``` +trace({ from: "processCheckout", to: "fetchRates" }) +→ status: ok, hopCount: 3 +→ hops: processCheckout → validatePayment → verifyCard → fetchRates +→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) +``` + +When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary). + ## Example: "Payment endpoint returns 500 intermittently" ``` diff --git a/gitnexus/skills/gitnexus-guide.md b/gitnexus/skills/gitnexus-guide.md index 7f90f4e6d..a5df5b665 100644 --- a/gitnexus/skills/gitnexus-guide.md +++ b/gitnexus/skills/gitnexus-guide.md @@ -35,6 +35,7 @@ For any task involving code understanding, debugging, impact analysis, or refact | `query` | Process-grouped code intelligence — execution flows related to a concept | | `context` | 360-degree symbol view — categorized refs, processes it participates in | | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `trace` | Shortest path between two symbols — "how does A reach B?" in one call | | `detect_changes` | Git-diff impact — what do your current changes affect | | `rename` | Multi-file coordinated rename with confidence-tagged edits | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | @@ -93,6 +94,16 @@ A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: f A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface. +### Shortest path between two symbols (`trace`) + +`trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 3–8 `context`/`impact` hops by hand. + +- `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols. +- Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates. +- `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols. + +Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`. + ## Resources Reference Lightweight reads (~100-500 tokens) for navigation: diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 9fb4bde39..def76601b 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -31,6 +31,7 @@ const COMMAND_DESCRIPTION_KEYS = { cypher: 'help.command.cypher.description', 'detect-changes': 'help.command.detectChanges.description', check: 'help.command.check.description', + trace: 'help.command.trace.description', 'eval-server': 'help.command.evalServer.description', group: 'help.command.group.description', 'group create': 'help.command.group.create.description', @@ -131,6 +132,14 @@ const OPTION_DESCRIPTION_KEYS = { 'check|--json': 'help.option.json', 'check|-r, --repo ': 'help.option.repo.target', 'check|--branch ': 'help.option.branch', + 'trace|--from-uid ': 'help.option.trace.fromUid', + 'trace|--from-file ': 'help.option.trace.fromFile', + 'trace|--to-uid ': 'help.option.trace.toUid', + 'trace|--to-file ': 'help.option.trace.toFile', + 'trace|--depth ': 'help.option.trace.depth', + 'trace|--include-tests': 'help.option.trace.includeTests', + 'trace|-r, --repo ': 'help.option.repo.target', + 'trace|--branch ': 'help.option.branch', 'eval-server|-p, --port ': 'help.option.port', 'eval-server|--host ': 'help.option.evalServer.host', 'eval-server|--idle-timeout ': 'help.option.evalServer.idleTimeout', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 9bbf3f428..caf61ab83 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -55,6 +55,8 @@ export const en = { 'tool.usage.context': 'Usage: gitnexus context [--uid ] [--file ]', 'tool.usage.impact': 'Usage: gitnexus impact [--uid ] [--file ] [--kind ] [--direction upstream|downstream]', + 'tool.usage.trace': + 'Usage: gitnexus trace [--from-uid ] [--to-uid ] [--depth ]', 'tool.usage.cypher': 'Usage: gitnexus cypher ', 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.", @@ -141,6 +143,8 @@ export const en = { 'help.command.context.description': '360-degree view of a code symbol: callers, callees, processes', 'help.command.impact.description': 'Blast radius analysis: what breaks if you change a symbol', + 'help.command.trace.description': + 'Find the shortest directed path between two symbols (call + class-member edges)', 'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph', 'help.command.detectChanges.description': 'Map git diff hunks to indexed symbols and affected execution flows', @@ -248,6 +252,12 @@ export const en = { 'help.option.impact.limit': 'Max symbols per depth level (default: 100)', 'help.option.impact.offset': 'Skip N symbols per depth level for pagination', 'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list', + 'help.option.trace.fromUid': 'Source symbol UID (zero-ambiguity lookup)', + 'help.option.trace.fromFile': 'Source file path to disambiguate common names', + 'help.option.trace.toUid': 'Target symbol UID (zero-ambiguity lookup)', + 'help.option.trace.toFile': 'Target file path to disambiguate common names', + 'help.option.trace.depth': 'Max path length in hops (default: 10)', + 'help.option.trace.includeTests': 'Traverse through test-file symbols (default: false)', 'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare', 'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)', 'help.option.check.cycles': 'Detect circular imports and fail when any are found', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 15dcf2229..01abeedc1 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -59,6 +59,8 @@ export const zhCN = { 'tool.usage.context': '用法:gitnexus context <符号名> [--uid ] [--file <路径>]', 'tool.usage.impact': '用法:gitnexus impact <符号名> [--uid ] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]', + 'tool.usage.trace': + '用法:gitnexus trace <起点> <终点> [--from-uid ] [--to-uid ] [--depth ]', 'tool.usage.cypher': '用法:gitnexus cypher ', 'tool.warn.unknownKind': "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。", @@ -138,6 +140,7 @@ export const zhCN = { 'help.command.query.description': '搜索知识图谱中与概念相关的执行流程', 'help.command.context.description': '查看代码符号的 360 度视图:调用者、被调用者、流程', 'help.command.impact.description': '影响面分析:修改符号会影响什么', + 'help.command.trace.description': '查找两个符号之间的最短有向路径(调用与类成员边)', 'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询', 'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程', 'help.command.check.description': '对已索引图谱运行结构检查', @@ -232,6 +235,12 @@ export const zhCN = { 'help.option.impact.limit': '每层深度最大符号数(默认:100)', 'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)', 'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表', + 'help.option.trace.fromUid': '源符号 UID(零歧义查找)', + 'help.option.trace.fromFile': '源文件路径,用于消除常见名称歧义', + 'help.option.trace.toUid': '目标符号 UID(零歧义查找)', + 'help.option.trace.toFile': '目标文件路径,用于消除常见名称歧义', + 'help.option.trace.depth': '最大路径跳数(默认:10)', + 'help.option.trace.includeTests': '遍历时包含测试文件中的符号(默认:false)', 'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare', 'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)', 'help.option.check.cycles': '检测循环导入,并在发现循环时失败', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 318ea736a..4ac78ae85 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -353,6 +353,19 @@ program .option('--summary-only', 'Return counts and risk only, omit symbol list') .action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand')); +program + .command('trace ') + .description('Find the shortest directed path between two symbols (call + class-member edges)') + .option('--from-uid ', 'Source symbol UID (zero-ambiguity)') + .option('--from-file ', 'Source file path hint') + .option('--to-uid ', 'Target symbol UID (zero-ambiguity)') + .option('--to-file ', 'Target file path hint') + .option('--depth ', 'Max path length in hops (default: 10)') + .option('--include-tests', 'Include test files in results') + .option('-r, --repo ', 'Target repository') + .option('--branch ', 'Scope to a specific branch index') + .action(createLbugLazyAction(() => import('./tool.js'), 'traceCommand')); + program .command('cypher ') .description('Execute raw Cypher query against the knowledge graph') diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 76850eb9f..111bb4bbc 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -268,3 +268,64 @@ export async function checkCommand(options?: { process.exitCode = 1; } } + +export async function traceCommand( + from?: string, + to?: string, + options?: { + fromUid?: string; + fromFile?: string; + toUid?: string; + toFile?: string; + depth?: string; + repo?: string; + branch?: string; + includeTests?: boolean; + }, +): Promise { + if (options?.fromUid?.startsWith('--') || options?.toUid?.startsWith('--')) { + cliErrorKey('tool.usage.trace'); + process.exit(1); + } + if ((!from?.trim() && !options?.fromUid) || (!to?.trim() && !options?.toUid)) { + cliErrorKey('tool.usage.trace'); + process.exit(1); + } + // Reject a non-numeric / non-positive --depth up front rather than forwarding + // NaN (which the backend would silently treat as the default). + if (options?.depth !== undefined) { + const parsedDepth = Number(options.depth); + if (!Number.isInteger(parsedDepth) || parsedDepth < 1) { + cliErrorKey('tool.usage.trace'); + process.exit(1); + } + } + + try { + const backend = await getBackend(); + const result = await backend.callTool('trace', { + from: from || undefined, + from_uid: options?.fromUid, + from_file: options?.fromFile, + to: to || undefined, + to_uid: options?.toUid, + to_file: options?.toFile, + maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined, + includeTests: options?.includeTests ?? false, + repo: options?.repo, + branch: options?.branch, + }); + output(result); + } catch (err: unknown) { + output({ + status: 'error', + error: + (err instanceof Error ? err.message : String(err)) || 'Trace analysis failed unexpectedly', + from: { name: from }, + to: { name: to }, + suggestion: + 'Try gitnexus context to see connections, or check if an interface bridges them.', + }); + process.exit(1); + } +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 6526a4bef..c20433670 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -408,6 +408,17 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string */ export const REPO_ID_HASH_LENGTH = 6; +interface TraceParams { + from?: string; + from_uid?: string; + from_file?: string; + to?: string; + to_uid?: string; + to_file?: string; + maxDepth?: number; + includeTests?: boolean; +} + interface ImpactParams { target: string; target_uid?: string; @@ -1320,6 +1331,8 @@ export class LocalBackend { return this.toolMap(repo, params); case 'api_impact': return this.apiImpact(repo, params); + case 'trace': + return this.trace(repo, params); default: throw new Error(`Unknown tool: ${method}`); } @@ -3949,6 +3962,261 @@ export class LocalBackend { }; } + private async trace(repo: RepoHandle, params: TraceParams): Promise { + try { + return await this._traceImpl(repo, params); + } catch (err: any) { + return { + status: 'error', + error: (err instanceof Error ? err.message : String(err)) || 'Trace analysis failed', + from: { name: params.from }, + to: { name: params.to }, + suggestion: + 'The graph query failed — try gitnexus context to see connections, ' + + 'or check if an interface bridges them.', + ...(isWalCorruptionError(err) ? { recoverySuggestion: WAL_RECOVERY_SUGGESTION } : {}), + }; + } + } + + private async _traceImpl(repo: RepoHandle, params: TraceParams): Promise { + await this.ensureInitialized(repo); + + // resolveSymbolCandidates feeds `from`/`to` into string operations + // (e.g. name.includes), so a non-string param would surface a low-level + // "x.includes is not a function". Reject it with a clear message instead. + const isStringOrAbsent = (v: unknown): boolean => v === undefined || typeof v === 'string'; + if ( + !isStringOrAbsent(params.from) || + !isStringOrAbsent(params.to) || + !isStringOrAbsent(params.from_uid) || + !isStringOrAbsent(params.to_uid) + ) { + return { + status: 'error', + error: "'from', 'to', and their *_uid variants must be strings.", + suggestion: 'Pass symbol names or UIDs as strings, e.g. trace from="A" to="B".', + }; + } + + const fromOutcome = await this.resolveSymbolCandidates( + repo, + { uid: params.from_uid, name: params.from }, + { file_path: params.from_file }, + ); + + if (fromOutcome.kind === 'not_found') { + return { + status: 'not_found', + error: `Source symbol '${params.from_uid ?? params.from}' not found.`, + suggestion: 'Check the symbol name or use --from-uid for zero-ambiguity.', + }; + } + if (fromOutcome.kind === 'ambiguous') { + return { + status: 'ambiguous', + role: 'from', + message: `Found ${fromOutcome.candidates.length} symbols matching '${params.from}'. Disambiguate with --from-uid.`, + candidates: fromOutcome.candidates, + }; + } + + const toOutcome = await this.resolveSymbolCandidates( + repo, + { uid: params.to_uid, name: params.to }, + { file_path: params.to_file }, + ); + + if (toOutcome.kind === 'not_found') { + return { + status: 'not_found', + error: `Target symbol '${params.to_uid ?? params.to}' not found.`, + suggestion: 'Check the symbol name or use --to-uid for zero-ambiguity.', + }; + } + if (toOutcome.kind === 'ambiguous') { + return { + status: 'ambiguous', + role: 'to', + message: `Found ${toOutcome.candidates.length} symbols matching '${params.to}'. Disambiguate with --to-uid.`, + candidates: toOutcome.candidates, + }; + } + + const fromSym = fromOutcome.symbol; + const toSym = toOutcome.symbol; + + if (fromSym.id === toSym.id) { + return { + status: 'ok', + from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }, + to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine }, + hopCount: 0, + hops: [{ name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }], + edges: [], + }; + } + + // Sanitize maxDepth at the real boundary: the MCP inputSchema's + // minimum/maximum is advisory only (callTool is reachable directly), so a + // caller can pass 0, a negative, NaN, or a non-integer. `??` does NOT + // recover 0/NaN, and Math.min has no lower bound — left unguarded, any of + // those makes the BFS loop run zero iterations and return a false no_path. + const DEFAULT_TRACE_DEPTH = 10; + const MAX_TRACE_DEPTH = 30; + const requestedDepth = + Number.isInteger(params.maxDepth) && (params.maxDepth as number) > 0 + ? (params.maxDepth as number) + : DEFAULT_TRACE_DEPTH; + const maxDepth = Math.min(requestedDepth, MAX_TRACE_DEPTH); + const includeTests = params.includeTests ?? false; + // Traversal vocabulary: CALLS for actual calls, HAS_METHOD so a class-rooted + // trace can descend into its methods. Not "calls only" — per-hop edge type is + // surfaced in edges[] so containment hops stay distinguishable. + const TRAVERSAL_EDGE_TYPES = ['CALLS', 'HAS_METHOD']; + + // Bound the traversal so a high-fanout hub (a logger/util reached by many + // symbols) can't materialize an unbounded frontier. Per-level rows are + // capped and the total visited set is capped; either cap sets `truncated` + // so a resulting no_path is never reported as if the graph was exhausted. + const PER_NODE_FANOUT_CAP = 200; + const ABS_ROW_CAP = 5000; + const MAX_VISITED = 50000; + let truncated = false; + + const visited = new Set([fromSym.id]); + let frontier = [fromSym.id]; + const parent = new Map< + string, + { + from: string; + name: string; + filePath: string; + startLine: number; + edgeType: string; + confidence: number; + } + >(); + + let found = false; + // The last node discovered at the deepest reached level — surfaced as + // `furthest` in the no_path response to hint where the chain breaks. + let lastReached: { + name: string; + filePath: string; + startLine: number; + } | null = null; + let reachedDepth = 0; + + for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !found; depth++) { + const nextFrontier: string[] = []; + // LadybugDB/Kuzu does not support a parameterized LIMIT, so the cap is + // interpolated (it is a derived integer, not user input). + const rowCap = Math.min(frontier.length * PER_NODE_FANOUT_CAP, ABS_ROW_CAP); + + const rows = await executeParameterized( + repo.lbugPath, + `MATCH (n)-[r:CodeRelation]->(m) + WHERE n.id IN $frontierIds AND r.type IN $edgeTypes + RETURN n.id AS sourceId, m.id AS id, m.name AS name, labels(m)[0] AS type, + m.filePath AS filePath, m.startLine AS startLine, + r.type AS edgeType, r.confidence AS confidence + LIMIT ${rowCap}`, + { frontierIds: frontier, edgeTypes: TRAVERSAL_EDGE_TYPES }, + ); + + // A clipped level may have dropped a node that lies on the only shortest + // path, so any subsequent no_path is not authoritative. + if (rows.length >= rowCap) truncated = true; + + for (const row of rows) { + // Decode once. The `?? row[N]` fallback handles LadybugDB tuple-mode + // returns; the positional indices mirror the RETURN column order above. + const nodeId = (row.id ?? row[1]) as string; + const sourceId = (row.sourceId ?? row[0]) as string; + const name = (row.name ?? row[2]) as string; + const filePath = (row.filePath ?? row[4]) as string; + const startLine = (row.startLine ?? row[5]) as number; + const edgeType = (row.edgeType ?? row[6]) as string; + const storedConfidence = row.confidence ?? row[7]; + const confidence = + typeof storedConfidence === 'number' && storedConfidence > 0 + ? storedConfidence + : confidenceForRelType(edgeType); + + // Match the explicitly-requested target before the test-file filter. + // resolveSymbolCandidates does not exclude test-file symbols, so a + // target (or a required hop) that lives in a test file would otherwise + // be dropped by the includeTests guard below and produce a false + // no_path even when a direct edge exists. + if (nodeId === toSym.id) { + parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence }); + found = true; + break; + } + + // Skip non-target nodes that live in test files unless includeTests. + if (!includeTests && isTestFilePath(filePath)) continue; + + if (!visited.has(nodeId)) { + visited.add(nodeId); + parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence }); + nextFrontier.push(nodeId); + lastReached = { name, filePath, startLine }; + reachedDepth = depth; + } + } + + frontier = nextFrontier; + if (visited.size >= MAX_VISITED) { + truncated = true; + break; + } + } + + if (found) { + const path: Array<{ name: string; filePath: string; startLine: number }> = []; + const edges: Array<{ relType: string; confidence: number }> = []; + let current = toSym.id; + + while (current !== fromSym.id) { + const info = parent.get(current)!; + path.unshift({ name: info.name, filePath: info.filePath, startLine: info.startLine }); + edges.unshift({ relType: info.edgeType, confidence: info.confidence }); + current = info.from; + } + path.unshift({ + name: fromSym.name, + filePath: fromSym.filePath, + startLine: fromSym.startLine, + }); + + return { + status: 'ok', + from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }, + to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine }, + hopCount: edges.length, + hops: path, + edges, + }; + } + + return { + status: 'no_path', + from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }, + to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine }, + furthest: lastReached ? { ...lastReached, depth: reachedDepth } : null, + ...(truncated ? { truncated: true } : {}), + suggestion: truncated + ? 'Search was truncated at a traversal cap before exhausting the graph — a path ' + + 'may still exist. Narrow the search (a lower --depth, or trace from a more ' + + 'specific symbol), or use gitnexus context to inspect connections.' + : 'No directed path found. The call chain likely breaks at dynamic dispatch, ' + + 'reflection, or an external API boundary. Try gitnexus context to see ' + + "both symbols' connections, or check if an interface/abstraction bridges them.", + }; + } + private async impact(repo: RepoHandle, params: ImpactParams): Promise { try { return await this._impactImpl(repo, params); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index e2ffe10a7..959f6a565 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -760,6 +760,45 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`, required: ['name'], }, }, + { + name: 'trace', + description: `Find the shortest directed path between two symbols over call and class-member edges. + +WHEN TO USE: Debugging "how does A reach B?" — answers in one call what would take 3-8 manual context/impact hops. Shows the exact chain with file:line positions plus a per-hop edge type and confidence. + +Traverses CALLS edges plus HAS_METHOD (class → member) edges, so a trace can descend from a class into its methods. Each hop's edge type is reported in edges[], so call hops and containment hops remain distinguishable. + +Returns: ordered hops with file:line, and an aligned edges[] of edge type + confidence. When no path exists, reports the furthest reachable node so you know where the chain breaks (and truncated: true if a traversal cap was hit first).`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + from: { type: 'string', description: 'Source symbol name' }, + from_uid: { type: 'string', description: 'Source symbol UID (zero-ambiguity)' }, + from_file: { type: 'string', description: 'Source file path hint for disambiguation' }, + to: { type: 'string', description: 'Target symbol name' }, + to_uid: { type: 'string', description: 'Target symbol UID (zero-ambiguity)' }, + to_file: { type: 'string', description: 'Target file path hint for disambiguation' }, + maxDepth: { + type: 'number', + description: 'Maximum path length in hops (default: 10)', + default: 10, + minimum: 1, + maximum: 30, + }, + includeTests: { + type: 'boolean', + description: 'Include test-file symbols in traversal (default: false)', + default: false, + }, + repo: { + type: 'string', + description: 'Repository name or path. Omit if only one repo is indexed.', + }, + }, + required: [], + }, + }, ]; /** @@ -783,6 +822,7 @@ const BRANCH_SCOPED_TOOLS = new Set([ 'tool_map', 'shape_check', 'api_impact', + 'trace', ]); for (const tool of GITNEXUS_TOOLS) { diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 0cd25fb70..f1b3dc83b 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -2,7 +2,7 @@ * Unit Tests: MCP Tool Definitions * * Tests: GITNEXUS_TOOLS from tools.ts - * - All 13 tools are defined (per-repo + group_list/group_sync) + * - All 17 tools are defined (per-repo + group_list/group_sync) * - Each tool has valid name, description, inputSchema * - Required fields are correct * - Optional repo parameter is present on tools that need it @@ -21,8 +21,8 @@ const MUTATING_TOOLS = new Set(['rename', 'group_sync']); const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']); describe('GITNEXUS_TOOLS', () => { - it('exports all tools (8 base + 1 explain + 1 pdg_query + 3 route/tool/shape + 1 api_impact + 2 group)', () => { - expect(GITNEXUS_TOOLS).toHaveLength(16); + it('exports all tools (8 base + 1 explain + 1 pdg_query + 3 route/tool/shape + 1 api_impact + 1 trace + 2 group)', () => { + expect(GITNEXUS_TOOLS).toHaveLength(17); }); it('contains all expected tool names', () => { @@ -40,6 +40,7 @@ describe('GITNEXUS_TOOLS', () => { 'explain', 'pdg_query', 'api_impact', + 'trace', ]), ); }); diff --git a/gitnexus/test/unit/trace-bfs.test.ts b/gitnexus/test/unit/trace-bfs.test.ts new file mode 100644 index 000000000..33ab72bff --- /dev/null +++ b/gitnexus/test/unit/trace-bfs.test.ts @@ -0,0 +1,911 @@ +/** + * Unit Tests: trace tool — BFS pathfinding, symbol resolution, gap reporting + * + * Drives the implementation of the `trace` MCP tool via TDD. + * Mocks LadybugDB; tests the LocalBackend trace() logic in isolation. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { lbugMocks, platformMocks } = vi.hoisted(() => ({ + lbugMocks: { + initLbug: vi.fn().mockResolvedValue(undefined), + executeQuery: vi.fn().mockResolvedValue([]), + executeParameterized: vi.fn().mockResolvedValue([]), + closeLbug: vi.fn().mockResolvedValue(undefined), + isLbugReady: vi.fn().mockReturnValue(true), + }, + platformMocks: { + isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true), + }, +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; +}); + +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; +}); + +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listRegisteredRepos: vi.fn().mockResolvedValue([ + { + name: 'test-project', + path: '/tmp/test-project', + storagePath: '/tmp/.gitnexus/test-project', + indexedAt: '2024-06-01T12:00:00Z', + lastCommit: 'abc123', + stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 }, + }, + ]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('../../src/core/git-staleness.js', () => ({ + checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), + checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), + checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), +})); + +vi.mock('../../src/storage/git.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getGitRoot: vi.fn().mockReturnValue(null) }; +}); + +vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...platformMocks }; +}); + +vi.mock('../../src/core/search/bm25-index.js', () => ({ + searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }), +})); + +vi.mock('../../src/mcp/core/embedder.js', () => ({ + embedQuery: vi.fn().mockResolvedValue([]), + getEmbeddingDims: vi.fn().mockReturnValue(384), +})); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { executeParameterized } from '../../src/mcp/core/lbug-adapter.js'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +const SYMBOL_A = { + id: 'func:A', + name: 'A', + type: 'Function', + filePath: 'src/a.ts', + startLine: 1, + endLine: 10, +}; +const SYMBOL_B = { + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + endLine: 5, +}; + +function makeResolveMock( + fromRows: any[], + toRows: any[], + bfsRowsByFrontier?: Record, +) { + const bfsMap = bfsRowsByFrontier ?? {}; + return (_db: string, _query: string, params: any) => { + // UID lookup keys on params.uid — the real query is `MATCH (n {id: $uid})`, + // so matching on query text ('WHERE n.id = $uid') never fired. + if (params.uid) { + if (fromRows.length === 1 && fromRows[0].id === params.uid) return fromRows; + if (toRows.length === 1 && toRows[0].id === params.uid) return toRows; + } + if (params.symName !== undefined) { + if (params.symName === fromRows[0]?.name && fromRows.length > 0) return fromRows; + if (params.symName === toRows[0]?.name && toRows.length > 0) return toRows; + } + if (params.frontierIds) { + // The real per-level query fetches neighbors for ALL frontier ids at + // once; concatenate so a multi-node frontier is modelled faithfully. + const rows: any[] = []; + for (const frontierId of params.frontierIds) { + if (bfsMap[frontierId]) rows.push(...bfsMap[frontierId]); + } + return rows; + } + return []; + }; +} + +async function makeBackend(): Promise { + const b = new LocalBackend(); + await b.init(); + return b; +} + +describe('trace: dispatch', () => { + let backend: LocalBackend; + + beforeEach(async () => { + vi.clearAllMocks(); + backend = await makeBackend(); + }); + + it('dispatches trace tool without throwing', async () => { + (executeParameterized as any).mockResolvedValue([]); + + const result = await backend.callTool('trace', { from: 'foo', to: 'bar' }); + + expect(result).toBeDefined(); + expect(result.status).toBe('not_found'); + }); + + it('returns not_found when source symbol does not exist', async () => { + (executeParameterized as any).mockResolvedValue([]); + + const result = await backend.callTool('trace', { from: 'nonexistent', to: 'bar' }); + + expect(result.status).toBe('not_found'); + expect(result.error).toContain('nonexistent'); + }); + it('returns ambiguous with candidates when source has multiple matches', async () => { + (executeParameterized as any).mockResolvedValue([ + { + id: 'func:login:1', + name: 'login', + type: 'Function', + filePath: 'src/auth.ts', + startLine: 1, + endLine: 10, + }, + { + id: 'func:login:2', + name: 'login', + type: 'Function', + filePath: 'src/admin.ts', + startLine: 5, + endLine: 15, + }, + ]); + + const result = await backend.callTool('trace', { from: 'login', to: 'bar' }); + + expect(result.status).toBe('ambiguous'); + expect(result.role).toBe('from'); + expect(result.candidates).toHaveLength(2); + }); + + it('returns ambiguous with candidates when target has multiple matches', async () => { + (executeParameterized as any).mockResolvedValueOnce([SYMBOL_A]).mockResolvedValue([ + { + id: 'func:db:1', + name: 'db', + type: 'Function', + filePath: 'src/db.ts', + startLine: 1, + endLine: 10, + }, + { + id: 'func:db:2', + name: 'db', + type: 'Function', + filePath: 'src/db2.ts', + startLine: 1, + endLine: 10, + }, + ]); + + const result = await backend.callTool('trace', { from: 'A', to: 'db' }); + + expect(result.status).toBe('ambiguous'); + expect(result.role).toBe('to'); + expect(result.candidates).toHaveLength(2); + }); +}); + +// ─── Group 2: BFS Core ────────────────────────────────────────────── + +describe('trace: BFS core', () => { + let backend: LocalBackend; + + beforeEach(async () => { + vi.clearAllMocks(); + backend = await makeBackend(); + }); + + it('returns 0-hop path when from and to are the same symbol', async () => { + (executeParameterized as any).mockResolvedValue([SYMBOL_A]); + + const result = await backend.callTool('trace', { from: 'A', to: 'A' }); + + expect(result.status).toBe('ok'); + expect(result.hopCount).toBe(0); + expect(result.hops).toHaveLength(1); + expect(result.hops[0].name).toBe('A'); + expect(result.edges).toHaveLength(0); + }); + + it('finds direct 1-hop path A→B', async () => { + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'B' }); + + expect(result.status).toBe('ok'); + expect(result.hopCount).toBe(1); + expect(result.hops).toHaveLength(2); + expect(result.hops[0].name).toBe('A'); + expect(result.hops[1].name).toBe('B'); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].relType).toBe('CALLS'); + expect(result.edges[0].confidence).toBe(1.0); + }); + + it('finds 2-hop path A→C→B', async () => { + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:C', + name: 'C', + type: 'Function', + filePath: 'src/c.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:C': [ + { + sourceId: 'func:C', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 0.95, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'B' }); + + expect(result.status).toBe('ok'); + expect(result.hopCount).toBe(2); + expect(result.hops).toHaveLength(3); + expect(result.hops.map((h: any) => h.name)).toEqual(['A', 'C', 'B']); + expect(result.edges[0].confidence).toBe(1.0); + expect(result.edges[1].confidence).toBe(0.95); + }); + + it('reports furthest reachable node when no path exists', async () => { + const SYMBOL_X = { + id: 'func:X', + name: 'X', + type: 'Function', + filePath: 'src/x.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_X], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:C', + name: 'C', + type: 'Function', + filePath: 'src/c.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'X' }); + + expect(result.status).toBe('no_path'); + expect(result.furthest).toBeDefined(); + expect(result.furthest.name).toBe('C'); + expect(result.furthest.depth).toBe(1); + expect(result.suggestion).toBeDefined(); + }); + + it('handles cycles without infinite loop', async () => { + const SYMBOL_X = { + id: 'func:X', + name: 'X', + type: 'Function', + filePath: 'src/x.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_X], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + { + sourceId: 'func:A', + id: 'func:A', + name: 'A', + type: 'Function', + filePath: 'src/a.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:B': [ + { + sourceId: 'func:B', + id: 'func:A', + name: 'A', + type: 'Function', + filePath: 'src/a.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'X' }); + + expect(result.status).toBe('no_path'); + }, 5000); + + it('respects maxDepth limit', async () => { + const SYMBOL_E = { + id: 'func:E', + name: 'E', + type: 'Function', + filePath: 'src/e.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_E], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:B': [ + { + sourceId: 'func:B', + id: 'func:C', + name: 'C', + type: 'Function', + filePath: 'src/c.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:C': [ + { + sourceId: 'func:C', + id: 'func:D', + name: 'D', + type: 'Function', + filePath: 'src/d.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:D': [ + { + sourceId: 'func:D', + id: 'func:E', + name: 'E', + type: 'Function', + filePath: 'src/e.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'E', maxDepth: 2 }); + + expect(result.status).toBe('no_path'); + }); + + it('treats maxDepth 0 / negative / NaN as the default rather than a false no_path', async () => { + const oneHop = { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }; + for (const badDepth of [0, -5, NaN]) { + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], oneHop), + ); + const result = await backend.callTool('trace', { from: 'A', to: 'B', maxDepth: badDepth }); + expect(result.status, `maxDepth=${badDepth}`).toBe('ok'); + expect(result.hopCount, `maxDepth=${badDepth}`).toBe(1); + } + }); + + it('reaches a target that lives in a test file even when includeTests is false', async () => { + const SYMBOL_T = { + id: 'func:T', + name: 'T', + type: 'Function', + filePath: 'src/t.test.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_T], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:T', + name: 'T', + type: 'Function', + filePath: 'src/t.test.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'T' }); + + expect(result.status).toBe('ok'); + expect(result.hopCount).toBe(1); + expect(result.hops[1].name).toBe('T'); + }); + + it('still filters a non-target test-file hop when includeTests is false', async () => { + const graph = { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:M', + name: 'M', + type: 'Function', + filePath: 'src/m.test.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:M': [ + { + sourceId: 'func:M', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }; + + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], graph), + ); + const filtered = await backend.callTool('trace', { from: 'A', to: 'B' }); + expect(filtered.status).toBe('no_path'); + + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], graph), + ); + const included = await backend.callTool('trace', { from: 'A', to: 'B', includeTests: true }); + expect(included.status).toBe('ok'); + expect(included.hops.map((h: any) => h.name)).toEqual(['A', 'M', 'B']); + }); + + it('caps the per-level query with a LIMIT and does not truncate a normal trace', async () => { + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'B' }); + + expect(result.status).toBe('ok'); + expect(result.truncated).toBeUndefined(); + const bfsQueries = ((executeParameterized as any).mock.calls as Array<[string, string, any]>) + .map(([, cypher]) => cypher) + .filter((c) => c.includes('r:CodeRelation')); + expect(bfsQueries.length).toBeGreaterThan(0); + expect(bfsQueries.every((c) => /LIMIT\s+\d+/.test(c))).toBe(true); + }); + + it('flags truncated when a frontier level hits the per-node row cap', async () => { + // _traceImpl caps a single-node frontier at PER_NODE_FANOUT_CAP (200) rows; + // returning exactly that many (none being the target) trips the cap. + const ROW_CAP = 200; + const hubRows = Array.from({ length: ROW_CAP }, (_, i) => ({ + sourceId: 'func:A', + id: `func:N${i}`, + name: `N${i}`, + type: 'Function', + filePath: 'src/n.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + })); + const SYMBOL_Z = { + id: 'func:Z', + name: 'Z', + type: 'Function', + filePath: 'src/z.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_Z], { 'func:A': hubRows }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'Z' }); + + expect(result.status).toBe('no_path'); + expect(result.truncated).toBe(true); + }); + + it('returns status:error for a non-string from/to instead of a low-level TypeError', async () => { + (executeParameterized as any).mockResolvedValue([]); + + const result = await backend.callTool('trace', { from: 42 as any, to: 'realSymbol' }); + + expect(result.status).toBe('error'); + expect(result.error).toMatch(/must be strings/); + }); + + it('returns status:error with a suggestion when the BFS query throws', async () => { + (executeParameterized as any).mockImplementation((_db: string, _q: string, params: any) => { + if (params.frontierIds) throw new Error('boom: graph exploded'); + if (params.symName === 'A') return [SYMBOL_A]; + if (params.symName === 'B') return [SYMBOL_B]; + return []; + }); + + const result = await backend.callTool('trace', { from: 'A', to: 'B' }); + + expect(result.status).toBe('error'); + expect(result.error).toContain('boom'); + expect(result.suggestion).toBeDefined(); + }); + + it('resolves from_uid/to_uid without name-based lookup', async () => { + (executeParameterized as any).mockImplementation((_db: string, query: string, params: any) => { + if (params.uid === 'uid:from') + return [ + { + id: 'uid:from', + name: 'A', + type: 'Function', + filePath: 'src/a.ts', + startLine: 1, + endLine: 10, + }, + ]; + if (params.uid === 'uid:to') + return [ + { + id: 'uid:to', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + endLine: 5, + }, + ]; + if (params.frontierIds?.includes('uid:from')) { + return [ + { + sourceId: 'uid:from', + id: 'uid:to', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ]; + } + return []; + }); + + const result = await backend.callTool('trace', { from_uid: 'uid:from', to_uid: 'uid:to' }); + + expect(result.status).toBe('ok'); + expect(result.hopCount).toBe(1); + const calls = (executeParameterized as any).mock.calls as Array< + [string, string, Record] + >; + for (const [, cypher] of calls) { + expect(cypher).not.toMatch(/WHERE n\.name = \$symName/); + } + }); + + it('finds a shortest path that runs through the second node of a multi-node frontier', async () => { + // A→B→E (dead end) and A→C→D (target). The path is only reachable via the + // second frontier node (C); a mock that returned just the first frontier + // node's neighbors would (wrongly) report no_path. + const SYMBOL_D = { + id: 'func:D', + name: 'D', + type: 'Function', + filePath: 'src/d.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_D], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + { + sourceId: 'func:A', + id: 'func:C', + name: 'C', + type: 'Function', + filePath: 'src/c.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:B': [ + { + sourceId: 'func:B', + id: 'func:E', + name: 'E', + type: 'Function', + filePath: 'src/e.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + 'func:C': [ + { + sourceId: 'func:C', + id: 'func:D', + name: 'D', + type: 'Function', + filePath: 'src/d.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'D' }); + + expect(result.status).toBe('ok'); + expect(result.hopCount).toBe(2); + expect(result.hops.map((h: any) => h.name)).toEqual(['A', 'C', 'D']); + }); + + it('falls back to the relation-type confidence when stored confidence is 0', async () => { + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], { + 'func:A': [ + { + sourceId: 'func:A', + id: 'func:B', + name: 'B', + type: 'Function', + filePath: 'src/b.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'A', to: 'B' }); + + expect(result.status).toBe('ok'); + expect(result.edges[0].confidence).toBe(0.9); // CALLS confidence floor + }); + + it('traverses HAS_METHOD edges and reports a mixed per-hop edge-type chain', async () => { + const SYMBOL_CLASS = { + id: 'class:K', + name: 'K', + type: 'Class', + filePath: 'src/k.ts', + startLine: 1, + endLine: 20, + }; + const SYMBOL_TGT = { + id: 'func:T2', + name: 'T2', + type: 'Function', + filePath: 'src/t2.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_CLASS], [SYMBOL_TGT], { + 'class:K': [ + { + sourceId: 'class:K', + id: 'func:m', + name: 'm', + type: 'Method', + filePath: 'src/k.ts', + startLine: 5, + edgeType: 'HAS_METHOD', + confidence: 0.95, + }, + ], + 'func:m': [ + { + sourceId: 'func:m', + id: 'func:T2', + name: 'T2', + type: 'Function', + filePath: 'src/t2.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ], + }), + ); + + const result = await backend.callTool('trace', { from: 'K', to: 'T2' }); + + expect(result.status).toBe('ok'); + expect(result.hopCount).toBe(2); + expect(result.edges.map((e: any) => e.relType)).toEqual(['HAS_METHOD', 'CALLS']); + expect(result.hops.map((h: any) => h.name)).toEqual(['K', 'm', 'T2']); + }); + + it('returns no_path with furthest null when the source has no outgoing edges', async () => { + const SYMBOL_X = { + id: 'func:X', + name: 'X', + type: 'Function', + filePath: 'src/x.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation(makeResolveMock([SYMBOL_A], [SYMBOL_X], {})); + + const result = await backend.callTool('trace', { from: 'A', to: 'X' }); + + expect(result.status).toBe('no_path'); + expect(result.furthest).toBeNull(); + }); + + it('uses from_file to disambiguate same-named symbols', async () => { + const helperA = { + id: 'func:helperA', + name: 'helper', + type: 'Function', + filePath: 'src/a.ts', + startLine: 1, + endLine: 5, + }; + const target = { + id: 'func:target', + name: 'target', + type: 'Function', + filePath: 'src/t.ts', + startLine: 1, + endLine: 5, + }; + (executeParameterized as any).mockImplementation((_db: string, _q: string, params: any) => { + if (params.symName === 'helper' && params.filePath === 'src/a.ts') return [helperA]; + if (params.symName === 'target') return [target]; + if (params.frontierIds?.includes('func:helperA')) { + return [ + { + sourceId: 'func:helperA', + id: 'func:target', + name: 'target', + type: 'Function', + filePath: 'src/t.ts', + startLine: 1, + edgeType: 'CALLS', + confidence: 1.0, + }, + ]; + } + return []; + }); + + const result = await backend.callTool('trace', { + from: 'helper', + from_file: 'src/a.ts', + to: 'target', + }); + + expect(result.status).toBe('ok'); + expect(result.from.filePath).toBe('src/a.ts'); + expect(result.hopCount).toBe(1); + }); +}); diff --git a/gitnexus/test/unit/trace-cli.test.ts b/gitnexus/test/unit/trace-cli.test.ts new file mode 100644 index 000000000..d0fdfe91b --- /dev/null +++ b/gitnexus/test/unit/trace-cli.test.ts @@ -0,0 +1,123 @@ +/** + * Unit Tests: CLI trace command wiring + * + * Tests that traceCommand forwards CLI flags to callTool('trace', ...) + * with correct parameter names. Mocked LocalBackend — no graph/DB. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { callTool, init } = vi.hoisted(() => ({ + callTool: vi.fn(), + init: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../../src/mcp/local/local-backend.js', () => ({ + LocalBackend: class { + init = init; + callTool = callTool; + }, + VALID_NODE_LABELS: new Set(['Function', 'Class', 'Interface', 'Method', 'Constructor']), +})); + +vi.mock('node:fs', () => ({ writeSync: vi.fn() })); + +import { traceCommand } from '../../src/cli/tool.js'; + +describe('CLI trace command', () => { + beforeEach(() => { + callTool.mockReset(); + callTool.mockResolvedValue({ status: 'ok', hopCount: 1 }); + }); + + it('forwards from/to as positional args', async () => { + await traceCommand('validateUser', 'executeQuery', {}); + + expect(callTool).toHaveBeenCalledTimes(1); + expect(callTool).toHaveBeenCalledWith( + 'trace', + expect.objectContaining({ + from: 'validateUser', + to: 'executeQuery', + }), + ); + }); + + it('forwards --from-uid/--to-uid as from_uid/to_uid', async () => { + await traceCommand('A', 'B', { + fromUid: 'uid:A', + toUid: 'uid:B', + }); + + expect(callTool).toHaveBeenCalledWith( + 'trace', + expect.objectContaining({ + from_uid: 'uid:A', + to_uid: 'uid:B', + }), + ); + }); + + it('forwards --from-file/--to-file as from_file/to_file', async () => { + await traceCommand('A', 'B', { + fromFile: 'src/a.ts', + toFile: 'src/b.ts', + }); + + expect(callTool).toHaveBeenCalledWith( + 'trace', + expect.objectContaining({ + from_file: 'src/a.ts', + to_file: 'src/b.ts', + }), + ); + }); + + it('forwards --depth as maxDepth', async () => { + await traceCommand('A', 'B', { depth: '5' }); + + expect(callTool).toHaveBeenCalledWith( + 'trace', + expect.objectContaining({ + maxDepth: 5, + }), + ); + }); + + it('exits with usage when from is missing and no from_uid', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + await expect(traceCommand(undefined, 'B', {})).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + it('exits with usage when --depth is non-numeric instead of forwarding NaN', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + await expect(traceCommand('A', 'B', { depth: 'abc' })).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(callTool).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it('exits with usage when --from-uid or --to-uid is a swallowed flag value', async () => { + for (const opts of [{ fromUid: '--oops' }, { toUid: '--oops' }]) { + callTool.mockReset(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + await expect(traceCommand('A', 'B', opts)).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(callTool).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + } + }); + + it('forwards --include-tests as includeTests', async () => { + await traceCommand('A', 'B', { includeTests: true }); + + expect(callTool).toHaveBeenCalledWith('trace', expect.objectContaining({ includeTests: true })); + }); +});