mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / scope-parity (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
* Initial plan * feat: detect sibling-clone graph drift via remote URL fingerprint Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: address review feedback — fake commit, same-commit case, regex docs Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(mcp): address review feedback — CI green, perf, dead branch, one-shot test Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc2259f7-94e4-4243-aaa9-e03b7c632d32 * Merge branch 'main' into copilot/fix-single-path-indexing-issue Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5840b3dd-e879-4854-a067-d1622bec2634 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * Merge branch 'main' into copilot/fix-single-path-indexing-issue Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9025262f-4dd4-4774-8f32-e14434100004 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: prettier format run-analyze.ts after merge with main Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a7be18dd-102f-4a7b-ac56-53fbd414fe3b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: realpath both sides of cwdGitRoot assertion for Windows 8.3 short-name compat Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b2a1c6a3-e454-4b87-b0e4-69d7c0d9a51b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(test): use path-agnostic assertion for cwdGitRoot on Windows (#1015) git rev-parse --show-toplevel returns long path names on Windows while os.tmpdir() returns 8.3 short names. fs.realpathSync does not expand short names, so exact path comparison always fails on Windows CI runners. Replace with behavioral assertions instead. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <copilot-swe-agent[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: evolution <wjc163@sina.cn>
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
/**
|
|
* P0 Integration Tests: Local Backend — callTool dispatch
|
|
*
|
|
* Tests the full LocalBackend.callTool() dispatch with a real LadybugDB
|
|
* instance, verifying cypher, context, impact, and query tools work
|
|
* end-to-end against seeded graph data with FTS indexes.
|
|
*/
|
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
|
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
|
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|
import {
|
|
LOCAL_BACKEND_SEED_DATA,
|
|
LOCAL_BACKEND_FTS_INDEXES,
|
|
} from '../fixtures/local-backend-seed.js';
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
|
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
|
findSiblingClones: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
// ─── Block 2: callTool dispatch tests ────────────────────────────────
|
|
|
|
withTestLbugDB(
|
|
'local-backend-calltool',
|
|
(handle) => {
|
|
describe('callTool dispatch with real DB', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
// backend is created in afterSetup and attached to the handle
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('cypher tool returns function names', async () => {
|
|
const result = await backend.callTool('cypher', {
|
|
query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
|
|
});
|
|
// cypher tool wraps results as markdown
|
|
expect(result).toHaveProperty('markdown');
|
|
expect(result).toHaveProperty('row_count');
|
|
expect(result.row_count).toBeGreaterThanOrEqual(3);
|
|
expect(result.markdown).toContain('login');
|
|
expect(result.markdown).toContain('validate');
|
|
expect(result.markdown).toContain('hash');
|
|
});
|
|
|
|
it('cypher tool blocks write queries', async () => {
|
|
const result = await backend.callTool('cypher', {
|
|
query:
|
|
"CREATE (n:Function {id: 'x', name: 'x', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})",
|
|
});
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/write operations/i);
|
|
});
|
|
|
|
it('context tool returns symbol info with callers and callees', async () => {
|
|
const result = await backend.callTool('context', { name: 'login' });
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).toBe('found');
|
|
// Should have the symbol identity
|
|
expect(result.symbol).toBeDefined();
|
|
expect(result.symbol.name).toBe('login');
|
|
expect(result.symbol.filePath).toBe('src/auth.ts');
|
|
// login calls validate and hash — should appear in outgoing.calls
|
|
expect(result.outgoing).toBeDefined();
|
|
expect(result.outgoing.calls).toBeDefined();
|
|
expect(result.outgoing.calls.length).toBeGreaterThanOrEqual(2);
|
|
const calleeNames = result.outgoing.calls.map((c: any) => c.name);
|
|
expect(calleeNames).toContain('validate');
|
|
expect(calleeNames).toContain('hash');
|
|
});
|
|
|
|
it('impact tool returns upstream dependents', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'validate',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
// validate is called by login, so login should appear at depth 1
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
expect(result.byDepth).toBeDefined();
|
|
const directDeps = result.byDepth[1] || result.byDepth['1'] || [];
|
|
expect(directDeps.length).toBeGreaterThanOrEqual(1);
|
|
const depNames = directDeps.map((d: any) => d.name);
|
|
expect(depNames).toContain('login');
|
|
});
|
|
|
|
it('query tool returns results for keyword search', async () => {
|
|
const result = await backend.callTool('query', { query: 'login' });
|
|
expect(result).not.toHaveProperty('error');
|
|
// Should have some combination of processes, process_symbols, or definitions
|
|
expect(result).toHaveProperty('processes');
|
|
expect(result).toHaveProperty('definitions');
|
|
// The search should find something (FTS or graph-based)
|
|
const totalResults =
|
|
(result.processes?.length || 0) +
|
|
(result.process_symbols?.length || 0) +
|
|
(result.definitions?.length || 0);
|
|
expect(totalResults).toBeGreaterThanOrEqual(1);
|
|
|
|
// #553: query response carries per-phase timing metadata.
|
|
expect(result.timing).toBeDefined();
|
|
expect(typeof result.timing.wall).toBe('number');
|
|
expect(result.timing.wall).toBeGreaterThanOrEqual(0);
|
|
// At least one of the search phases must have fired for any
|
|
// non-error response — bm25 and/or vector always runs.
|
|
expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('unknown tool throws', async () => {
|
|
await expect(backend.callTool('nonexistent_tool', {})).rejects.toThrow(/unknown tool/i);
|
|
});
|
|
});
|
|
|
|
describe('impact tool relationTypes filtering', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('filters by HAS_METHOD only', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'AuthService',
|
|
direction: 'downstream',
|
|
relationTypes: ['HAS_METHOD'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
|
const names = d1.map((d: any) => d.name);
|
|
expect(names).toContain('authenticate');
|
|
// Should NOT include CALLS-reachable symbols like validate/hash
|
|
expect(names).not.toContain('validate');
|
|
expect(names).not.toContain('hash');
|
|
});
|
|
|
|
it('filters by OVERRIDES only', async () => {
|
|
// The seed has two Method nodes named 'authenticate' (AuthService's
|
|
// override and BaseService's base). Per #470, `impact` now returns
|
|
// a ranked-ambiguous response when the target name hits multiple
|
|
// symbols, so we must disambiguate with file_path to get the
|
|
// AuthService override (the one with the outgoing METHOD_OVERRIDES
|
|
// edge we want to follow downstream).
|
|
const result = await backend.callTool('impact', {
|
|
target: 'authenticate',
|
|
file_path: 'src/auth.ts',
|
|
direction: 'downstream',
|
|
relationTypes: ['METHOD_OVERRIDES'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).not.toBe('ambiguous');
|
|
// AuthService.authenticate overrides BaseService.authenticate
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
|
const names = d1.map((d: any) => d.name);
|
|
expect(names).toContain('authenticate');
|
|
});
|
|
|
|
it('expands legacy OVERRIDES to include METHOD_OVERRIDES (dual-read)', async () => {
|
|
// Pass the LEGACY alias 'OVERRIDES' — impactByUid should flatMap-expand
|
|
// it to ['OVERRIDES', 'METHOD_OVERRIDES'] so the METHOD_OVERRIDES edge
|
|
// between BaseService.authenticate and AuthService.authenticate is found.
|
|
// file_path hint disambiguates the two 'authenticate' methods per #470.
|
|
const result = await backend.callTool('impact', {
|
|
target: 'authenticate',
|
|
file_path: 'src/auth.ts',
|
|
direction: 'downstream',
|
|
relationTypes: ['OVERRIDES'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).not.toBe('ambiguous');
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
|
const names = d1.map((d: any) => d.name);
|
|
expect(names).toContain('authenticate');
|
|
});
|
|
|
|
it('does not return HAS_METHOD results when filtering by CALLS only', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'AuthService',
|
|
direction: 'downstream',
|
|
relationTypes: ['CALLS'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
// AuthService has no outgoing CALLS edges, only HAS_METHOD
|
|
expect(result.impactedCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('tool parameter edge cases', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('context tool returns error for nonexistent symbol', async () => {
|
|
const result = await backend.callTool('context', { name: 'nonexistent_xyz_symbol_999' });
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/not found/i);
|
|
});
|
|
|
|
it('query tool returns error for empty query', async () => {
|
|
const result = await backend.callTool('query', { query: '' });
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/required/i);
|
|
});
|
|
|
|
it('query tool returns error for missing query param', async () => {
|
|
const result = await backend.callTool('query', {});
|
|
expect(result).toHaveProperty('error');
|
|
});
|
|
|
|
it('cypher tool returns error for invalid Cypher syntax', async () => {
|
|
const result = await backend.callTool('cypher', {
|
|
query: 'THIS IS NOT VALID CYPHER AT ALL',
|
|
});
|
|
expect(result).toHaveProperty('error');
|
|
});
|
|
|
|
it('context tool returns error when no name or uid provided', async () => {
|
|
const result = await backend.callTool('context', {});
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/required/i);
|
|
});
|
|
|
|
// ─── impact error handling tests (#321) ───────────────────────────
|
|
// Verify that impact() returns structured JSON instead of crashing
|
|
|
|
it('impact tool returns structured error for unknown symbol', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'nonexistent_symbol_xyz_999',
|
|
direction: 'upstream',
|
|
});
|
|
// Must return structured JSON, not throw
|
|
expect(result).toBeDefined();
|
|
// Should have either an error field (not found) or impactedCount 0
|
|
// Either outcome is valid — the key is it doesn't crash
|
|
if (result.error) {
|
|
expect(typeof result.error).toBe('string');
|
|
} else {
|
|
expect(result.impactedCount).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('impact error response has consistent target shape', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'nonexistent_symbol_xyz_999',
|
|
direction: 'downstream',
|
|
});
|
|
// When an error is returned, target must be an object (not raw string)
|
|
// so downstream API consumers can safely access result.target.name
|
|
if (result.error && result.target !== undefined) {
|
|
expect(typeof result.target).toBe('object');
|
|
expect(result.target).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
it('impact partial results: traversalComplete flag when depth fails', async () => {
|
|
// Even if traversal fails at some depth, partial results should be returned
|
|
// and partial:true should only be set when some results were collected
|
|
const result = await backend.callTool('impact', {
|
|
target: 'validate',
|
|
direction: 'upstream',
|
|
maxDepth: 10, // Large depth to trigger multi-level traversal
|
|
});
|
|
// Should succeed (validate exists in seed data)
|
|
expect(result).not.toHaveProperty('error');
|
|
if (result.partial) {
|
|
// If partial, must still have some results
|
|
expect(result.impactedCount).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
{
|
|
seed: LOCAL_BACKEND_SEED_DATA,
|
|
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
|
|
poolAdapter: true,
|
|
afterSetup: async (handle) => {
|
|
// Configure listRegisteredRepos mock with handle values
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'test-repo',
|
|
path: '/test/repo',
|
|
storagePath: handle.tmpHandle.dbPath,
|
|
indexedAt: new Date().toISOString(),
|
|
lastCommit: 'abc123',
|
|
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
|
|
},
|
|
]);
|
|
|
|
const backend = new LocalBackend();
|
|
await backend.init();
|
|
// Stash backend on handle so tests can access it
|
|
(handle as any)._backend = backend;
|
|
},
|
|
},
|
|
);
|