mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
fix(search): report partially missing FTS indexes in query results (#3448)
This commit is contained in:
parent
f99dde8aa3
commit
668fac7635
9 changed files with 418 additions and 38 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
12
gitnexus/test/fixtures/local-backend-seed.ts
vendored
12
gitnexus/test/fixtures/local-backend-seed.ts
vendored
|
|
@ -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] }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<any[]>) {
|
||||
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 <path>'),
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <path>');
|
||||
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');
|
||||
});
|
||||
|
|
|
|||
114
gitnexus/test/unit/query-fts-missing-index.test.ts
Normal file
114
gitnexus/test/unit/query-fts-missing-index.test.ts
Normal file
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue