/** * 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(); 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(); 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'; import { DART_PACKAGE_IDENTITY_REASON } from '../../src/core/ingestion/languages/dart/package-dependencies.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 }); }); it('does not delete legacy Kuzu files while initializing a read backend', async () => { setupSingleRepo(); await backend.init(); expect(cleanupOldKuzuFiles).not.toHaveBeenCalled(); }); }); describe('LocalBackend.countRepos', () => { let backend: LocalBackend; beforeEach(() => { backend = new LocalBackend(); vi.clearAllMocks(); }); it('counts the validated registry, ignoring raw ENOENT ghost entries', async () => { (listRegisteredRepos as any).mockImplementation(async (opts?: { validate?: boolean }) => opts?.validate ? [MOCK_REPO_ENTRY] : [ MOCK_REPO_ENTRY, { ...MOCK_REPO_ENTRY, name: 'ghost-project', path: '/tmp/ghost-project', storagePath: '/tmp/.gitnexus/ghost-project', }, ], ); await expect(backend.countRepos()).resolves.toBe(1); expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true }); }); it('returns 0 when every registry row is a ghost', async () => { (listRegisteredRepos as any).mockImplementation(async (opts?: { validate?: boolean }) => opts?.validate ? [] : [ { ...MOCK_REPO_ENTRY, name: 'ghost-a', path: '/tmp/ghost-a', storagePath: '/tmp/.gitnexus/ghost-a', }, { ...MOCK_REPO_ENTRY, name: 'ghost-b', path: '/tmp/ghost-b', storagePath: '/tmp/.gitnexus/ghost-b', }, ], ); await expect(backend.countRepos()).resolves.toBe(0); expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true }); }); it('reports the in-memory size after refresh, not the raw registry file', async () => { (listRegisteredRepos as any).mockImplementation(async (opts?: { validate?: boolean }) => opts?.validate ? [MOCK_REPO_ENTRY] : [ MOCK_REPO_ENTRY, { ...MOCK_REPO_ENTRY, name: 'ghost-project', path: '/tmp/ghost-project', storagePath: '/tmp/.gitnexus/ghost-project', }, ], ); expect(backend.cachedRepoCount()).toBe(0); await backend.init(); expect(backend.cachedRepoCount()).toBe(1); }); }); 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', }); // toMatchObject, not toEqual: since #3291 every hot-read-tool response // also carries the `staleness` ref field. This test is about parameter // normalization, so it pins the payload it cares about and ignores it. expect(result).toMatchObject({ status: 'normalized' }); const dispatched = impactSpy.mock.calls[0][1] as Record; 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 ', }); // toMatchObject: responses carry the #3291 `staleness` ref field too. expect(result).toMatchObject({ status: 'normalized' }); const dispatched = contextSpy.mock.calls[0][1] as Record; 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, }); // toMatchObject: responses carry the #3291 `staleness` ref field too. expect(result).toMatchObject({ status: 'normalized' }); expect(contextSpy.mock.calls[0][1]).toMatchObject({ name: 'validate' }); }); it('treats adapter-materialized blank optional aliases as absent', async () => { const impactSpy = vi .spyOn(backend as any, 'impact') .mockResolvedValue({ status: 'normalized' }); const contextSpy = vi .spyOn(backend as any, 'context') .mockResolvedValue({ status: 'normalized' }); await backend.callTool('impact', { target: 'validate', name: '', symbol: ' ', direction: 'upstream', }); await backend.callTool('context', { name: 'validate', file_path: '', file: ' ', }); expect(impactSpy.mock.calls[0][1]).toMatchObject({ target: 'validate' }); expect(impactSpy.mock.calls[0][1]).not.toHaveProperty('name'); expect(impactSpy.mock.calls[0][1]).not.toHaveProperty('symbol'); expect(contextSpy.mock.calls[0][1]).toMatchObject({ name: 'validate' }); expect(contextSpy.mock.calls[0][1]).not.toHaveProperty('file_path'); expect(contextSpy.mock.calls[0][1]).not.toHaveProperty('file'); }); 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('folds CLI-style depth onto maxDepth before impact (#3261)', async () => { const impactSpy = vi .spyOn(backend as any, 'impact') .mockResolvedValue({ status: 'normalized' }); await backend.callTool('impact', { target: 'validate', direction: 'upstream', depth: 2, }); expect(impactSpy.mock.calls[0][1]).toMatchObject({ target: 'validate', maxDepth: 2 }); expect(impactSpy.mock.calls[0][1]).not.toHaveProperty('depth'); }); it('treats depth 0 as omitted when maxDepth is present (#2279)', async () => { const impactSpy = vi .spyOn(backend as any, 'impact') .mockResolvedValue({ status: 'normalized' }); await backend.callTool('impact', { target: 'validate', direction: 'upstream', maxDepth: 2, depth: 0, }); expect(impactSpy.mock.calls[0][1]).toMatchObject({ target: 'validate', maxDepth: 2 }); expect(impactSpy.mock.calls[0][1]).not.toHaveProperty('depth'); }); it.each([ ['impact', { target: 'validate', name: 'login', direction: 'upstream' }], ['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }], ['impact', { target: 'validate', direction: 'upstream', maxDepth: 3, depth: 1 }], ['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, 'selectToolRepository'); const result = await backend.callTool(method, params); expect(result.error).toMatch(/conflicting mcp parameters/i); expect(resolveSpy).not.toHaveBeenCalled(); }); it.each([ ['impact', { name: 42, direction: 'upstream' }], ['context', { name: 'validate', file: null }], ])('rejects invalid %s aliases before repository resolution', async (method, params) => { const resolveSpy = vi.spyOn(backend, 'selectToolRepository'); const result = await backend.callTool(method, params); expect(result.error).toMatch(/non-empty string/i); expect(resolveSpy).not.toHaveBeenCalled(); }); it('still rejects a blank required impact target before repository resolution', async () => { const resolveSpy = vi.spyOn(backend, 'selectToolRepository'); const result = await backend.callTool('impact', { target: '', name: ' ', direction: 'upstream', }); expect(result.error).toMatch(/requires target, name, symbol, or target_uid/i); expect(resolveSpy).not.toHaveBeenCalled(); }); it('rejects a missing impact target before repository resolution', async () => { const resolveSpy = vi.spyOn(backend, 'selectToolRepository'); 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('reports UNKNOWN instead of a blast radius when the target resolves without a node id (#3354)', async () => { // Every query returns the same id-less row: the resolver picks it as the // single match, and the frontier query would answer for no symbol at all. (executeParameterized as any).mockResolvedValue([{ name: 'runSweep', type: 'Function' }]); const result = await backend.callTool('impact', { target: 'runSweep', direction: 'upstream' }); expect(result).toMatchObject({ target: { name: 'runSweep' }, impactedCount: null, risk: 'UNKNOWN', }); expect(result.error).toMatch(/without a node id/); expect(result).not.toHaveProperty('byDepthCounts'); }); it.each([[' '], ['']])( 'treats a blank target_uid %j as omitted and resolves the name (#3354)', async (targetUid) => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('impact', { target: 'validate', target_uid: targetUid, direction: 'upstream', }); // Name resolution ran (no rows → not found by NAME), not a lookup of the blank uid. expect(result.error).toBe("Target 'validate' not found"); const boundParams = (executeParameterized as any).mock.calls.map((c: unknown[]) => c[2]); expect(boundParams).not.toContainEqual(expect.objectContaining({ uid: expect.anything() })); expect(boundParams).toContainEqual(expect.objectContaining({ symName: 'validate' })); }, ); it('treats a non-string impact target_uid as omitted instead of throwing (#3354)', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('impact', { target: 'validate', target_uid: 42, direction: 'upstream', }); expect(result.error).toBe("Target 'validate' not found"); const boundParams = (executeParameterized as any).mock.calls.map((c: unknown[]) => c[2]); expect(boundParams).not.toContainEqual(expect.objectContaining({ uid: expect.anything() })); }); it.each([[42], [true], [{ id: 'Function:src/auth.ts:validate' }], [['x']]])( 'treats a non-string context uid %j as omitted instead of throwing (#3354)', async (uid) => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('context', { name: 'validate', uid }); expect(result).toEqual({ error: "Symbol 'validate' not found" }); const boundParams = (executeParameterized as any).mock.calls.map((c: unknown[]) => c[2]); expect(boundParams).not.toContainEqual(expect.objectContaining({ uid: expect.anything() })); }, ); it('names the unresolved trace source, not a blank from_uid (#3354)', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('trace', { from: 'missingSource', from_uid: ' ', to: 'validate', }); expect(result).toMatchObject({ status: 'not_found', error: "Source symbol 'missingSource' not found.", }); }); it('names the unresolved trace target, not a blank to_uid (#3354)', async () => { const sourceRow = { id: 'Function:src/a.ts:start', name: 'start', type: 'Function', filePath: 'src/a.ts', startLine: 1, endLine: 2, }; (executeParameterized as any).mockImplementation( async (_path: string, _query: string, bound: Record | undefined) => bound?.uid === sourceRow.id ? [sourceRow] : [], ); const result = await backend.callTool('trace', { from_uid: sourceRow.id, to: 'missingTarget', to_uid: ' ', }); (executeParameterized as any).mockReset(); (executeParameterized as any).mockResolvedValue([]); expect(result).toMatchObject({ status: 'not_found', error: "Target symbol 'missingTarget' not found.", }); }); 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'"); // Package-identity edges are IMPORTS metadata for incremental // invalidation. They must be excluded inside this same group, before // LIMIT, or they fill the 100000-row cap on a cycle-free graph. expect(reasonNullAlternativeOf(query)).toContain( `r.reason <> '${DART_PACKAGE_IDENTITY_REASON}'`, ); 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('redacts a space-containing vendor path from the MCP query warning', async () => { const { extensionManager, resetExtensionState } = await import('../../src/core/lbug/extension-loader.js'); const spaced = '/tmp/fts vendor/lbug-fts/prebuilds/linux-x64/libfts.lbug_extension'; await extensionManager.ensure( vi .fn() .mockRejectedValue(new Error(`Failed to load library '${spaced}': invalid ELF header`)), 'fts', 'FTS', { policy: 'load-only', vendorRoot: '/tmp/empty-vendor-root' }, ); const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ results: [], ftsAvailable: false }); (executeParameterized as any).mockResolvedValue([]); try { const result = await backend.callTool('query', { query: 'ProcessActivity' }); expect(result).toHaveProperty('warning'); expect(String((result as { warning?: string }).warning)).toContain('invalid ELF header'); expect(String((result as { warning?: string }).warning)).not.toMatch( /fts vendor|\/tmp\/|C:\\Users\\/, ); } finally { resetExtensionState(); } }); 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; 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 content availability with a missing symbol', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('context', { name: 'doesNotExist', include_content: true, }); expect(result.error).toContain('not found'); expect(result.contentAvailability).toEqual({ requested: true, profile: 'full', available: true, scope: 'full', }); }); it('context tool returns content availability with ambiguous 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', include_content: true }); expect(result.status).toBe('ambiguous'); expect(result.candidates).toHaveLength(2); expect(result.contentAvailability).toEqual({ requested: true, profile: 'full', available: true, scope: 'full', }); // #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): Promise => { (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]>)( '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 }> => (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, })); 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 OR \(n\.id STARTS WITH \$filePrefix AND \(n\.filePath = \$symName OR n\.filePath ENDS WITH \$suffix\)\)\) AND n\.id STARTS WITH \$kindPrefix/; const calls = resolverCalls(); expect(calls).toHaveLength(2); expect(calls.filter((c) => parenthesised.test(c.query))).toHaveLength(2); }); it('exact File path wins over suffixed matches during qualified resolution (#3084 review P2)', async () => { (executeParameterized as any).mockImplementation(async (_repo: string, query: string) => { if (query.startsWith('MATCH (n)')) { return [ { id: 'File:src/lib/a.ts', name: 'a.ts', filePath: 'src/lib/a.ts', kind: 'File', total_hits: 1, }, { id: 'File:lib/a.ts', name: 'a.ts', filePath: 'lib/a.ts', kind: 'File', total_hits: 1, }, ]; } return [{ total: 2 }]; }); const result = await backend.callTool('context', { name: 'lib/a.ts' }); expect(result).toMatchObject({ status: 'found', symbol: { filePath: 'lib/a.ts', uid: 'File:lib/a.ts', }, }); }); it('not_found impact queries return impactedCount null and risk UNKNOWN across modes (#3074 / #3084 review)', async () => { (executeParameterized as any).mockImplementation(async () => []); const cgResult = await backend.callTool('impact', { target: 'nonexistent_target_xyz' }); expect(cgResult).toMatchObject({ error: "Target 'nonexistent_target_xyz' not found", impactedCount: null, risk: 'UNKNOWN', }); const pdgResult = await backend.callTool('impact', { target: 'nonexistent_target_xyz', mode: 'pdg', }); expect(pdgResult).toMatchObject({ error: "Target 'nonexistent_target_xyz' not found", impactedCount: null, risk: 'UNKNOWN', pdgResultVersion: 3, }); }); 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 => { (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 => { 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] >; 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'], runtimeConfirmed: true, runtimeSource: 'spring-actuator', runtimeStatus: 'runtime-confirmed', 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('runtimeEvidence', { confirmed: true, source: 'spring-actuator', status: 'runtime-confirmed', }); 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, runtimeConfirmed: false, runtimeSource: null, runtimeStatus: null, 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[0].runtimeEvidence).toEqual({ confirmed: false }); 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('route_map falls back for legacy Route schemas and reports unconfirmed evidence', async () => { vi.mocked(executeParameterized) .mockRejectedValueOnce( new Error('Binder exception: Cannot find property runtimeConfirmed for n.'), ) .mockResolvedValueOnce([verbRow('GET', '/legacy', 'LegacyController.java')]); const result = await backend.callTool('route_map', { route: '/legacy' }); // Modern Route query, legacy retry, then the linked-flow batch. expect(executeParameterized).toHaveBeenCalledTimes(3); expect(result.routes[0].runtimeEvidence).toEqual({ confirmed: false }); }); 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'); expect(result.routes[0].runtimeEvidence).toEqual({ confirmed: false }); }); // 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 " … 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).toBeNull(); 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).toBeNull(); 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 when cwd is outside every indexed path', async () => { const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project-sibling'); try { setupMultipleRepos(); await backend.init(); await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow( 'Multiple repositories indexed', ); } finally { cwdSpy.mockRestore(); } }); it('defaults to the deepest indexed repo containing cwd (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-outer-')); const nestedDir = path.join(outerDir, 'packages', 'nested'); const cwdDir = path.join(nestedDir, 'src'); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir); (listRegisteredRepos as any).mockResolvedValue([ { ...MOCK_REPO_ENTRY, name: 'outer', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }, { ...MOCK_REPO_ENTRY, name: 'nested', path: nestedDir, storagePath: path.join(nestedDir, '.gitnexus'), }, ]); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); const resolved = await backend.selectToolRepository(undefined, undefined, { allowCwdDefault: true, }); expect(resolved.repoPath).toBe(nestedDir); const explicit = await backend.resolveRepo('outer'); expect(explicit.repoPath).toBe(outerDir); } finally { cwdSpy.mockRestore(); } }); it('refreshes before accepting a cached cwd ancestor (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-stale-outer-')); const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-stale-other-')); const nestedDir = path.join(outerDir, 'vendor', 'nested'); const cwdDir = path.join(nestedDir, 'src'); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir, otherDir); const outerEntry = { ...MOCK_REPO_ENTRY, name: 'outer', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }; const nestedEntry = { ...MOCK_REPO_ENTRY, name: 'nested', path: nestedDir, storagePath: path.join(nestedDir, '.gitnexus'), }; const otherEntry = { ...MOCK_REPO_ENTRY, name: 'other', path: otherDir, storagePath: path.join(otherDir, '.gitnexus'), }; (listRegisteredRepos as any) .mockResolvedValueOnce([outerEntry, otherEntry]) .mockResolvedValue([outerEntry, nestedEntry, otherEntry]); (getGitRoot as any).mockImplementation((value: string) => { const resolved = path.resolve(value); if (resolved === nestedDir || resolved.startsWith(`${nestedDir}${path.sep}`)) { return nestedDir; } if (resolved === outerDir || resolved.startsWith(`${outerDir}${path.sep}`)) { return outerDir; } if (resolved === otherDir || resolved.startsWith(`${otherDir}${path.sep}`)) { return otherDir; } return null; }); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); const resolved = await backend.selectToolRepository(undefined, undefined, { allowCwdDefault: true, }); expect(resolved.repoPath).toBe(nestedDir); expect(listRegisteredRepos).toHaveBeenCalledTimes(2); } finally { cwdSpy.mockRestore(); } }); it('refreshes a cached singleton before repo-less read dispatch (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-singleton-outer-')); const nestedDir = path.join(outerDir, 'packages', 'nested'); const cwdDir = path.join(nestedDir, 'src'); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir); const outerEntry = { ...MOCK_REPO_ENTRY, name: 'outer', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }; const nestedEntry = { ...MOCK_REPO_ENTRY, name: 'nested', path: nestedDir, storagePath: path.join(nestedDir, '.gitnexus'), }; (listRegisteredRepos as any) .mockResolvedValueOnce([outerEntry]) .mockResolvedValue([outerEntry, nestedEntry]); (getGitRoot as any).mockReturnValue(outerDir); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); (executeParameterized as any).mockResolvedValue([]); await backend.callTool('cypher', { statement: 'MATCH (n) RETURN n LIMIT 1' }); expect((executeParameterized as any).mock.calls.at(-1)?.[0]).toBe( path.join(nestedDir, '.gitnexus', 'lbug'), ); expect(listRegisteredRepos).toHaveBeenCalledTimes(2); } finally { cwdSpy.mockRestore(); } }); it('refreshes a cached singleton before enforcing repo-less rename safety (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rename-outer-')); const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rename-other-')); const cwdDir = path.join(outerDir, 'src'); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir, otherDir); const outerEntry = { ...MOCK_REPO_ENTRY, name: 'outer', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }; const otherEntry = { ...MOCK_REPO_ENTRY, name: 'other', path: otherDir, storagePath: path.join(otherDir, '.gitnexus'), }; (listRegisteredRepos as any) .mockResolvedValueOnce([outerEntry]) .mockResolvedValue([outerEntry, otherEntry]); (getGitRoot as any).mockReturnValue(outerDir); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); await expect( backend.callTool('rename', { symbol_name: 'oldName', new_name: 'newName', dry_run: false, }), ).rejects.toThrow('Multiple repositories indexed'); expect(listRegisteredRepos).toHaveBeenCalledTimes(2); } finally { cwdSpy.mockRestore(); } }); it('keeps explicit duplicate aliases on exact git-root disambiguation (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-alias-outer-')); const nestedDir = path.join(outerDir, 'packages', 'nested'); const cwdDir = path.join(nestedDir, 'src'); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir); (listRegisteredRepos as any).mockResolvedValue([ { ...MOCK_REPO_ENTRY, name: 'shared', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }, { ...MOCK_REPO_ENTRY, name: 'shared', path: nestedDir, storagePath: path.join(nestedDir, '.gitnexus'), }, ]); (getGitRoot as any).mockReturnValue(outerDir); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); const resolved = await backend.resolveRepo('shared'); expect(resolved.repoPath).toBe(outerDir); } finally { cwdSpy.mockRestore(); } }); it('does not cross a nested git boundary when git root shelling fails (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rootless-outer-')); const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rootless-other-')); const nestedDir = path.join(outerDir, 'vendor', 'nested'); const cwdDir = path.join(nestedDir, 'src'); mkdirSync(path.join(nestedDir, '.git'), { recursive: true }); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir, otherDir); (listRegisteredRepos as any).mockResolvedValue([ { ...MOCK_REPO_ENTRY, name: 'outer', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }, { ...MOCK_REPO_ENTRY, name: 'other', path: otherDir, storagePath: path.join(otherDir, '.gitnexus'), }, ]); (getGitRoot as any).mockReturnValue(null); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow( 'Multiple repositories indexed', ); } finally { cwdSpy.mockRestore(); } }); it('keeps cwd routing opt-in for direct backend helpers (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-direct-outer-')); const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-direct-other-')); const cwdDir = path.join(outerDir, 'src'); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir, otherDir); (listRegisteredRepos as any).mockResolvedValue([ { ...MOCK_REPO_ENTRY, name: 'outer', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }, { ...MOCK_REPO_ENTRY, name: 'other', path: otherDir, storagePath: path.join(otherDir, '.gitnexus'), }, ]); (getGitRoot as any).mockReturnValue(outerDir); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); await expect(backend.queryProcesses()).rejects.toThrow('Multiple repositories indexed'); } finally { cwdSpy.mockRestore(); } }); it('does not default across an unindexed nested git boundary (#3073)', async () => { const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-git-outer-')); const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-git-other-')); const nestedDir = path.join(outerDir, 'vendor', 'nested'); const cwdDir = path.join(nestedDir, 'src'); mkdirSync(cwdDir, { recursive: true }); duplicateFixtureDirs.push(outerDir, otherDir); (listRegisteredRepos as any).mockResolvedValue([ { ...MOCK_REPO_ENTRY, name: 'outer', path: outerDir, storagePath: path.join(outerDir, '.gitnexus'), }, { ...MOCK_REPO_ENTRY, name: 'other', path: otherDir, storagePath: path.join(otherDir, '.gitnexus'), }, ]); (getGitRoot as any).mockImplementation((value: string) => { const resolved = path.resolve(value); if (resolved === nestedDir || resolved.startsWith(`${nestedDir}${path.sep}`)) { return nestedDir; } if (resolved === outerDir || resolved.startsWith(`${outerDir}${path.sep}`)) { return outerDir; } if (resolved === otherDir || resolved.startsWith(`${otherDir}${path.sep}`)) { return otherDir; } return null; }); const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); try { await backend.init(); await expect( backend.selectToolRepository(undefined, undefined, { allowCwdDefault: true }), ).rejects.toThrow('Multiple repositories indexed'); } finally { cwdSpy.mockRestore(); } }); it('keeps mutating rename explicit with multiple repos (#3073)', async () => { const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project/src'); try { setupMultipleRepos(); await backend.init(); await expect( backend.callTool('rename', { symbol_name: 'oldName', new_name: 'newName', dry_run: true, }), ).rejects.toThrow('Multiple repositories indexed'); } finally { cwdSpy.mockRestore(); } }); 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 = {}; 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); }); // #3256: `unknown` is listing-only (the hot read tools drop it; see // tool-staleness.test.ts), so list_repos is where it must appear. `diverged` // carries its hint and no invented count; `current` carries nothing. it('reports unknown and diverged staleness on the listing (#3256)', async () => { setupSingleRepo(); await backend.init(); const { checkStalenessAsync } = await import('../../src/core/git-staleness.js'); const check = checkStalenessAsync as unknown as ReturnType; try { check.mockResolvedValue({ isStale: false, commitsBehind: 0, status: 'unknown' }); expect((await backend.listRepos())[0].staleness).toEqual({ status: 'unknown' }); check.mockResolvedValue({ isStale: false, commitsBehind: 0, status: 'diverged', hint: 'HEAD moved on', }); expect((await backend.listRepos())[0].staleness).toEqual({ status: 'diverged', hint: 'HEAD moved on', }); check.mockResolvedValue({ isStale: false, commitsBehind: 0, status: 'current' }); expect((await backend.listRepos())[0].staleness).toBeUndefined(); } finally { // The module-level mock is shared; put back the factory's default. check.mockResolvedValue({ isStale: false, commitsBehind: 0 }); } }); }); // ─── 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/ 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'); // #3291: the pin's label, not the flat/primary slot — withToolStaleness // copies handle.branch onto the hot-tool payload. expect(handle.branch).toBe('feature/x'); }); it('a pinned-branch tool result names the pin in staleness.branch (#3291)', async () => { // beforeEach clearAllMocks() drops the module-level git-staleness factory // impl; restore a resolving current so withToolStaleness attaches the ref. const { checkStalenessAsync } = await import('../../src/core/git-staleness.js'); (checkStalenessAsync as any).mockResolvedValue({ isStale: false, commitsBehind: 0, status: 'current', }); vi.spyOn(backend as any, 'impact').mockResolvedValue({ ok: true }); const result = (await backend.callTool('impact', { target: 'doWork', repo: 'multi', branch: 'feature/x', })) as { staleness: { branch?: string; lastCommit?: string } }; expect(result.staleness.branch).toBe('feature/x'); expect(result.staleness.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 ` 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 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 ` // 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// 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, 'selectToolRepository') .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 -> counted gap reported. expect(flatRes).toMatchObject({ staleness: { status: 'behind', commitsBehind: 5, lastCommit: 'FLATSHA' }, }); // Branch index (different lbugPath + lastCommit) is current. Since #3291 the // field rides on every response, so absence can no longer be the proof; what // shows the flat handle's cached entry was NOT reused (the pre-fix // repoPath-keyed bug) is that this one reports its OWN commit and its own // status, with no trace of the flat handle's counted gap. expect(branchRes).toMatchObject({ staleness: { status: 'current', lastCommit: 'BRANCHSHA' }, }); const branchStaleness = (branchRes as { staleness: { commitsBehind?: number } }).staleness; expect(branchStaleness.commitsBehind).toBeUndefined(); }); }); // #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, 'selectToolRepository').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; }; // 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((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((_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(); }); });