mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(storage): load VECTOR only when needed * test(storage): verify VECTOR reopen lifecycle --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
4892 lines
198 KiB
TypeScript
4892 lines
198 KiB
TypeScript
/**
|
||
* 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, afterEach, afterAll } from 'vitest';
|
||
import type { StalenessInfo } from '../../src/core/git-staleness.js';
|
||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||
import fsPromises from 'fs/promises';
|
||
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 } = vi.hoisted(() => ({
|
||
lbugMocks: {
|
||
initLbug: vi.fn().mockResolvedValue(undefined),
|
||
executeQuery: vi.fn().mockResolvedValue([]),
|
||
executeParameterized: vi.fn().mockResolvedValue([]),
|
||
ensureVectorExtension: vi.fn().mockResolvedValue(true),
|
||
closeLbug: vi.fn().mockResolvedValue(undefined),
|
||
isLbugReady: 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([]),
|
||
// U2: expose loadMeta as a spy that delegates to the REAL implementation by
|
||
// default (so branch-scope resolution, #2106, is unaffected). The
|
||
// impact-mode block overrides it per-test to stamp a READY PDG layer, so the
|
||
// U2 layer-presence probe falls THROUGH to the post-check surface (the
|
||
// `_runImpactPDG` delegate / ambiguous fan-out) those tests assert. The
|
||
// four-state degradation contract itself is covered in
|
||
// test/integration/impact-pdg-degradation.test.ts.
|
||
loadMeta: vi.fn(actual.loadMeta),
|
||
};
|
||
});
|
||
|
||
// `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),
|
||
};
|
||
});
|
||
|
||
// 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),
|
||
}));
|
||
|
||
// #2175: lets the @group-forward path be exercised without real group.yaml infra.
|
||
// No existing test in this file uses an @repo, so this mock is inert for them.
|
||
const { resolveAtMemberMock } = vi.hoisted(() => ({ resolveAtMemberMock: vi.fn() }));
|
||
vi.mock('../../src/core/group/resolve-at-member.js', () => ({
|
||
resolveAtGroupMemberRepoPath: resolveAtMemberMock,
|
||
}));
|
||
|
||
import {
|
||
LocalBackend,
|
||
REPO_ID_HASH_LENGTH,
|
||
parseListReposPagination,
|
||
} from '../../src/mcp/local/local-backend.js';
|
||
import {
|
||
betterBridgeEvidence,
|
||
pdgBridgeEvidenceForImpact,
|
||
} from '../../src/mcp/local/pdg-impact.js';
|
||
import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/emit.js';
|
||
import { IMPORT_CYCLE_LIMIT } from '../../src/core/graph/import-cycles.js';
|
||
import {
|
||
listRegisteredRepos,
|
||
cleanupOldKuzuFiles,
|
||
getStoragePaths,
|
||
loadMeta,
|
||
type RegistryEntry,
|
||
} 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';
|
||
import {
|
||
DEFERRED_IMPORT_REASON_SUFFIX,
|
||
TYPE_ONLY_IMPORT_REASON_SUFFIX,
|
||
} from '../../src/core/ingestion/scope-resolution/graph-bridge/imports-to-edges.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 },
|
||
};
|
||
|
||
/**
|
||
* The `( ... )` group guarded by `r.reason IS NULL OR`, as one whitespace-
|
||
* collapsed string.
|
||
*
|
||
* The `check` query's reason exclusions are only correct INSIDE that
|
||
* alternative — hoisted out, they would also drop every edge whose `reason` is
|
||
* null, which is every producer that is not scope resolution. Substring
|
||
* assertions cannot tell the two placements apart, so the group is extracted by
|
||
* balancing parentheses from the guard to its own close.
|
||
*
|
||
* Returns `''` when the guard is absent, which fails the membership assertions
|
||
* rather than passing vacuously.
|
||
*/
|
||
function reasonNullAlternativeOf(query: string): string {
|
||
const flat = query.replace(/\s+/g, ' ');
|
||
const guard = 'r.reason IS NULL OR ';
|
||
const guardAt = flat.indexOf(guard);
|
||
const open = flat.indexOf('(', guardAt + guard.length);
|
||
if (guardAt < 0 || open < 0) return '';
|
||
let depth = 0;
|
||
for (let i = open; i < flat.length; i++) {
|
||
depth += Number(flat[i] === '(') - Number(flat[i] === ')');
|
||
if (depth === 0) return flat.slice(open + 1, i);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
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();
|
||
backend = new LocalBackend();
|
||
setupSingleRepo();
|
||
await backend.init();
|
||
});
|
||
|
||
it('routes list_repos without needing repo param', async () => {
|
||
// No-arg compatibility: callTool('list_repos', {}) returns the first page as
|
||
// a { repositories, pagination } object (Strategy A — always paginated, #2119).
|
||
const result = await backend.callTool('list_repos', {});
|
||
expect(Array.isArray(result.repositories)).toBe(true);
|
||
expect(result.repositories[0].name).toBe('test-project');
|
||
expect(result.pagination).toEqual({
|
||
total: 1,
|
||
limit: 50,
|
||
offset: 0,
|
||
returned: 1,
|
||
hasMore: false,
|
||
});
|
||
});
|
||
|
||
it('throws for unknown tool name', async () => {
|
||
await expect(backend.callTool('nonexistent_tool', {})).rejects.toThrow(
|
||
'Unknown tool: nonexistent_tool',
|
||
);
|
||
});
|
||
|
||
it.each(['name', 'symbol'] as const)(
|
||
'normalizes impact.%s to target once before local dispatch',
|
||
async (alias) => {
|
||
const impactSpy = vi
|
||
.spyOn(backend as any, 'impact')
|
||
.mockResolvedValue({ status: 'normalized' });
|
||
|
||
const result = await backend.callTool('impact', {
|
||
[alias]: ' validate ',
|
||
direction: 'upstream',
|
||
});
|
||
|
||
expect(result).toEqual({ status: 'normalized' });
|
||
const dispatched = impactSpy.mock.calls[0][1] as Record<string, unknown>;
|
||
expect(dispatched.target).toBe('validate');
|
||
expect(dispatched).not.toHaveProperty('name');
|
||
expect(dispatched).not.toHaveProperty('symbol');
|
||
},
|
||
);
|
||
|
||
it('normalizes context.file to file_path once before local dispatch', async () => {
|
||
const contextSpy = vi
|
||
.spyOn(backend as any, 'context')
|
||
.mockResolvedValue({ status: 'normalized' });
|
||
|
||
const result = await backend.callTool('context', {
|
||
name: 'validate',
|
||
file: ' src/auth.ts ',
|
||
});
|
||
|
||
expect(result).toEqual({ status: 'normalized' });
|
||
const dispatched = contextSpy.mock.calls[0][1] as Record<string, unknown>;
|
||
expect(dispatched.file_path).toBe('src/auth.ts');
|
||
expect(dispatched).not.toHaveProperty('file');
|
||
});
|
||
|
||
it('treats undefined optional alias keys from CLI callers as absent', async () => {
|
||
const contextSpy = vi
|
||
.spyOn(backend as any, 'context')
|
||
.mockResolvedValue({ status: 'normalized' });
|
||
|
||
const result = await backend.callTool('context', {
|
||
name: 'validate',
|
||
file_path: undefined,
|
||
file: undefined,
|
||
});
|
||
|
||
expect(result).toEqual({ status: 'normalized' });
|
||
expect(contextSpy.mock.calls[0][1]).toMatchObject({ name: 'validate' });
|
||
});
|
||
|
||
it('allows agreeing canonical and alias values after trimming', async () => {
|
||
const impactSpy = vi
|
||
.spyOn(backend as any, 'impact')
|
||
.mockResolvedValue({ status: 'normalized' });
|
||
|
||
await backend.callTool('impact', {
|
||
target: 'validate',
|
||
name: ' validate ',
|
||
symbol: 'validate',
|
||
direction: 'upstream',
|
||
});
|
||
|
||
expect(impactSpy.mock.calls[0][1]).toMatchObject({ target: 'validate' });
|
||
});
|
||
|
||
it.each([
|
||
['impact', { target: 'validate', name: 'login', direction: 'upstream' }],
|
||
['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }],
|
||
['context', { name: 'validate', file_path: 'src/auth.ts', file: 'src/login.ts' }],
|
||
])('rejects conflicting %s aliases before repository resolution', async (method, params) => {
|
||
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
|
||
|
||
const result = await backend.callTool(method, params);
|
||
|
||
expect(result.error).toMatch(/conflicting mcp parameters/i);
|
||
expect(resolveSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each([
|
||
['impact', { target: '', direction: 'upstream' }],
|
||
['impact', { name: 42, direction: 'upstream' }],
|
||
['context', { name: 'validate', file: ' ' }],
|
||
['context', { name: 'validate', file: null }],
|
||
])('rejects invalid %s aliases before repository resolution', async (method, params) => {
|
||
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
|
||
|
||
const result = await backend.callTool(method, params);
|
||
|
||
expect(result.error).toMatch(/non-empty string/i);
|
||
expect(resolveSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects a missing impact target before repository resolution', async () => {
|
||
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
|
||
|
||
const result = await backend.callTool('impact', { direction: 'upstream' });
|
||
|
||
expect(result.error).toMatch(/requires target, name, symbol, or target_uid/i);
|
||
expect(resolveSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('preserves target_uid-only impact dispatch', async () => {
|
||
const impactSpy = vi
|
||
.spyOn(backend as any, 'impact')
|
||
.mockResolvedValue({ status: 'normalized' });
|
||
|
||
await backend.callTool('impact', {
|
||
target_uid: 'Function:src/auth.ts:validate',
|
||
direction: 'upstream',
|
||
});
|
||
|
||
expect(impactSpy.mock.calls[0][1]).toMatchObject({
|
||
target_uid: 'Function:src/auth.ts:validate',
|
||
});
|
||
});
|
||
|
||
it('normalizes impact aliases before @group forwarding', async () => {
|
||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||
const groupImpactSpy = vi
|
||
.spyOn(backend.getGroupService(), 'groupImpact')
|
||
.mockResolvedValue({ status: 'normalized' } as any);
|
||
|
||
await backend.callTool('impact', {
|
||
symbol: 'validate',
|
||
direction: 'upstream',
|
||
repo: '@grp',
|
||
});
|
||
|
||
expect(groupImpactSpy.mock.calls[0][0]).toMatchObject({ target: 'validate' });
|
||
});
|
||
|
||
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('checks cycles using only non-synthetic import edges', async () => {
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{ source: 'src/a.ts', target: 'src/b.ts' },
|
||
{ source: 'src/b.ts', target: 'src/a.ts' },
|
||
]);
|
||
|
||
const result = await backend.callTool('check', { cycles: true });
|
||
|
||
expect(result).toEqual({
|
||
status: 'cycles_found',
|
||
enumeration: 'complete',
|
||
cycleCount: 1,
|
||
componentCount: 1,
|
||
cycles: [{ files: ['src/a.ts', 'src/b.ts', 'src/a.ts'] }],
|
||
});
|
||
const query = (executeParameterized as any).mock.calls.at(-1)[1] as string;
|
||
expect(query).toContain("r.reason <> 'swift-scope: implicit module visibility'");
|
||
expect(query).toContain("r.reason <> 'markdown-link'");
|
||
// A cycle means "these modules cannot be initialized in any order", so
|
||
// edges that carry no initialization order are excluded too: deferred
|
||
// imports (`import()`, function-local) run later, and TypeScript
|
||
// `import type` is erased by `tsc` and never runs. `imports-to-edges.ts`
|
||
// tags both by reason suffix; dropping either clause from this query
|
||
// reports the standard cycle-BREAKING idioms as cycles.
|
||
// The exclusions must sit INSIDE the `reason IS NULL OR (...)` alternative,
|
||
// or an untagged edge — every producer that is not scope resolution — stops
|
||
// counting and the check goes quiet. Asserting the fragments appear
|
||
// *somewhere* does not pin that: a query with the `IS NULL OR` in one place
|
||
// and an `ENDS WITH` hoisted out of the group satisfies `toContain` while
|
||
// silently dropping those edges. So extract the balanced group and assert
|
||
// membership in it.
|
||
expect(reasonNullAlternativeOf(query)).toContain(
|
||
`NOT r.reason ENDS WITH '${DEFERRED_IMPORT_REASON_SUFFIX}'`,
|
||
);
|
||
expect(reasonNullAlternativeOf(query)).toContain(
|
||
`NOT r.reason ENDS WITH '${TYPE_ONLY_IMPORT_REASON_SUFFIX}'`,
|
||
);
|
||
expect(reasonNullAlternativeOf(query)).toContain("r.reason <> 'markdown-link'");
|
||
expect(query).toContain('LIMIT 100001');
|
||
});
|
||
|
||
it('uses the advertised cycles default when check arguments are omitted', async () => {
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
|
||
await expect(backend.callTool('check', undefined)).resolves.toEqual({
|
||
status: 'clean',
|
||
enumeration: 'complete',
|
||
cycleCount: 0,
|
||
componentCount: 0,
|
||
cycles: [],
|
||
});
|
||
});
|
||
|
||
it('reports every elementary cycle, not one per cyclic component', async () => {
|
||
// One strongly connected component holding five elementary cycles. The
|
||
// previous implementation returned a single BFS path for it and called
|
||
// that `cycleCount: 1`, so this pins that `cycleCount` now counts cycles
|
||
// and `componentCount` carries the old tangle count under its own name.
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{ source: 'src/a.ts', target: 'src/b.ts' },
|
||
{ source: 'src/b.ts', target: 'src/c.ts' },
|
||
{ source: 'src/c.ts', target: 'src/d.ts' },
|
||
{ source: 'src/d.ts', target: 'src/a.ts' },
|
||
{ source: 'src/a.ts', target: 'src/z.ts' },
|
||
{ source: 'src/z.ts', target: 'src/a.ts' },
|
||
{ source: 'src/b.ts', target: 'src/d.ts' },
|
||
{ source: 'src/d.ts', target: 'src/b.ts' },
|
||
]);
|
||
|
||
await expect(backend.callTool('check', { cycles: true })).resolves.toEqual({
|
||
status: 'cycles_found',
|
||
enumeration: 'complete',
|
||
cycleCount: 5,
|
||
componentCount: 1,
|
||
cycles: [
|
||
{ files: ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/a.ts'] },
|
||
{ files: ['src/a.ts', 'src/b.ts', 'src/d.ts', 'src/a.ts'] },
|
||
{ files: ['src/a.ts', 'src/z.ts', 'src/a.ts'] },
|
||
{ files: ['src/b.ts', 'src/c.ts', 'src/d.ts', 'src/b.ts'] },
|
||
{ files: ['src/b.ts', 'src/d.ts', 'src/b.ts'] },
|
||
],
|
||
});
|
||
});
|
||
|
||
it('degrades to component representatives rather than a shortened cycle list', async () => {
|
||
// A 9-file complete import graph has 125,664 elementary cycles, past
|
||
// IMPORT_CYCLE_LIMIT. The response must NOT carry a capped `cycles` array
|
||
// that reads as complete -- but it must still be actionable, so it carries
|
||
// one representative per component, `truncated`, and an `enumeration` field
|
||
// naming what the list is. `cycleCount` is null rather than a number: there
|
||
// is no count a caller could mistake for the real one.
|
||
//
|
||
// Asserted, not just stated: raising the cap above this graph's cycle count
|
||
// would silently turn this into a `complete` case and the expectation below
|
||
// would fail for a reason that has nothing to do with degradation.
|
||
const K9_ELEMENTARY_CYCLES = 109_600;
|
||
expect(K9_ELEMENTARY_CYCLES).toBeGreaterThan(IMPORT_CYCLE_LIMIT);
|
||
const files = Array.from({ length: 9 }, (_, index) => `src/${index}.ts`);
|
||
(executeParameterized as any).mockResolvedValue(
|
||
files.flatMap((source) =>
|
||
files.filter((target) => target !== source).map((target) => ({ source, target })),
|
||
),
|
||
);
|
||
|
||
await expect(backend.callTool('check', { cycles: true })).resolves.toEqual({
|
||
status: 'cycles_found',
|
||
enumeration: 'component-representatives',
|
||
truncated: true,
|
||
cycleCount: null,
|
||
componentCount: 1,
|
||
cycles: [{ files: ['src/0.ts', 'src/1.ts', 'src/0.ts'] }],
|
||
});
|
||
});
|
||
|
||
it('names every cyclic component when capped, including ones never enumerated', async () => {
|
||
// A 9-file complete graph (125,664 cycles) blows the cap long before the
|
||
// disjoint y/z tangle is ever reached. The representative list must still
|
||
// name BOTH components -- it comes from the decomposition, not from the
|
||
// abandoned enumeration, so a tangle the search never got to is still
|
||
// reported.
|
||
const files = Array.from({ length: 9 }, (_, index) => `src/${index}.ts`);
|
||
(executeParameterized as any).mockResolvedValue([
|
||
...files.flatMap((source) =>
|
||
files.filter((target) => target !== source).map((target) => ({ source, target })),
|
||
),
|
||
{ source: 'src/y.ts', target: 'src/z.ts' },
|
||
{ source: 'src/z.ts', target: 'src/y.ts' },
|
||
]);
|
||
|
||
await expect(backend.callTool('check', { cycles: true })).resolves.toEqual({
|
||
status: 'cycles_found',
|
||
enumeration: 'component-representatives',
|
||
truncated: true,
|
||
cycleCount: null,
|
||
componentCount: 2,
|
||
cycles: [
|
||
{ files: ['src/0.ts', 'src/1.ts', 'src/0.ts'] },
|
||
{ files: ['src/y.ts', 'src/z.ts', 'src/y.ts'] },
|
||
],
|
||
});
|
||
});
|
||
|
||
it('fails closed when the import-edge safety limit is reached', async () => {
|
||
(executeParameterized as any).mockResolvedValue({ length: 100_001 });
|
||
|
||
await expect(backend.callTool('check', { cycles: true })).resolves.toEqual({
|
||
error: 'Import graph exceeds the 100000 edge safety limit.',
|
||
truncated: true,
|
||
});
|
||
});
|
||
|
||
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 cap = _captureLogger();
|
||
try {
|
||
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');
|
||
// The FTS fallback is a gracefully-degraded result, not an operation failure:
|
||
// it must log at warn (40), never error (50), matching its sibling
|
||
// import-failure fallback. Pins the severity against regression.
|
||
const fts = cap.records().find((r) => /BM25\/FTS search failed/.test(String(r.msg ?? '')));
|
||
expect(fts).toBeDefined();
|
||
expect(fts?.level).toBe(40);
|
||
} finally {
|
||
cap.restore();
|
||
}
|
||
});
|
||
|
||
it('falls back to the exact scan with a once-per-backend warning when the vector index query fails', async () => {
|
||
// Once the lazy extension preflight succeeds, a runtime index-query failure
|
||
// routes semantic search onto the exact scan.
|
||
const cap = _captureLogger();
|
||
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
|
||
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
|
||
if (cypher.includes('QUERY_VECTOR_INDEX')) {
|
||
throw new Error(
|
||
'Binder exception: Trying to read from an index on table CodeEmbedding but its extension is not loaded.',
|
||
);
|
||
}
|
||
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,
|
||
);
|
||
// The vector lane was attempted…
|
||
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(true);
|
||
// …and its failure routed the query onto the exact scan.
|
||
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 index query failed; using exact scan fallback',
|
||
),
|
||
),
|
||
).toBe(true);
|
||
} finally {
|
||
cap.restore();
|
||
}
|
||
});
|
||
|
||
it('issues vector index query when VECTOR is supported by the platform', async () => {
|
||
(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);
|
||
// The configured threshold must reach the WHERE clause (MCP default 0.6), guarding
|
||
// against a regression that drops the filter or re-hardcodes a different value.
|
||
expect(queries.some((cypher: string) => cypher.includes('distance < 0.6'))).toBe(true);
|
||
});
|
||
|
||
it('threads GITNEXUS_VECTOR_MAX_DISTANCE into the vector index WHERE clause', async () => {
|
||
vi.mocked(executeQuery).mockImplementation(async (_repoId: string, cypher: string) => {
|
||
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
|
||
return [];
|
||
});
|
||
vi.mocked(executeParameterized).mockResolvedValue([]);
|
||
|
||
const previous = process.env.GITNEXUS_VECTOR_MAX_DISTANCE;
|
||
process.env.GITNEXUS_VECTOR_MAX_DISTANCE = '0.42';
|
||
try {
|
||
await backend.callTool('query', { query: 'auth' });
|
||
const queries = vi
|
||
.mocked(executeQuery)
|
||
.mock.calls.map(([, cypher]: [string, string]) => cypher);
|
||
expect(queries.some((cypher: string) => cypher.includes('distance < 0.42'))).toBe(true);
|
||
expect(queries.some((cypher: string) => cypher.includes('distance < 0.6'))).toBe(false);
|
||
} finally {
|
||
if (previous === undefined) delete process.env.GITNEXUS_VECTOR_MAX_DISTANCE;
|
||
else process.env.GITNEXUS_VECTOR_MAX_DISTANCE = previous;
|
||
}
|
||
});
|
||
|
||
it('query tool returns error for empty query', async () => {
|
||
const result = await backend.callTool('query', { query: '' });
|
||
expect(result.error).toContain('search_query');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
it('query tool returns error for whitespace-only query', async () => {
|
||
const result = await backend.callTool('query', { query: ' ' });
|
||
expect(result.error).toContain('search_query');
|
||
expect(result.error).toContain('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);
|
||
});
|
||
|
||
// ── #2175: backward-compatible parameter-alias dispatch ──────────────────
|
||
// Claude Code drops a tool-call argument named exactly "query", so the query
|
||
// and cypher tools advertise search_query / statement. The handlers must accept
|
||
// the new names AND keep accepting the legacy "query" key (verified by the
|
||
// existing tests above, which still pass { query: ... }).
|
||
|
||
it('query tool accepts the new search_query parameter (#2175)', async () => {
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
const result = await backend.callTool('query', { search_query: 'auth' });
|
||
expect(result).toHaveProperty('processes');
|
||
expect(result).toHaveProperty('definitions');
|
||
expect(result).not.toHaveProperty('error');
|
||
});
|
||
|
||
it('query tool prefers search_query over the legacy query when both are given (#2175)', async () => {
|
||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
|
||
await backend.callTool('query', { search_query: 'newName', query: 'oldName' });
|
||
|
||
// bm25Search passes the resolved search text as arg 0 to searchFTSFromLbug.
|
||
const lastTerm = String(vi.mocked(searchFTSFromLbug).mock.calls.at(-1)?.[0] ?? '');
|
||
expect(lastTerm).toBe('newName');
|
||
});
|
||
|
||
it('query tool returns error when neither search_query nor query is provided (#2175)', async () => {
|
||
const result = await backend.callTool('query', {});
|
||
expect(result.error).toContain('search_query');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
it('cypher tool accepts the new statement parameter (#2175)', async () => {
|
||
(executeParameterized as any).mockResolvedValue([{ name: 'test', filePath: 'src/test.ts' }]);
|
||
const result = await backend.callTool('cypher', {
|
||
statement: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath LIMIT 5',
|
||
});
|
||
expect(result).toHaveProperty('markdown');
|
||
expect(result).toHaveProperty('row_count');
|
||
expect(result.row_count).toBe(1);
|
||
});
|
||
|
||
it('cypher tool prefers statement over the legacy query when both are given (#2175)', async () => {
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
await backend.callTool('cypher', {
|
||
statement: 'MATCH (a) RETURN a',
|
||
query: 'MATCH (b) RETURN b',
|
||
});
|
||
const passedCypher = (executeParameterized as any).mock.calls.at(-1)[1] as string;
|
||
expect(passedCypher).toBe('MATCH (a) RETURN a');
|
||
});
|
||
|
||
it('executeCypher (internal API) still works via the legacy query field (#2175)', async () => {
|
||
(executeParameterized as any).mockResolvedValue([{ name: 'x' }]);
|
||
const result = await backend.executeCypher('test-project', 'MATCH (n) RETURN n LIMIT 1');
|
||
expect(result).not.toHaveProperty('error');
|
||
const passedCypher = (executeParameterized as any).mock.calls.at(-1)[1] as string;
|
||
expect(passedCypher).toBe('MATCH (n) RETURN n LIMIT 1');
|
||
});
|
||
|
||
it('query tool returns error for empty search_query (new key) (#2175)', async () => {
|
||
const result = await backend.callTool('query', { search_query: '' });
|
||
expect(result.error).toContain('search_query');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
it('query tool returns error for whitespace-only search_query (new key) (#2175)', async () => {
|
||
const result = await backend.callTool('query', { search_query: ' ' });
|
||
expect(result.error).toContain('search_query');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
it('search legacy alias accepts the new search_query parameter (#2175)', async () => {
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
const result = await backend.callTool('search', { search_query: 'auth' });
|
||
expect(result).toHaveProperty('processes');
|
||
expect(result).not.toHaveProperty('error');
|
||
});
|
||
|
||
it('cypher tool returns a friendly required error when neither statement nor query is given (#2175)', async () => {
|
||
const result = await backend.callTool('cypher', {});
|
||
expect(result.error).toContain('statement');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
// #2175 review: the @group-forward path reads `query` from the forwarded args, so it
|
||
// must resolve the search_query alias itself (new name wins, mirroring query()).
|
||
it('group-mode query forwards the resolved search_query alias (#2175)', async () => {
|
||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||
const groupQuerySpy = vi
|
||
.spyOn(backend.getGroupService(), 'groupQuery')
|
||
.mockResolvedValue({ ok: true } as any);
|
||
|
||
await backend.callTool('query', {
|
||
search_query: 'alias-wins',
|
||
query: 'legacy-loses',
|
||
repo: '@grp',
|
||
});
|
||
|
||
expect(groupQuerySpy).toHaveBeenCalledTimes(1);
|
||
expect((groupQuerySpy.mock.calls[0][0] as any).query).toBe('alias-wins');
|
||
groupQuerySpy.mockRestore();
|
||
});
|
||
|
||
it('group-mode query still forwards a legacy-only query (#2175)', async () => {
|
||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||
const groupQuerySpy = vi
|
||
.spyOn(backend.getGroupService(), 'groupQuery')
|
||
.mockResolvedValue({ ok: true } as any);
|
||
|
||
await backend.callTool('query', { query: 'legacy', repo: '@grp' });
|
||
|
||
expect((groupQuerySpy.mock.calls[0][0] as any).query).toBe('legacy');
|
||
groupQuerySpy.mockRestore();
|
||
});
|
||
|
||
// U3: `trace` with an @group repo routes to the cross-repo groupTrace path
|
||
// and forwards the trace params (incl. the experimental pdg/crossDepth flags).
|
||
it('group-mode trace routes to groupTrace and forwards trace params', async () => {
|
||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||
const groupTraceSpy = vi
|
||
.spyOn(backend.getGroupService(), 'groupTrace')
|
||
.mockResolvedValue({ status: 'ok' });
|
||
|
||
await backend.callTool('trace', {
|
||
from: 'A',
|
||
to: 'B',
|
||
pdg: true,
|
||
crossDepth: 3,
|
||
repo: '@grp',
|
||
});
|
||
|
||
expect(groupTraceSpy).toHaveBeenCalledTimes(1);
|
||
const args = groupTraceSpy.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(args).toMatchObject({ name: 'grp', from: 'A', to: 'B', pdg: true, crossDepth: 3 });
|
||
groupTraceSpy.mockRestore();
|
||
});
|
||
|
||
// U3: a non-@group trace must NOT route to groupTrace — single-repo behavior
|
||
// is untouched (here it resolves to not_found against the empty mocked graph).
|
||
it('single-repo trace does not route to groupTrace', async () => {
|
||
const groupTraceSpy = vi.spyOn(backend.getGroupService(), 'groupTrace');
|
||
vi.mocked(executeParameterized).mockResolvedValue([]);
|
||
|
||
const result = await backend.callTool('trace', { from: 'A', to: 'B' });
|
||
|
||
expect(groupTraceSpy).not.toHaveBeenCalled();
|
||
expect(result).toMatchObject({ status: 'not_found' });
|
||
groupTraceSpy.mockRestore();
|
||
});
|
||
|
||
// The destination trace (omit `to`) is a cross-repo @group feature; a single-repo
|
||
// trace without `to` must error clearly, not return an opaque "symbol not found".
|
||
it('single-repo trace without `to` returns an actionable error', async () => {
|
||
const result = await backend.callTool('trace', { from: 'A' });
|
||
expect(result).toMatchObject({
|
||
status: 'error',
|
||
error: expect.stringContaining('requires `to`'),
|
||
});
|
||
});
|
||
|
||
// #2175 review: the MCP envelope is not schema-validated, so a client can send a
|
||
// non-string value for a string param. Resolve it to a friendly required-param error
|
||
// rather than throwing TypeError on `.trim()` (query() and cypher() both).
|
||
it('query tool returns a friendly error (no throw) for a non-string search_query (#2175)', async () => {
|
||
const result = await backend.callTool('query', { search_query: 123 as any });
|
||
expect(result.error).toContain('search_query');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
it('cypher tool returns a friendly error (no throw) for a non-string statement (#2175)', async () => {
|
||
const result = await backend.callTool('cypher', { statement: 123 as any });
|
||
expect(result.error).toContain('statement');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
// #2175 review (PR #2186): resolution prefers the first NON-BLANK string, so a blank
|
||
// new-name value falls back to a valid legacy value instead of clobbering it.
|
||
it('query tool: a blank new search_query falls back to a valid legacy query (#2175)', async () => {
|
||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
|
||
const result = await backend.callTool('query', { search_query: '', query: 'real' });
|
||
|
||
expect(result).not.toHaveProperty('error');
|
||
expect(result).toHaveProperty('processes');
|
||
const lastTerm = String(vi.mocked(searchFTSFromLbug).mock.calls.at(-1)?.[0] ?? '');
|
||
expect(lastTerm).toBe('real');
|
||
});
|
||
|
||
it('query tool: a whitespace-only new search_query falls back to a valid legacy query (#2175)', async () => {
|
||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
|
||
const result = await backend.callTool('query', { search_query: ' ', query: 'real' });
|
||
|
||
expect(result).not.toHaveProperty('error');
|
||
const lastTerm = String(vi.mocked(searchFTSFromLbug).mock.calls.at(-1)?.[0] ?? '');
|
||
expect(lastTerm).toBe('real');
|
||
});
|
||
|
||
it('query tool: both keys blank still returns the required error (#2175)', async () => {
|
||
const result = await backend.callTool('query', { search_query: '', query: ' ' });
|
||
expect(result.error).toContain('search_query');
|
||
expect(result.error).toContain('parameter is required');
|
||
});
|
||
|
||
it('cypher tool: a blank statement falls back to a valid legacy query (#2175)', async () => {
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
await backend.callTool('cypher', { statement: '', query: 'MATCH (n) RETURN n LIMIT 1' });
|
||
const passedCypher = (executeParameterized as any).mock.calls.at(-1)[1] as string;
|
||
expect(passedCypher).toBe('MATCH (n) RETURN n LIMIT 1');
|
||
});
|
||
|
||
it('group-mode query: a blank new search_query falls back to the legacy query (#2175)', async () => {
|
||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||
const groupQuerySpy = vi
|
||
.spyOn(backend.getGroupService(), 'groupQuery')
|
||
.mockResolvedValue({ ok: true } as any);
|
||
|
||
await backend.callTool('query', { search_query: '', query: 'real', repo: '@grp' });
|
||
|
||
expect((groupQuerySpy.mock.calls[0][0] as any).query).toBe('real');
|
||
groupQuerySpy.mockRestore();
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
// #2787 — LadybugDB returns an ARBITRARY subset when a LIMIT has no ORDER BY,
|
||
// and a different subset from one process to the next. With 92 nodes named
|
||
// `constructor` in this repo's own index, the resolver's LIMIT 20 window
|
||
// moved every run, so `impact`/`context` resolved a different symbol each
|
||
// time and the HIGH/CRITICAL warning the agent workflow relies on fired at
|
||
// random. These assert the emitted SQL, which is the only shape that fails
|
||
// deterministically — a run-N-times-and-compare test would pass by luck at
|
||
// the ~5-8% flip rate actually measured.
|
||
/** N same-named Function rows, ids ascending so the window order is obvious. */
|
||
const collideRows = (count: number) =>
|
||
Array.from({ length: count }, (_, i) => ({
|
||
id: `Function:src/f${String(i).padStart(2, '0')}.ts:collide`,
|
||
name: 'collide',
|
||
type: 'Function',
|
||
filePath: `src/f${String(i).padStart(2, '0')}.ts`,
|
||
startLine: 1,
|
||
endLine: 3,
|
||
}));
|
||
|
||
// The window must come back FULL (20 rows). The resolver only issues the COUNT
|
||
// when the window saturated its own LIMIT — a short page already proves the
|
||
// exact total, so counting again would be a second unlabeled full scan for a
|
||
// number we hold. A 0-row fixture would therefore assert the wrong shape.
|
||
const resolverQueriesFor = async (params: Record<string, unknown>): Promise<string[]> => {
|
||
(executeParameterized as any).mockClear();
|
||
(executeParameterized as any).mockResolvedValue(collideRows(20));
|
||
await backend.callTool('context', params);
|
||
return (executeParameterized as any).mock.calls
|
||
.map((c: unknown[]) => String(c[1]))
|
||
.filter((q: string) => q.includes('$symName'));
|
||
};
|
||
|
||
// Two queries share `$symName`: the ordered 20-row window, and the COUNT that
|
||
// reports the TRUE match total (the window length would report the cap — 20
|
||
// when 92 match). Both halves are pinned; the COUNT must carry no LIMIT of
|
||
// its own or it would just re-report the cap.
|
||
const expectWindowAndCount = (queries: string[]): void => {
|
||
expect(queries).toHaveLength(2);
|
||
expect(queries.filter((q) => /ORDER BY n\.id LIMIT 20/.test(q))).toHaveLength(1);
|
||
expect(
|
||
queries.filter((q) => /RETURN COUNT\(\*\) AS total/.test(q) && !/LIMIT/.test(q)),
|
||
).toHaveLength(1);
|
||
};
|
||
|
||
// One case per WHERE-clause shape the resolver builds — all three must carry
|
||
// the ordered window and its COUNT.
|
||
it.each([
|
||
['bare name', { name: 'main' }],
|
||
['file_path hint', { name: 'main', file_path: 'src/a.ts' }],
|
||
['qualified name', { name: 'src/a.ts:main' }],
|
||
] as Array<[string, Record<string, unknown>]>)(
|
||
'resolver window is pinned by ORDER BY n.id, with a COUNT for the true total — %s (#2787)',
|
||
async (_label, params) => {
|
||
expectWindowAndCount(await resolverQueriesFor(params));
|
||
},
|
||
);
|
||
|
||
it('ambiguous context reports the COUNT as the match total, not the capped window (#2787)', async () => {
|
||
const windowRows = Array.from({ length: 20 }, (_, i) => ({
|
||
id: `Function:src/f${String(i).padStart(2, '0')}.ts:collide`,
|
||
name: 'collide',
|
||
type: 'Function',
|
||
filePath: `src/f${String(i).padStart(2, '0')}.ts`,
|
||
startLine: 1,
|
||
endLine: 3,
|
||
}));
|
||
(executeParameterized as any).mockClear();
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) =>
|
||
/RETURN COUNT\(\*\) AS total/.test(query) ? [{ total: 92 }] : windowRows,
|
||
);
|
||
const result = await backend.callTool('context', { name: 'collide' });
|
||
(executeParameterized as any).mockReset();
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
|
||
expect(result).toMatchObject({
|
||
status: 'ambiguous',
|
||
totalCandidates: 92,
|
||
candidatesTruncated: true,
|
||
});
|
||
expect(result.candidates).toHaveLength(20);
|
||
expect(result.message).toContain('Found 92 symbols');
|
||
expect(result.message).toContain('showing 20');
|
||
});
|
||
|
||
it('no multi-row LIMIT on the context path is left unordered (#2787)', async () => {
|
||
(executeParameterized as any).mockClear();
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{
|
||
id: 'Class:src/a.ts:Widget',
|
||
name: 'Widget',
|
||
type: 'Class',
|
||
filePath: 'src/a.ts',
|
||
startLine: 1,
|
||
endLine: 9,
|
||
},
|
||
]);
|
||
await backend.callTool('context', { name: 'Widget' });
|
||
const captured: string[] = (executeParameterized as any).mock.calls.map((c: unknown[]) =>
|
||
String(c[1]),
|
||
);
|
||
// Guard against a vacuous pass: a resolver that bailed early would capture
|
||
// one query and satisfy the invariant below trivially.
|
||
expect(captured.length).toBeGreaterThan(1);
|
||
// `LIMIT 1` anchored on a unique id is a singleton lookup, not a window —
|
||
// which rows come back cannot vary. Every other cap must be ordered.
|
||
const unordered = captured
|
||
.filter((q) => /\bLIMIT\s+\d+/.test(q))
|
||
.filter((q) => !/LIMIT\s+1\b/.test(q))
|
||
.filter((q) => !/ORDER BY/.test(q));
|
||
expect(unordered).toEqual([]);
|
||
});
|
||
|
||
// ── #2787 review fixes ────────────────────────────────────────────────
|
||
// Each of these pins a behaviour the ORDER BY work itself introduced or left
|
||
// exposed. `executeParameterized` is routed on QUERY TEXT (mock-internal
|
||
// `if`s, the established pattern in this file) so a single leg can be made to
|
||
// fail or return a shaped page without touching the others.
|
||
describe('#2787 review fixes', () => {
|
||
/** Restore the file-wide default (`executeParameterized` → `[]`). */
|
||
const restoreQueryMock = (): void => {
|
||
(executeParameterized as any).mockReset();
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
};
|
||
|
||
// Every test below installs its own query routing. `afterEach` puts the
|
||
// file-wide default back — on the throwing path as well as the clean one,
|
||
// exactly like the per-test `finally` blocks it replaces — so a failure
|
||
// here still cannot leak a mock into the rest of the suite.
|
||
afterEach(restoreQueryMock);
|
||
|
||
/** The two `$symName` legs the resolver emits, with their bound params. */
|
||
const resolverCalls = (): Array<{ query: string; params: Record<string, unknown> }> =>
|
||
(executeParameterized as any).mock.calls
|
||
.filter((c: unknown[]) => String(c[1]).includes('$symName'))
|
||
.map((c: unknown[]) => ({
|
||
query: String(c[1]),
|
||
params: (c[2] ?? {}) as Record<string, unknown>,
|
||
}));
|
||
|
||
it('keys every multi-relType ref window uid-major, not category-major (#2787 review F1)', async () => {
|
||
// Supplement to the real-DB spread test in
|
||
// test/integration/local-backend-calltool.test.ts: that one proves the
|
||
// BEHAVIOUR on the primary incoming window; this one proves the same key
|
||
// reaches the four windows a single fixture cannot exercise at once (the
|
||
// Class-only Constructor / File / typed-Property expansions, plus outgoing).
|
||
// See the incoming-ref window in `_contextImpl` (#2787 F1) for why a
|
||
// category-major key starves whole buckets silently.
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) =>
|
||
query.includes('$uid')
|
||
? [
|
||
{
|
||
id: 'Class:src/a.ts:Widget',
|
||
name: 'Widget',
|
||
type: 'Class',
|
||
filePath: 'src/a.ts',
|
||
startLine: 1,
|
||
endLine: 9,
|
||
},
|
||
]
|
||
: [],
|
||
);
|
||
// A Class target opens the #480 expansion windows as well as the two
|
||
// primary ones.
|
||
await backend.callTool('context', { uid: 'Class:src/a.ts:Widget' });
|
||
|
||
const windows = (executeParameterized as any).mock.calls
|
||
.map((c: unknown[]) => String(c[1]))
|
||
// The single-relType ADVISED_BY windows are keyed `ORDER BY uid` alone
|
||
// (one category, nothing to starve) and are excluded by `r.type IN [`.
|
||
.filter((q: string) => /RETURN r\.type AS relType/.test(q) && /r\.type IN \[/.test(q));
|
||
expect(windows).toHaveLength(5);
|
||
expect(windows.filter((q: string) => /ORDER BY uid, relType/.test(q))).toHaveLength(5);
|
||
});
|
||
|
||
it('marks the match total as a LOWER BOUND when only the COUNT leg fails (#2787 review F3)', async () => {
|
||
// The COUNT rides alongside the window so the response can report the TRUE
|
||
// match count instead of the cap. When that leg fails the code falls back to
|
||
// the window length — which, un-marked, is byte-identical to a genuine
|
||
// N-match result and silently reinstates the pre-PR undercount. The failure
|
||
// must therefore be BOTH marked on the payload and logged.
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) => {
|
||
// Both legs carry `$symName`, so the COUNT must be matched first.
|
||
if (/RETURN COUNT\(\*\) AS total/.test(query)) throw new Error('count leg exploded');
|
||
if (query.includes('$symName')) return collideRows(20);
|
||
return [];
|
||
});
|
||
const cap = _captureLogger();
|
||
try {
|
||
const result = await backend.callTool('context', { name: 'collide' });
|
||
|
||
expect(result).toMatchObject({
|
||
status: 'ambiguous',
|
||
totalCandidates: 20,
|
||
totalIsLowerBound: true,
|
||
});
|
||
expect(result.candidates).toHaveLength(20);
|
||
// The prose is what an agent actually reads, so the hedge has to be there
|
||
// too — "Found 20 symbols" asserts an exactness the resolver no longer has.
|
||
expect(result.message).toContain("Found at least 20 symbols matching 'collide'");
|
||
// `candidatesTruncated` is driven by `total > candidates.length`, and with
|
||
// the COUNT dead the total floors at the window length — so the flag is
|
||
// absent here and CANNOT stand in for the lower-bound marker. That is the
|
||
// whole point: without `totalIsLowerBound` this response is byte-identical
|
||
// to a genuine, exactly-20-match result.
|
||
expect(result).not.toHaveProperty('candidatesTruncated');
|
||
// …and the swallowed failure is observable in telemetry, not silent.
|
||
expect(
|
||
cap.records().filter((r) => String(r.context) === 'resolve:candidate-count'),
|
||
).toHaveLength(1);
|
||
} finally {
|
||
cap.restore();
|
||
}
|
||
});
|
||
|
||
it('a successful COUNT leg reports an EXACT total with no lower-bound marker (#2787 review F3)', async () => {
|
||
// Negative control for the test above: the marker must be absent on the
|
||
// healthy path, or it degrades into noise that consumers learn to ignore.
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) => {
|
||
if (/RETURN COUNT\(\*\) AS total/.test(query)) return [{ total: 92 }];
|
||
if (query.includes('$symName')) return collideRows(20);
|
||
return [];
|
||
});
|
||
const result = await backend.callTool('context', { name: 'collide' });
|
||
|
||
expect(result).toMatchObject({ status: 'ambiguous', totalCandidates: 92 });
|
||
expect(result).not.toHaveProperty('totalIsLowerBound');
|
||
expect(result.message).toContain("Found 92 symbols matching 'collide'");
|
||
expect(result.message).not.toContain('at least');
|
||
});
|
||
|
||
it('a kind hint filters in the WHERE clause on BOTH legs, it does not merely score (#2787 review F5)', async () => {
|
||
// See `resolveSymbolCandidates` (#2787 F5) for why the id order is
|
||
// label-major and why the hint therefore has to filter, not merely score.
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) =>
|
||
query.includes('$symName') ? collideRows(20) : [],
|
||
);
|
||
await backend.callTool('context', { name: 'collide', kind: 'Function' });
|
||
|
||
const calls = resolverCalls();
|
||
// The filtered window returned rows, so the unfiltered fallback stays out:
|
||
// exactly the window + its COUNT.
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls.filter((c) => /AND n\.id STARTS WITH \$kindPrefix/.test(c.query))).toHaveLength(
|
||
2,
|
||
);
|
||
// The COUNT must carry the SAME filter, or `totalCandidates` reports the
|
||
// unfiltered population next to a filtered page.
|
||
expect(calls.map((c) => c.params.kindPrefix)).toEqual(['Function:', 'Function:']);
|
||
});
|
||
|
||
it('a kind hint on a qualified name keeps the id/name OR-clause parenthesised (#2787 review F5)', async () => {
|
||
// `AND` binds tighter than `OR`: an unparenthesised
|
||
// `n.id = $symName OR n.name = $symName AND n.id STARTS WITH $kindPrefix`
|
||
// applies the kind filter to the name branch ONLY, so a qualified-id lookup
|
||
// silently ignores the hint.
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) =>
|
||
query.includes('$symName') ? collideRows(20) : [],
|
||
);
|
||
await backend.callTool('context', { name: 'src/a.ts:collide', kind: 'Function' });
|
||
|
||
const parenthesised =
|
||
/WHERE \(n\.id = \$symName OR n\.name = \$symName\) AND n\.id STARTS WITH \$kindPrefix/;
|
||
const calls = resolverCalls();
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls.filter((c) => parenthesised.test(c.query))).toHaveLength(2);
|
||
});
|
||
|
||
it('retries UNFILTERED when the kind hint matches no label prefix (#2787 review F5)', async () => {
|
||
// `kind` is a free-form string on the tool schema. A miscased or
|
||
// repo-absent kind must not turn a real name into `not_found` — the
|
||
// resolver falls back to the unfiltered window and treats the hint as a
|
||
// ranking term again. Modelled by failing the `$kindPrefix` leg to zero rows.
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) => {
|
||
if (query.includes('$kindPrefix')) return [];
|
||
if (query.includes('$symName')) return collideRows(20);
|
||
return [];
|
||
});
|
||
const result = await backend.callTool('context', { name: 'collide', kind: 'function' });
|
||
|
||
// Filtered window (empty), THEN unfiltered window + its COUNT. The filtered
|
||
// leg issues NO count: a window shorter than its own LIMIT already proves
|
||
// the total, so counting again would be a second unlabeled full scan for a
|
||
// number we hold — here, zero.
|
||
expect(resolverCalls().map((c) => c.query.includes('$kindPrefix'))).toEqual([
|
||
true,
|
||
false,
|
||
false,
|
||
]);
|
||
// Not `{ error: "Symbol 'collide' not found" }`.
|
||
expect(result).toMatchObject({ status: 'ambiguous' });
|
||
expect(result.candidates).toHaveLength(20);
|
||
});
|
||
|
||
// #2787 review F6 — two distinct entry points can collide on (total_hits,
|
||
// filePath, name); equal `total_hits` is the norm. The old three-key
|
||
// comparator therefore tied, and a tie in `Array.prototype.sort` (stable in
|
||
// V8) falls through to `Map` insertion order — i.e. raw DB row order, the
|
||
// exact nondeterminism this issue is about. The entry-point id (the map key)
|
||
// is the unique key that closes the order.
|
||
const COLLIDING_ENTRY_POINT_ROWS = [
|
||
{
|
||
pId: 'proc:zeta-flow',
|
||
name: 'Zeta Flow',
|
||
processType: 'intra_community',
|
||
entryPointId: 'ep:zeta',
|
||
hits: 3,
|
||
minStep: 5,
|
||
stepCount: 4,
|
||
epName: 'step',
|
||
epType: 'Function',
|
||
epFilePath: 'src/hooks/useSigma.ts',
|
||
},
|
||
{
|
||
pId: 'proc:alpha-flow',
|
||
name: 'Alpha Flow',
|
||
processType: 'intra_community',
|
||
entryPointId: 'ep:alpha',
|
||
hits: 3,
|
||
minStep: 2,
|
||
stepCount: 4,
|
||
epName: 'step',
|
||
epType: 'Method',
|
||
epFilePath: 'src/hooks/useSigma.ts',
|
||
},
|
||
];
|
||
|
||
const impactWithCollidingEntryPoints = async (): Promise<any> => {
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) => {
|
||
if (query.includes('$symName')) {
|
||
return [
|
||
{
|
||
id: 'func:main',
|
||
name: 'main',
|
||
type: 'Function',
|
||
filePath: 'src/index.ts',
|
||
startLine: 1,
|
||
endLine: 5,
|
||
},
|
||
];
|
||
}
|
||
if (query.includes('$frontierIds')) {
|
||
return [
|
||
{
|
||
sourceId: 'func:main',
|
||
id: 'func:caller',
|
||
name: 'caller',
|
||
type: 'Function',
|
||
filePath: 'src/caller.ts',
|
||
relType: 'CALLS',
|
||
confidence: 0.9,
|
||
},
|
||
];
|
||
}
|
||
// The chunked process/entry-point aggregation.
|
||
if (query.includes('p.entryPointId')) return COLLIDING_ENTRY_POINT_ROWS;
|
||
return [];
|
||
});
|
||
return backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||
};
|
||
|
||
it('orders affected_processes by entry-point id when name/filePath/hits all tie (#2787 review F6)', async () => {
|
||
const result = await impactWithCollidingEntryPoints();
|
||
|
||
// Both entry points are named `step`, live in the same file and carry the
|
||
// same hit count, so ONLY the id tiebreak can decide: `ep:alpha` before
|
||
// `ep:zeta`, regardless of the row order the DB handed back (here, zeta
|
||
// first). The id itself stays out of the payload — `earliest_broken_step`
|
||
// and `type` are what make the order observable.
|
||
expect(result.affected_processes).toEqual([
|
||
{
|
||
name: 'step',
|
||
type: 'Method',
|
||
filePath: 'src/hooks/useSigma.ts',
|
||
affected_process_count: 1,
|
||
total_hits: 3,
|
||
earliest_broken_step: 2,
|
||
},
|
||
{
|
||
name: 'step',
|
||
type: 'Function',
|
||
filePath: 'src/hooks/useSigma.ts',
|
||
affected_process_count: 1,
|
||
total_hits: 3,
|
||
earliest_broken_step: 5,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('pins the process-chunk row order with ORDER BY pId (#2787 review F6)', async () => {
|
||
await impactWithCollidingEntryPoints();
|
||
|
||
// The comparator tiebreak above only fixes the FINAL order. Aggregation
|
||
// (`affected_process_count`, `total_hits`, `Math.min` on the step) reads
|
||
// rows as they arrive and the map is keyed on first sight, so the chunk
|
||
// query needs its own total order too.
|
||
const chunkQueries = (executeParameterized as any).mock.calls
|
||
.map((c: unknown[]) => String(c[1]))
|
||
.filter((q: string) => q.includes('p.entryPointId'));
|
||
expect(chunkQueries).toHaveLength(1);
|
||
expect(chunkQueries[0]).toMatch(/ORDER BY pId/);
|
||
});
|
||
});
|
||
|
||
// ── #2787 — the impact BFS frontier query was the only `ORDER BY` in the
|
||
// backend with no `LIMIT` to escape into (every other one pairs its key with a
|
||
// small limit, so the engine answers from a bounded top-k heap). It now
|
||
// returns rows unordered and the traversal re-establishes, in JS, exactly the
|
||
// two properties that key provided. Nothing downstream re-establishes them for
|
||
// it: `byDepth` slices `impacted` without re-sorting, and the process/module
|
||
// enrichment reads a positional prefix of the same array.
|
||
describe('#2787 impact BFS frontier ordering', () => {
|
||
const TARGET_ROW = {
|
||
id: 'Function:src/index.ts:main',
|
||
name: 'main',
|
||
type: 'Function',
|
||
filePath: 'src/index.ts',
|
||
startLine: 1,
|
||
endLine: 5,
|
||
};
|
||
const ROOT = TARGET_ROW.id;
|
||
|
||
interface FrontierRow {
|
||
sourceId: string;
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
filePath: string;
|
||
relType: string;
|
||
confidence: number;
|
||
}
|
||
|
||
/** One frontier-query row. `id` is `Label:filePath:name`, as in a real index. */
|
||
const edge = (
|
||
sourceId: string,
|
||
id: string,
|
||
relType: string,
|
||
confidence: number,
|
||
): FrontierRow => {
|
||
const [, filePath, name] = id.split(':');
|
||
return { sourceId, id, name, type: 'Function', filePath, relType, confidence };
|
||
};
|
||
|
||
/**
|
||
* Drive `_runImpactBFS` with a scripted frontier: `levels[d - 1]` is what the
|
||
* depth-`d` query returns, in exactly that row order. Every other query
|
||
* (process/module enrichment, epistemic probe) returns nothing, so `byDepth`
|
||
* is precisely what the traversal produced.
|
||
*/
|
||
const impactOverFrontier = async (levels: FrontierRow[][]): Promise<any> => {
|
||
let level = 0;
|
||
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) => {
|
||
if (query.includes('$symName')) return [TARGET_ROW];
|
||
if (query.includes('$frontierIds')) return levels[level++] ?? [];
|
||
return [];
|
||
});
|
||
return backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||
};
|
||
|
||
const stamped = (items: any[]): unknown[] =>
|
||
items.map((it) => ({ id: it.id, relationType: it.relationType, confidence: it.confidence }));
|
||
|
||
it('leaves the frontier query unordered — the sort has no top-k escape', async () => {
|
||
await impactOverFrontier([]);
|
||
|
||
const frontierQueries = (executeParameterized as any).mock.calls
|
||
.map((c: unknown[]) => String(c[1]))
|
||
.filter((q: string) => q.includes('$frontierIds'));
|
||
expect(frontierQueries.length).toBeGreaterThan(0);
|
||
expect(frontierQueries.filter((q: string) => /ORDER BY/.test(q))).toEqual([]);
|
||
// …and equally: no LIMIT was added in its place. The traversal must see
|
||
// every neighbour edge; only the ORDERING moved.
|
||
expect(frontierQueries.filter((q: string) => /LIMIT/.test(q))).toEqual([]);
|
||
});
|
||
|
||
// (a) Diamond: `delta` and `epsilon` are each reached at depth 2 from BOTH
|
||
// depth-1 frontier nodes. The DB key made the stamped relationType/confidence
|
||
// the argmax under `relType ASC, confidence DESC, sourceId ASC`; the JS
|
||
// argmax must pick the same edge, and must pick it from either permutation.
|
||
const DIAMOND_LEVEL_1 = [
|
||
edge(ROOT, 'Function:src/alpha.ts:alpha', 'CALLS', 0.9),
|
||
edge(ROOT, 'Function:src/beta.ts:beta', 'CALLS', 0.9),
|
||
];
|
||
// `delta` discriminates relType (CALLS < IMPORTS) AGAINST confidence — the
|
||
// weaker-confidence CALLS edge wins, so a "highest confidence" shortcut fails
|
||
// here. `epsilon` ties on relType and is decided by `confidence DESC`, across
|
||
// the 0.8 `fuzzy` boundary the tool description publishes.
|
||
const DIAMOND_LEVEL_2 = [
|
||
edge('Function:src/alpha.ts:alpha', 'Function:src/delta.ts:delta', 'IMPORTS', 0.6),
|
||
edge('Function:src/beta.ts:beta', 'Function:src/delta.ts:delta', 'CALLS', 0.55),
|
||
edge('Function:src/alpha.ts:alpha', 'Function:src/epsilon.ts:epsilon', 'CALLS', 0.7),
|
||
edge('Function:src/beta.ts:beta', 'Function:src/epsilon.ts:epsilon', 'CALLS', 0.85),
|
||
];
|
||
const DIAMOND_ARGMAX = [
|
||
{ id: 'Function:src/delta.ts:delta', relationType: 'CALLS', confidence: 0.55 },
|
||
{ id: 'Function:src/epsilon.ts:epsilon', relationType: 'CALLS', confidence: 0.85 },
|
||
];
|
||
|
||
it('stamps the argmax edge on a diamond-reached node', async () => {
|
||
const result = await impactOverFrontier([DIAMOND_LEVEL_1, DIAMOND_LEVEL_2]);
|
||
expect(stamped(result.byDepth[2])).toEqual(DIAMOND_ARGMAX);
|
||
});
|
||
|
||
it('stamps the same argmax edge when the rows arrive reversed', async () => {
|
||
// The second of two fixed permutations — not a randomised or repeat-N
|
||
// probe. Without the JS argmax the surviving edge is whichever row landed
|
||
// first, so this permutation would stamp IMPORTS/0.6 on `delta` and 0.7 on
|
||
// `epsilon` instead.
|
||
const result = await impactOverFrontier([
|
||
[...DIAMOND_LEVEL_1].reverse(),
|
||
[...DIAMOND_LEVEL_2].reverse(),
|
||
]);
|
||
expect(stamped(result.byDepth[2])).toEqual(DIAMOND_ARGMAX);
|
||
});
|
||
|
||
// (b) `impacted` — and therefore `byDepth`, which slices it — is ordered by
|
||
// node id ascending in UTF-16 code units, whatever order the rows arrive in.
|
||
// The ids below also separate code-unit order from `localeCompare`: 'Z'
|
||
// (0x5A) precedes 'a' (0x61) in code units, while a locale collator sorts
|
||
// `apple` and `mango` ahead of `Zebra`.
|
||
const SCRAMBLED_LEVEL_1 = [
|
||
edge(ROOT, 'Function:src/mango.ts:mango', 'CALLS', 0.9),
|
||
edge(ROOT, 'Function:src/Zebra.ts:Zebra', 'CALLS', 0.9),
|
||
edge(ROOT, 'Function:src/apple.ts:apple', 'CALLS', 0.9),
|
||
];
|
||
const SCRAMBLED_LEVEL_2 = [
|
||
edge('Function:src/mango.ts:mango', 'Function:src/quince.ts:quince', 'CALLS', 0.9),
|
||
edge('Function:src/apple.ts:apple', 'Function:src/Fig.ts:Fig', 'CALLS', 0.9),
|
||
];
|
||
const ID_ASCENDING_1 = [
|
||
'Function:src/Zebra.ts:Zebra',
|
||
'Function:src/apple.ts:apple',
|
||
'Function:src/mango.ts:mango',
|
||
];
|
||
const ID_ASCENDING_2 = ['Function:src/Fig.ts:Fig', 'Function:src/quince.ts:quince'];
|
||
|
||
it('orders byDepth by node id ascending, whatever order the rows arrive in', async () => {
|
||
const result = await impactOverFrontier([SCRAMBLED_LEVEL_1, SCRAMBLED_LEVEL_2]);
|
||
|
||
expect(result.byDepth[1].map((it: any) => it.id)).toEqual(ID_ASCENDING_1);
|
||
expect(result.byDepth[2].map((it: any) => it.id)).toEqual(ID_ASCENDING_2);
|
||
expect(result.byDepthCounts).toEqual({ 1: 3, 2: 2 });
|
||
});
|
||
|
||
it('orders byDepth identically when the rows arrive reversed', async () => {
|
||
const result = await impactOverFrontier([
|
||
[...SCRAMBLED_LEVEL_1].reverse(),
|
||
[...SCRAMBLED_LEVEL_2].reverse(),
|
||
]);
|
||
|
||
expect(result.byDepth[1].map((it: any) => it.id)).toEqual(ID_ASCENDING_1);
|
||
expect(result.byDepth[2].map((it: any) => it.id)).toEqual(ID_ASCENDING_2);
|
||
});
|
||
});
|
||
|
||
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);
|
||
// #2687: undetermined, NOT a numeric zero — a measured 0 is indistinguishable
|
||
// from a genuine "nothing depends on this".
|
||
expect(result.impactedCount).toBeNull();
|
||
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');
|
||
});
|
||
|
||
it('rename: a swallowed apply-edit write failure degrades to status:partial + failed_files (#2283)', async () => {
|
||
// Resolve the definition, no graph refs. readFile succeeds (so a def edit is
|
||
// recorded), but writeFile fails on apply — the failure is swallowed via
|
||
// logQueryError. The result must NOT report a clean success: it degrades to
|
||
// 'partial' and lists the unwritten file, instead of status:'success'.
|
||
(executeParameterized as any)
|
||
.mockResolvedValueOnce([
|
||
{
|
||
id: 'func:oldName',
|
||
name: 'oldName',
|
||
type: 'Function',
|
||
filePath: 'src/target.ts',
|
||
startLine: 1,
|
||
endLine: 5,
|
||
},
|
||
])
|
||
.mockResolvedValue([]);
|
||
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-rename-'));
|
||
(listRegisteredRepos as any).mockResolvedValue([
|
||
{ ...MOCK_REPO_ENTRY, path: repoDir, storagePath: path.join(repoDir, '.gitnexus') },
|
||
]);
|
||
backend = new LocalBackend();
|
||
await backend.init();
|
||
|
||
// The symbol is stored at 0-based startLine 1; context() presents it 1-based
|
||
// (line 2) and rename subtracts 1 to recover the 0-based file index (1), so
|
||
// `oldName` must sit on the file's 0-based line 1 for the definition edit to
|
||
// fire. (#2380: the mock previously put it on line 0, which stopped matching
|
||
// once context() went 1-based.)
|
||
const readSpy = vi
|
||
.spyOn(fsPromises, 'readFile')
|
||
.mockResolvedValue('\nfunction oldName() {}\n' as unknown as Buffer);
|
||
const writeSpy = vi
|
||
.spyOn(fsPromises, 'writeFile')
|
||
.mockRejectedValue(new Error('EACCES: permission denied'));
|
||
try {
|
||
const result = await backend.callTool('rename', {
|
||
symbol_name: 'oldName',
|
||
new_name: 'newName',
|
||
dry_run: false,
|
||
});
|
||
expect(result.status).toBe('partial');
|
||
expect(result.failed_files).toContain('src/target.ts');
|
||
// It DID attempt to apply (not a dry run) — `applied` stays true; the
|
||
// honest signal is the 'partial' status + failed_files, not `applied`.
|
||
expect(result.applied).toBe(true);
|
||
} finally {
|
||
readSpy.mockRestore();
|
||
writeSpy.mockRestore();
|
||
rmSync(repoDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// 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);
|
||
});
|
||
|
||
// ── #2308: same-URL multi-verb route contract ──
|
||
// After #2302 a same URL exposes one Route node per HTTP verb. A bare-URL (or
|
||
// bare-file) api_impact lookup therefore returns the wrapped { routes, total }
|
||
// form; passing `method` collapses it back to the singular shape.
|
||
const verbRow = (
|
||
method: string | null,
|
||
routeName: string,
|
||
handlerFile: string,
|
||
middleware: string[] | null = null,
|
||
) => ({
|
||
routeId: `Route:${method ? `${method} ` : ''}${routeName}`,
|
||
routeName,
|
||
method,
|
||
handlerFile,
|
||
responseKeys: null,
|
||
errorKeys: null,
|
||
middleware,
|
||
consumerName: null,
|
||
consumerFile: null,
|
||
fetchReason: null,
|
||
});
|
||
const ordersVerbRows = [
|
||
verbRow('GET', '/api/orders', 'api/orders.ts'),
|
||
verbRow('POST', '/api/orders', 'api/orders.ts'),
|
||
];
|
||
|
||
it('api_impact returns the wrapped form for same-URL multi-verb routes, each with its method', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue(ordersVerbRows);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders' });
|
||
expect(result.total).toBe(2);
|
||
expect(result.routes).toHaveLength(2);
|
||
expect(result.routes.map((r: { method: string | null }) => r.method).sort()).toEqual([
|
||
'GET',
|
||
'POST',
|
||
]);
|
||
expect(result.routes).toMatchObject([{ route: '/api/orders' }, { route: '/api/orders' }]);
|
||
});
|
||
|
||
it('api_impact narrows a multi-verb URL to one route when method is given', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue(ordersVerbRows);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: 'POST' });
|
||
expect(result.method).toBe('POST');
|
||
expect(result.route).toBe('/api/orders');
|
||
expect(result.routes).toBeUndefined();
|
||
expect(result.total).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact matches the method selector case-insensitively', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue(ordersVerbRows);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: 'post' });
|
||
expect(result.method).toBe('POST');
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact returns a verb-not-found error when method matches no route at the URL', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue(ordersVerbRows);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: 'delete' });
|
||
expect(result.error).toContain('/api/orders');
|
||
expect(result.error).toContain('DELETE');
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact omits the verb clause when the URL itself does not exist', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([]);
|
||
const result = await backend.callTool('api_impact', {
|
||
route: '/does/not/exist',
|
||
method: 'GET',
|
||
});
|
||
expect(result.error).toContain('/does/not/exist');
|
||
expect(result.error).not.toContain('with method');
|
||
});
|
||
|
||
// #2308: the shared `method` field also surfaces on route_map and shape_check
|
||
// (same fetchRoutesWithConsumers query), so both are documented + covered here.
|
||
it('route_map surfaces each route method', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow('GET', '/api/orders', 'api/orders.ts'),
|
||
verbRow('POST', '/api/orders', 'api/orders.ts'),
|
||
]);
|
||
const result = await backend.callTool('route_map', { route: '/api/orders' });
|
||
expect(result.routes.map((r: { method: string | null }) => r.method).sort()).toEqual([
|
||
'GET',
|
||
'POST',
|
||
]);
|
||
});
|
||
|
||
it('route_map surfaces a null method for verbless routes', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow(null, '/blog/[slug]', 'app/blog/[slug]/route.ts'),
|
||
]);
|
||
const result = await backend.callTool('route_map', { route: '/blog/[slug]' });
|
||
expect(result.routes[0].method).toBeNull();
|
||
});
|
||
|
||
it('shape_check surfaces each route method', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
{
|
||
...verbRow('GET', '/api/orders', 'api/orders.ts'),
|
||
responseKeys: ['data', 'total'],
|
||
consumerName: 'OrdersList',
|
||
consumerFile: 'src/OrdersList.tsx',
|
||
fetchReason: 'fetch-url-match|keys:data',
|
||
},
|
||
]);
|
||
const result = await backend.callTool('shape_check', { route: '/api/orders' });
|
||
expect(result.routes[0].method).toBe('GET');
|
||
});
|
||
|
||
// The partial-middleware warning is driven by a per-handler verb count taken
|
||
// from the UNFILTERED match, so a method-scoped query on a multi-verb handler
|
||
// still flags partial middleware. Counting the filtered set would drop it.
|
||
it('api_impact keeps middlewareDetection partial under a method filter', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow('GET', '/api/orders', 'api/orders.ts', ['withAuth']),
|
||
verbRow('POST', '/api/orders', 'api/orders.ts', ['withAuth']),
|
||
]);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: 'GET' });
|
||
expect(result.method).toBe('GET');
|
||
expect(result.middlewareDetection).toBe('partial');
|
||
expect(result.middlewareNote).toContain('route exports');
|
||
});
|
||
|
||
it('api_impact returns the wrapped form for a same-handler multi-verb file lookup', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow('GET', '/api/orders', 'app/api/orders/route.ts'),
|
||
verbRow('POST', '/api/orders', 'app/api/orders/route.ts'),
|
||
]);
|
||
const result = await backend.callTool('api_impact', { file: 'app/api/orders/route.ts' });
|
||
expect(result.total).toBe(2);
|
||
expect(result.routes.map((r: { method: string | null }) => r.method).sort()).toEqual([
|
||
'GET',
|
||
'POST',
|
||
]);
|
||
});
|
||
|
||
it('api_impact surfaces a null method for method-less (verbless) routes', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow(null, '/blog/[slug]', 'app/blog/[slug]/route.ts'),
|
||
]);
|
||
const result = await backend.callTool('api_impact', { route: '/blog/[slug]' });
|
||
expect(result.method).toBeNull();
|
||
expect(result.route).toBe('/blog/[slug]');
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact narrows a multi-verb file lookup to one route when method is given', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow('GET', '/api/orders', 'app/api/orders/route.ts'),
|
||
verbRow('POST', '/api/orders', 'app/api/orders/route.ts'),
|
||
]);
|
||
const result = await backend.callTool('api_impact', {
|
||
file: 'app/api/orders/route.ts',
|
||
method: 'POST',
|
||
});
|
||
expect(result.method).toBe('POST');
|
||
expect(result.route).toBe('/api/orders');
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact excludes verbless routes from a method selector', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow(null, '/api/orders', 'api/orders.ts'),
|
||
verbRow('GET', '/api/orders', 'api/orders.ts'),
|
||
]);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: 'GET' });
|
||
expect(result.method).toBe('GET');
|
||
expect(result.route).toBe('/api/orders');
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
// A method-agnostic route persists with method '*' (Django function views) and
|
||
// handles every verb — unlike a verbless (null) route, a method selector MUST
|
||
// match it, or api_impact reports a false "no routes" for a live handler.
|
||
it('api_impact matches a wildcard (*) route against a specific method selector', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([verbRow('*', '/django/view', 'views.py')]);
|
||
const result = await backend.callTool('api_impact', { route: '/django/view', method: 'POST' });
|
||
expect(result.method).toBe('*');
|
||
expect(result.route).toBe('/django/view');
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact matches a wildcard (*) route case-insensitively for any verb', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([verbRow('*', '/django/view', 'views.py')]);
|
||
const result = await backend.callTool('api_impact', { route: '/django/view', method: 'get' });
|
||
expect(result.method).toBe('*');
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact includes a wildcard (*) route alongside a concrete verb under a selector', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue([
|
||
verbRow('*', '/api/orders', 'api/orders.ts'),
|
||
verbRow('GET', '/api/orders', 'api/orders.ts'),
|
||
]);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: 'GET' });
|
||
expect(result.total).toBe(2);
|
||
expect(result.routes.map((r: { method: string | null }) => r.method).sort()).toEqual([
|
||
'*',
|
||
'GET',
|
||
]);
|
||
});
|
||
|
||
// The `method` param is not schema-validated at the transport, so api_impact
|
||
// must reject a non-string verb with a structured error (not a thrown
|
||
// TypeError) and treat empty/whitespace as no selector.
|
||
it('api_impact returns a structured error for a non-string method', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue(ordersVerbRows);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: 5 });
|
||
expect(result.error).toContain('method');
|
||
expect(result.error).toContain('string');
|
||
expect(result.routes).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact treats an empty-string method as no selector', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue(ordersVerbRows);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: '' });
|
||
expect(result.total).toBe(2);
|
||
expect(result.error).toBeUndefined();
|
||
});
|
||
|
||
it('api_impact treats a whitespace-only method as no selector', async () => {
|
||
vi.mocked(executeParameterized).mockResolvedValue(ordersVerbRows);
|
||
const result = await backend.callTool('api_impact', { route: '/api/orders', method: ' ' });
|
||
expect(result.total).toBe(2);
|
||
expect(result.error).toBeUndefined();
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
|
||
// ─── impact mode param (KTD1/KTD5/KTD12 — U1) ───────────────────────
|
||
//
|
||
// The MCP JSON-schema enum is advisory only (server forwards args
|
||
// unvalidated, callTool is reachable directly), so the backend `mode`
|
||
// validation is load-bearing. These tests pin: callgraph is the unchanged
|
||
// default, pdg routes to the extracted traversal plus interprocedural symbol
|
||
// reach, invalid modes hard-error, and the remaining incompatible params /
|
||
// @group targets are rejected.
|
||
|
||
describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
|
||
let backend: LocalBackend;
|
||
|
||
// Resolve the target to a single Function so impact reaches the single-branch
|
||
// dispatch (callgraph BFS or the PDG traversal). The callgraph BFS then issues
|
||
// executeQuery for its frontier; the PDG path delegates to runImpactPDG.
|
||
function resolveSingleTarget() {
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
]);
|
||
(executeQuery as any).mockResolvedValue([]);
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
// U2: stamp a READY PDG layer (both caps) so the layer-presence probe in
|
||
// `_impactImpl` falls THROUGH to the mode-dispatch surface these tests pin
|
||
// (the `_runImpactPDG` delegate / the ambiguous fan-out under `mode:'pdg'`).
|
||
// Degraded-layer behavior is owned by the integration degradation suite.
|
||
vi.mocked(loadMeta).mockResolvedValue({
|
||
pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 },
|
||
} as any);
|
||
backend = new LocalBackend();
|
||
setupSingleRepo();
|
||
await backend.init();
|
||
});
|
||
|
||
it('mode absent → callgraph result (target populated, no mode-error, BFS runs)', async () => {
|
||
resolveSingleTarget();
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||
// A clean callgraph result carries no mode error and runs the BFS.
|
||
expect(result.error ?? '').not.toMatch(/Invalid "mode"/);
|
||
expect(result.error ?? '').not.toMatch(/not yet implemented/);
|
||
expect(result.target).toBeDefined();
|
||
expect(bfsSpy).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("mode:'callgraph' and mode:undefined are byte-identical to absent (regression guard)", async () => {
|
||
resolveSingleTarget();
|
||
const absent = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||
const callgraph = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'callgraph',
|
||
});
|
||
const undef = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: undefined,
|
||
});
|
||
expect(callgraph).toEqual(absent);
|
||
expect(undef).toEqual(absent);
|
||
});
|
||
|
||
it("mode:'pdg' routes to the PDG traversal and attaches interprocedural symbol reach", async () => {
|
||
resolveSingleTarget();
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'pdg',
|
||
});
|
||
// The call reaches the real `_runImpactPDG` traversal, then composes the
|
||
// interprocedural symbol reach into the same pdg result.
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.mode).toBe('pdg');
|
||
expect(Array.isArray(result.reachableBlocks)).toBe(true);
|
||
expect(result.pdgInterprocedural).toBeDefined();
|
||
expect(bfsSpy).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("mode:'pdg' labels interprocedural symbols as a callgraph bridge", async () => {
|
||
resolveSingleTarget();
|
||
vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'downstream',
|
||
impactedCount: 1,
|
||
risk: 'LOW',
|
||
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
|
||
byDepthCounts: { 1: 1 },
|
||
affected_processes: [],
|
||
affected_modules: [],
|
||
byDepth: {
|
||
1: [
|
||
{
|
||
depth: 1,
|
||
id: 'func:callee',
|
||
name: 'callee',
|
||
type: 'Function',
|
||
filePath: 'src/callee.ts',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
});
|
||
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.mode).toBe('pdg');
|
||
expect(result.pdgInterprocedural.evidence).toBe('callgraph-bridge');
|
||
expect(result.pdgInterprocedural.evidenceCounts['callgraph-bridge']).toBe(1);
|
||
expect(result.pdgEvidence.interprocedural).toBe('callgraph-bridge');
|
||
expect(result.interproceduralByDepth[1][0].pdgEvidence).toBe('callgraph-bridge');
|
||
expect(result.note).toContain('labeled as a PDG evidence bridge');
|
||
});
|
||
|
||
it("mode:'pdg' preserves unproven bridge evidence when call-site proof is unavailable", async () => {
|
||
resolveSingleTarget();
|
||
vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'downstream',
|
||
impactedCount: 1,
|
||
risk: 'LOW',
|
||
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
|
||
byDepthCounts: { 1: 1 },
|
||
affected_processes: [],
|
||
affected_modules: [],
|
||
byDepth: {
|
||
1: [
|
||
{
|
||
depth: 1,
|
||
id: 'func:callee',
|
||
name: 'callee',
|
||
type: 'Function',
|
||
filePath: 'src/callee.ts',
|
||
pdgEvidence: 'unproven-bridge',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
});
|
||
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.mode).toBe('pdg');
|
||
expect(result.pdgInterprocedural.evidence).toBe('unproven-bridge');
|
||
expect(result.pdgInterprocedural.evidenceCounts['unproven-bridge']).toBe(1);
|
||
expect(result.pdgEvidence.interprocedural).toBe('unproven-bridge');
|
||
expect(result.note).toContain('labeled unproven-bridge');
|
||
});
|
||
|
||
it.each([['PDG'], ['pgd'], [''], [0], [null]])(
|
||
'invalid mode %j → structured {error}, never a callgraph result (KTD5 anti-silent-fallback)',
|
||
async (bad) => {
|
||
resolveSingleTarget();
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: bad as any,
|
||
});
|
||
expect(result.error).toMatch(/Invalid "mode"/);
|
||
expect(result.risk).toBe('UNKNOWN');
|
||
// A typo'd mode must NEVER quietly run callgraph.
|
||
expect(bfsSpy).not.toHaveBeenCalled();
|
||
},
|
||
);
|
||
|
||
it.each([['callgraph'], [undefined]])(
|
||
'line param with mode:%j → structured {error} (line is PDG-only), never a callgraph result',
|
||
async (mode) => {
|
||
resolveSingleTarget();
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: mode as any,
|
||
line: 8,
|
||
});
|
||
expect(result.error).toMatch(/'line' is only supported with mode:'pdg'/);
|
||
expect(result.risk).toBe('UNKNOWN');
|
||
// A PDG-only param on the callgraph path must NOT silently run the BFS.
|
||
expect(bfsSpy).not.toHaveBeenCalled();
|
||
},
|
||
);
|
||
|
||
// #2279: some MCP client/agent adapters serialize an *omitted* optional
|
||
// numeric field as `0`. On the callgraph path `line` is meaningless, so a
|
||
// literal `line: 0` must be tolerated as omitted (NOT the PDG-only error) and
|
||
// route to the normal BFS — distinct from a genuine positive `line` (above),
|
||
// which stays a hard error.
|
||
it.each<['callgraph' | undefined]>([['callgraph'], [undefined]])(
|
||
'mode:%j + adapter-materialized line:0 is treated as omitted and runs the BFS (#2279)',
|
||
async (mode) => {
|
||
resolveSingleTarget();
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode,
|
||
line: 0,
|
||
});
|
||
// No PDG-only error, no positive-integer error — line:0 is swallowed.
|
||
expect(result.error ?? '').not.toMatch(/'line' is only supported with mode:'pdg'/);
|
||
expect(result.error ?? '').not.toMatch(/'line' must be a positive integer/);
|
||
expect(result.target).toBeDefined();
|
||
expect(bfsSpy).toHaveBeenCalledTimes(1);
|
||
},
|
||
);
|
||
|
||
it.each<['callgraph' | undefined]>([['callgraph'], [undefined]])(
|
||
'mode:%j + line:-1 still errors — the line:0 coercion is narrow, only literal 0 (#2279)',
|
||
async (mode) => {
|
||
resolveSingleTarget();
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode,
|
||
line: -1,
|
||
});
|
||
// A negative line is a real mistake, not an adapter-materialized "omitted":
|
||
// it must NOT be swallowed like line:0, and stays the PDG-only hard error.
|
||
expect(result.error).toMatch(/'line' is only supported with mode:'pdg'/);
|
||
},
|
||
);
|
||
|
||
it("mode:'callgraph'/undefined + line:0 is byte-identical to omitting line (#2279)", async () => {
|
||
resolveSingleTarget();
|
||
const omitted = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||
const callgraphZero = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'callgraph',
|
||
line: 0,
|
||
});
|
||
const undefZero = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: undefined,
|
||
line: 0,
|
||
});
|
||
// The normalization must leave the callgraph result indistinguishable from a
|
||
// call that never carried `line` — the spurious 0 must not leak into output.
|
||
expect(callgraphZero).toEqual(omitted);
|
||
expect(undefZero).toEqual(omitted);
|
||
});
|
||
|
||
it.each([[0], [-1], [1.5]])(
|
||
"mode:'pdg' + non-positive-integer line %j → structured {error}, never routed to traversal",
|
||
async (badLine) => {
|
||
resolveSingleTarget();
|
||
const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'pdg',
|
||
line: badLine as any,
|
||
});
|
||
expect(result.error).toMatch(/'line' must be a positive integer/);
|
||
expect(result.risk).toBe('UNKNOWN');
|
||
// The validation fires BEFORE the traversal — a bad line never seeds a slice.
|
||
expect(pdgSpy).not.toHaveBeenCalled();
|
||
},
|
||
);
|
||
|
||
it("mode:'pdg' + downstream line:8 routes to the PDG traversal and seeds bridge evidence", async () => {
|
||
resolveSingleTarget();
|
||
// The target-resolution row doubles as the calleesOfBlocks row: `callees`
|
||
// ('callee') is the leaf name persisted on the slice's BasicBlock, the
|
||
// statement-precise substrate the bridge keys on.
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{
|
||
id: 'func:main',
|
||
name: 'main',
|
||
type: 'Function',
|
||
filePath: 'src/index.ts',
|
||
callees: 'callee',
|
||
},
|
||
]);
|
||
// A line-seeded downstream slice with one reachable block → the dispatch
|
||
// queries that block's callees and seeds the bridge with them.
|
||
const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({
|
||
mode: 'pdg',
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'downstream',
|
||
risk: 'UNKNOWN',
|
||
impactedCount: 0,
|
||
epistemic: 'pdg-intra-procedural',
|
||
reachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
// Intra-only slice (no inter-procedural hop) ⇒ the intra reach the bridge
|
||
// keys on equals reachableBlocks (FIX 6: bridge keys on intraReachableBlocks).
|
||
intraReachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
blockCount: 1,
|
||
affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }],
|
||
affectedStatementCount: 1,
|
||
criterionLine: 8,
|
||
});
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
line: 8,
|
||
});
|
||
// A valid line routes cleanly into the PDG engine — no line/mode error.
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.mode).toBe('pdg');
|
||
expect(pdgSpy).toHaveBeenCalledTimes(1);
|
||
expect(bfsSpy).toHaveBeenCalledTimes(1);
|
||
const bridge = bfsSpy.mock.calls[0][4].pdgBridge;
|
||
// The bridge now carries the slice's callee names (statement-precise reach),
|
||
// resolved from BasicBlock.callees — not the dead call-site-line keys.
|
||
expect(bridge).toBeDefined();
|
||
expect([...bridge.sliceCalleeNames]).toContain('callee');
|
||
expect(result.pdgInterprocedural).toBeDefined();
|
||
});
|
||
|
||
it("mode:'pdg' downstream: a callee invoked ON the seeded line is proven even with no downstream dependents", async () => {
|
||
// Regression for the PR #2227 tri-review P2: the seed block is excluded from
|
||
// `reachableBlocks` (seed-minus-reachable convention), so a callee called
|
||
// directly on the changed line — with NO downstream-dependent block — used to
|
||
// be dropped from the statement-precise set. The dispatch now unions the seed
|
||
// block's callees, so it must be proven.
|
||
resolveSingleTarget();
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{
|
||
id: 'func:main',
|
||
name: 'main',
|
||
type: 'Function',
|
||
filePath: 'src/index.ts',
|
||
callees: 'seedCallee',
|
||
},
|
||
]);
|
||
// reachableBlocks EMPTY (line N has no downstream dependents) but seedBlocks
|
||
// carries the changed line's own block — the case that regressed.
|
||
vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({
|
||
mode: 'pdg',
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'downstream',
|
||
risk: 'UNKNOWN',
|
||
impactedCount: 0,
|
||
epistemic: 'pdg-intra-procedural',
|
||
reachableBlocks: [],
|
||
intraReachableBlocks: [],
|
||
seedBlocks: ['BasicBlock:src/index.ts:8:0:0'],
|
||
blockCount: 0,
|
||
affectedStatements: [],
|
||
affectedStatementCount: 0,
|
||
criterionLine: 8,
|
||
});
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
line: 8,
|
||
});
|
||
const bridge = bfsSpy.mock.calls[0][4].pdgBridge;
|
||
// The bridge is seeded from the seed block (not just reachableBlocks), so the
|
||
// seed-line callee is provable.
|
||
expect(bridge).toBeDefined();
|
||
expect([...bridge.sliceCalleeNames]).toContain('seedCallee');
|
||
});
|
||
|
||
it('betterBridgeEvidence keeps callgraph-bridge regardless of parent order (U3 order-independence)', () => {
|
||
const proven = { evidence: 'callgraph-bridge' as const, basis: 'in slice' };
|
||
const unproven = { evidence: 'unproven-bridge' as const, basis: 'not in slice' };
|
||
// A node reached from a proven and an unproven parent is proven either way —
|
||
// the diamond label does not depend on which edge the BFS visits first.
|
||
expect(betterBridgeEvidence(unproven, proven).evidence).toBe('callgraph-bridge');
|
||
expect(betterBridgeEvidence(proven, unproven).evidence).toBe('callgraph-bridge');
|
||
// First verdict wins when neither is stronger; undefined existing takes the candidate.
|
||
expect(betterBridgeEvidence(undefined, unproven).evidence).toBe('unproven-bridge');
|
||
expect(betterBridgeEvidence(unproven, unproven).evidence).toBe('unproven-bridge');
|
||
});
|
||
|
||
it('pdgBridgeEvidenceForImpact treats a truncated-slice (sentinel) as callee-unknown → proven', () => {
|
||
// A slice block that hit the per-statement site cap has an incomplete callee
|
||
// list; the sentinel forces callgraph-equal so an absent-but-real callee is
|
||
// not under-proven.
|
||
const truncated = pdgBridgeEvidenceForImpact({
|
||
bridge: {
|
||
sliceCalleeNames: new Set([CALLEES_TRUNCATED_SENTINEL, 'foo']),
|
||
sliceCalleeIds: new Set(),
|
||
},
|
||
depth: 1,
|
||
calleeName: 'unrelatedNotInSlice',
|
||
});
|
||
expect(truncated.evidence).toBe('callgraph-bridge');
|
||
// Without the sentinel, a callee not in the slice is unproven.
|
||
const notTruncated = pdgBridgeEvidenceForImpact({
|
||
bridge: { sliceCalleeNames: new Set(['foo']), sliceCalleeIds: new Set() },
|
||
depth: 1,
|
||
calleeName: 'unrelatedNotInSlice',
|
||
});
|
||
expect(notTruncated.evidence).toBe('unproven-bridge');
|
||
});
|
||
|
||
it("mode:'pdg' degrades gracefully when the slice-callees query fails (no bridge, no throw)", async () => {
|
||
// calleesOfBlocks swallows a DB error and returns an empty set, so the bridge
|
||
// is not built and the inter-procedural reach falls back to callgraph-equal —
|
||
// never surfacing the error or producing a partial proven/unproven labeling.
|
||
resolveSingleTarget();
|
||
// The slice-callees query (RETURN b.callees) throws; every other query (target
|
||
// resolution) returns the resolved symbol row.
|
||
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
|
||
if (query.includes('RETURN b.callees')) throw new Error('slice-callees query failed');
|
||
return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }];
|
||
});
|
||
// A line-seeded downstream slice so calleesOfBlocks is attempted.
|
||
vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({
|
||
mode: 'pdg',
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'downstream',
|
||
risk: 'UNKNOWN',
|
||
impactedCount: 0,
|
||
epistemic: 'pdg-intra-procedural',
|
||
reachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
intraReachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
seedBlocks: ['BasicBlock:src/index.ts:8:0:0'],
|
||
blockCount: 1,
|
||
affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }],
|
||
affectedStatementCount: 1,
|
||
criterionLine: 8,
|
||
});
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const cap = _captureLogger();
|
||
try {
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
line: 8,
|
||
});
|
||
// The error was swallowed: no bridge passed to the BFS, and no error surfaced.
|
||
expect(result.error).toBeUndefined();
|
||
expect(bfsSpy.mock.calls[0][4].pdgBridge).toBeUndefined();
|
||
// The swallowed, gracefully-degraded query failure is logged at warn (40),
|
||
// never error (50): it degraded to a safe fallback and is not an operation
|
||
// failure. Pinning the severity guards against a regression to a false
|
||
// ERROR alarm that would drown genuine, operation-aborting failures.
|
||
const slice = cap.records().find((r) => r.context === 'impact:pdg-slice-callees');
|
||
expect(slice).toBeDefined();
|
||
expect(slice?.level).toBe(40);
|
||
} finally {
|
||
cap.restore();
|
||
}
|
||
});
|
||
|
||
it("mode:'pdg' slice-callees failing with a benign missing-table error logs at debug, not warn", async () => {
|
||
// A repo analyzed without the optional column/table (e.g. a pre-v3 PDG index
|
||
// missing `calleeIds`, or a BasicBlock table that simply isn't there) makes the
|
||
// slice-callees query fail with a benign "missing optional data" error. That is a
|
||
// normal configuration, not a degradation, so logQueryError routes it to debug —
|
||
// suppressed at the default info level. We capture AT debug so the record is
|
||
// visible: the assertion is that it was emitted AND at debug (level 10), which
|
||
// distinguishes "logged at debug" from "not logged at all" — an info-level
|
||
// absence check could not tell those apart and would pass vacuously if the
|
||
// logQueryError call were deleted.
|
||
resolveSingleTarget();
|
||
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
|
||
if (query.includes('RETURN b.callees')) throw new Error('Table BasicBlock does not exist');
|
||
return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }];
|
||
});
|
||
vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({
|
||
mode: 'pdg',
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'downstream',
|
||
risk: 'UNKNOWN',
|
||
impactedCount: 0,
|
||
epistemic: 'pdg-intra-procedural',
|
||
reachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
intraReachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
seedBlocks: ['BasicBlock:src/index.ts:8:0:0'],
|
||
blockCount: 1,
|
||
affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }],
|
||
affectedStatementCount: 1,
|
||
criterionLine: 8,
|
||
});
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const cap = _captureLogger('debug');
|
||
try {
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
line: 8,
|
||
});
|
||
// Still degrades cleanly to no bridge / no surfaced error.
|
||
expect(result.error).toBeUndefined();
|
||
expect(bfsSpy.mock.calls[0][4].pdgBridge).toBeUndefined();
|
||
// The benign failure was emitted at debug (20) — NOT warn (40)/error (50).
|
||
// Capturing at debug proves the call fired and chose the suppressed level.
|
||
const slice = cap.records().find((r) => r.context === 'impact:pdg-slice-callees');
|
||
expect(slice).toBeDefined();
|
||
expect(slice?.level).toBe(20);
|
||
} finally {
|
||
cap.restore();
|
||
}
|
||
});
|
||
|
||
it("mode:'pdg' slice-callees failing with a non-schema 'not found' error logs at warn, not debug (#2283)", async () => {
|
||
// "Symbol not found" is an operation failure, not a benign missing optional
|
||
// table — isBenignMissingTableError must NOT match an unscoped "not found"
|
||
// (only "<table|column|property|…> … not found"), so it stays visible at warn
|
||
// rather than being demoted to the suppressed debug level.
|
||
resolveSingleTarget();
|
||
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
|
||
if (query.includes('RETURN b.callees')) throw new Error('Symbol not found');
|
||
return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }];
|
||
});
|
||
vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({
|
||
mode: 'pdg',
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'downstream',
|
||
risk: 'UNKNOWN',
|
||
impactedCount: 0,
|
||
epistemic: 'pdg-intra-procedural',
|
||
reachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
intraReachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
|
||
seedBlocks: ['BasicBlock:src/index.ts:8:0:0'],
|
||
blockCount: 1,
|
||
affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }],
|
||
affectedStatementCount: 1,
|
||
criterionLine: 8,
|
||
});
|
||
vi.spyOn(backend as any, '_runImpactBFS');
|
||
const cap = _captureLogger();
|
||
try {
|
||
await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
line: 8,
|
||
});
|
||
const slice = cap.records().find((r) => r.context === 'impact:pdg-slice-callees');
|
||
expect(slice).toBeDefined();
|
||
expect(slice?.level).toBe(40);
|
||
} finally {
|
||
cap.restore();
|
||
}
|
||
});
|
||
|
||
it("mode:'pdg' + crossDepth → hard {error} (single-repo PDG impact)", async () => {
|
||
resolveSingleTarget();
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'pdg',
|
||
crossDepth: 2,
|
||
});
|
||
expect(result.error).toMatch(/not supported with mode:'pdg'/);
|
||
expect(result.error).toContain('crossDepth');
|
||
expect(bfsSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each([
|
||
['relationTypes', { relationTypes: ['CALLS'] }, (opts: any) => opts.relationTypes],
|
||
['minConfidence', { minConfidence: 0.5 }, (opts: any) => opts.minConfidence],
|
||
])("mode:'pdg' + %s feeds the interprocedural symbol reach", async (_label, extra, readOpt) => {
|
||
resolveSingleTarget();
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({
|
||
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
risk: 'LOW',
|
||
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
|
||
byDepthCounts: {},
|
||
affected_processes: [],
|
||
affected_modules: [],
|
||
byDepth: {},
|
||
});
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'pdg',
|
||
...extra,
|
||
});
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.mode).toBe('pdg');
|
||
expect(bfsSpy).toHaveBeenCalledTimes(1);
|
||
expect(readOpt(bfsSpy.mock.calls[0][4])).toBeDefined();
|
||
});
|
||
|
||
it("ambiguous target under mode:'pdg' never invokes interprocedural fan-out (KTD5 ambiguous trap)", async () => {
|
||
// Two same-name Functions → resolver returns ambiguous.
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{
|
||
id: 'func:login:1',
|
||
name: 'login',
|
||
type: 'Function',
|
||
filePath: 'src/auth.ts',
|
||
startLine: 5,
|
||
},
|
||
{
|
||
id: 'func:login:2',
|
||
name: 'login',
|
||
type: 'Function',
|
||
filePath: 'src/admin/login.ts',
|
||
startLine: 8,
|
||
},
|
||
]);
|
||
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
|
||
const result = await backend.callTool('impact', {
|
||
target: 'login',
|
||
direction: 'upstream',
|
||
mode: 'pdg',
|
||
});
|
||
expect(result.status).toBe('ambiguous');
|
||
expect(result.mode).toBe('pdg');
|
||
expect(result.candidates).toHaveLength(2);
|
||
// #2687: undetermined, NOT a numeric zero. This branch runs no per-candidate
|
||
// fan-out, so it carries no maxImpactedCount to correct a zero against.
|
||
expect(result.impactedCount).toBeNull();
|
||
expect(result.risk).toBe('UNKNOWN');
|
||
// The callgraph per-candidate probe fan-out MUST NOT run under pdg.
|
||
expect(bfsSpy).not.toHaveBeenCalled();
|
||
// No per-candidate blast radius is computed yet (U4), so the candidate
|
||
// entries carry no impactedCount field from a callgraph probe.
|
||
for (const c of result.candidates) {
|
||
expect(c.impactedCount).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
it("unknown target with mode:'pdg' returns the normalized PDG error envelope", async () => {
|
||
(executeParameterized as any).mockResolvedValue([]);
|
||
const result = await backend.callTool('impact', {
|
||
target: 'missingSymbol',
|
||
direction: 'upstream',
|
||
mode: 'pdg',
|
||
});
|
||
expect(result.error).toMatch(/not found/);
|
||
expect(result.mode).toBe('pdg');
|
||
expect(result.target).toEqual({ name: 'missingSymbol' });
|
||
expect(result.direction).toBe('upstream');
|
||
expect(result.impactedCount).toBe(0);
|
||
expect(result.risk).toBe('UNKNOWN');
|
||
});
|
||
|
||
it("runtime failures with mode:'pdg' return the normalized PDG error envelope", async () => {
|
||
const failing = new Error('pdg query failed');
|
||
const implSpy = vi.spyOn(backend as any, '_impactImpl').mockRejectedValueOnce(failing);
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'downstream',
|
||
mode: 'pdg',
|
||
});
|
||
expect(result.error).toBe('pdg query failed');
|
||
expect(result.mode).toBe('pdg');
|
||
expect(result.target).toEqual({ name: 'main' });
|
||
expect(result.direction).toBe('downstream');
|
||
expect(result.impactedCount).toBe(0);
|
||
expect(result.risk).toBe('UNKNOWN');
|
||
expect(result.suggestion).toMatch(/context/);
|
||
implSpy.mockRestore();
|
||
});
|
||
|
||
it("@group target with mode:'pdg' is rejected (KTD12 — PDG is single-repo)", async () => {
|
||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'pdg',
|
||
repo: '@grp',
|
||
});
|
||
expect(result.error).toMatch(/not supported for @group targets/);
|
||
expect(result.mode).toBe('pdg');
|
||
expect(result.target).toEqual({ name: 'main' });
|
||
expect(result.direction).toBe('upstream');
|
||
expect(result.impactedCount).toBe(0);
|
||
expect(result.risk).toBe('UNKNOWN');
|
||
});
|
||
|
||
it("@group target with mode:'callgraph' still forwards to group impact (unchanged)", async () => {
|
||
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
|
||
// groupImpact is reached only if the mode gate passes; we don't assert its
|
||
// payload (group infra is stubbed), only that no mode-error short-circuited.
|
||
const result = await backend.callTool('impact', {
|
||
target: 'main',
|
||
direction: 'upstream',
|
||
mode: 'callgraph',
|
||
repo: '@grp',
|
||
});
|
||
expect(result?.error ?? '').not.toMatch(/not supported for @group targets/);
|
||
expect(result?.error ?? '').not.toMatch(/Invalid "mode"/);
|
||
});
|
||
});
|
||
|
||
// ─── 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.listRepos();
|
||
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('serves all sibling clones through the list_repos tool with siblings/remoteUrl intact (#2054, #2119)', async () => {
|
||
const { dirs, entries } = makeSiblingClonesFixture(4);
|
||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||
await backend.init();
|
||
|
||
// Exercise the real TOOL surface (callTool → listReposPage), not just
|
||
// listRepos(): the paginated wrapper must not drop sibling-clone fields
|
||
// during its sort + slice.
|
||
const page = await backend.callTool('list_repos', {});
|
||
expect(page.repositories).toHaveLength(4);
|
||
expect(page.pagination.total).toBe(4);
|
||
const paths = page.repositories.map((r: any) => path.resolve(r.path)).sort();
|
||
expect(paths).toEqual(dirs.map((d) => path.resolve(d)).sort());
|
||
for (const entry of page.repositories) {
|
||
expect(entry.remoteUrl).toBe('git@github.com:MYCOMPANY/REPO.git');
|
||
expect(entry.siblings).toHaveLength(3);
|
||
}
|
||
});
|
||
|
||
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.listRepos();
|
||
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.listRepos();
|
||
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 clone’s 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.listRepos()).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.listRepos();
|
||
expect(listed).toHaveLength(6);
|
||
|
||
// All six ids are distinct (clones 3–6 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.listRepos();
|
||
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.listRepos();
|
||
expect(repos).toEqual([]);
|
||
});
|
||
|
||
it('returns repo metadata', async () => {
|
||
setupSingleRepo();
|
||
await backend.init();
|
||
const repos = await backend.listRepos();
|
||
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.listRepos();
|
||
await backend.listRepos();
|
||
// listRegisteredRepos called: once in init, once per listRepos
|
||
expect(listRegisteredRepos).toHaveBeenCalledTimes(3);
|
||
});
|
||
});
|
||
|
||
// ─── list_repos pagination (#2119) ─────────────────────────────────────
|
||
|
||
describe('parseListReposPagination', () => {
|
||
const opts = { defaultLimit: 50, maxLimit: 200 };
|
||
|
||
it('applies defaults when nothing is supplied', () => {
|
||
expect(parseListReposPagination(undefined, opts)).toEqual({ limit: 50, offset: 0 });
|
||
expect(parseListReposPagination({}, opts)).toEqual({ limit: 50, offset: 0 });
|
||
});
|
||
|
||
it('accepts valid integer limit/offset', () => {
|
||
expect(parseListReposPagination({ limit: 10, offset: 20 }, opts)).toEqual({
|
||
limit: 10,
|
||
offset: 20,
|
||
});
|
||
});
|
||
|
||
it('rejects a limit above the maximum (does not silently clamp)', () => {
|
||
expect(() => parseListReposPagination({ limit: 201 }, opts)).toThrow(/limit/);
|
||
expect(() => parseListReposPagination({ limit: 99999 }, opts)).toThrow(/limit/);
|
||
});
|
||
|
||
it('accepts a valid in-range limit, including the boundary', () => {
|
||
expect(parseListReposPagination({ limit: 200 }, opts).limit).toBe(200);
|
||
expect(parseListReposPagination({ limit: 199 }, opts).limit).toBe(199);
|
||
});
|
||
|
||
it('rejects malformed limit values', () => {
|
||
for (const bad of [0, -5, 1.5, NaN, Infinity, '5', null, true, {}]) {
|
||
expect(() => parseListReposPagination({ limit: bad as any }, opts)).toThrow(/limit/);
|
||
}
|
||
});
|
||
|
||
it('rejects malformed offset values', () => {
|
||
for (const bad of [-1, 2.5, NaN, Infinity, '0', null, false]) {
|
||
expect(() => parseListReposPagination({ offset: bad as any }, opts)).toThrow(/offset/);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('LocalBackend.listReposPage / callTool list_repos pagination (#2119)', () => {
|
||
let backend: LocalBackend;
|
||
|
||
// Build N registry entries with unique, lexically-ordered names + paths and
|
||
// no remoteUrl (so no sibling grouping). Zero-padding makes lexical order
|
||
// equal numeric order, so page boundaries are predictable.
|
||
const id = (i: number) => `repo-${String(i).padStart(4, '0')}`;
|
||
const makeRepoEntries = (count: number) =>
|
||
Array.from({ length: count }, (_, i) => ({
|
||
...MOCK_REPO_ENTRY,
|
||
name: id(i),
|
||
path: `/tmp/repos/${id(i)}`,
|
||
storagePath: `/tmp/repos/${id(i)}/.gitnexus`,
|
||
}));
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
backend = new LocalBackend();
|
||
});
|
||
|
||
it('default page caps a large registry and reports continuation metadata', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', {});
|
||
expect(page.repositories).toHaveLength(50);
|
||
expect(page.pagination).toEqual({
|
||
total: 437,
|
||
limit: 50,
|
||
offset: 0,
|
||
returned: 50,
|
||
hasMore: true,
|
||
nextOffset: 50,
|
||
});
|
||
// First page starts at the first repo in deterministic order.
|
||
expect(page.repositories[0].name).toBe(id(0));
|
||
});
|
||
|
||
it('limit controls the page size', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { limit: 100 });
|
||
expect(page.repositories).toHaveLength(100);
|
||
expect(page.pagination.limit).toBe(100);
|
||
expect(page.pagination.nextOffset).toBe(100);
|
||
});
|
||
|
||
it('offset selects a middle page', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { limit: 50, offset: 50 });
|
||
expect(page.repositories[0].name).toBe(id(50));
|
||
expect(page.repositories[49].name).toBe(id(99));
|
||
// Assert total + limit too (a total miscalculation at non-zero offset would
|
||
// otherwise slip past this targeted middle-page test).
|
||
expect(page.pagination).toEqual({
|
||
total: 437,
|
||
limit: 50,
|
||
offset: 50,
|
||
returned: 50,
|
||
hasMore: true,
|
||
nextOffset: 100,
|
||
});
|
||
});
|
||
|
||
it('returns the final partial page with hasMore=false and no nextOffset', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { limit: 50, offset: 400 });
|
||
expect(page.repositories).toHaveLength(37); // 437 - 400
|
||
expect(page.pagination.returned).toBe(37);
|
||
expect(page.pagination.hasMore).toBe(false);
|
||
expect(page.pagination).not.toHaveProperty('nextOffset');
|
||
});
|
||
|
||
it('limit larger than the remaining count returns only the remaining entries', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { limit: 200, offset: 400 });
|
||
expect(page.repositories).toHaveLength(37);
|
||
expect(page.pagination.hasMore).toBe(false);
|
||
});
|
||
|
||
it('offset equal to total returns an empty page (total preserved)', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { offset: 437 });
|
||
expect(page.repositories).toHaveLength(0);
|
||
expect(page.pagination).toMatchObject({ total: 437, returned: 0, hasMore: false });
|
||
expect(page.pagination).not.toHaveProperty('nextOffset');
|
||
});
|
||
|
||
it('offset beyond total returns an empty page', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { offset: 1000 });
|
||
expect(page.repositories).toHaveLength(0);
|
||
expect(page.pagination).toMatchObject({
|
||
total: 437,
|
||
offset: 1000,
|
||
returned: 0,
|
||
hasMore: false,
|
||
});
|
||
});
|
||
|
||
it('accepts a negative-zero offset (treated as the first page)', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { limit: 5, offset: -0 });
|
||
expect(page.repositories[0].name).toBe(id(0));
|
||
expect(page.pagination.returned).toBe(5);
|
||
// -0 is accepted (not rejected) and behaves as offset 0 (=== treats them equal).
|
||
expect(page.pagination.offset === 0).toBe(true);
|
||
});
|
||
|
||
it('accepts a MAX_SAFE_INTEGER offset and returns an empty page', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', { offset: Number.MAX_SAFE_INTEGER });
|
||
expect(page.repositories).toHaveLength(0);
|
||
expect(page.pagination).toMatchObject({ total: 437, returned: 0, hasMore: false });
|
||
expect(page.pagination).not.toHaveProperty('nextOffset');
|
||
});
|
||
|
||
it('returns the full set with metadata when everything fits on one page', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(3));
|
||
await backend.init();
|
||
|
||
const page = await backend.callTool('list_repos', {});
|
||
expect(page.repositories).toHaveLength(3);
|
||
expect(page.pagination).toEqual({
|
||
total: 3,
|
||
limit: 50,
|
||
offset: 0,
|
||
returned: 3,
|
||
hasMore: false,
|
||
});
|
||
});
|
||
|
||
it('rejects a limit above the maximum through the real callTool path', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
|
||
await backend.init();
|
||
|
||
await expect(backend.callTool('list_repos', { limit: 99999 })).rejects.toThrow(/limit/);
|
||
// A request at the documented maximum is still accepted.
|
||
const page = await backend.callTool('list_repos', { limit: 200 });
|
||
expect(page.repositories).toHaveLength(200);
|
||
expect(page.pagination.limit).toBe(200);
|
||
expect(page.pagination.hasMore).toBe(true);
|
||
});
|
||
|
||
it('rejects malformed limit/offset through the real callTool path', async () => {
|
||
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(3));
|
||
await backend.init();
|
||
|
||
await expect(backend.callTool('list_repos', { limit: 0 })).rejects.toThrow(/limit/);
|
||
await expect(backend.callTool('list_repos', { limit: -5 })).rejects.toThrow(/limit/);
|
||
await expect(backend.callTool('list_repos', { limit: 1.5 })).rejects.toThrow(/limit/);
|
||
await expect(backend.callTool('list_repos', { limit: 'all' as any })).rejects.toThrow(/limit/);
|
||
await expect(backend.callTool('list_repos', { offset: -1 })).rejects.toThrow(/offset/);
|
||
await expect(backend.callTool('list_repos', { offset: 2.5 })).rejects.toThrow(/offset/);
|
||
});
|
||
|
||
it('traverses every repository exactly once across pages (the #2119 guarantee)', async () => {
|
||
const entries = makeRepoEntries(437);
|
||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||
await backend.init();
|
||
|
||
const collected: string[] = [];
|
||
let offset = 0;
|
||
const limit = 50;
|
||
// Hard cap iterations to avoid an infinite loop if hasMore were ever wrong.
|
||
for (let guard = 0; guard < 100; guard++) {
|
||
const page = await backend.callTool('list_repos', { limit, offset });
|
||
collected.push(...page.repositories.map((r: any) => r.path));
|
||
expect(page.pagination.total).toBe(437);
|
||
if (!page.pagination.hasMore) break;
|
||
offset = page.pagination.nextOffset;
|
||
}
|
||
|
||
expect(collected).toHaveLength(437);
|
||
expect(new Set(collected).size).toBe(437); // no duplicates
|
||
expect(new Set(collected)).toEqual(new Set(entries.map((e) => e.path))); // exact set
|
||
});
|
||
|
||
it('orders pages deterministically by name then path, stable across calls', async () => {
|
||
// Scrambled input order; two entries deliberately SHARE a name (collision)
|
||
// and must be tie-broken by path, never collapsed.
|
||
const entries = [
|
||
{ ...MOCK_REPO_ENTRY, name: 'zeta', path: '/tmp/z', storagePath: '/tmp/z/.gitnexus' },
|
||
{ ...MOCK_REPO_ENTRY, name: 'shared', path: '/tmp/b', storagePath: '/tmp/b/.gitnexus' },
|
||
{ ...MOCK_REPO_ENTRY, name: 'Alpha', path: '/tmp/a', storagePath: '/tmp/a/.gitnexus' },
|
||
{ ...MOCK_REPO_ENTRY, name: 'shared', path: '/tmp/a2', storagePath: '/tmp/a2/.gitnexus' },
|
||
];
|
||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||
await backend.init();
|
||
|
||
const first = await backend.callTool('list_repos', {});
|
||
const order = first.repositories.map((r: any) => `${r.name}@${r.path}`);
|
||
// lower-cased name primary (Alpha < shared < zeta), path tie-break for the
|
||
// two "shared" entries (/tmp/a2 < /tmp/b).
|
||
expect(order).toEqual(['Alpha@/tmp/a', 'shared@/tmp/a2', 'shared@/tmp/b', 'zeta@/tmp/z']);
|
||
expect(first.repositories).toHaveLength(4); // collision not collapsed
|
||
|
||
// Re-listing yields identical page boundaries.
|
||
const second = await backend.callTool('list_repos', {});
|
||
expect(second.repositories.map((r: any) => `${r.name}@${r.path}`)).toEqual(order);
|
||
});
|
||
});
|
||
|
||
// ─── 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('keeps one markdown line per row when a cell value contains newlines (#2310)', async () => {
|
||
// A multi-line `content` value must not split its row across physical lines —
|
||
// otherwise the rendered table is corrupt and the CLI `--limit` line-slice
|
||
// keeps the wrong number of rows.
|
||
(executeParameterized as any).mockResolvedValue([
|
||
{ name: 'a', content: 'export function a() {\n return 1;\n}' },
|
||
{ name: 'b', content: 'line1\nline2' },
|
||
]);
|
||
const result = await backend.callTool('cypher', {
|
||
query: 'MATCH (n:Function) RETURN n.name AS name, n.content AS content',
|
||
});
|
||
const lines = result.markdown.split('\n');
|
||
// header + separator + exactly one line per data row, no embedded newlines.
|
||
expect(lines).toHaveLength(2 + result.row_count);
|
||
expect(result.row_count).toBe(2);
|
||
expect(result.markdown).not.toMatch(/\n[^|]/);
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
// ─── resolveRepo branch scope (#2106) ────────────────────────────────
|
||
|
||
describe('LocalBackend.resolveRepo branch scope (#2106)', () => {
|
||
let backend: LocalBackend;
|
||
|
||
// Per-run unique dir: a fixed shared os.tmpdir() path lets concurrent
|
||
// vitest runs on one host rm each other's materialized sub-index stub
|
||
// mid-test (the documented parallel-agents workflow).
|
||
const MULTI_DIR = mkdtempSync(path.join(os.tmpdir(), 'gnx-2106-multi-'));
|
||
const BRANCH_ENTRY = {
|
||
name: 'multi',
|
||
path: MULTI_DIR,
|
||
storagePath: path.join(MULTI_DIR, '.gitnexus'),
|
||
indexedAt: '2026-06-10T12:00:00Z',
|
||
lastCommit: 'mainsha',
|
||
branch: 'main',
|
||
branches: [{ branch: 'feature/x', indexedAt: '2026-06-10T13:00:00Z', lastCommit: 'featsha' }],
|
||
stats: { files: 1, nodes: 1 },
|
||
};
|
||
|
||
const flatLbug = path.join(BRANCH_ENTRY.storagePath, 'lbug');
|
||
// The pinned sub-index must exist on disk: applyBranchScope serves a
|
||
// branches[] summary only when its lbug is really there (#2364 review F1
|
||
// arm ii — a stale summary must not route to an adopt-deleted dir).
|
||
const branchLbug = getStoragePaths(BRANCH_ENTRY.path, 'feature/x').lbugPath;
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
mkdirSync(path.dirname(branchLbug), { recursive: true });
|
||
writeFileSync(branchLbug, 'stub');
|
||
backend = new LocalBackend();
|
||
(listRegisteredRepos as any).mockResolvedValue([BRANCH_ENTRY]);
|
||
await backend.init();
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(BRANCH_ENTRY.storagePath, { recursive: true, force: true });
|
||
});
|
||
|
||
afterAll(() => {
|
||
rmSync(MULTI_DIR, { recursive: true, force: true });
|
||
});
|
||
|
||
it('no branch param resolves the flat workspace lbug', async () => {
|
||
const handle = await backend.resolveRepo('multi');
|
||
expect(handle.lbugPath).toBe(flatLbug);
|
||
});
|
||
|
||
it('the workspace-recorded branch name resolves the flat lbug', async () => {
|
||
const handle = await backend.resolveRepo('multi', 'main');
|
||
expect(handle.lbugPath).toBe(flatLbug);
|
||
});
|
||
|
||
it('an indexed pinned branch resolves a branches/<slug> lbug', async () => {
|
||
const handle = await backend.resolveRepo('multi', 'feature/x');
|
||
expect(handle.lbugPath).not.toBe(flatLbug);
|
||
expect(handle.lbugPath).toContain(path.join('.gitnexus', 'branches'));
|
||
expect(path.basename(handle.lbugPath)).toBe('lbug');
|
||
// The branch handle reports the branch's own commit, not the primary's.
|
||
expect(handle.lastCommit).toBe('featsha');
|
||
});
|
||
|
||
it('an un-indexed branch throws a clear error', async () => {
|
||
await expect(backend.resolveRepo('multi', 'nope')).rejects.toThrow(/not indexed/i);
|
||
// Post-#2354 guidance: a bare `analyze --branch <X>` refuses unless X is
|
||
// checked out, so the message must lead with the checkout (#2364 F6).
|
||
await expect(backend.resolveRepo('multi', 'nope')).rejects.toThrow(
|
||
/workspace index follows the checked-out branch/,
|
||
);
|
||
await expect(backend.resolveRepo('multi', 'nope')).rejects.toThrow(
|
||
/check out "nope" and re-run: gitnexus analyze/,
|
||
);
|
||
});
|
||
|
||
it('a legacy entry with no top-level branch still routes an indexed branch', async () => {
|
||
// Pre-#2106 entries have no `branch` field; branch routing must still work
|
||
// off branches[] alone.
|
||
(listRegisteredRepos as any).mockResolvedValue([{ ...BRANCH_ENTRY, branch: undefined }]);
|
||
await backend.init();
|
||
const handle = await backend.resolveRepo('multi', 'feature/x');
|
||
expect(handle.lbugPath).toContain(path.join('.gitnexus', 'branches'));
|
||
});
|
||
|
||
it('a legacy entry resolves --branch <workspace-branch> via the flat meta (#2106 R4)', async () => {
|
||
// Pre-#2106 flat index: registry entry has no `branch`/`branches`, but the
|
||
// flat meta.json records the workspace branch. `--branch <that branch>`
|
||
// must resolve to the flat handle (read from meta), while an unindexed
|
||
// branch still errors.
|
||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2106-legacy-'));
|
||
const storagePath = path.join(dir, '.gitnexus');
|
||
mkdirSync(storagePath, { recursive: true });
|
||
writeFileSync(
|
||
path.join(storagePath, 'meta.json'),
|
||
JSON.stringify({ repoPath: dir, lastCommit: 'abc', indexedAt: 'now', branch: 'main' }),
|
||
);
|
||
try {
|
||
(listRegisteredRepos as any).mockResolvedValue([
|
||
{ name: 'legacy', path: dir, storagePath, indexedAt: 'now', lastCommit: 'abc' },
|
||
]);
|
||
await backend.init();
|
||
const handle = await backend.resolveRepo('legacy', 'main');
|
||
expect(handle.lbugPath).toBe(path.join(storagePath, 'lbug'));
|
||
await expect(backend.resolveRepo('legacy', 'feature')).rejects.toThrow(/not indexed/i);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('a stale cached handle still resolves the restamped workspace branch via flat meta (#2354)', async () => {
|
||
// The flat workspace slot follows the checked-out working tree: a plain
|
||
// analyze after a branch switch restamps the flat meta.json without any
|
||
// repo-resolution miss that would refresh a long-lived server's handle.
|
||
// The cached handle still says branch 'main'; the on-disk flat meta is the
|
||
// truth ('feature/z') and must win over a stale "not indexed" error.
|
||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2354-restamp-'));
|
||
const storagePath = path.join(dir, '.gitnexus');
|
||
mkdirSync(storagePath, { recursive: true });
|
||
writeFileSync(
|
||
path.join(storagePath, 'meta.json'),
|
||
JSON.stringify({ repoPath: dir, lastCommit: 'zzz', indexedAt: 'now', branch: 'feature/z' }),
|
||
);
|
||
try {
|
||
(listRegisteredRepos as any).mockResolvedValue([
|
||
{
|
||
name: 'flipped',
|
||
path: dir,
|
||
storagePath,
|
||
indexedAt: 'now',
|
||
lastCommit: 'aaa',
|
||
branch: 'main',
|
||
},
|
||
]);
|
||
await backend.init();
|
||
const handle = await backend.resolveRepo('flipped', 'feature/z');
|
||
expect(handle.lbugPath).toBe(path.join(storagePath, 'lbug'));
|
||
// A genuinely unindexed branch still errors (never serves the wrong DB).
|
||
await expect(backend.resolveRepo('flipped', 'nope')).rejects.toThrow(/not indexed/i);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('a stale cached label errors instead of serving the flat handle (#2364 F1 arm i)', async () => {
|
||
// Long-lived server cached branch 'main'; a plain analyze on feature/z
|
||
// restamped the flat meta (and the pool reinit will hot-swap content).
|
||
// Requesting the OLD label must error — the flat DB no longer holds main.
|
||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2364-stale-label-'));
|
||
const storagePath = path.join(dir, '.gitnexus');
|
||
mkdirSync(storagePath, { recursive: true });
|
||
writeFileSync(
|
||
path.join(storagePath, 'meta.json'),
|
||
JSON.stringify({ repoPath: dir, lastCommit: 'zzz', indexedAt: 'now', branch: 'feature/z' }),
|
||
);
|
||
try {
|
||
const entry: RegistryEntry = {
|
||
name: 'flipped',
|
||
path: dir,
|
||
storagePath,
|
||
indexedAt: 'now',
|
||
lastCommit: 'aaa',
|
||
branch: 'main',
|
||
};
|
||
vi.mocked(listRegisteredRepos).mockResolvedValue([entry]);
|
||
await backend.init();
|
||
const callsBefore = vi.mocked(listRegisteredRepos).mock.calls.length;
|
||
await expect(backend.resolveRepo('flipped', 'main')).rejects.toThrow(/not indexed/i);
|
||
// Exactly one refreshRepos fired for cache coherence (observed via its
|
||
// unconditional first call — refreshRepos itself is private).
|
||
expect(vi.mocked(listRegisteredRepos).mock.calls.length - callsBefore).toBe(1);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('a stale summary whose sub-index was adopted falls through to the flat handle (#2364 F1 arm ii)', async () => {
|
||
// The cached branches[] summary still lists feature/z, but adopt deleted
|
||
// branches/<slug>/ and the flat slot now owns the label: serve flat.
|
||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2364-adopted-'));
|
||
const storagePath = path.join(dir, '.gitnexus');
|
||
mkdirSync(storagePath, { recursive: true });
|
||
writeFileSync(
|
||
path.join(storagePath, 'meta.json'),
|
||
JSON.stringify({ repoPath: dir, lastCommit: 'zzz', indexedAt: 'now', branch: 'feature/z' }),
|
||
);
|
||
try {
|
||
const entry: RegistryEntry = {
|
||
name: 'adopted',
|
||
path: dir,
|
||
storagePath,
|
||
indexedAt: 'now',
|
||
lastCommit: 'aaa',
|
||
branch: 'main',
|
||
branches: [{ branch: 'feature/z', indexedAt: 'now', lastCommit: 'zzz' }],
|
||
};
|
||
vi.mocked(listRegisteredRepos).mockResolvedValue([entry]);
|
||
await backend.init();
|
||
const handle = await backend.resolveRepo('adopted', 'feature/z');
|
||
expect(handle.lbugPath).toBe(path.join(storagePath, 'lbug'));
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('a dangling summary with a disagreeing flat meta errors honestly (#2364 F3 window)', async () => {
|
||
// Partial fast-path failure: adopt deleted the sub-index but the flat
|
||
// meta was never restamped (saveMeta runs last). The degraded state must
|
||
// yield the not-indexed error — no ghost route, no wrong data.
|
||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2364-dangling-'));
|
||
const storagePath = path.join(dir, '.gitnexus');
|
||
mkdirSync(storagePath, { recursive: true });
|
||
writeFileSync(
|
||
path.join(storagePath, 'meta.json'),
|
||
JSON.stringify({ repoPath: dir, lastCommit: 'aaa', indexedAt: 'now', branch: 'main' }),
|
||
);
|
||
try {
|
||
const entry: RegistryEntry = {
|
||
name: 'dangling',
|
||
path: dir,
|
||
storagePath,
|
||
indexedAt: 'now',
|
||
lastCommit: 'aaa',
|
||
branch: 'main',
|
||
branches: [{ branch: 'feature/z', indexedAt: 'now', lastCommit: 'zzz' }],
|
||
};
|
||
vi.mocked(listRegisteredRepos).mockResolvedValue([entry]);
|
||
await backend.init();
|
||
const callsBefore = vi.mocked(listRegisteredRepos).mock.calls.length;
|
||
await expect(backend.resolveRepo('dangling', 'feature/z')).rejects.toThrow(/not indexed/i);
|
||
expect(vi.mocked(listRegisteredRepos).mock.calls.length - callsBefore).toBe(1);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('callTool threads the branch param through resolveRepo (un-indexed branch errors)', async () => {
|
||
// If callTool dropped `branch` from repoParams, this would resolve the flat
|
||
// handle and NOT throw — so the rejection proves the param is threaded.
|
||
await expect(backend.callTool('query', { repo: 'multi', branch: 'nope' })).rejects.toThrow(
|
||
/not indexed/i,
|
||
);
|
||
});
|
||
|
||
it('callTool resolves an indexed branch without error', async () => {
|
||
const res = await backend.callTool('query', {
|
||
query: 'auth',
|
||
repo: 'multi',
|
||
branch: 'feature/x',
|
||
});
|
||
expect(res).toBeDefined();
|
||
expect(res).not.toHaveProperty('error');
|
||
});
|
||
|
||
it('evicts an opened branch pool when the repo leaves the registry (#2106 R3)', async () => {
|
||
// Open the branch pool via a tool call (ensureInitialized records its key).
|
||
await backend.callTool('query', { query: 'auth', repo: 'multi', branch: 'feature/x' });
|
||
lbugMocks.closeLbug.mockClear();
|
||
// Unregister the repo, then trigger a refresh (init re-reads the registry).
|
||
(listRegisteredRepos as any).mockResolvedValue([]);
|
||
await backend.init();
|
||
const closedPaths = lbugMocks.closeLbug.mock.calls.map((c: any[]) => String(c[0]));
|
||
expect(closedPaths.some((p) => p.includes(path.join('.gitnexus', 'branches')))).toBe(true);
|
||
});
|
||
});
|
||
|
||
// #2655 review: the per-index tool-staleness cache must key by lbugPath, not
|
||
// repoPath — flat and branch handles for one repo share a repoPath but carry
|
||
// different lastCommit values, so a repoPath key would serve one handle's
|
||
// freshness for the other within the TTL window.
|
||
describe('LocalBackend tool-staleness cache keying (#2655 review)', () => {
|
||
let backend: LocalBackend;
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
backend = new LocalBackend();
|
||
setupSingleRepo();
|
||
await backend.init();
|
||
});
|
||
|
||
it('does not share a staleness entry between flat and branch handles of one repo', async () => {
|
||
const flat = {
|
||
id: 'r',
|
||
name: 'r',
|
||
repoPath: '/r',
|
||
storagePath: '/r/.gitnexus',
|
||
lbugPath: '/r/.gitnexus/lbug',
|
||
indexedAt: '',
|
||
lastCommit: 'FLATSHA',
|
||
};
|
||
const branch = {
|
||
...flat,
|
||
lbugPath: `/r/.gitnexus/${path.join('branches', 'x', 'lbug')}`,
|
||
lastCommit: 'BRANCHSHA',
|
||
};
|
||
vi.spyOn(backend, 'resolveRepo')
|
||
.mockResolvedValueOnce(flat as any)
|
||
.mockResolvedValueOnce(branch as any);
|
||
// The tool itself returns a plain (staleness-carryable) object.
|
||
vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true });
|
||
|
||
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
|
||
(checkStalenessAsync as any).mockImplementation((_repoPath: string, lastCommit: string) =>
|
||
Promise.resolve(
|
||
lastCommit === 'FLATSHA'
|
||
? { isStale: true, commitsBehind: 5, hint: '5 behind' }
|
||
: { isStale: false, commitsBehind: 0 },
|
||
),
|
||
);
|
||
|
||
const flatRes = await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
const branchRes = await backend.callTool('query', {
|
||
search_query: 'x',
|
||
repo: 'r',
|
||
branch: 'x',
|
||
});
|
||
|
||
// Flat index (lastCommit=FLATSHA) is 5 behind -> field present.
|
||
expect(flatRes).toMatchObject({ staleness: { commitsBehind: 5 } });
|
||
// Branch index (different lbugPath + lastCommit) is current; it must NOT
|
||
// inherit the flat handle's cached staleness (the pre-fix repoPath-keyed bug).
|
||
expect(branchRes).not.toHaveProperty('staleness');
|
||
});
|
||
});
|
||
|
||
// #2655 review F1–F4: the staleness signal wired into query/cypher/context/impact
|
||
// must degrade gracefully on a rejecting freshness check, attach on every wrapped
|
||
// tool (not just query), leave the adjacent read tools alone, and dedupe/expire
|
||
// its per-index cache.
|
||
describe('LocalBackend tool-staleness signal (#2655 review)', () => {
|
||
let backend: LocalBackend;
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
backend = new LocalBackend();
|
||
setupSingleRepo();
|
||
await backend.init();
|
||
});
|
||
|
||
const handle = {
|
||
id: 'r',
|
||
name: 'r',
|
||
repoPath: '/r',
|
||
storagePath: '/r/.gitnexus',
|
||
lbugPath: '/r/.gitnexus/lbug',
|
||
indexedAt: '',
|
||
lastCommit: 'HEADSHA',
|
||
};
|
||
|
||
const stubResolve = () => vi.spyOn(backend, 'resolveRepo').mockResolvedValue(handle as any);
|
||
|
||
const stubStale = async () => {
|
||
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
|
||
(checkStalenessAsync as any).mockResolvedValue({
|
||
isStale: true,
|
||
commitsBehind: 3,
|
||
hint: '3 behind',
|
||
});
|
||
return checkStalenessAsync as unknown as ReturnType<typeof vi.fn>;
|
||
};
|
||
|
||
// F1: a rejecting checkStalenessAsync must never fail the tool nor poison the
|
||
// 5s cache entry — the result comes back without a staleness field, and a
|
||
// later call (after the poisoned entry is evicted) still works.
|
||
it('degrades to no-staleness when the freshness check rejects, then recovers', async () => {
|
||
stubResolve();
|
||
vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true });
|
||
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
|
||
(checkStalenessAsync as any)
|
||
.mockRejectedValueOnce(new Error('git blew up'))
|
||
.mockResolvedValue({ isStale: true, commitsBehind: 2, hint: '2 behind' });
|
||
|
||
const rejected = await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
expect(rejected).toMatchObject({ ok: true });
|
||
expect(rejected).not.toHaveProperty('staleness');
|
||
|
||
// The rejected entry must not be cached — the next call re-runs and attaches.
|
||
const recovered = await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
expect(recovered).toMatchObject({ ok: true, staleness: { commitsBehind: 2 } });
|
||
});
|
||
|
||
// F2: every wrapped tool attaches the field on a carryable object result.
|
||
it('attaches staleness on query, context, and impact object results', async () => {
|
||
stubResolve();
|
||
await stubStale();
|
||
vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true });
|
||
vi.spyOn(backend as any, 'context').mockResolvedValue({ symbol: 'x' });
|
||
vi.spyOn(backend as any, 'impact').mockResolvedValue({ impactedCount: 0 });
|
||
|
||
expect(await backend.callTool('query', { search_query: 'x', repo: 'r' })).toMatchObject({
|
||
staleness: { commitsBehind: 3, hint: '3 behind' },
|
||
});
|
||
expect(await backend.callTool('context', { name: 'x', repo: 'r' })).toMatchObject({
|
||
staleness: { commitsBehind: 3 },
|
||
});
|
||
expect(await backend.callTool('impact', { target: 'x', repo: 'r' })).toMatchObject({
|
||
staleness: { commitsBehind: 3 },
|
||
});
|
||
});
|
||
|
||
// F2: cypher's tabular {markdown,row_count} object gets the field; a raw-array
|
||
// (non-tabular) result keeps its shape untouched so Array.isArray consumers work.
|
||
it('attaches staleness to the cypher table object but never to a raw-array result', async () => {
|
||
stubResolve();
|
||
await stubStale();
|
||
|
||
// Non-empty array of keyed objects -> formatCypherAsMarkdown returns {markdown,row_count}.
|
||
lbugMocks.executeParameterized.mockResolvedValueOnce([{ a: 1 }]);
|
||
const tabular = await backend.callTool('cypher', {
|
||
statement: 'MATCH (n) RETURN n',
|
||
repo: 'r',
|
||
});
|
||
expect(tabular).toMatchObject({ row_count: 1, staleness: { commitsBehind: 3 } });
|
||
|
||
// Empty result -> formatCypherAsMarkdown passes the raw array through unchanged.
|
||
lbugMocks.executeParameterized.mockResolvedValueOnce([]);
|
||
const raw = await backend.callTool('cypher', { statement: 'MATCH (n) RETURN n', repo: 'r' });
|
||
expect(Array.isArray(raw)).toBe(true);
|
||
expect(raw).toHaveLength(0);
|
||
});
|
||
|
||
// F3: drift guard — exactly the four read tools route through stalenessForTool;
|
||
// the adjacent read-ish tools must not, so a future tool added without staleness
|
||
// (or one dropped) is caught.
|
||
it('routes only query/cypher/context/impact through the freshness check', async () => {
|
||
stubResolve();
|
||
await stubStale();
|
||
const spy = vi.spyOn(backend as any, 'stalenessForTool');
|
||
// Stub each tool to a benign object so dispatch reaches withToolStaleness.
|
||
for (const m of [
|
||
'query',
|
||
'context',
|
||
'impact',
|
||
'explain',
|
||
'pdgQuery',
|
||
'detectChanges',
|
||
'check',
|
||
]) {
|
||
vi.spyOn(backend as any, m).mockResolvedValue({ ok: true });
|
||
}
|
||
// cypher runs its real path; a keyed-object row makes formatCypherAsMarkdown
|
||
// return a carryable {markdown,row_count} so the freshness check is reached.
|
||
lbugMocks.executeParameterized.mockResolvedValue([{ a: 1 }]);
|
||
|
||
await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
await backend.callTool('cypher', { statement: 'RETURN 1', repo: 'r' });
|
||
await backend.callTool('context', { name: 'x', repo: 'r' });
|
||
await backend.callTool('impact', { target: 'x', repo: 'r' });
|
||
const wrappedCalls = spy.mock.calls.length;
|
||
|
||
await backend.callTool('explain', { target: 'x', repo: 'r' });
|
||
await backend.callTool('pdg_query', { anchor: 'x', repo: 'r' });
|
||
await backend.callTool('detect_changes', { scope: 'unstaged', repo: 'r' });
|
||
await backend.callTool('check', { cycles: true, repo: 'r' });
|
||
|
||
expect(wrappedCalls).toBe(4);
|
||
expect(spy.mock.calls.length).toBe(4);
|
||
});
|
||
|
||
// F4: the per-index freshness result is deduped within TOOL_STALENESS_TTL_MS and
|
||
// recomputed once the window elapses. Drive time via Date.now (not fake timers,
|
||
// which would entangle the awaited async dispatch with the microtask queue).
|
||
it('dedupes the freshness check within the TTL and recomputes after it expires', async () => {
|
||
stubResolve();
|
||
vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true });
|
||
const check = await stubStale();
|
||
check.mockClear();
|
||
|
||
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||
await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
expect(check).toHaveBeenCalledTimes(1); // deduped within the window
|
||
|
||
dateSpy.mockReturnValue(1000 + 5000 + 1); // past TOOL_STALENESS_TTL_MS
|
||
await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
expect(check).toHaveBeenCalledTimes(2); // recomputed after expiry
|
||
|
||
dateSpy.mockRestore();
|
||
});
|
||
|
||
// @group-routed calls forward to callToolAtGroupRepo BEFORE the wrapping
|
||
// switch, so they deliberately never get the staleness signal (multi-repo,
|
||
// single-commit staleness is ill-defined). Pin that so it can't silently flip.
|
||
it('does not attach staleness to an @group-routed call', async () => {
|
||
const groupSpy = vi
|
||
.spyOn(backend as any, 'callToolAtGroupRepo')
|
||
.mockResolvedValue({ ok: true });
|
||
const freshSpy = vi.spyOn(backend as any, 'stalenessForTool');
|
||
await stubStale(); // stale — but @group must skip the signal regardless
|
||
|
||
const res = await backend.callTool('query', { search_query: 'x', repo: '@grp' });
|
||
|
||
expect(groupSpy).toHaveBeenCalledOnce();
|
||
expect(freshSpy).not.toHaveBeenCalled();
|
||
expect(res).not.toHaveProperty('staleness');
|
||
});
|
||
|
||
// The freshness check is deduped by sharing the IN-FLIGHT promise, not merely
|
||
// by reusing an already-resolved value: two calls that arrive before the first
|
||
// `checkStalenessAsync` settles must still spawn only one.
|
||
it('shares one in-flight freshness check across truly concurrent calls', async () => {
|
||
stubResolve();
|
||
vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true });
|
||
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(2000);
|
||
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
|
||
let settle: (v: StalenessInfo) => void = () => {};
|
||
const pending = new Promise<StalenessInfo>((res) => {
|
||
settle = res;
|
||
});
|
||
(checkStalenessAsync as any).mockClear();
|
||
(checkStalenessAsync as any).mockReturnValue(pending);
|
||
|
||
// Both dispatched before the check resolves — they must share the entry.
|
||
const p1 = backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
const p2 = backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
await new Promise((r) => setTimeout(r, 0)); // let both reach stalenessForTool
|
||
settle({ isStale: true, commitsBehind: 1, hint: '1 behind' });
|
||
const [r1, r2] = await Promise.all([p1, p2]);
|
||
|
||
expect(checkStalenessAsync).toHaveBeenCalledTimes(1); // one spawn, shared
|
||
expect(r1).toMatchObject({ staleness: { commitsBehind: 1 } });
|
||
expect(r2).toMatchObject({ staleness: { commitsBehind: 1 } });
|
||
dateSpy.mockRestore();
|
||
});
|
||
|
||
// The evict-on-reject is guarded by object identity (=== entry), so a LATE
|
||
// rejection from a superseded entry must not drop the newer entry that
|
||
// replaced it after the TTL rolled over.
|
||
it('a late rejection does not evict the newer cache entry', async () => {
|
||
stubResolve();
|
||
vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true });
|
||
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
|
||
let rejectFirst: (e: unknown) => void = () => {};
|
||
const first = new Promise<StalenessInfo>((_res, rej) => {
|
||
rejectFirst = rej;
|
||
});
|
||
(checkStalenessAsync as any)
|
||
.mockReturnValueOnce(first) // entry 1 — held open, will reject late
|
||
.mockResolvedValue({ isStale: true, commitsBehind: 7, hint: '7 behind' }); // entry 2+
|
||
|
||
const p1 = backend.callTool('query', { search_query: 'x', repo: 'r' }); // installs entry1 @1000
|
||
await new Promise((r) => setTimeout(r, 0)); // entry1 installed, awaiting `first`
|
||
|
||
dateSpy.mockReturnValue(1000 + 5000 + 1); // past TTL → next call installs entry2
|
||
const r2 = await backend.callTool('query', { search_query: 'x', repo: 'r' });
|
||
expect(r2).toMatchObject({ staleness: { commitsBehind: 7 } });
|
||
|
||
rejectFirst(new Error('late git failure')); // entry1's guarded catch must NOT evict entry2
|
||
await p1.catch(() => {}); // p1 degrades to no-staleness
|
||
|
||
const callsBefore = (checkStalenessAsync as any).mock.calls.length;
|
||
const r3 = await backend.callTool('query', { search_query: 'x', repo: 'r' }); // still within entry2 TTL
|
||
expect((checkStalenessAsync as any).mock.calls.length).toBe(callsBefore); // cache hit → entry2 survived
|
||
expect(r3).toMatchObject({ staleness: { commitsBehind: 7 } });
|
||
dateSpy.mockRestore();
|
||
});
|
||
});
|