feat(mcp): add trace tool for shortest call path between symbols (#2173)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* feat(mcp): add trace tool for shortest call path between symbols (#1821)

Implement the \	race\ MCP tool and \gitnexus trace\ CLI command that finds
the shortest directed call path between two symbols using BFS over CALLS +
HAS_METHOD edges.

- MCP tool definition in tools.ts with READ_ONLY annotations
- Directed BFS in local-backend.ts with parent-map path reconstruction
- Symbol resolution via resolveSymbolCandidates (name/UID/file-hint)
- Gap reporting with furthest reachable node and depth tracking
- CLI wiring: gitnexus trace <from> <to> [--from-uid] [--to-uid] [--depth]
- i18n keys in en.ts and zh-CN.ts + help-i18n.ts registration
- ARCHITECTURE.md tools table entry
- 16 unit tests (11 BFS core + 5 CLI wiring)

* test(mcp): account for trace tool in tools.test.ts count

The trace tool makes GITNEXUS_TOOLS length 15; update the hardcoded
count, add 'trace' to the expected-names list, and refresh the stale
"13 tools" comment and it() title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): sanitize trace maxDepth to reject 0/NaN/negative

`Math.min(params.maxDepth ?? 10, 30)` had no lower bound and `??` does
not recover 0 or NaN, so `--depth 0|-5|abc` made the BFS loop run zero
iterations and return a false `no_path`. Clamp at the real boundary with
a `Number.isInteger && > 0` guard (the MCP inputSchema minimum is
advisory only), and reject a non-numeric `--depth` in the CLI up front
rather than forwarding NaN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): check trace target before applying test-file filter

The `isTestFilePath` filter ran before the target-equality check, but
resolveSymbolCandidates does not exclude test-file symbols. A target (or
a required hop) that lives in a test file was therefore skipped under the
default includeTests=false and produced a false no_path with a
misleading dynamic-dispatch suggestion. Match the explicitly-requested
target first; non-target test-file nodes are still filtered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(mcp): bound trace BFS with per-level LIMIT and visited cap

The per-level query had no LIMIT and the visited set was uncapped, so a
high-fanout hub could materialize an unbounded frontier. Cap per-level
rows (interpolated LIMIT — Kuzu does not bind LIMIT) and the total
visited set; either cap sets a `truncated` flag so a resulting no_path
reports that the search was cut short rather than implying the graph was
exhausted.

Note: the sibling impact BFS shares the same unbounded pattern; applying
the cap there is deferred (out of scope for this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): clarify trace traverses call + class-member edges

trace was advertised as a "shortest call path" but also traverses
HAS_METHOD (class→member) containment edges so a class-rooted trace can
descend into its methods. Keep that capability (consistent with impact/
context) and make the docs honest: rename EDGE_TYPES→TRAVERSAL_EDGE_TYPES,
state the call + class-member traversal in the MCP/CLI/i18n/ARCHITECTURE
descriptions, and note each hop's edge type is reported in edges[]. No
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): set status:'error' on trace failure responses

Every trace return path sets a `status` discriminator except the
caught-error path, so a consumer switching on `result.status` saw
undefined on failure. Add status:'error' to both the backend trace()
catch and the CLI traceCommand catch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): return a friendly error for non-string trace from/to

A non-string from/to reaching resolveSymbolCandidates surfaced a
low-level "x.includes is not a function" via name.includes. Guard the
four name/uid params at the top of _traceImpl and return a structured
status:'error' with a clear message instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): single row-decode + drop dead field in trace BFS

Decode each BFS row once into named locals instead of repeating
`(row.x ?? row[N])` across the two parent.set calls and the
furthest-tracking. Drop the `type` field from the parent map value (it
was written but never read), and rename the internal `deepestInfo` to
`lastReached` for accuracy (the output field `furthest` is unchanged).
Pure refactor — no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): dedicated trace includeTests i18n key + guard coverage

`trace|--include-tests` reused the impact help key, so rewording the
impact option would silently change trace's help text. Add a dedicated
help.option.trace.includeTests key in en + zh-CN and repoint it. Add CLI
coverage for the (already symmetric) --from-uid/--to-uid flag-value guard
and for --include-tests forwarding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): faithful BFS mock + expand trace coverage

Fix makeResolveMock: concatenate neighbors across ALL frontier ids (it
returned only the first node's, so a multi-node frontier was unmodelled)
and key the UID branch on params.uid (the old query-text match never
fired). Add coverage: shortest path through the second frontier node
(proves the mock fix), confidence floor fallback, HAS_METHOD traversal
with a mixed edge-type chain, no_path furthest:null, and from_file
disambiguation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(trace): apply root prettier formatting to trace files

The root `quality / format` gate (prettier --check, printWidth 100) runs
on the full repo and flagged the trace sources/tests (the local config
masks it). Reformat to root style — no behavior change; trace + tools
suites and tsc stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): document the trace tool for AI agents

