GitNexus/gitnexus/test/unit/tools.test.ts
Goutham Krishna Mandati 4c73b18387
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 (#2173)
* 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>
2026-06-14 10:43:01 +01:00

290 lines
12 KiB
TypeScript

/**
* Unit Tests: MCP Tool Definitions
*
* Tests: GITNEXUS_TOOLS from tools.ts
* - 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
*/
import { describe, it, expect } from 'vitest';
import {
GITNEXUS_TOOLS,
LIST_REPOS_DEFAULT_LIMIT,
LIST_REPOS_MAX_LIMIT,
} from '../../src/mcp/tools.js';
const GROUP_TOOLS = new Set(['group_list', 'group_sync']);
const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
// Read-only tools that legitimately reach external systems. Add a tool name
// here when introducing a read-only tool that needs openWorldHint: true.
const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']);
describe('GITNEXUS_TOOLS', () => {
it('exports all tools (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', () => {
const names = GITNEXUS_TOOLS.map((t) => t.name);
expect(names).toEqual(
expect.arrayContaining([
'list_repos',
'query',
'cypher',
'context',
'detect_changes',
'check',
'rename',
'impact',
'explain',
'pdg_query',
'api_impact',
'trace',
]),
);
});
it('each tool has name, description, and inputSchema', () => {
for (const tool of GITNEXUS_TOOLS) {
expect(tool.name).toBeTruthy();
expect(typeof tool.name).toBe('string');
expect(tool.description).toBeTruthy();
expect(typeof tool.description).toBe('string');
expect(tool.annotations).toBeDefined();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe('object');
expect(tool.inputSchema.properties).toBeDefined();
expect(Array.isArray(tool.inputSchema.required)).toBe(true);
}
});
it('each tool exposes all MCP safety annotations', () => {
for (const tool of GITNEXUS_TOOLS) {
expect(typeof tool.annotations.readOnlyHint).toBe('boolean');
expect(typeof tool.annotations.destructiveHint).toBe('boolean');
expect(typeof tool.annotations.idempotentHint).toBe('boolean');
expect(typeof tool.annotations.openWorldHint).toBe('boolean');
}
});
it('read-only tools are marked non-destructive and idempotent', () => {
for (const tool of GITNEXUS_TOOLS) {
if (MUTATING_TOOLS.has(tool.name)) continue;
expect(tool.annotations.readOnlyHint).toBe(true);
expect(tool.annotations.destructiveHint).toBe(false);
expect(tool.annotations.idempotentHint).toBe(true);
expect(tool.annotations.openWorldHint).toBe(OPEN_WORLD_READ_ONLY_TOOLS.has(tool.name));
}
});
it('query is marked open-world because it may use external embeddings', () => {
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
expect(queryTool.annotations).toEqual({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
});
});
it('rename and group_sync are marked mutating and non-idempotent', () => {
for (const name of ['rename', 'group_sync'] as const) {
const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!;
expect(tool.annotations).toEqual({
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
});
}
});
it('query tool requires "search_query" parameter (renamed from "query" for #2175)', () => {
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
expect(queryTool.inputSchema.required).toContain('search_query');
// The legacy "query" key must NOT be advertised — Claude Code drops it (#2175).
expect(queryTool.inputSchema.required).not.toContain('query');
expect(queryTool.inputSchema.properties.query).toBeUndefined();
expect(queryTool.inputSchema.properties.search_query).toBeDefined();
expect(queryTool.inputSchema.properties.search_query.type).toBe('string');
});
it('cypher tool requires "statement" parameter (renamed from "query" for #2175)', () => {
const cypherTool = GITNEXUS_TOOLS.find((t) => t.name === 'cypher')!;
expect(cypherTool.inputSchema.required).toContain('statement');
expect(cypherTool.inputSchema.required).not.toContain('query');
expect(cypherTool.inputSchema.properties.query).toBeUndefined();
expect(cypherTool.inputSchema.properties.statement).toBeDefined();
expect(cypherTool.inputSchema.properties.statement.type).toBe('string');
expect(cypherTool.inputSchema.properties.params).toBeDefined();
expect(cypherTool.inputSchema.properties.params.type).toBe('object');
expect(cypherTool.inputSchema.properties.params.description).toContain('prepared statement');
});
it('context tool has no required parameters', () => {
const contextTool = GITNEXUS_TOOLS.find((t) => t.name === 'context')!;
expect(contextTool.inputSchema.required).toEqual([]);
});
it('impact tool requires target and direction', () => {
const impactTool = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!;
expect(impactTool.inputSchema.required).toContain('target');
expect(impactTool.inputSchema.required).toContain('direction');
});
it('rename tool requires new_name', () => {
const renameTool = GITNEXUS_TOOLS.find((t) => t.name === 'rename')!;
expect(renameTool.inputSchema.required).toContain('new_name');
});
it('detect_changes tool has no required parameters', () => {
const detectTool = GITNEXUS_TOOLS.find((t) => t.name === 'detect_changes')!;
expect(detectTool.inputSchema.required).toEqual([]);
});
it('list_repos tool exposes optional limit/offset pagination params', () => {
const listTool = GITNEXUS_TOOLS.find((t) => t.name === 'list_repos')!;
const props = listTool.inputSchema.properties;
expect(props.limit).toBeDefined();
expect(props.limit.type).toBe('integer');
expect(props.offset).toBeDefined();
expect(props.offset.type).toBe('integer');
// Pagination is opt-in: zero-arg callers must still be valid.
expect(listTool.inputSchema.required).toEqual([]);
// No `repo` param on list_repos (it lists all repos).
expect(props.repo).toBeUndefined();
// Description must teach an LLM to page through every repository.
expect(listTool.description.toLowerCase()).toContain('paginat');
expect(listTool.description).toContain('nextOffset');
expect(listTool.description).toContain('hasMore');
});
it('list_repos schema bounds match the exported pagination constants', () => {
const listTool = GITNEXUS_TOOLS.find((t) => t.name === 'list_repos')!;
const { limit, offset } = listTool.inputSchema.properties;
expect(limit.minimum).toBe(1);
expect(limit.maximum).toBe(LIST_REPOS_MAX_LIMIT);
expect(limit.default).toBe(LIST_REPOS_DEFAULT_LIMIT);
expect(offset.minimum).toBe(0);
expect(offset.default).toBe(0);
// Sane, documented bounds (guards against accidental constant drift).
expect(LIST_REPOS_DEFAULT_LIMIT).toBeLessThanOrEqual(LIST_REPOS_MAX_LIMIT);
expect(LIST_REPOS_DEFAULT_LIMIT).toBeGreaterThan(0);
});
it('per-repo tools have optional repo parameter for backend selection', () => {
for (const tool of GITNEXUS_TOOLS) {
if (tool.name === 'list_repos') continue;
if (GROUP_TOOLS.has(tool.name)) continue;
expect(tool.inputSchema.properties.repo).toBeDefined();
expect(tool.inputSchema.properties.repo.type).toBe('string');
expect(tool.inputSchema.required).not.toContain('repo');
}
});
it('per-repo tools have an optional branch scope param (#2106); group/list tools do not', () => {
for (const tool of GITNEXUS_TOOLS) {
if (tool.name === 'list_repos' || GROUP_TOOLS.has(tool.name)) {
expect(tool.inputSchema.properties.branch).toBeUndefined();
continue;
}
expect(tool.inputSchema.properties.branch, tool.name).toBeDefined();
expect(tool.inputSchema.properties.branch.type).toBe('string');
// Optional — omitting it keeps the default/primary-branch behavior.
expect(tool.inputSchema.required).not.toContain('branch');
}
});
it('group tools without backend repo param omit repo property', () => {
for (const name of ['group_list', 'group_sync'] as const) {
const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!;
expect(tool.inputSchema.properties).not.toHaveProperty('repo');
}
});
it('impact, query, and context expose optional service with minLength', () => {
for (const n of ['impact', 'query', 'context'] as const) {
const tool = GITNEXUS_TOOLS.find((t) => t.name === n)!;
const svc = tool.inputSchema.properties.service;
expect(svc, n).toBeDefined();
expect(svc!.minLength).toBe(1);
}
});
it('impact schema bounds match cross-impact validation ranges', () => {
const impact = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!;
expect(impact.inputSchema.properties.maxDepth.minimum).toBe(1);
expect(impact.inputSchema.properties.maxDepth.maximum).toBe(32);
expect(impact.inputSchema.properties.minConfidence.minimum).toBe(0);
expect(impact.inputSchema.properties.minConfidence.maximum).toBe(1);
expect(impact.inputSchema.properties.timeoutMs.maximum).toBe(3600000);
});
it('detect_changes scope has correct enum values', () => {
const detectTool = GITNEXUS_TOOLS.find((t) => t.name === 'detect_changes')!;
const scopeProp = detectTool.inputSchema.properties.scope;
expect(scopeProp.enum).toEqual(['unstaged', 'staged', 'all', 'compare']);
});
// ─── explain (#2083 M3 U6) ─────────────────────────────────────────
it('explain tool is anchorless-optional with a bounded limit and a branch scope', () => {
const explainTool = GITNEXUS_TOOLS.find((t) => t.name === 'explain')!;
expect(explainTool).toBeDefined();
// Anchorless calls (enumerate all findings) must be valid.
expect(explainTool.inputSchema.required).toEqual([]);
expect(explainTool.inputSchema.properties.target).toBeDefined();
expect(explainTool.inputSchema.properties.target.type).toBe('string');
const limit = explainTool.inputSchema.properties.limit;
expect(limit).toBeDefined();
expect(limit.type).toBe('integer');
expect(limit.minimum).toBe(1);
expect(limit.maximum).toBeGreaterThan(0);
// Branch-scoped per #2106 (injected via BRANCH_SCOPED_TOOLS).
expect(explainTool.inputSchema.properties.branch).toBeDefined();
});
it('explain description names the --pdg requirement and the KTD10 contract caveats', () => {
const explainTool = GITNEXUS_TOOLS.find((t) => t.name === 'explain')!;
const d = explainTool.description;
expect(d).toContain('--pdg');
expect(d).toContain('intra-procedural');
// The named blind-spot classes (plan KTD10) must reach the consumer.
expect(d.toLowerCase()).toContain('closure/callback');
expect(d.toLowerCase()).toContain('property/field');
expect(d.toLowerCase()).toContain('guard-style');
expect(d.toLowerCase()).toContain('cross-function');
expect(d.toLowerCase()).toContain('commonjs');
expect(d.toLowerCase()).toContain('exception');
});
it('api_impact tool has no required parameters', () => {
const apiImpactTool = GITNEXUS_TOOLS.find((t) => t.name === 'api_impact')!;
expect(apiImpactTool).toBeDefined();
expect(apiImpactTool.inputSchema.required).toEqual([]);
expect(apiImpactTool.inputSchema.properties.route).toBeDefined();
expect(apiImpactTool.inputSchema.properties.file).toBeDefined();
expect(apiImpactTool.inputSchema.properties.repo).toBeDefined();
});
it('impact relationTypes is array of strings', () => {
const impactTool = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!;
const relProp = impactTool.inputSchema.properties.relationTypes;
expect(relProp.type).toBe('array');
expect(relProp.items).toEqual({ type: 'string' });
});
it('route_map description defers to api_impact for pre-change analysis', () => {
const routeMapTool = GITNEXUS_TOOLS.find((t) => t.name === 'route_map')!;
expect(routeMapTool.description).toContain('api_impact');
expect(routeMapTool.description).toContain('pre-change analysis');
});
it('shape_check description defers to api_impact for pre-change analysis', () => {
const shapeCheckTool = GITNEXUS_TOOLS.find((t) => t.name === 'shape_check')!;
expect(shapeCheckTool.description).toContain('api_impact');
expect(shapeCheckTool.description).toContain('pre-change analysis');
});
});