GitNexus/gitnexus/test/unit/calltool-dispatch.test.ts

1891 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit Tests: LocalBackend callTool dispatch & lifecycle
*
* Tests the callTool dispatch logic, resolveRepo, init/disconnect,
* error cases, and silent failure patterns — all with mocked LadybugDB.
*
* These are pure unit tests that mock the LadybugDB layer to test
* the dispatch and error handling logic in isolation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import os from 'os';
import path from 'path';
// We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend.
// local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js
// re-exports from the same module, so we mock the canonical source.
// vi.hoisted runs before vi.mock hoisting, making the fns available to both factories.
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 };
});
// Re-export shim must resolve to the same mocks
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([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
};
});
// `core/git-staleness` is also imported by `local-backend.ts` (for
// `checkStaleness` and `checkCwdMatch`). Stub it out here so unit
// tests don't shell out to git.
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,
isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform,
};
});
// Also mock the search modules to avoid loading onnxruntime
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, REPO_ID_HASH_LENGTH } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js';
import { getGitRoot } from '../../src/storage/git.js';
import { _captureLogger } from '../../src/core/logger.js';
import {
initLbug,
executeQuery,
executeParameterized,
isLbugReady,
closeLbug,
} from '../../src/mcp/core/lbug-adapter.js';
// ─── Helpers ─────────────────────────────────────────────────────────
const MOCK_REPO_ENTRY = {
name: 'test-project',
path: '/tmp/test-project',
storagePath: '/tmp/.gitnexus/test-project',
indexedAt: '2024-06-01T12:00:00Z',
lastCommit: 'abc1234567890',
stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 },
};
function setupSingleRepo() {
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
}
function setupMultipleRepos() {
(listRegisteredRepos as any).mockResolvedValue([
MOCK_REPO_ENTRY,
{
...MOCK_REPO_ENTRY,
name: 'other-project',
path: '/tmp/other-project',
storagePath: '/tmp/.gitnexus/other-project',
},
]);
}
function setupNoRepos() {
(listRegisteredRepos as any).mockResolvedValue([]);
}
const duplicateFixtureDirs: string[] = [];
function makeDuplicateNameFixture() {
const mainDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-main-'));
const wtDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-wt-'));
duplicateFixtureDirs.push(mainDir, wtDir);
for (const dir of [mainDir, wtDir]) {
const storagePath = path.join(dir, '.gitnexus');
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
writeFileSync(path.join(storagePath, 'meta.json'), '{}');
}
return {
mainDir,
wtDir,
entries: [
{
...MOCK_REPO_ENTRY,
name: 'shared',
path: mainDir,
storagePath: path.join(mainDir, '.gitnexus'),
},
{
...MOCK_REPO_ENTRY,
name: 'shared',
path: wtDir,
storagePath: path.join(wtDir, '.gitnexus'),
},
],
};
}
function makeSharedPrefixFixture(nameA: string, nameB: string) {
const dirA = mkdtempSync(path.join(os.tmpdir(), `gnx-${nameA}-`));
const dirB = mkdtempSync(path.join(os.tmpdir(), `gnx-${nameB}-`));
duplicateFixtureDirs.push(dirA, dirB);
for (const dir of [dirA, dirB]) {
const storagePath = path.join(dir, '.gitnexus');
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
writeFileSync(path.join(storagePath, 'meta.json'), '{}');
}
return {
dirA,
dirB,
entries: [
{ ...MOCK_REPO_ENTRY, name: nameA, path: dirA, storagePath: path.join(dirA, '.gitnexus') },
{ ...MOCK_REPO_ENTRY, name: nameB, path: dirB, storagePath: path.join(dirB, '.gitnexus') },
],
};
}
// Mirrors the legacy `repoId()` suffix that #2054 replaced for genuine
// collisions: base64url is an *encoding*, not a hash, so paths sharing a long
// prefix produce the same sliced suffix. Used by the #2054 tests to assert the
// collision precondition actually holds (so the regression isn't vacuous).
function legacyPathSuffix(p: string): string {
return Buffer.from(p).toString('base64url').slice(0, REPO_ID_HASH_LENGTH).toLowerCase();
}
/**
* Build N sibling clones of one remote under a SINGLE parent directory, named
* REPO, REPO_2, …, REPO_N. All clones share the remote-inferred registry name
* ("REPO") and the same remoteUrl — this is the real-world #2054 setup. Because
* the clones live under one parent, their absolute paths share a long common
* prefix, which is exactly what made the 6-char base64url suffix collide.
* (mkdtemp'ing each clone separately would NOT reproduce the bug — the random
* suffixes diverge in the first few bytes.)
*/
function makeSiblingClonesFixture(count: number, remoteUrl = 'git@github.com:MYCOMPANY/REPO.git') {
const parent = mkdtempSync(path.join(os.tmpdir(), 'gnx-2054-'));
duplicateFixtureDirs.push(parent);
const folders = Array.from({ length: count }, (_, i) => (i === 0 ? 'REPO' : `REPO_${i + 1}`));
const dirs: string[] = [];
const entries = folders.map((folder) => {
const dir = path.join(parent, folder);
const storagePath = path.join(dir, '.gitnexus');
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
writeFileSync(path.join(storagePath, 'meta.json'), '{}');
dirs.push(dir);
return { ...MOCK_REPO_ENTRY, name: 'REPO', path: dir, storagePath, remoteUrl };
});
return { parent, dirs, entries };
}
// ─── LocalBackend lifecycle ──────────────────────────────────────────
describe('LocalBackend.init', () => {
let backend: LocalBackend;
beforeEach(() => {
backend = new LocalBackend();
vi.clearAllMocks();
});
it('returns true when repos are available', async () => {
setupSingleRepo();
const result = await backend.init();
expect(result).toBe(true);
});
it('returns false when no repos are registered', async () => {
setupNoRepos();
const result = await backend.init();
expect(result).toBe(false);
});
it('calls listRegisteredRepos with validate: true', async () => {
setupSingleRepo();
await backend.init();
expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true });
});
});
describe('LocalBackend.disconnect', () => {
let backend: LocalBackend;
beforeEach(() => {
backend = new LocalBackend();
vi.clearAllMocks();
});
it('does not throw when no repos are initialized', async () => {
setupNoRepos();
await backend.init();
await expect(backend.disconnect()).resolves.not.toThrow();
});
it('calls closeLbug on disconnect', async () => {
setupSingleRepo();
await backend.init();
await backend.disconnect();
expect(closeLbug).toHaveBeenCalled();
});
});
// ─── callTool dispatch ───────────────────────────────────────────────
describe('LocalBackend.callTool', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('routes list_repos without needing repo param', async () => {
const result = await backend.callTool('list_repos', {});
expect(Array.isArray(result)).toBe(true);
expect(result[0].name).toBe('test-project');
});
it('throws for unknown tool name', async () => {
await expect(backend.callTool('nonexistent_tool', {})).rejects.toThrow(
'Unknown tool: nonexistent_tool',
);
});
it('dispatches query tool', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { query: 'auth' });
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
});
it('includes FTS-unavailable warning when ftsAvailable is false (#1403)', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ results: [], ftsAvailable: false });
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { query: 'ProcessActivity' });
expect(result).toHaveProperty('warning');
expect((result as any).warning).toMatch(/gitnexus analyze --repair-fts/);
});
it('does not include warning when ftsAvailable is true with zero results', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ results: [], ftsAvailable: true });
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { query: 'nonexistent' });
expect(result).not.toHaveProperty('warning');
});
it('does not crash when searchFTSFromLbug throws (#1489)', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable'));
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { query: 'auth' });
// Should still return a valid result shape (semantic-only fallback)
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
expect(result).not.toHaveProperty('error');
});
it('skips vector index query when VECTOR is unsupported by the platform', async () => {
const cap = _captureLogger();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
if (cypher.includes('MATCH (e:CodeEmbedding)')) return [];
return [];
});
(executeParameterized as any).mockResolvedValue([]);
try {
await backend.callTool('query', { query: 'auth' });
const queries = (executeQuery as any).mock.calls.map(
([, cypher]: [string, string]) => cypher,
);
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(false);
expect(
queries.some(
(cypher: string) =>
cypher.includes('RETURN e.nodeId AS nodeId') &&
cypher.includes('e.embedding AS embedding'),
),
).toBe(true);
expect(
cap
.records()
.some((r) =>
String(r.msg ?? '').includes(
'GitNexus [query:vector]: VECTOR extension not supported on this platform',
),
),
).toBe(true);
} finally {
cap.restore();
}
});
it('issues vector index query when VECTOR is supported by the platform', async () => {
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
return [];
});
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('query', { query: 'auth' });
const queries = (executeQuery as any).mock.calls.map(([, cypher]: [string, string]) => cypher);
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(true);
});
it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result.error).toContain('query parameter is required');
});
it('query tool returns error for whitespace-only query', async () => {
const result = await backend.callTool('query', { query: ' ' });
expect(result.error).toContain('query parameter is required');
});
it('dispatches cypher tool and blocks write queries', async () => {
(executeParameterized as any).mockRejectedValueOnce(new Error('read-only database'));
const result = await backend.callTool('cypher', { query: 'CREATE (n:Test)' });
expect(result).toHaveProperty('error');
expect(result.error).toContain('Write operations');
});
it('dispatches cypher tool with valid read query', async () => {
(executeParameterized as any).mockResolvedValue([{ name: 'test', filePath: 'src/test.ts' }]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath LIMIT 5',
});
// formatCypherAsMarkdown returns { markdown, row_count } for tabular results
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBe(1);
});
it('dispatches context tool', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:main',
name: 'main',
type: 'Function',
filePath: 'src/index.ts',
startLine: 1,
endLine: 10,
},
]);
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('found');
expect(result.symbol.name).toBe('main');
});
it('context tool returns error when name and uid are both missing', async () => {
const result = await backend.callTool('context', {});
expect(result.error).toContain('Either "name" or "uid"');
});
it('context tool returns not-found for missing symbol', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('context', { name: 'doesNotExist' });
expect(result.error).toContain('not found');
});
it('context tool returns disambiguation for multiple matches', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:main:1',
name: 'main',
type: 'Function',
filePath: 'src/a.ts',
startLine: 1,
endLine: 5,
},
{
id: 'func:main:2',
name: 'main',
type: 'Function',
filePath: 'src/b.ts',
startLine: 1,
endLine: 5,
},
]);
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
// #470: every candidate carries a relevance score in [0, 1] and the list
// is sorted descending by score (with deterministic tiebreakers).
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.score).toBeGreaterThanOrEqual(0);
expect(c.score).toBeLessThanOrEqual(1);
}
expect(result.candidates[0].score).toBeGreaterThanOrEqual(result.candidates[1].score);
});
it('context tool ranks file_path match higher than non-match (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:handleConnect:1',
name: 'handleConnect',
type: 'Function',
filePath: 'src/lib/socket.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:handleConnect:2',
name: 'handleConnect',
type: 'Function',
filePath: 'src/App.tsx',
startLine: 42,
endLine: 60,
},
]);
const result = await backend.callTool('context', {
name: 'handleConnect',
file_path: 'App.tsx',
});
// In production, `WHERE n.filePath CONTAINS $filePath` would pre-filter
// at the DB layer and only `src/App.tsx` would come back — resolving
// via the single-candidate early return rather than via scoring. The
// `executeParameterized` mock here returns both rows regardless of the
// WHERE clause parameters, so this asserts that the resolver ends up
// picking the App.tsx candidate in either case (via mock-relaxed DB
// pre-filter or via scoring promotion). The dedicated scoring-promotion
// path is covered by the next `it()` block below.
expect(result.status).toBe('found');
expect(result.symbol.filePath).toBe('src/App.tsx');
});
it('context tool promotes top candidate via scoring when multiple rows survive DB pre-filter (#470)', async () => {
// This test explicitly exercises the scored-promotion path (#470
// review): both candidates satisfy the file_path hint (so DB
// pre-filter would return both in production), and promotion is
// determined purely by the combined file_path + kind score.
(executeParameterized as any).mockResolvedValue([
{
id: 'fn:App:1',
name: 'render',
type: 'Function',
filePath: 'src/components/App.tsx',
startLine: 10,
endLine: 20,
},
{
id: 'method:App:1',
name: 'render',
type: 'Method',
filePath: 'src/pages/App.tsx',
startLine: 5,
endLine: 15,
},
]);
const result = await backend.callTool('context', {
name: 'render',
file_path: 'App.tsx',
kind: 'Function',
});
// Expected scoring:
// Function candidate: 0.50 base + 0.40 file_path + 0.20 kind = 1.10 → cap 1.00
// Method candidate: 0.50 base + 0.40 file_path + 0.00 kind = 0.90
// Top score ≥ 0.95 and beats runner-up by 0.10 → confident promotion
// to `{ status: 'found' }` with the Function.
expect(result.status).toBe('found');
expect(result.symbol.filePath).toBe('src/components/App.tsx');
expect(result.symbol.kind).toBe('Function');
});
it('context tool returns ranked candidates when file_path only partially narrows (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:foo:1',
name: 'foo',
type: 'Function',
filePath: 'src/a.ts',
startLine: 1,
endLine: 5,
},
{
id: 'func:foo:2',
name: 'foo',
type: 'Function',
filePath: 'src/b.ts',
startLine: 1,
endLine: 5,
},
]);
// No hints → both candidates score 0.56 (0.50 base + 0.06 Function
// priority). Tied scores fall back to deterministic tiebreakers.
const result = await backend.callTool('context', { name: 'foo' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.candidates[0].score).toBeCloseTo(0.56, 2);
expect(result.candidates[1].score).toBeCloseTo(0.56, 2);
});
it('context tool boosts the candidate whose kind matches the hint (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'method:save:1',
name: 'save',
type: 'Method',
filePath: 'src/service.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:save:1',
name: 'save',
type: 'Function',
filePath: 'src/util.ts',
startLine: 5,
endLine: 15,
},
]);
const result = await backend.callTool('context', { name: 'save', kind: 'Function' });
// When kind hint is given, kind-priority bonus is suppressed and +0.20
// kind-match bonus applies instead. Function becomes the top candidate.
expect(result.status).toBe('ambiguous');
expect(result.candidates[0].kind).toBe('Function');
expect(result.candidates[0].score).toBeGreaterThan(result.candidates[1].score);
});
it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => {
// resolveSymbolCandidates issues a single name query; mock it to return
// two Function rows in different files with no hints.
(executeParameterized as any).mockResolvedValue([
{
id: 'func:login:1',
name: 'login',
type: 'Function',
filePath: 'src/auth.ts',
startLine: 5,
endLine: 15,
},
{
id: 'func:login:2',
name: 'login',
type: 'Function',
filePath: 'src/admin/login.ts',
startLine: 8,
endLine: 20,
},
]);
const result = await backend.callTool('impact', { target: 'login', direction: 'upstream' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
expect(result.target.name).toBe('login');
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.uid).toBeDefined();
expect(c.kind).toBe('Function');
}
});
it('impact tool resolves via target_uid without running the name-based resolver (#470)', async () => {
// UID path: exactly one executeParameterized call for the lookup, then
// the BFS issues executeQuery calls (which we mock empty). Crucially,
// no `WHERE n.name =` query fires.
(executeParameterized as any).mockResolvedValue([
{
id: 'uid:1234',
name: 'pickedByUid',
type: 'Function',
filePath: 'src/pick.ts',
startLine: 1,
endLine: 10,
},
]);
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', {
target: 'ignoredName',
target_uid: 'uid:1234',
direction: 'upstream',
});
// No ambiguous shape and no name-lookup error — the uid short-circuit won.
expect(result.status).not.toBe('ambiguous');
expect(result.target).toBeDefined();
// All executeParameterized calls this test dispatched must have been
// uid-keyed, never name-keyed. That proves the name resolver was skipped.
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('dispatches impact tool', async () => {
// impact() calls executeParameterized to find target, then executeQuery for traversal
(executeParameterized as any).mockResolvedValue([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
]);
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
expect(result).toBeDefined();
expect(result.target).toBeDefined();
});
it('impact byDepth items include a processes field (default empty when no processes)', async () => {
// Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows.
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
// BFS frontier query is now parameterized (#1907 U3).
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
return Promise.resolve([
{
id: 'func:caller',
name: 'caller',
type: 'Function',
filePath: 'src/uses-main.ts',
relType: 'CALLS',
confidence: 0.9,
},
]);
}
// Symbol resolution.
return Promise.resolve([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
]);
});
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
expect(d1.length).toBeGreaterThan(0);
for (const item of d1) {
expect(item).toHaveProperty('processes');
expect(Array.isArray(item.processes)).toBe(true);
}
});
it('impact populates byDepth processes when STEP_IN_PROCESS rows exist', async () => {
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
// BFS frontier query is now parameterized (#1907 U3).
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
return Promise.resolve([
{
id: 'func:caller',
name: 'caller',
type: 'Function',
filePath: 'src/uses-main.ts',
relType: 'CALLS',
confidence: 0.9,
},
]);
}
// Symbol resolver name-lookup
if (cypher.includes('WHERE n.name =')) {
return Promise.resolve([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
]);
}
// Aggregation pass (must return at least one row so per-symbol pass is gated open)
if (cypher.includes('COUNT(DISTINCT s.id)')) {
return Promise.resolve([
{
pId: 'proc:cron_daily',
name: 'Daily cron',
heuristicLabel: 'Daily cron',
processType: 'cron',
entryPointId: 'func:cron_entry',
hits: 1,
minStep: 1,
stepCount: 5,
epName: 'cron_entry',
epType: 'Function',
epFilePath: 'src/cron.ts',
},
]);
}
// New per-symbol pass added by this change
if (cypher.includes('RETURN s.id AS sid')) {
return Promise.resolve([
{
sid: 'func:caller',
pid: 'proc:cron_daily',
pName: 'Daily cron',
pType: 'cron',
step: 2,
},
]);
}
return Promise.resolve([]);
});
(executeQuery as any).mockResolvedValue([
{
id: 'func:caller',
name: 'caller',
type: 'Function',
filePath: 'src/uses-main.ts',
relType: 'CALLS',
confidence: 0.9,
},
]);
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
const caller = d1.find((it: any) => it.id === 'func:caller');
expect(caller).toBeDefined();
expect(caller.processes).toHaveLength(1);
expect(caller.processes[0]).toMatchObject({
id: 'proc:cron_daily',
label: 'Daily cron',
processType: 'cron',
step: 2,
});
});
it('impact summaryOnly:true skips the per-symbol STEP_IN_PROCESS enrichment pass', async () => {
// Resolver returns target; BFS returns one caller; aggregation returns one process row.
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
// BFS frontier query is now parameterized (#1907 U3) — return a caller so
// the per-symbol-skip assertion below is meaningful (not vacuous).
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
return Promise.resolve([
{
id: 'func:caller',
name: 'caller',
type: 'Function',
filePath: 'src/a.ts',
relType: 'CALLS',
confidence: 0.9,
},
]);
}
if (cypher.includes('WHERE n.name =')) {
return Promise.resolve([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
]);
}
if (cypher.includes('COUNT(DISTINCT s.id)')) {
return Promise.resolve([
{
pId: 'proc:daily',
name: 'Daily cron',
heuristicLabel: 'Daily cron',
processType: 'cron',
entryPointId: 'func:cron_entry',
hits: 1,
minStep: 1,
stepCount: 5,
epName: 'cron_entry',
epType: 'Function',
epFilePath: 'src/cron.ts',
},
]);
}
return Promise.resolve([]);
});
(executeQuery as any).mockResolvedValue([
{
id: 'func:caller',
name: 'caller',
type: 'Function',
filePath: 'src/a.ts',
relType: 'CALLS',
confidence: 0.9,
},
]);
const result = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
summaryOnly: true,
});
// summaryOnly should return base fields only, no byDepth
expect(result.summary).toBeDefined();
expect(result.byDepth).toBeUndefined();
// The per-symbol enrichment query contains 'RETURN s.id AS sid'; verify it
// was never called (the gate should have suppressed it).
const perSymbolCalls = (executeParameterized as any).mock.calls.filter(
([, cypher]: [string, string]) =>
typeof cypher === 'string' && cypher.includes('RETURN s.id AS sid'),
);
expect(perSymbolCalls).toHaveLength(0);
});
it('impactByUid preserves byDepth while skipping per-symbol enrichment (group fan-out)', async () => {
// Regression guard for the cross-repo by_depth contract: impactByUid must
// suppress only the per-symbol STEP_IN_PROCESS pass, NOT the whole byDepth
// field. cross-impact.ts reads fan.byDepth to populate group `by_depth`;
// using summaryOnly here would silently empty it.
//
// impactByUid takes an explicit repoId and calls refreshRepos() internally.
// Use a fresh backend whose repo path is already absolute/resolved so the
// derived repoId stays stable across that refresh (an unresolved POSIX
// fixture path triggers the path-collision rehash and drops the key).
const resolvedRepoPath = path.resolve('/tmp/test-project');
(listRegisteredRepos as any).mockResolvedValue([
{ ...MOCK_REPO_ENTRY, path: resolvedRepoPath },
]);
backend = new LocalBackend();
await backend.init();
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
// BFS frontier query is now parameterized (#1907 U3).
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
return Promise.resolve([
{
id: 'func:caller',
name: 'caller',
type: 'Function',
filePath: 'src/uses-main.ts',
relType: 'CALLS',
confidence: 0.9,
},
]);
}
// UID resolver
if (cypher.includes('WHERE n.id = $uid')) {
return Promise.resolve([
{ id: 'func:main', name: 'main', filePath: 'src/index.ts', type: 'Function' },
]);
}
// Aggregation pass (returns a process row so affectedProcesses > 0; if the
// per-symbol pass were not skipped, this would open its gate)
if (cypher.includes('COUNT(DISTINCT s.id)')) {
return Promise.resolve([
{
pId: 'proc:daily',
name: 'Daily cron',
heuristicLabel: 'Daily cron',
processType: 'cron',
entryPointId: 'func:cron_entry',
hits: 1,
minStep: 1,
stepCount: 5,
epName: 'cron_entry',
epType: 'Function',
epFilePath: 'src/cron.ts',
},
]);
}
return Promise.resolve([]);
});
(executeQuery as any).mockResolvedValue([
{
id: 'func:caller',
name: 'caller',
type: 'Function',
filePath: 'src/uses-main.ts',
relType: 'CALLS',
confidence: 0.9,
},
]);
const result = await backend.impactByUid('test-project', 'uid:main', 'upstream', {
maxDepth: 5,
relationTypes: ['CALLS'],
minConfidence: 0,
includeTests: true,
});
// byDepth must survive (Finding A regression guard)
expect(result).not.toBeNull();
expect(result.byDepth).toBeDefined();
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
expect(d1.find((it: any) => it.id === 'func:caller')).toBeDefined();
// The per-symbol enrichment query must never fire under skipPerSymbolEnrichment
const perSymbolCalls = (executeParameterized as any).mock.calls.filter(
([, cypher]: [string, string]) =>
typeof cypher === 'string' && cypher.includes('RETURN s.id AS sid'),
);
expect(perSymbolCalls).toHaveLength(0);
});
it('dispatches detect_changes tool', async () => {
// detect_changes calls execFileSync which we haven't mocked at module level,
// so it will throw a git error — that's fine, we test the error path
const result = await backend.callTool('detect_changes', { scope: 'unstaged' });
// Should either return changes or a git error
expect(result).toBeDefined();
expect(result.error || result.summary).toBeDefined();
});
it('dispatches rename tool', async () => {
(executeParameterized as any)
.mockResolvedValueOnce([
{
id: 'func:oldName',
name: 'oldName',
type: 'Function',
filePath: 'src/test.ts',
startLine: 1,
endLine: 5,
},
])
.mockResolvedValue([]);
const result = await backend.callTool('rename', {
symbol_name: 'oldName',
new_name: 'newName',
dry_run: true,
});
expect(result).toBeDefined();
});
it('rename returns error when both symbol_name and symbol_uid are missing', async () => {
const result = await backend.callTool('rename', { new_name: 'newName' });
expect(result.error).toContain('Either symbol_name or symbol_uid');
});
// api_impact tool
it('dispatches api_impact tool with route param', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/grants',
routeName: '/api/grants',
handlerFile: 'app/api/grants/route.ts',
responseKeys: ['data', 'pagination'],
errorKeys: ['error', 'message'],
middleware: ['withAuth'],
consumerName: 'GrantsList',
consumerFile: 'src/GrantsList.tsx',
fetchReason: 'fetch-url-match|keys:data,pagination',
},
]);
const result = await backend.callTool('api_impact', { route: '/api/grants' });
expect(result).toHaveProperty('route', '/api/grants');
expect(result).toHaveProperty('handler', 'app/api/grants/route.ts');
expect(result).toHaveProperty('responseShape');
expect(result.responseShape.success).toEqual(['data', 'pagination']);
expect(result.responseShape.error).toEqual(['error', 'message']);
expect(result).toHaveProperty('middleware', ['withAuth']);
expect(result).toHaveProperty('consumers');
expect(result.consumers).toHaveLength(1);
expect(result).toHaveProperty('impactSummary');
expect(result.impactSummary.directConsumers).toBe(1);
expect(result.impactSummary.riskLevel).toBe('LOW');
});
it('api_impact returns error when no route or file param', async () => {
const result = await backend.callTool('api_impact', {});
expect(result.error).toContain('Either "route" or "file"');
});
it('api_impact returns error when no routes found', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('api_impact', { route: '/api/nonexistent' });
expect(result.error).toContain('No routes found');
});
it('api_impact detects mismatches and bumps risk level', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/data',
routeName: '/api/data',
handlerFile: 'api/data.ts',
responseKeys: ['items'],
errorKeys: ['error'],
middleware: null,
consumerName: 'DataView',
consumerFile: 'src/DataView.tsx',
fetchReason: 'fetch-url-match|keys:items,meta',
},
]);
const result = await backend.callTool('api_impact', { route: '/api/data' });
expect(result.mismatches).toBeDefined();
expect(result.mismatches).toHaveLength(1);
expect(result.mismatches[0].field).toBe('meta');
expect(result.mismatches[0].reason).toContain('not in response shape');
// 1 consumer = LOW, but mismatch bumps to MEDIUM
expect(result.impactSummary.riskLevel).toBe('MEDIUM');
});
it('api_impact supports file param lookup', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/users',
routeName: '/api/users',
handlerFile: 'app/api/users/route.ts',
responseKeys: ['users'],
errorKeys: null,
middleware: null,
consumerName: null,
consumerFile: null,
fetchReason: null,
},
]);
const result = await backend.callTool('api_impact', { file: 'app/api/users/route.ts' });
expect(result.route).toBe('/api/users');
expect(result.impactSummary.directConsumers).toBe(0);
expect(result.impactSummary.riskLevel).toBe('LOW');
});
it('api_impact returns array for multiple matching routes', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/a',
routeName: '/api/a',
handlerFile: 'api/a.ts',
responseKeys: null,
errorKeys: null,
middleware: null,
consumerName: null,
consumerFile: null,
fetchReason: null,
},
{
routeId: 'Route:/api/b',
routeName: '/api/b',
handlerFile: 'api/b.ts',
responseKeys: null,
errorKeys: null,
middleware: null,
consumerName: null,
consumerFile: null,
fetchReason: null,
},
]);
const result = await backend.callTool('api_impact', { route: '/api/' });
expect(result.routes).toHaveLength(2);
expect(result.total).toBe(2);
});
it('api_impact HIGH risk for 10+ consumers', async () => {
const rows = [];
for (let i = 0; i < 10; i++) {
rows.push({
routeId: 'Route:/api/popular',
routeName: '/api/popular',
handlerFile: 'api/popular.ts',
responseKeys: ['data'],
errorKeys: null,
middleware: null,
consumerName: `Consumer${i}`,
consumerFile: `src/Consumer${i}.tsx`,
fetchReason: null,
});
}
(executeParameterized as any).mockResolvedValue(rows);
const result = await backend.callTool('api_impact', { route: '/api/popular' });
expect(result.impactSummary.directConsumers).toBe(10);
expect(result.impactSummary.riskLevel).toBe('HIGH');
});
// Legacy tool aliases
it('dispatches "search" as alias for query', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('search', { query: 'auth' });
expect(result).toHaveProperty('processes');
});
it('dispatches "explore" as alias for context', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:main',
name: 'main',
type: 'Function',
filePath: 'src/index.ts',
startLine: 1,
endLine: 10,
},
]);
const result = await backend.callTool('explore', { name: 'main' });
// explore calls context — which may return found or ambiguous depending on mock
expect(result).toBeDefined();
expect(result.status === 'found' || result.symbol || result.error === undefined).toBeTruthy();
});
});
// ─── Repo resolution ────────────────────────────────────────────────
describe('LocalBackend.resolveRepo', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
(getGitRoot as any).mockReturnValue(null);
backend = new LocalBackend();
});
afterEach(() => {
for (const dir of duplicateFixtureDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it('resolves single repo without param', async () => {
setupSingleRepo();
await backend.init();
const result = await backend.callTool('list_repos', {});
expect(result).toHaveLength(1);
});
it('throws when no repos are registered', async () => {
setupNoRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
'No indexed repositories',
);
});
it('throws for ambiguous repos without param', async () => {
setupMultipleRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
'Multiple repositories indexed',
);
});
it('resolves repo by name parameter', async () => {
setupMultipleRepos();
await backend.init();
// With repo param, it should resolve correctly
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', {
query: 'auth',
repo: 'test-project',
});
expect(result).toHaveProperty('processes');
});
it('throws for unknown repo name', async () => {
setupSingleRepo();
await backend.init();
await expect(backend.callTool('query', { query: 'test', repo: 'nonexistent' })).rejects.toThrow(
'not found',
);
});
it('prefers duplicate-name repo matching process.cwd() git root (#1658)', async () => {
const { wtDir, entries } = makeDuplicateNameFixture();
(listRegisteredRepos as any).mockResolvedValue(entries);
(getGitRoot as any).mockReturnValue(wtDir);
await backend.init();
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('query', { query: 'test', repo: 'shared' });
const resolved = await backend.resolveRepo('shared');
expect(resolved.repoPath).toBe(wtDir);
});
it('throws RegistryAmbiguousTargetError when duplicate name cannot be disambiguated (#1658)', async () => {
const { entries } = makeDuplicateNameFixture();
(listRegisteredRepos as any).mockResolvedValue(entries);
(getGitRoot as any).mockReturnValue(null);
await backend.init();
await expect(backend.resolveRepo('shared')).rejects.toThrow(/Multiple registered repos match/);
await expect(backend.resolveRepo('shared')).rejects.toThrow(/absolute path/i);
});
it('resolves duplicate-name repos by absolute path before name (#1658)', async () => {
const { mainDir, wtDir, entries } = makeDuplicateNameFixture();
(listRegisteredRepos as any).mockResolvedValue(entries);
(getGitRoot as any).mockReturnValue(mainDir);
await backend.init();
(executeParameterized as any).mockResolvedValue([]);
const resolved = await backend.resolveRepo(wtDir);
expect(resolved.repoPath).toBe(wtDir);
});
it('does not treat a bare duplicate alias as a relative path (#1658)', async () => {
const { entries } = makeDuplicateNameFixture();
(listRegisteredRepos as any).mockResolvedValue(entries);
(getGitRoot as any).mockReturnValue(null);
await backend.init();
await expect(backend.resolveRepo('shared')).rejects.toThrow(/Multiple registered repos match/);
});
it('refreshes registry after ambiguity when duplicates are removed (#1658)', async () => {
const { mainDir, entries } = makeDuplicateNameFixture();
const singleEntry = [entries[0]];
(listRegisteredRepos as any).mockResolvedValueOnce(entries).mockResolvedValueOnce(singleEntry);
(getGitRoot as any).mockReturnValue(null);
await backend.init();
const resolved = await backend.resolveRepo('shared');
expect(resolved.repoPath).toBe(mainDir);
});
it('detect_changes surfaces RegistryAmbiguousTargetError on duplicate repo name (#1658)', async () => {
const { entries } = makeDuplicateNameFixture();
(listRegisteredRepos as any).mockResolvedValue(entries);
(getGitRoot as any).mockReturnValue(null);
await backend.init();
await expect(
backend.callTool('detect_changes', { scope: 'unstaged', repo: 'shared' }),
).rejects.toThrow(/Multiple registered repos match/);
});
it('resolves second duplicate-name repo by its stable hashed id (#1658)', async () => {
const { wtDir, entries } = makeDuplicateNameFixture();
(listRegisteredRepos as any).mockResolvedValue(entries);
// Couples this test to repoId's suffix formula on purpose — if repoId changes
// its suffix, this assertion should fail and force a re-review of the hashed-id
// resolution tier. Mirrors LocalBackend.repoId: base64url(repoPath) sliced to
// REPO_ID_HASH_LENGTH and lowercased so it survives the paramLower lookup in
// resolveRepoFromCache.
const wtId = `shared-${Buffer.from(wtDir)
.toString('base64url')
.slice(0, REPO_ID_HASH_LENGTH)
.toLowerCase()}`;
await backend.init();
const resolved = await backend.resolveRepo(wtId);
expect(resolved.repoPath).toBe(wtDir);
});
it('does not silently return first partial match for ambiguous prefix (#1658)', async () => {
const { dirA, entries } = makeSharedPrefixFixture('project-a', 'project-b');
(listRegisteredRepos as any).mockResolvedValue(entries);
(getGitRoot as any).mockReturnValue(null);
await backend.init();
await expect(backend.resolveRepo('project')).rejects.toThrow(/Repository "project" not found/);
// Sanity: exact names still resolve unambiguously against the same fixture.
const exact = await backend.resolveRepo('project-a');
expect(exact.name).toBe('project-a');
expect(exact.repoPath).toBe(dirA);
});
it('resolves repo case-insensitively', async () => {
setupSingleRepo();
await backend.init();
(executeParameterized as any).mockResolvedValue([]);
// Should match even with different case
const result = await backend.callTool('query', {
query: 'test',
repo: 'Test-Project',
});
expect(result).toHaveProperty('processes');
});
it('refreshes registry on repo miss', async () => {
setupNoRepos();
await backend.init();
// Now make a repo appear
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
// The resolve should re-read the registry and find the new repo
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', {
query: 'test',
repo: 'test-project',
});
expect(result).toHaveProperty('processes');
// listRegisteredRepos should have been called again
expect(listRegisteredRepos).toHaveBeenCalledTimes(2); // once in init, once in refreshRepos
});
it('emits sibling-clone drift warning exactly once per (repo, cwd) pair', async () => {
// Regression guard for the one-shot stderr warning emitted when
// the caller's cwd is in a sibling clone of the resolved index.
// The cache must short-circuit BOTH `console.error` and the
// underlying `checkCwdMatch` git shellouts on subsequent calls.
const { checkCwdMatch } = await import('../../src/core/git-staleness.js');
(listRegisteredRepos as any).mockResolvedValue([
{ ...MOCK_REPO_ENTRY, remoteUrl: 'https://example.com/foo/bar' },
]);
(checkCwdMatch as any).mockResolvedValue({
match: 'sibling-by-remote',
entry: { ...MOCK_REPO_ENTRY, remoteUrl: 'https://example.com/foo/bar' },
cwdGitRoot: '/tmp/sibling-clone',
cwdHead: 'feedface',
hint: '⚠️ stale sibling clone',
});
const cap = _captureLogger();
try {
await backend.init();
// Three resolveRepo invocations from the same cwd:
await backend.callTool('list_repos', {}); // resolveRepo not called for list_repos
// Use a real resolveRepo path:
await backend.resolveRepo();
await backend.resolveRepo();
await backend.resolveRepo();
const drift = cap
.records()
.filter((r) => String(r.msg ?? '').includes('stale sibling clone'));
expect(drift).toHaveLength(1);
// checkCwdMatch should also only run once — the cache check
// happens BEFORE the shellout-heavy match call.
expect(checkCwdMatch).toHaveBeenCalledTimes(1);
} finally {
cap.restore();
(checkCwdMatch as any).mockResolvedValue({ match: 'none' });
}
});
});
// ─── repo-id collisions (sibling clones) ────────────────────────────
describe('LocalBackend repo-id collisions (#2054)', () => {
let backend: LocalBackend;
beforeEach(() => {
vi.clearAllMocks();
(getGitRoot as any).mockReturnValue(null);
backend = new LocalBackend();
});
afterEach(() => {
for (const dir of duplicateFixtureDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it('lists all four sibling clones that share a name and remote (#2054)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
(listRegisteredRepos as any).mockResolvedValue(entries);
// Precondition: the historical 6-char base64url suffixes really do collide
// for these sibling paths — otherwise this test would not exercise the bug.
expect(legacyPathSuffix(dirs[1])).toBe(legacyPathSuffix(dirs[2]));
expect(legacyPathSuffix(dirs[2])).toBe(legacyPathSuffix(dirs[3]));
expect(await backend.init()).toBe(true);
const listed = await backend.callTool('list_repos', {});
expect(listed).toHaveLength(4);
// Every distinct on-disk clone survives exactly once — no silent overwrite.
const listedPaths = listed.map((r: any) => path.resolve(r.path)).sort();
expect(listedPaths).toEqual(dirs.map((d) => path.resolve(d)).sort());
expect(new Set(listedPaths).size).toBe(4);
// The shared remoteUrl must NOT collapse the entries; instead each entry
// reports the other three as siblings (existing list_repos contract).
for (const entry of listed) {
expect(entry.remoteUrl).toBe('git@github.com:MYCOMPANY/REPO.git');
expect(entry.siblings).toHaveLength(3);
}
// Every clone is addressable by its absolute path.
for (const dir of dirs) {
const resolved = await backend.resolveRepo(dir);
expect(resolved.repoPath).toBe(dir);
}
// Re-running list_repos (which re-reads the registry) is idempotent.
const again = await backend.callTool('list_repos', {});
expect(again).toHaveLength(4);
});
it('assigns distinct, resolvable generated ids past the first legacy collision (#2054)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
// Resolve each clone by path, collect its in-memory id.
const handles = await Promise.all(dirs.map((d) => backend.resolveRepo(d)));
const ids = handles.map((h) => h.id);
// Ids are unique across all four clones.
expect(new Set(ids).size).toBe(4);
// First clone keeps the bare name; the rest are name-prefixed generated ids.
expect(ids[0]).toBe('repo');
for (const id of ids.slice(1)) expect(id.startsWith('repo-')).toBe(true);
// Clones that collided on the legacy suffix fall back to a content hash —
// i.e. they are NOT addressable by the (colliding) legacy id, but ARE
// addressable by whatever stable id they actually hold.
const collidedLegacy = `repo-${legacyPathSuffix(dirs[2])}`;
expect(handles[2].id).not.toBe(collidedLegacy);
expect(handles[3].id).not.toBe(handles[2].id);
// Each *suffixed* generated id resolves back to its own clone. The bare
// "repo" id is intentionally shadowed by the shared repo *name* (the #1658
// name tier runs before the id tier), so the first clone is addressed by
// path instead — covered by the headline test.
for (const h of handles.slice(1)) {
const viaId = await backend.resolveRepo(h.id);
expect(viaId.repoPath).toBe(h.repoPath);
}
});
it('keeps each clones generated id stable across a registry reorder (#2067)', async () => {
// Ids are assigned over a path-sorted view, so the same resolved path always
// gets the same id regardless of registry order — a memorized hashed id
// can't drift to a different clone after a reorder.
const { dirs, entries } = makeSiblingClonesFixture(4);
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const before: Record<string, string> = {};
for (const d of dirs) before[d] = (await backend.resolveRepo(d)).id;
// Reverse the registry order and refresh.
(listRegisteredRepos as any).mockResolvedValue([...entries].reverse());
await backend.callTool('list_repos', {});
for (const d of dirs) {
expect((await backend.resolveRepo(d)).id).toBe(before[d]); // same path → same id
}
});
it('refresh stability: reorder, remove-one, and re-add never drop a different clone (#2054)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
const listedPaths = async () =>
(await backend.callTool('list_repos', {})).map((r: any) => path.resolve(r.path)).sort();
const allPaths = dirs.map((d) => path.resolve(d)).sort();
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
expect(await listedPaths()).toEqual(allPaths);
// Reordering the registry must not silently lose a clone. (Under path-sorted
// assignment a reorder is a no-op for id assignment; id stability across
// reorder is asserted separately above. This step remains a set-survival
// guard.)
(listRegisteredRepos as any).mockResolvedValue([...entries].reverse());
expect(await listedPaths()).toEqual(allPaths);
// Removing one entry prunes only that entry.
(listRegisteredRepos as any).mockResolvedValue(entries.slice(0, 3));
expect(await listedPaths()).toEqual(
dirs
.slice(0, 3)
.map((d) => path.resolve(d))
.sort(),
);
// Re-adding it restores it without replacing another clone.
(listRegisteredRepos as any).mockResolvedValue(entries);
expect(await listedPaths()).toEqual(allPaths);
});
it('gives two same-name clones independent pools and never evicts on id reassignment (#2067)', async () => {
// The pool (and the init/staleness/reinit maps) are keyed by the immutable
// lbugPath, so two clones that transiently share a name-derived id get
// SEPARATE pool entries — neither can be served the other's database — and a
// pure id reassignment (path still registered) needs no pool eviction.
const parent = mkdtempSync(path.join(os.tmpdir(), 'gnx-remap-'));
duplicateFixtureDirs.push(parent);
const a = path.join(parent, 'A'); // 'A' sorts before 'B'
const b = path.join(parent, 'B');
const lbug = (dir: string) => path.join(dir, '.gitnexus', 'lbug');
const mk = (dir: string) => {
mkdirSync(lbug(dir), { recursive: true });
writeFileSync(path.join(dir, '.gitnexus', 'meta.json'), '{}');
return {
...MOCK_REPO_ENTRY,
name: 'dup',
path: dir,
storagePath: path.join(dir, '.gitnexus'),
};
};
const entryA = mk(a);
const entryB = mk(b);
// Start with only B → B owns the bare "dup" id; resolve it.
(listRegisteredRepos as any).mockResolvedValue([entryB]);
await backend.init();
const handleB = await backend.resolveRepo(b);
expect(handleB.id).toBe('dup');
// Add A (sorts before B) → the bare "dup" id is reassigned to A.
(closeLbug as any).mockClear();
(listRegisteredRepos as any).mockResolvedValue([entryB, entryA]);
await backend.callTool('list_repos', {});
const handleA = await backend.resolveRepo(a);
expect(handleA.id).toBe('dup'); // A now owns the bare id
expect((await backend.resolveRepo(b)).id).not.toBe('dup'); // B moved to a suffix
// Reassigning the id evicts nothing — both paths are still registered.
expect(closeLbug).not.toHaveBeenCalled();
// Each clone initializes its OWN pool entry, keyed by its own lbugPath — no
// cross-serving even though they shared the "dup" id.
(initLbug as any).mockClear();
await (backend as any).ensureInitialized(handleA);
await (backend as any).ensureInitialized(handleB);
expect(initLbug).toHaveBeenCalledWith(lbug(a), lbug(a));
expect(initLbug).toHaveBeenCalledWith(lbug(b), lbug(b));
});
it('releases the pooled connection when a repo path leaves the registry (#2054)', async () => {
// When a clone's path is unregistered its pooled LadybugDB connection must
// be released. The pool is keyed by lbugPath, so eviction targets the path.
const parent = mkdtempSync(path.join(os.tmpdir(), 'gnx-vanish-'));
duplicateFixtureDirs.push(parent);
const dir = path.join(parent, 'solo');
const lbugPath = path.join(dir, '.gitnexus', 'lbug');
mkdirSync(lbugPath, { recursive: true });
writeFileSync(path.join(dir, '.gitnexus', 'meta.json'), '{}');
const entry = {
...MOCK_REPO_ENTRY,
name: 'solo',
path: dir,
storagePath: path.join(dir, '.gitnexus'),
};
(listRegisteredRepos as any).mockResolvedValue([entry]);
await backend.init();
expect((await backend.resolveRepo(dir)).id).toBe('solo');
// Registry now empty → the clone's path vanishes on refresh.
(closeLbug as any).mockClear();
(listRegisteredRepos as any).mockResolvedValue([]);
await backend.callTool('list_repos', {});
expect(closeLbug).toHaveBeenCalledWith(lbugPath);
});
it('initializes the resolved clone, not a clone the id was remapped to mid-call (#2067)', async () => {
// ensureInitialized takes the resolved RepoHandle, so even if a concurrent
// refresh remaps the (floating) bare id to a different clone between resolve
// and init, it opens the clone the caller actually resolved — not whatever
// the id now points at. Pre-fix (by-id re-derivation) it opened the remapped
// clone's database.
const parent = mkdtempSync(path.join(os.tmpdir(), 'gnx-race-'));
duplicateFixtureDirs.push(parent);
const a = path.join(parent, 'A'); // 'A' sorts before 'B'
const b = path.join(parent, 'B');
const mk = (dir: string) => {
mkdirSync(path.join(dir, '.gitnexus', 'lbug'), { recursive: true });
writeFileSync(path.join(dir, '.gitnexus', 'meta.json'), '{}');
return {
...MOCK_REPO_ENTRY,
name: 'dup',
path: dir,
storagePath: path.join(dir, '.gitnexus'),
};
};
const entryA = mk(a);
const entryB = mk(b);
// Only B registered → B owns the bare "dup" id; resolve it.
(listRegisteredRepos as any).mockResolvedValue([entryB]);
await backend.init();
const resolvedB = await backend.resolveRepo(b);
expect(resolvedB.id).toBe('dup');
// Concurrent refresh adds A (sorts first) → the bare "dup" id now maps to A.
(listRegisteredRepos as any).mockResolvedValue([entryB, entryA]);
await backend.callTool('list_repos', {});
expect((await backend.resolveRepo(a)).id).toBe('dup'); // id remapped to A
// Initialize with the handle resolved BEFORE the remap → must open B's path
// (pool keyed by B's lbugPath), never A's.
(initLbug as any).mockClear();
await (backend as any).ensureInitialized(resolvedB);
const lbug = (dir: string) => path.join(dir, '.gitnexus', 'lbug');
expect(initLbug).toHaveBeenCalledWith(lbug(b), lbug(b));
expect(initLbug).not.toHaveBeenCalledWith(lbug(a), lbug(a));
});
it('handles more than four sibling clones — all listed once and resolvable (#2067)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(6);
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const listed = await backend.callTool('list_repos', {});
expect(listed).toHaveLength(6);
// All six ids are distinct (clones 36 exercise the sha256 fallback tier).
const ids = await Promise.all(dirs.map(async (d) => (await backend.resolveRepo(d)).id));
expect(new Set(ids).size).toBe(6);
for (const d of dirs) expect((await backend.resolveRepo(d)).repoPath).toBe(d);
});
it('lists same-name clones with no remoteUrl without grouping or collapse (#2067)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(2);
// Strip remoteUrl — same name, no remote fingerprint.
const noRemote = entries.map((e) => ({ ...e, remoteUrl: undefined }));
(listRegisteredRepos as any).mockResolvedValue(noRemote);
await backend.init();
const listed = await backend.callTool('list_repos', {});
expect(listed).toHaveLength(2); // both present, not collapsed
for (const e of listed) {
expect(e.remoteUrl).toBeUndefined();
expect(e.siblings).toBeUndefined(); // no remote → no sibling grouping
}
for (const d of dirs) expect((await backend.resolveRepo(d)).repoPath).toBe(d);
});
});
// ─── getContext ──────────────────────────────────────────────────────
describe('LocalBackend.getContext', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('returns context for single repo without specifying id', () => {
const ctx = backend.getContext();
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
expect(ctx!.stats.fileCount).toBe(10);
expect(ctx!.stats.functionCount).toBe(50);
});
it('returns context by repo id', () => {
const ctx = backend.getContext('test-project');
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
});
it('returns single repo context even with unknown id (single-repo fallback)', () => {
// When only 1 repo is registered, getContext falls through the id check
// and returns the single repo's context. This is intentional behavior.
const ctx = backend.getContext('nonexistent');
// The id doesn't match, but since repos.size === 1, it returns that single context
// This is the actual behavior — test documents it
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
});
});
// ─── LadybugDB lazy initialization ──────────────────────────────────────
describe('ensureInitialized', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('calls initLbug on first tool call', async () => {
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('query', { query: 'test' });
expect(initLbug).toHaveBeenCalled();
});
it('retries initLbug if connection was evicted', async () => {
(executeParameterized as any).mockResolvedValue([]);
// First call initializes
await backend.callTool('query', { query: 'test' });
expect(initLbug).toHaveBeenCalledTimes(1);
// Simulate idle eviction
(isLbugReady as any).mockReturnValueOnce(false);
await backend.callTool('query', { query: 'test' });
expect(initLbug).toHaveBeenCalledTimes(2);
});
it('handles initLbug failure gracefully', async () => {
(initLbug as any).mockRejectedValueOnce(new Error('DB locked'));
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow('DB locked');
});
});
// ─── Cypher write blocking through callTool ──────────────────────────
describe('callTool cypher write blocking', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
const writeQueries = [
'CREATE (n:Function {name: "test"})',
'MATCH (n) DELETE n',
'MATCH (n) SET n.name = "hacked"',
'MERGE (n:Function {name: "test"})',
'MATCH (n) REMOVE n.name',
'DROP TABLE Function',
'ALTER TABLE Function ADD COLUMN foo STRING',
'COPY Function FROM "file.csv"',
'MATCH (n) DETACH DELETE n',
];
for (const query of writeQueries) {
it(`blocks write query: ${query.slice(0, 30)}...`, async () => {
(executeParameterized as any).mockRejectedValueOnce(new Error('read-only database'));
const result = await backend.callTool('cypher', { query });
expect(result).toHaveProperty('error');
expect(result.error).toContain('Write operations');
});
}
it('allows read query through callTool', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name LIMIT 5',
});
// Should not have error property with write-block message
expect(result.error).toBeUndefined();
});
});
// ─── listRepos ──────────────────────────────────────────────────────
describe('LocalBackend.listRepos', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
});
it('returns empty array when no repos', async () => {
setupNoRepos();
await backend.init();
const repos = await backend.callTool('list_repos', {});
expect(repos).toEqual([]);
});
it('returns repo metadata', async () => {
setupSingleRepo();
await backend.init();
const repos = await backend.callTool('list_repos', {});
expect(repos).toHaveLength(1);
expect(repos[0]).toEqual(
expect.objectContaining({
name: 'test-project',
path: '/tmp/test-project',
indexedAt: expect.any(String),
lastCommit: expect.any(String),
}),
);
});
it('re-reads registry on each listRepos call', async () => {
setupSingleRepo();
await backend.init();
await backend.callTool('list_repos', {});
await backend.callTool('list_repos', {});
// listRegisteredRepos called: once in init, once per listRepos
expect(listRegisteredRepos).toHaveBeenCalledTimes(3);
});
});
// ─── Cypher LadybugDB not ready ────────────────────────────────────────
describe('cypher tool LadybugDB not ready', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('returns error when LadybugDB is not ready', async () => {
(isLbugReady as any).mockReturnValue(false);
// initLbug will succeed but isLbugReady returns false after ensureInitialized
// Actually ensureInitialized checks isLbugReady and re-inits — let's make that pass
// then the cypher method checks isLbugReady again
(isLbugReady as any)
.mockReturnValueOnce(false) // ensureInitialized check
.mockReturnValueOnce(false); // cypher's own check
const result = await backend.callTool('cypher', {
query: 'MATCH (n) RETURN n LIMIT 1',
});
expect(result.error).toContain('LadybugDB not ready');
});
});
// ─── formatCypherAsMarkdown ──────────────────────────────────────────
describe('cypher result formatting', () => {
let backend: LocalBackend;
beforeEach(async () => {
// Full reset of all mocks to prevent state leaking from other tests
vi.resetAllMocks();
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
(cleanupOldKuzuFiles as any).mockResolvedValue({ found: false, needsReindex: false });
(initLbug as any).mockResolvedValue(undefined);
(isLbugReady as any).mockReturnValue(true);
(closeLbug as any).mockResolvedValue(undefined);
(executeParameterized as any).mockResolvedValue([]);
backend = new LocalBackend();
await backend.init();
});
it('formats tabular results as markdown table', async () => {
(executeParameterized as any).mockResolvedValue([
{ name: 'main', filePath: 'src/index.ts' },
{ name: 'helper', filePath: 'src/utils.ts' },
]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath',
});
expect(result).toHaveProperty('markdown');
expect(result.markdown).toContain('name');
expect(result.markdown).toContain('main');
expect(result.row_count).toBe(2);
});
it('returns empty array as-is', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name LIMIT 0',
});
expect(result).toEqual([]);
});
it('returns error object when cypher fails', async () => {
(executeParameterized as any).mockRejectedValue(new Error('Syntax error'));
const result = await backend.callTool('cypher', {
query: 'INVALID CYPHER SYNTAX',
});
expect(result).toHaveProperty('error');
expect(result.error).toContain('Syntax error');
});
});