Add `trace` to the GitNexus skill docs so agents reach for it instead of
hand-chaining context/impact hops. The guide gains a Tools Reference row
and a "shortest path between two symbols" subsection (params, result
shape, status/furthest/truncated semantics); the debugging skill gains a
"how does A reach B?" pattern row and a trace tool example. Mirrored to
the .claude and claude-plugin copies (byte-identical) and the cursor copy
(compact style).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): drop unused trace test fixtures (CodeQL js/unused-local-variable)

CodeQL flagged two unused locals in the trace BFS tests: the top-level
SYMBOL_C and a SYMBOL_D inside the maxDepth test (both defined, never
referenced). Remove them. No behavior change — 58 trace/tools tests stay
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Goutham Krishna Mandati 2026-06-14 15:13:01 +05:30 committed by GitHub
parent 1967512211
commit 4c73b18387
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1529 additions and 3 deletions

View file

@ -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"
```

View file

@ -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 38 `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:

View file

@ -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 |

View file

@ -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"
```

View file

@ -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 38 `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:

View file

@ -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"
```

View file

@ -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"
```

View file

@ -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 38 `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:

View file

@ -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 <name>': 'help.option.repo.target',
'check|--branch <name>': 'help.option.branch',
'trace|--from-uid <uid>': 'help.option.trace.fromUid',
'trace|--from-file <path>': 'help.option.trace.fromFile',
'trace|--to-uid <uid>': 'help.option.trace.toUid',
'trace|--to-file <path>': 'help.option.trace.toFile',
'trace|--depth <n>': 'help.option.trace.depth',
'trace|--include-tests': 'help.option.trace.includeTests',
'trace|-r, --repo <name>': 'help.option.repo.target',
'trace|--branch <name>': 'help.option.branch',
'eval-server|-p, --port <port>': 'help.option.port',
'eval-server|--host <host>': 'help.option.evalServer.host',
'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',

View file

@ -55,6 +55,8 @@ export const en = {
'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
'tool.usage.impact':
'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
'tool.usage.trace':
'Usage: gitnexus trace <from> <to> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
'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',

View file

@ -59,6 +59,8 @@ export const zhCN = {
'tool.usage.context': '用法gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
'tool.usage.impact':
'用法gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
'tool.usage.trace':
'用法gitnexus trace <起点> <终点> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
'tool.usage.cypher': '用法gitnexus cypher <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': '检测循环导入,并在发现循环时失败',

View file

@ -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 <from> <to>')
.description('Find the shortest directed path between two symbols (call + class-member edges)')
.option('--from-uid <uid>', 'Source symbol UID (zero-ambiguity)')
.option('--from-file <path>', 'Source file path hint')
.option('--to-uid <uid>', 'Target symbol UID (zero-ambiguity)')
.option('--to-file <path>', 'Target file path hint')
.option('--depth <n>', 'Max path length in hops (default: 10)')
.option('--include-tests', 'Include test files in results')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index')
.action(createLbugLazyAction(() => import('./tool.js'), 'traceCommand'));
program
.command('cypher <query>')
.description('Execute raw Cypher query against the knowledge graph')

View file

@ -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<void> {
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 <symbol> to see connections, or check if an interface bridges them.',
});
process.exit(1);
}
}

View file

@ -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<any> {
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 <symbol> to see connections, ' +
'or check if an interface bridges them.',
...(isWalCorruptionError(err) ? { recoverySuggestion: WAL_RECOVERY_SUGGESTION } : {}),
};
}
}
private async _traceImpl(repo: RepoHandle, params: TraceParams): Promise<any> {
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<string>([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 <symbol> to inspect connections.'
: 'No directed path found. The call chain likely breaks at dynamic dispatch, ' +
'reflection, or an external API boundary. Try gitnexus context <symbol> to see ' +
"both symbols' connections, or check if an interface/abstraction bridges them.",
};
}
private async impact(repo: RepoHandle, params: ImpactParams): Promise<any> {
try {
return await this._impactImpl(repo, params);

View file

@ -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) {

View file

@ -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',
]),
);
});

View file

@ -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<typeof import('../../src/storage/repo-manager.js')>();
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<typeof import('../../src/storage/git.js')>();
return { ...actual, getGitRoot: vi.fn().mockReturnValue(null) };
});
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
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<string, any[]>,
) {
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<LocalBackend> {
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<string, unknown>]
>;
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);
});
});

View file

@ -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 }));
});
});