GitNexus/gitnexus/test/unit/trace-bfs.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

911 lines
26 KiB
TypeScript

/**
* 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);
});
});