diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 59f903261..92adff18a 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -4044,6 +4044,7 @@ export const buildFtsQueryCypher = ( * @param query - Search query string * @param limit - Maximum results * @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR) + * @param missingIndex - Preserve the empty-result default, or propagate missing indexes for diagnostics * @returns Array of { node properties, score } */ export const queryFTS = async ( @@ -4052,6 +4053,7 @@ export const queryFTS = async ( query: string, limit: number = 20, conjunctive: boolean = false, + missingIndex: 'empty' | 'throw' = 'empty', ): Promise< Array<{ nodeId: string; name: string; filePath: string; score: number; [key: string]: any }> > => { @@ -4082,7 +4084,7 @@ export const queryFTS = async ( // NEW-6 — this used to be a bare `.includes('does not exist')` check // that could not tell the two apart). const message = e instanceof Error ? e.message : String(e); - if (classifyFtsQueryError(message) === 'missing-index') { + if (missingIndex === 'empty' && classifyFtsQueryError(message) === 'missing-index') { return []; } throw e; diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index 8aaa279ef..c105a62a3 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -47,6 +47,8 @@ export interface FTSSearchResponse { * which only does so when every table failed). */ nonBenignErrors?: string[]; + /** Configured table.index names that could not be queried because their index is missing. */ + missingIndexes?: string[]; } /** @@ -142,6 +144,7 @@ export const searchFTSFromLbug = async ( const resultsByIndex: any[][] = []; let queriesSucceeded = 0; const nonBenignErrors: string[] = []; + const missingIndexes: string[] = []; const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts'); if (ftsExtension && !ftsExtension.loaded) { @@ -171,24 +174,28 @@ export const searchFTSFromLbug = async ( if (outcome.rows) { queriesSucceeded++; resultsByIndex.push(outcome.rows); - } else if (!outcome.benign) { + } else if (outcome.benign) { + missingIndexes.push(`${table}.${indexName}`); + } else { nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error')); } } } else { // Use core lbug adapter (CLI / pipeline context) — also sequential for safety. - // tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing - // index (via the SAME classifyFtsQueryError this module re-exports); a - // missing-table or real query error rethrows here — track it the same way - // the MCP pool path does instead of a bare `catch {}` that dropped it. + // Opt into missing-index propagation so absent indexes cannot masquerade + // as successful zero-match queries. Keep core/pool classification identical. for (const { table, indexName } of FTS_INDEXES) { try { - const result = await queryFTS(table, indexName, searchQuery, limit, false); + const result = await queryFTS(table, indexName, searchQuery, limit, false, 'throw'); queriesSucceeded++; resultsByIndex.push(result); } catch (e) { const message = e instanceof Error ? e.message : String(e); - nonBenignErrors.push(redactPaths(message)); + if (classifyFtsQueryError(message) === 'missing-index') { + missingIndexes.push(`${table}.${indexName}`); + } else { + nonBenignErrors.push(redactPaths(message)); + } } } } @@ -234,5 +241,6 @@ export const searchFTSFromLbug = async ( })), ftsAvailable, ...(nonBenignErrors.length > 0 && { nonBenignErrors }), + ...(missingIndexes.length > 0 && { missingIndexes }), }; }; diff --git a/gitnexus/src/core/search/fts-indexes.ts b/gitnexus/src/core/search/fts-indexes.ts index 2070fadf3..5f8b59f13 100644 --- a/gitnexus/src/core/search/fts-indexes.ts +++ b/gitnexus/src/core/search/fts-indexes.ts @@ -110,19 +110,21 @@ export const ftsDegradedWarning = ( }; /** - * Warning for when the FTS extension is loaded and indexes exist, but every - * configured table's query failed for a real, non-benign reason (timeout, - * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s - * missing-index case. `--repair-fts` will not fix a query/connection error, - * so this deliberately does NOT suggest it: reusing the missing-index - * message here would reproduce, for this cause, the exact misleading - * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1). + * Warning when no FTS query succeeded and at least one failed for a real, + * non-benign reason (timeout, connection reset, native fault). `--repair-fts` + * will not fix those errors. If indexes are also missing, the caller composes + * their repair guidance separately; do not deny that additional failure cause. */ -export const ftsQueryFailedWarning = (context: FtsWarningContext): string => +export const ftsQueryFailedWarning = ( + context: FtsWarningContext, + hasMissingIndexes = false, +): string => 'FTS keyword search failed — every configured index query returned an error' + (context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') + - '; results do not include keyword matches. This is not a missing-index ' + - 'condition — see server logs for details.' + + '; results do not include keyword matches. ' + + (hasMissingIndexes + ? 'See server logs for query error details.' + : 'This is not a missing-index condition — see server logs for details.') + ` (resolved: ${formatResolvedSuffix(context)})`; // Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5b2f62a9c..cb8546543 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -3172,6 +3172,7 @@ export class LocalBackend { // regardless of whether OTHER tables succeeded — previously a real error // on N-1 of N tables while one succeeded left zero diagnostic trail. const ftsQueryErrors = bm25SearchResult?.nonBenignErrors; + const ftsMissingIndexes = bm25SearchResult?.missingIndexes; if (ftsQueryErrors) { // tri-review NEW-5: these strings are already classified non-benign by // classifyFtsQueryError — do NOT route them through logQueryError, @@ -3661,13 +3662,15 @@ export class LocalBackend { branch: repo.branch, indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt, }; - // tri-review NEW-1: every table failing for a REAL error (timeout, - // connection reset) is not a missing-index condition — `ftsDegradedWarning`'s - // "run --repair-fts" headline won't fix it. Route to a dedicated message - // instead of burying the real cause as a trailing suffix on bad advice. + // Real errors (timeout, connection reset) need their own diagnosis. + // When some indexes are also missing, preserve both causes and append + // their repair guidance below even though no FTS query succeeded. warnings.push( ftsQueryErrors - ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] }) + ? ftsQueryFailedWarning( + { ...warningContext, lastErrorRedacted: ftsQueryErrors[0] }, + !!ftsMissingIndexes?.length, + ) : ftsDegradedWarning(warningContext, ftsDisabledReason), ); } else if (ftsQueryErrors) { @@ -3680,6 +3683,12 @@ export class LocalBackend { `FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`, ); } + if (ftsMissingIndexes?.length && (ftsUsed || ftsQueryErrors)) { + warnings.push( + `FTS keyword search is incomplete: missing configured indexes (${ftsMissingIndexes.join(', ')}). ` + + 'Results may be missing matches from those node types. Run `gitnexus analyze --repair-fts`.', + ); + } // #2331: a CJK query against a server process resolving // GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase // matches with no other signal — this is the only place an agent driving @@ -3811,7 +3820,7 @@ export class LocalBackend { // #2767: a partial FTS failure (some tables ok, one or more real errors) // is as much a "results may be incomplete" signal as enrichmentDegraded — // flag it the same way rather than only via the warning string. - const ftsPartial = ftsUsed && !!ftsQueryErrors; + const ftsPartial = ftsUsed && (!!ftsQueryErrors || !!ftsMissingIndexes?.length); return { processes, @@ -3832,7 +3841,12 @@ export class LocalBackend { query: string, limit: number, disabledReason?: FtsDisabledReason, - ): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> { + ): Promise<{ + results: any[]; + ftsUsed: boolean; + nonBenignErrors?: string[]; + missingIndexes?: string[]; + }> { if (disabledReason) return { results: [], ftsUsed: false }; let searchFTSFromLbug; try { @@ -3866,6 +3880,7 @@ export class LocalBackend { const bm25Results = ftsResponse?.results ?? []; const ftsUsed = ftsResponse?.ftsAvailable ?? false; const nonBenignErrors = ftsResponse?.nonBenignErrors; + const missingIndexes = ftsResponse?.missingIndexes; const results: any[] = []; @@ -3939,7 +3954,12 @@ export class LocalBackend { } } - return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }; + return { + results, + ftsUsed, + ...(nonBenignErrors && { nonBenignErrors }), + ...(missingIndexes && { missingIndexes }), + }; } /** diff --git a/gitnexus/test/fixtures/local-backend-seed.ts b/gitnexus/test/fixtures/local-backend-seed.ts index 4878348b2..1f7fd02c9 100644 --- a/gitnexus/test/fixtures/local-backend-seed.ts +++ b/gitnexus/test/fixtures/local-backend-seed.ts @@ -1,4 +1,5 @@ import type { FTSIndexDef } from '../helpers/test-indexed-db.js'; +import { FTS_INDEXES } from '../../src/core/search/fts-schema.js'; export const LOCAL_BACKEND_SEED_DATA = [ // Files @@ -60,9 +61,8 @@ export const LOCAL_BACKEND_SEED_DATA = [ CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`, ]; -export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = [ - { table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] }, - { table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] }, - { table: 'Method', indexName: 'method_fts', columns: ['name', 'content', 'description'] }, - { table: 'File', indexName: 'file_fts', columns: ['name', 'content'] }, -]; +// Healthy-backend fixtures must mirror the complete configured search index set. +// The old four-table subset is now correctly reported as partial FTS coverage. +export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = FTS_INDEXES.map( + ({ table, indexName, properties }) => ({ table, indexName, columns: [...properties] }), +); diff --git a/gitnexus/test/integration/fts-repair-warm-session.test.ts b/gitnexus/test/integration/fts-repair-warm-session.test.ts index 2550ba874..ac9ce2713 100644 --- a/gitnexus/test/integration/fts-repair-warm-session.test.ts +++ b/gitnexus/test/integration/fts-repair-warm-session.test.ts @@ -32,6 +32,7 @@ const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1'; type QueryResult = { error?: unknown; warning?: string; + partial?: boolean; definitions?: Array<{ id: string }>; process_symbols?: Array<{ id: string }>; }; @@ -40,7 +41,8 @@ const matchedIds = (r: QueryResult): string[] => [...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id); const ftsMissing = (r: QueryResult): boolean => - typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning); + typeof r.warning === 'string' && + /FTS indexes missing|missing configured indexes/i.test(r.warning); /** * Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or @@ -90,14 +92,14 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( await tmpHandle.cleanup(); }); - it( - 'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts', + it.each(['all', 'Function'] as const)( + 'a warm session repairs missing %s indexes without restarting', { timeout: 60_000 }, - async (ctx) => { + async (missing, ctx) => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js'); - // ── Step 1: build the index WITHOUT FTS (analyzed before repair) ──── + // ── Step 1: build an index with all or just one FTS index missing ── await adapter.initLbug(lbugPath); const ftsAvailable = await adapter.loadFTSExtension(undefined, { @@ -118,6 +120,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( await adapter.executeQuery( `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`, ); + if (missing === 'Function') { + await createSearchFTSIndexes(); + await adapter.dropFTSIndex('Function', 'function_fts'); + } await adapter.flushWAL(); await adapter.closeLbug(); @@ -129,7 +135,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( stats: { files: 1, nodes: 1 }, capabilities: { graph: { provider: 'ladybugdb', status: 'available' }, - fts: { provider: 'ladybugdb-fts', status: 'unavailable' }, + fts: { + provider: 'ladybugdb-fts', + status: missing === 'all' ? 'unavailable' : 'available', + }, vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 }, }, }; @@ -142,6 +151,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( const before = await backend.callTool('query', { query: 'login' }); expect(before.error).toBeUndefined(); expect(ftsMissing(before)).toBe(true); + if (missing === 'Function') { + expect(before.partial).toBe(true); + expect(before.warning).toContain('Function.function_fts'); + } // ── Step 3: out-of-band --repair-fts (separate writable session) ──── // Same production functions the repair-fts branch of runFullAnalysis @@ -163,6 +176,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( const after = await waitForFtsRecognized(backend, 'login'); expect(after.error).toBeUndefined(); expect(ftsMissing(after)).toBe(false); + expect(after.partial).toBeUndefined(); expect(matchedIds(after)).toContain('func:login'); }, ); diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts index 793cf7aae..08b2fc46a 100644 --- a/gitnexus/test/unit/bm25-search.test.ts +++ b/gitnexus/test/unit/bm25-search.test.ts @@ -647,3 +647,125 @@ describe('BM25 search', () => { }); }); }); + +describe('FTS index completeness', () => { + beforeEach(async () => { + resetExtensionState(); + mockExecuteParameterized.mockReset(); + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(queryFTS).mockReset(); + }); + + const missing = (table: string, indexName: string) => + new Error(`Binder exception: Table ${table} doesn't have an index with name ${indexName}.`); + + for (const pooled of [false, true]) { + const mode = pooled ? 'pool' : 'core'; + const repo = pooled ? 'completeness-test' : undefined; + + async function useOutcome(outcome: (table: string, indexName: string) => Promise) { + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(queryFTS).mockImplementation((table, indexName) => outcome(table, indexName)); + mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => { + const match = cypher.match(/QUERY_FTS_INDEX\('([^']+)', '([^']+)'/); + return outcome(match![1], match![2]); + }); + } + + it(`${mode}: distinguishes healthy zero matches from missing indexes`, async () => { + await useOutcome(async () => []); + const result = await searchFTSFromLbug('no matches', 5, repo); + expect(result).toEqual({ results: [], ftsAvailable: true }); + }); + + it(`${mode}: reports one missing index while retaining successful matches`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + if (table !== 'File') return []; + return pooled + ? [{ node: { filePath: 'src/auth.ts', id: 'file:auth' }, score: 3 }] + : [{ filePath: 'src/auth.ts', nodeId: 'file:auth', score: 3 }]; + }); + const result = await searchFTSFromLbug('auth', 5, repo); + expect(result.ftsAvailable).toBe(true); + expect(result.results[0]).toMatchObject({ filePath: 'src/auth.ts', score: 3 }); + expect(result.missingIndexes).toEqual(['Function.function_fts']); + expect(result.nonBenignErrors).toBeUndefined(); + }); + + it(`${mode}: reports all missing indexes without claiming FTS is available`, async () => { + await useOutcome(async (table, indexName) => { + throw missing(table, indexName); + }); + const result = await searchFTSFromLbug('auth', 5, repo); + expect(result.ftsAvailable).toBe(false); + expect(result.results).toEqual([]); + if (!pooled) { + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + for (const { table, indexName } of FTS_INDEXES) { + expect(queryFTS).toHaveBeenCalledWith(table, indexName, 'auth', 5, false, 'throw'); + } + } + expect(result.missingIndexes).toEqual( + FTS_INDEXES.map(({ table, indexName }) => `${table}.${indexName}`), + ); + expect(result.nonBenignErrors).toBeUndefined(); + }); + + it(`${mode}: preserves redacted real errors alongside missing-index diagnostics`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + if (table === 'Class') + throw new Error('connection reset at /home/alice/private/index.lbug'); + return []; + }); + const result = await searchFTSFromLbug('auth', 5, repo); + expect(result.ftsAvailable).toBe(true); + expect(result.missingIndexes).toEqual(['Function.function_fts']); + expect(result.nonBenignErrors).toHaveLength(1); + expect(result.nonBenignErrors![0]).toContain('connection reset'); + expect(JSON.stringify(result)).not.toContain('/home/alice'); + }); + + it(`${mode}: retains both failure causes when no configured index query succeeds`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + throw new Error('connection reset at /home/alice/private/index.lbug'); + }); + + const result = await searchFTSFromLbug('auth', 5, repo); + + expect(result.ftsAvailable).toBe(false); + expect(result.results).toEqual([]); + expect(result.missingIndexes).toEqual(['Function.function_fts']); + expect(result.nonBenignErrors).toEqual( + Array(FTS_INDEXES.length - 1).fill('connection reset at '), + ); + }); + + it(`${mode}: clears missing-index diagnostics after a repaired query`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + return []; + }); + expect((await searchFTSFromLbug('auth', 5, repo)).missingIndexes).toEqual([ + 'Function.function_fts', + ]); + await useOutcome(async () => []); + expect(await searchFTSFromLbug('auth', 5, repo)).toEqual({ results: [], ftsAvailable: true }); + }); + + it(`${mode}: explicit opt-out makes no queries and emits no missing-index diagnosis`, async () => { + await useOutcome(async (table, indexName) => { + throw missing(table, indexName); + }); + expect(await searchFTSFromLbug('auth', 5, repo, 'disabled-by-flag')).toEqual({ + results: [], + ftsAvailable: false, + }); + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + expect(queryFTS).not.toHaveBeenCalled(); + expect(mockExecuteParameterized).not.toHaveBeenCalled(); + }); + } +}); diff --git a/gitnexus/test/unit/query-degraded-signal.test.ts b/gitnexus/test/unit/query-degraded-signal.test.ts index a7ed807e8..f4c9e763c 100644 --- a/gitnexus/test/unit/query-degraded-signal.test.ts +++ b/gitnexus/test/unit/query-degraded-signal.test.ts @@ -42,6 +42,7 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { }); import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { resetExtensionState } from '../../src/core/lbug/extension-loader.js'; // A backend whose hybrid search yields exactly one matched symbol, so the // enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized @@ -415,3 +416,100 @@ describe('query: degraded-enrichment signal', () => { } }); }); + +describe('query: partial missing FTS indexes', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadMetaMock.mockResolvedValue(null); + executeParameterizedMock.mockResolvedValue([]); + }); + afterEach(() => vi.unstubAllEnvs()); + + it('returns successful symbols with a repair hint and partial flag when another index is missing', async () => { + const b = makeBackend(true) as any; + const resultWithMissing = await b.backend.bm25Search(); + b.backend.bm25Search.mockResolvedValue({ + ...resultWithMissing, + missingIndexes: ['Function.function_fts'], + }); + + const result = await runQuery(b); + + expect(result.definitions.map((d: any) => d.id)).toContain('func:x'); + expect(result.partial).toBe(true); + expect(result.warning).toContain('Function.function_fts'); + expect(result.warning).toContain('repair-fts'); + }); + + it('composes missing indexes with real FTS errors and enrichment failures', async () => { + const b = makeBackend(true, ['connection reset']) as any; + const resultWithMissing = await b.backend.bm25Search(); + b.backend.bm25Search.mockResolvedValue({ + ...resultWithMissing, + missingIndexes: ['Function.function_fts'], + }); + executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => { + if (cypher.includes('STEP_IN_PROCESS')) throw new Error('timed out'); + return []; + }); + + const result = await runQuery(b); + + expect(result.partial).toBe(true); + expect(result.warning).toContain('Function.function_fts'); + expect(result.warning).toContain('FTS keyword search partially failed'); + expect(result.warning).toContain('enrichment'); + }); +}); + +it('propagates missing-index diagnostics through the real bm25Search helper into query', async () => { + vi.clearAllMocks(); + loadMetaMock.mockResolvedValue(null); + executeParameterizedMock.mockResolvedValue([]); + const search = await import('../../src/core/search/bm25-index.js'); + const spy = vi.spyOn(search, 'searchFTSFromLbug').mockResolvedValue({ + results: [], + ftsAvailable: true, + missingIndexes: ['Function.function_fts'], + }); + try { + const b = makeBackend(true) as any; + b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search; + const result = await runQuery(b); + expect(result.partial).toBe(true); + expect(result.warning).toContain('Function.function_fts'); + } finally { + spy.mockRestore(); + } +}); + +it('reports missing indexes and real errors when every FTS query fails through the search boundary', async () => { + vi.clearAllMocks(); + resetExtensionState(); + loadMetaMock.mockResolvedValue(null); + executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => { + if (cypher.includes("QUERY_FTS_INDEX('Function'")) { + throw new Error( + "Binder exception: Table Function doesn't have an index with name function_fts.", + ); + } + if (cypher.includes('QUERY_FTS_INDEX')) { + throw new Error('connection reset at /home/alice/private/index.lbug'); + } + return []; + }); + const b = makeBackend(false) as any; + b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search; + + const result = await runQuery(b); + + expect(result.definitions).toEqual([]); + expect(result.warning).toContain('FTS keyword search failed'); + expect(result.warning).toContain('connection reset at '); + expect(result.warning).toContain('Function.function_fts'); + expect(result.warning).toContain('gitnexus analyze --repair-fts'); + expect(result.warning).toContain('resolved: repo1'); + expect(result.warning).not.toContain('not a missing-index'); + expect(result.warning).not.toContain('partially failed'); + expect(JSON.stringify(result)).not.toContain('/home/alice'); +}); diff --git a/gitnexus/test/unit/query-fts-missing-index.test.ts b/gitnexus/test/unit/query-fts-missing-index.test.ts new file mode 100644 index 000000000..aa50a50bf --- /dev/null +++ b/gitnexus/test/unit/query-fts-missing-index.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const native = vi.hoisted(() => ({ + error: undefined as string | undefined, + params: [] as unknown[], +})); + +// Control the native statement result, while exercising the real adapter's +// connection lock, prepared-query path and missing-index error handling. +vi.mock('@ladybugdb/core', () => { + const emptyResult = { + getAll: async () => [], + close: async () => {}, + isSuccess: () => true, + getErrorMessage: async () => '', + }; + class Database { + async init() {} + async close() {} + } + class Connection { + async query() { + return emptyResult; + } + async prepare() { + return { + isSuccess: () => native.error === undefined, + getErrorMessage: async () => native.error ?? '', + }; + } + async execute(_statement: unknown, params: unknown) { + native.params.push(params); + return emptyResult; + } + async close() {} + } + const mod = { Database, Connection }; + return { ...mod, default: mod, lbug: mod }; +}); + +import { closeLbug, queryFTS, withLbugDb } from '../../src/core/lbug/lbug-adapter.js'; + +const MISSING = "Binder exception: Table Function doesn't have an index with name function_fts."; + +describe('queryFTS missing-index diagnostics', () => { + let dir: string; + let dbPath: string; + beforeEach(async () => { + native.error = undefined; + native.params = []; + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-test-')); + dbPath = path.join(dir, 'lbug'); + await fs.writeFile(dbPath, ''); + }); + afterEach(async () => { + await closeLbug(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('retains the existing empty-result default for a missing index', async () => { + await withLbugDb( + dbPath, + async () => { + native.error = MISSING; + expect(await queryFTS('Function', 'function_fts', 'auth')).toEqual([]); + }, + { readOnly: true, skipFts: true }, + ); + }); + + it('can propagate a missing index so search does not count it as a successful zero-match query', async () => { + await withLbugDb( + dbPath, + async () => { + native.error = MISSING; + await expect( + queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'), + ).rejects.toThrow(MISSING); + }, + { readOnly: true, skipFts: true }, + ); + }); + + it('preserves prepared query binding and successful empty results in diagnostic mode', async () => { + await withLbugDb( + dbPath, + async () => { + const query = "auth' DELETE n"; + expect(await queryFTS('Function', 'function_fts', query, 5, false, 'throw')).toEqual([]); + expect(native.params).toEqual([{ query }]); + }, + { readOnly: true, skipFts: true }, + ); + }); + + it('still propagates real failures in either missing-index mode', async () => { + await withLbugDb( + dbPath, + async () => { + native.error = 'Runtime exception: connection reset'; + await expect(queryFTS('Function', 'function_fts', 'auth')).rejects.toThrow( + 'connection reset', + ); + await expect( + queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'), + ).rejects.toThrow('connection reset'); + }, + { readOnly: true, skipFts: true }, + ); + }); +});