mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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
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:
parent
1967512211
commit
4c73b18387
18 changed files with 1529 additions and 3 deletions
|
|
@ -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"
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '检测循环导入,并在发现循环时失败',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
911
gitnexus/test/unit/trace-bfs.test.ts
Normal file
911
gitnexus/test/unit/trace-bfs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
123
gitnexus/test/unit/trace-cli.test.ts
Normal file
123
gitnexus/test/unit/trace-cli.test.ts
Normal 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 }));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue