diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index e99f81aa4..534989792 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -35,7 +35,10 @@ import { type ExactEmbeddingRow, } from '../../core/embeddings/exact-search.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js'; -import { getExactScanLimit } from '../../core/platform/capabilities.js'; +import { + getExactScanLimit, + isVectorExtensionSupportedByPlatform, +} from '../../core/platform/capabilities.js'; import { PhaseTimer } from '../../core/search/phase-timer.js'; import { checkStaleness, checkCwdMatch } from '../../core/git-staleness.js'; // AI context generation is CLI-only (gitnexus analyze) @@ -223,6 +226,14 @@ export class LocalBackend { */ private warnedSiblingDrift: Set = new Set(); + /** + * One-shot stderr warning for the VECTOR-extension fallback. Without this + * guard the diagnostic would fire on every `semanticSearch()` call on + * platforms where the extension is unsupported (e.g. Windows), making MCP + * stderr noisy per DoD §2.8. + */ + private warnedVectorUnsupported = false; + /** * Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers. */ @@ -1071,9 +1082,10 @@ export class LocalBackend { string, { distance: number; chunkIndex: number; startLine: number; endLine: number } >(); - try { - bestChunks = await collectBestChunks(limit, async (fetchLimit) => { - const vectorQuery = ` + if (isVectorExtensionSupportedByPlatform()) { + try { + bestChunks = await collectBestChunks(limit, async (fetchLimit) => { + const vectorQuery = ` CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit}) YIELD node AS emb, distance @@ -1084,17 +1096,28 @@ export class LocalBackend { ORDER BY distance `; - const embResults = await executeQuery(repo.id, vectorQuery); - return embResults.map((row) => ({ - nodeId: row.nodeId ?? row[0], - chunkIndex: row.chunkIndex ?? row[1] ?? 0, - startLine: row.startLine ?? row[2] ?? 0, - endLine: row.endLine ?? row[3] ?? 0, - distance: row.distance ?? row[4], - })); - }); - } catch { - bestChunks = new Map(); + const embResults = await executeQuery(repo.id, vectorQuery); + return embResults.map((row) => ({ + nodeId: row.nodeId ?? row[0], + chunkIndex: row.chunkIndex ?? row[1] ?? 0, + startLine: row.startLine ?? row[2] ?? 0, + endLine: row.endLine ?? row[3] ?? 0, + distance: row.distance ?? row[4], + })); + }); + } catch { + bestChunks = new Map(); + } + } else if (!this.warnedVectorUnsupported) { + // Rare diagnostic: surface why we fell back to the exact scan path so + // operators can see at a glance that the VECTOR extension is missing on + // this runtime (e.g. Windows builds without the optional native + // dependency). Emitted once per `LocalBackend` instance lifetime to + // avoid noisy stderr on hot semantic-search paths (DoD §2.8). + this.warnedVectorUnsupported = true; + console.error( + 'GitNexus [query:vector]: VECTOR index unavailable for this runtime; using exact scan fallback', + ); } if (bestChunks.size === 0) { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index d57bd9051..2299f90a3 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // 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(() => ({ +const { lbugMocks, platformMocks } = vi.hoisted(() => ({ lbugMocks: { initLbug: vi.fn().mockResolvedValue(undefined), executeQuery: vi.fn().mockResolvedValue([]), @@ -21,6 +21,9 @@ const { lbugMocks } = vi.hoisted(() => ({ closeLbug: vi.fn().mockResolvedValue(undefined), isLbugReady: vi.fn().mockReturnValue(true), }, + platformMocks: { + isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true), + }, })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { @@ -48,6 +51,14 @@ vi.mock('../../src/core/git-staleness.js', () => ({ checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), })); +vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform, + }; +}); + // Also mock the search modules to avoid loading onnxruntime vi.mock('../../src/core/search/bm25-index.js', () => ({ searchFTSFromLbug: vi.fn().mockResolvedValue([]), @@ -157,6 +168,7 @@ describe('LocalBackend.callTool', () => { beforeEach(async () => { vi.clearAllMocks(); + platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true); backend = new LocalBackend(); setupSingleRepo(); await backend.init(); @@ -181,6 +193,38 @@ describe('LocalBackend.callTool', () => { expect(result).toHaveProperty('definitions'); }); + it('skips vector index query when VECTOR is unsupported by the platform', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false); + (executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => { + if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }]; + if (cypher.includes('MATCH (e:CodeEmbedding)')) return []; + return []; + }); + (executeParameterized as any).mockResolvedValue([]); + + try { + await backend.callTool('query', { query: 'auth' }); + + const queries = (executeQuery as any).mock.calls.map( + ([, cypher]: [string, string]) => cypher, + ); + expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(false); + expect( + queries.some( + (cypher: string) => + cypher.includes('RETURN e.nodeId AS nodeId') && + cypher.includes('e.embedding AS embedding'), + ), + ).toBe(true); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('GitNexus [query:vector]: VECTOR index unavailable'), + ); + } finally { + consoleError.mockRestore(); + } + }); + it('query tool returns error for empty query', async () => { const result = await backend.callTool('query', { query: '' }); expect(result.error).toContain('query parameter is required');