GitNexus/gitnexus/test/unit/bm25-search.test.ts
Gergő Magyar cae60c2bd4
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(mcp): reject corrupt query identities and refresh index metadata (#3571)
* fix(mcp): reject corrupt query identities and refresh index metadata

* fix(mcp): preserve reader and search integrity

Keep replaced readers unknown until matching metadata is published.
Defer shared-handle refresh until other consumers release the old graph
to avoid rebuilding connections against the same stale generation.

Propagate identity corruption after both hybrid search lanes settle,
so the sibling cannot query after its database session is released.
Select the regressions for the OS matrix and retain Python coverage
without optional Swift grammars.

Validation: 393 focused tests, nine native generation tests and ten
race executions across five fresh processes pass. Build and source/test
typechecks pass. The early full run passed 26,096 tests and failed eight:
seven reproduce on baseline; the mixed-source publication case passes
in clean reruns. Windows/macOS native execution remains for CI.

Graph analysis maps all edited production symbols; coverage remains
partial for the anonymous FTS test callback, manually reviewed.

Refs #3571

* fix(lbug): bump @ladybugdb/core to 0.21.2 to stop doubled rows after delete and re-copy

LadybugDB 0.21.0 and 0.21.1 return every row of a table that spans more
than one native vector twice once the incremental writeback has DETACH
DELETEd it and a COPY into an earlier table has run. NodeGroup::scan let a
morsel that ended before the group's first live row wrap an unsigned
subtraction and rescan to the end of the group (LadybugDB #1085, fixed in
0.21.2). No primary key error fires. The identity guard from #3442 stops
the run with "unexpected or duplicate ID", so a Swift edit that pulls every
importer into the write set failed on repos with more than 2048 structs.
Only unreleased main was affected: 1.6.12 ships 0.18.3.

Pin 0.21.2 in package.json, update the six @ladybugdb/core entries in the
lockfile, and move coreVersion in the vendored FTS manifest. The FTS
extension stays at 0.21.0 because no v0.21.1 or v0.21.2 artifact is
published; the pairing assertion and its unit tests pass.

Add a regression to the delete-nodes-for-files suite: 2049 structs with
CONTAINS edges are loaded, every file is deleted, the graph is loaded
again, and each id and edge must appear exactly once.

Verification (Linux, Node 22.18):
- lbug-delete-nodes-for-files: 11/11 on 0.21.2. With the 0.21.1 native
  packages swapped in, the new test fails with 4098 rows instead of 2049.
- Reporter recipe end to end on the built CLI: incremental analyze exits 0
  with no duplicate ids on a Swift-heavy fixture and on a 70-file Python
  fixture, with and without FTS. On 0.21.1 the Swift fixture exits 1.
- A 0.21.1-written database opens and reads under 0.21.2.
- prettier, eslint and typecheck:tests are clean on the changed files.
- The full lbug-db project had not finished when this was committed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(fts): say what the Windows warm-session branch covers

On Windows the repair step calls poolClose, which removes the pool entry
before the in-place writer opens. The step therefore checks the backend's
cached capability state, not a live pool connection. The comment now says so
instead of implying the pool stays warm. No behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* revert: take the @ladybugdb/core 0.21.2 bump out of this PR

Reverts 0cdc3dfe3. The bump fixes LadybugDB 0.21.0 and 0.21.1 returning every
row of a large table twice after a delete and re-copy, but on Windows it
crashes the vendored FTS extension, which is still the v0.21.0 build: four of
eight Windows shards failed with exit code 3221225477 (0xC0000005) in tests
that load libfts and build an FTS index. All eight passed on 9d79092ab with
core 0.21.1. A Windows extension is only published for v0.21.0, not for
v0.21.1 or v0.21.2.

The bump moves to its own draft PR (#3578) until a matching Windows
extension exists. This PR returns to its original query-integrity and
index-freshness scope.

Restores gitnexus/package.json, the six @ladybugdb/core entries in
package-lock.json, the coreVersion in vendor/lbug-fts/manifest.json, and
removes the regression test that belongs with the bump. All four files are
identical to origin/main (1b4e3f0a8) after this commit.

Verification:
- git diff origin/main for the four files is empty.
- fts-artifact-coverage and vendored-extension-path unit tests pass (31),
  and assert-publish-fts-coverage reports core 0.21.1 paired with extension
  0.21.0.
- The graph check on the staged revert was partial, because it only deletes
  test code. The forward change was gated clean: 4 files, 6 symbols, all in
  that test, no affected processes.
- Windows CI has not run on this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:06:26 +01:00

774 lines
32 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { searchFTSFromLbug, type BM25SearchResult } from '../../src/core/search/bm25-index.js';
import { classifyFtsQueryError } from '../../src/core/lbug/lbug-adapter.js';
import { extensionManager, resetExtensionState } from '../../src/core/lbug/extension-loader.js';
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/lbug/lbug-adapter.js')>();
return {
...actual,
queryFTS: vi.fn().mockResolvedValue([]),
createFTSIndex: vi.fn().mockResolvedValue(undefined),
dropFTSIndex: vi.fn().mockResolvedValue(undefined),
};
});
// Pool adapter is dynamically imported by the MCP-pool path of
// `searchFTSFromLbug`. We mock it so we can drive the executor without
// spinning up a real LadybugDB pool.
const mockExecuteParameterized = vi.fn();
vi.mock('../../src/core/lbug/pool-adapter.js', () => ({
executeParameterized: (repoId: string, cypher: string, params: Record<string, any>) =>
mockExecuteParameterized(repoId, cypher, params),
addPoolCloseListener: vi.fn(),
}));
describe('BM25 search', () => {
describe('createSearchFTSIndexes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates every configured index on the writable analysis path', async () => {
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
await createSearchFTSIndexes();
expect(vi.mocked(createFTSIndex).mock.calls).toEqual(
FTS_INDEXES.map((i) => [i.table, i.indexName, [...i.properties], 'porter']),
);
});
it('returns no missing indexes when every configured index covers its columns', async () => {
// One SHOW_INDEXES call returns a catalog row per configured index, each
// covering exactly its expected properties.
const showIndexesRows = FTS_INDEXES.map((i) => ({
table_name: i.table,
index_name: i.indexName,
index_type: 'FTS',
property_names: [...i.properties],
}));
const executeQuery = vi.fn().mockResolvedValue(showIndexesRows);
const { verifySearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
const missing = await verifySearchFTSIndexes(executeQuery);
expect(missing).toEqual([]);
expect(executeQuery).toHaveBeenCalledTimes(1);
});
it('reports an index that exists but does not cover its configured columns', async () => {
// Model a pre-#2299 stale Function index: present, but name+content only,
// missing `description`. Every other index covers its columns.
const staleIndex = 'function_fts';
const showIndexesRows = FTS_INDEXES.map((i) => ({
table_name: i.table,
index_name: i.indexName,
index_type: 'FTS',
property_names: i.indexName === staleIndex ? ['name', 'content'] : [...i.properties],
}));
const executeQuery = vi.fn().mockResolvedValue(showIndexesRows);
const { verifySearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
const missing = await verifySearchFTSIndexes(executeQuery);
expect(missing).toEqual(['Function.function_fts']);
});
it('reports an index that is absent from the catalog entirely', async () => {
// Every configured index present and covering, except const_fts is missing.
const absentIndex = 'const_fts';
const showIndexesRows = FTS_INDEXES.filter((i) => i.indexName !== absentIndex).map((i) => ({
table_name: i.table,
index_name: i.indexName,
index_type: 'FTS',
property_names: [...i.properties],
}));
const executeQuery = vi.fn().mockResolvedValue(showIndexesRows);
const { verifySearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
const missing = await verifySearchFTSIndexes(executeQuery);
expect(missing).toEqual(['Const.const_fts']);
});
});
describe('searchFTSFromLbug', () => {
it('returns empty results when LadybugDB is not initialized', async () => {
// Simulate an uninitialized DB: queryFTS throws instead of returning rows
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockRejectedValue(new Error('DB not initialized'));
const { results, ftsAvailable } = await searchFTSFromLbug('test query');
expect(Array.isArray(results)).toBe(true);
expect(results).toHaveLength(0);
expect(ftsAvailable).toBe(false);
});
it('handles empty query', async () => {
const { results } = await searchFTSFromLbug('');
expect(Array.isArray(results)).toBe(true);
});
it('accepts custom limit parameter', async () => {
const { results } = await searchFTSFromLbug('test', 5);
expect(Array.isArray(results)).toBe(true);
});
});
describe('BM25SearchResult type', () => {
it('has correct shape', () => {
const result: BM25SearchResult = {
filePath: 'src/index.ts',
score: 1.5,
rank: 1,
};
expect(result.filePath).toBe('src/index.ts');
expect(result.score).toBe(1.5);
expect(result.rank).toBe(1);
});
it('accepts optional nodeIds field', () => {
const result: BM25SearchResult = {
filePath: 'src/index.ts',
score: 1.5,
rank: 1,
nodeIds: ['func:id1', 'func:id2'],
};
expect(result.nodeIds).toEqual(['func:id1', 'func:id2']);
});
});
describe('score aggregation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('sums only top-3 scoring nodes per file when more than 3 match', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
// File table: empty; Function table: 5 hits for the same file; rest: empty
vi.mocked(queryFTS)
.mockResolvedValueOnce([]) // File
.mockResolvedValueOnce([
// Function — 5 hits, scores 10/9/8/7/6
{ filePath: 'src/views.py', score: 10, nodeId: 'func:node1', name: 'get_queryset' },
{ filePath: 'src/views.py', score: 9, nodeId: 'func:node2', name: 'post' },
{ filePath: 'src/views.py', score: 8, nodeId: 'func:node3', name: 'delete' },
{ filePath: 'src/views.py', score: 7, nodeId: 'func:node4', name: 'patch' },
{ filePath: 'src/views.py', score: 6, nodeId: 'func:node5', name: 'put' },
])
.mockResolvedValueOnce([]) // Class
.mockResolvedValueOnce([]) // Method
.mockResolvedValueOnce([]); // Interface
const { results } = await searchFTSFromLbug('queryset');
expect(results).toHaveLength(1);
expect(results[0].filePath).toBe('src/views.py');
// Only top-3 scores (10+9+8=27), not naive sum of all 5 (10+9+8+7+6=40)
expect(results[0].score).toBe(27);
expect(results[0].nodeIds).toEqual(['func:node1', 'func:node2', 'func:node3']);
});
it('propagates nodeIds for files with fewer than 3 matching nodes', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS)
.mockResolvedValueOnce([]) // File
.mockResolvedValueOnce([
// Function — 2 hits
{ filePath: 'src/models.py', score: 5, nodeId: 'func:m1', name: 'save' },
{ filePath: 'src/models.py', score: 3, nodeId: 'func:m2', name: 'delete' },
])
.mockResolvedValueOnce([]) // Class
.mockResolvedValueOnce([]) // Method
.mockResolvedValueOnce([]); // Interface
const { results } = await searchFTSFromLbug('model');
expect(results).toHaveLength(1);
expect(results[0].score).toBe(8); // 5+3
expect(results[0].nodeIds).toEqual(['func:m1', 'func:m2']);
});
it('rejects empty native nodeIds instead of hiding them in file-only results', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS)
.mockResolvedValueOnce([]) // File
.mockResolvedValueOnce([
// Function — nodes with no id
{ filePath: 'src/utils.py', score: 5, nodeId: '', name: 'helper' },
{ filePath: 'src/utils.py', score: 3, nodeId: '', name: 'util' },
])
.mockResolvedValueOnce([]) // Class
.mockResolvedValueOnce([]) // Method
.mockResolvedValueOnce([]); // Interface
await expect(searchFTSFromLbug('util')).rejects.toThrow('invalid symbol identity');
});
it('merges hits across multiple index tables for the same file', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS)
.mockResolvedValueOnce([
// File table
{ filePath: 'src/auth.py', score: 4, nodeId: 'file:auth', name: 'auth.py' },
])
.mockResolvedValueOnce([
// Function table
{ filePath: 'src/auth.py', score: 9, nodeId: 'func:login', name: 'login' },
])
.mockResolvedValueOnce([
// Class table
{ filePath: 'src/auth.py', score: 7, nodeId: 'cls:User', name: 'User' },
])
.mockResolvedValueOnce([]) // Method
.mockResolvedValueOnce([]); // Interface
const { results } = await searchFTSFromLbug('auth');
expect(results).toHaveLength(1);
// All 3 hits (scores 9+7+4=20) — each from a different table, all top-3
expect(results[0].score).toBe(20);
expect(results[0].nodeIds).toEqual(['func:login', 'cls:User', 'file:auth']);
});
it('ranks files by aggregated score descending', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS)
.mockResolvedValueOnce([]) // File
.mockResolvedValueOnce([
// Function — hits across two files
{ filePath: 'src/low.py', score: 2, nodeId: 'func:a', name: 'a' },
{ filePath: 'src/high.py', score: 9, nodeId: 'func:b', name: 'b' },
])
.mockResolvedValueOnce([]) // Class
.mockResolvedValueOnce([]) // Method
.mockResolvedValueOnce([]); // Interface
const { results } = await searchFTSFromLbug('fn');
expect(results[0].filePath).toBe('src/high.py');
expect(results[1].filePath).toBe('src/low.py');
expect(results[0].rank).toBe(1);
expect(results[1].rank).toBe(2);
});
});
describe('MCP pool path', () => {
const REPO = 'test-repo-readonly-fts';
beforeEach(() => {
mockExecuteParameterized.mockReset();
});
it('queries existing FTS indexes without issuing CREATE_FTS_INDEX', async () => {
mockExecuteParameterized.mockImplementation(
async (_repo: string, cypher: string, params: Record<string, any>) => {
if (cypher.includes('CREATE_FTS_INDEX')) {
throw new Error('query path must stay read-only');
}
if (params.query === 'login' && cypher.includes("QUERY_FTS_INDEX('Function'")) {
return [{ node: { filePath: 'src/auth.ts', id: 'func:login' }, score: 8 }];
}
return [];
},
);
const { results } = await searchFTSFromLbug('login', 5, REPO);
expect(results).toEqual([
{ filePath: 'src/auth.ts', score: 8, rank: 1, nodeIds: ['func:login'] },
]);
expect(
mockExecuteParameterized.mock.calls.some((c) => String(c[1]).includes('CREATE_FTS_INDEX')),
).toBe(false);
});
it('binds FTS user query text as a parameter in pool mode', async () => {
mockExecuteParameterized.mockResolvedValue([]);
const userQuery = "BrowserWindow create delete set remove 'main' window";
await searchFTSFromLbug(userQuery, 5, REPO);
expect(mockExecuteParameterized).toHaveBeenCalled();
for (const call of mockExecuteParameterized.mock.calls) {
const cypher = String(call[1]);
expect(cypher).toContain('$query');
expect(cypher).not.toContain(userQuery);
expect(cypher.toUpperCase()).not.toMatch(/\bCREATE\b/);
expect(cypher.toUpperCase()).not.toMatch(/\bDELETE\b/);
expect(cypher.toUpperCase()).not.toMatch(/\bSET\b/);
expect(cypher.toUpperCase()).not.toMatch(/\bREMOVE\b/);
expect(call[2]).toEqual({ query: userQuery });
}
});
it('uses the configured FTS query set on every call', async () => {
mockExecuteParameterized.mockResolvedValue([]);
await searchFTSFromLbug('anything', 5, REPO);
const queryCalls = mockExecuteParameterized.mock.calls.filter((c) =>
String(c[1]).includes('QUERY_FTS_INDEX'),
);
expect(queryCalls.map((c) => String(c[1]).match(/QUERY_FTS_INDEX\('([^']+)'/)?.[1])).toEqual(
FTS_INDEXES.map((i) => i.table),
);
});
});
describe('classifyFtsQueryError (#2767)', () => {
it('classifies the real "doesn\'t have an index" message (confirmed against a live QUERY_FTS_INDEX call) as missing-index', () => {
expect(
classifyFtsQueryError(
"Prepare failed: Binder exception: Table File doesn't have an index with name file_fts.",
),
).toBe('missing-index');
});
it('classifies the real "table does not exist" message (confirmed against a live QUERY_FTS_INDEX call on a nonexistent table) as missing-table, distinct from missing-index (tri-review NEW-6)', () => {
// Empirically confirmed: the table-missing message uses "does not
// exist", NOT "doesn't have an index with name" — a genuinely different
// phrasing from missing-index, not the same condition under two names.
// Conflating them was the exact bug: a corrupted/partial DB (table
// itself gone) would have been silently treated as the ordinary
// "index not built yet" case.
expect(
classifyFtsQueryError(
'Prepare failed: Binder exception: Table TotallyNonexistentTable does not exist.',
),
).toBe('missing-table');
});
it('classifies a Catalog-exception "does not exist" message as missing-table too (both exception classes covered)', () => {
expect(classifyFtsQueryError('Catalog exception: Table SomeTable does not exist.')).toBe(
'missing-table',
);
});
it('classifies the extension-unavailable Catalog exception as other, not benign (mirrors the confirmed DROP_FTS_INDEX shape for QUERY_FTS_INDEX)', () => {
// Message shape confirmed for DROP_FTS_INDEX in
// drop-fts-index-error-classification.test.ts; QUERY_FTS_INDEX would
// fail identically when the extension isn't loaded (same catalog).
expect(
classifyFtsQueryError(
"Catalog exception: function QUERY_FTS_INDEX is not defined. This function exists in the FTS extension. You can install and load the extension by running 'INSTALL FTS; LOAD EXTENSION FTS;'.",
),
).toBe('other');
});
it('does not misclassify a real, differently-classed error that echoes the benign phrase in its body', () => {
// Adversarial case: a Runtime exception (not Binder/Catalog) that
// happens to echo the user's own search text — which could itself
// contain "does not exist" — must not be anchored away as benign.
expect(
classifyFtsQueryError(
'Runtime exception: FTS query syntax error near "the config file does not exist here"',
),
).toBe('other');
});
it('does not misclassify a real Binder-class error unrelated to a missing FTS index', () => {
expect(classifyFtsQueryError('Binder exception: column X does not match expected type')).toBe(
'other',
);
});
it('classifies any other message as other', () => {
expect(classifyFtsQueryError('Query execution timed out after 30000ms')).toBe('other');
expect(classifyFtsQueryError('Connection pool exhausted')).toBe('other');
});
});
describe('MCP pool path — real vs benign FTS query errors (#2767)', () => {
const REPO = 'test-repo-error-classification';
beforeEach(() => {
mockExecuteParameterized.mockReset();
});
it('a benign missing-index error on every table leaves nonBenignErrors unset (unchanged behavior)', async () => {
mockExecuteParameterized.mockRejectedValue(
new Error("Binder exception: Table Function doesn't have an index with name function_fts."),
);
const response = await searchFTSFromLbug('login', 5, REPO);
expect(response.ftsAvailable).toBe(false);
expect(response.nonBenignErrors).toBeUndefined();
});
it('a missing-table error (table itself gone, not just its FTS index) surfaces as non-benign — schema drift is not the ordinary degraded state (tri-review NEW-6)', async () => {
mockExecuteParameterized.mockRejectedValue(
new Error('Binder exception: Table Function does not exist.'),
);
const response = await searchFTSFromLbug('login', 5, REPO);
expect(response.ftsAvailable).toBe(false);
expect(response.nonBenignErrors!.length).toBeGreaterThan(0);
});
it('a real error on every table surfaces it in nonBenignErrors, redacted', async () => {
mockExecuteParameterized.mockRejectedValue(
new Error(
'Query execution failed: connection reset at /home/alice/.gitnexus/lbug/main.lbug',
),
);
const response = await searchFTSFromLbug('login', 5, REPO);
expect(response.ftsAvailable).toBe(false);
expect(response.nonBenignErrors).toBeDefined();
expect(response.nonBenignErrors!.length).toBeGreaterThan(0);
expect(response.nonBenignErrors![0]).toContain('connection reset');
expect(response.nonBenignErrors![0]).not.toMatch(/\/home\/alice/);
});
it('a real error on one table while another succeeds is still reported (partial-failure gap closed)', async () => {
let call = 0;
mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => {
call++;
if (cypher.includes("QUERY_FTS_INDEX('Function'")) {
throw new Error('Query execution timed out after 30000ms');
}
if (cypher.includes("QUERY_FTS_INDEX('File'")) {
return [{ node: { filePath: 'src/index.ts', id: 'file:index' }, score: 3 }];
}
return [];
});
const response = await searchFTSFromLbug('login', 5, REPO);
// At least one table succeeded, so the client-visible availability
// signal and result set are unaffected (regression guard).
expect(response.ftsAvailable).toBe(true);
expect(response.results.length).toBeGreaterThan(0);
// But the real error on the OTHER table is not silently dropped.
expect(response.nonBenignErrors).toBeDefined();
expect(response.nonBenignErrors![0]).toContain('timed out');
expect(call).toBe(FTS_INDEXES.length);
});
});
describe('short-circuits when the FTS extension is unavailable (tri-review NEW-4)', () => {
const REPO = 'test-repo-extension-unavailable';
afterEach(() => {
resetExtensionState();
});
it('MCP pool path: skips per-table QUERY_FTS_INDEX calls and reports no nonBenignErrors when the extension failed to load', async () => {
await extensionManager.ensure(
vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
'fts',
'FTS',
{ policy: 'load-only' },
);
mockExecuteParameterized.mockReset();
const response = await searchFTSFromLbug('login', 5, REPO);
// The expected degraded-capability state — not per-table query errors.
expect(response.ftsAvailable).toBe(false);
expect(response.nonBenignErrors).toBeUndefined();
// No redundant round-trips to a pool that can't have FTS loaded.
expect(mockExecuteParameterized).not.toHaveBeenCalled();
});
it('CLI/pipeline path (no repoId): also skips per-table calls and reports no nonBenignErrors — same expected state, same silence (fixes the pool-only guard a /simplify altitude pass caught)', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
await extensionManager.ensure(
vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
'fts',
'FTS',
{ policy: 'load-only' },
);
vi.mocked(queryFTS).mockClear();
const response = await searchFTSFromLbug('login', 5); // no repoId → CLI/pipeline branch
expect(response.ftsAvailable).toBe(false);
expect(response.nonBenignErrors).toBeUndefined();
expect(vi.mocked(queryFTS)).not.toHaveBeenCalled();
});
});
describe('GITNEXUS_FTS_CJK_SEGMENTATION query-side transform (#2331)', () => {
const CJK_REPO = 'test-repo-cjk-query';
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('leaves the query unchanged by default (mode: none)', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
await searchFTSFromLbug('审批流程');
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
expect(call[2]).toBe('审批流程');
}
});
it('bigram-segments the query before it reaches queryFTS when enabled', async () => {
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
await searchFTSFromLbug('审批流程');
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
expect(call[2]).toBe('审批 批流 流程');
}
});
it('bigram-segments the query in pool mode too, still bound via $query', async () => {
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
mockExecuteParameterized.mockResolvedValue([]);
await searchFTSFromLbug('审批流程', 5, CJK_REPO);
expect(mockExecuteParameterized).toHaveBeenCalled();
for (const call of mockExecuteParameterized.mock.calls) {
expect(String(call[1])).toContain('$query');
expect(String(call[1])).not.toContain('审批流程');
expect(call[2]).toEqual({ query: '审批 批流 流程' });
}
});
it('skips segmentation for a pathologically long query, searching it unchanged', async () => {
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
const longQuery = '审批流程'.repeat(1000); // well past the 2000-char cap
await searchFTSFromLbug(longQuery);
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
expect(call[2]).toBe(longQuery);
}
});
it('segments a query at exactly the 2000-character cap', async () => {
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
const atCapQuery = '审'.repeat(2000);
await searchFTSFromLbug(atCapQuery);
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
expect(call[2]).not.toBe(atCapQuery); // segmented, not passed through raw
expect(call[2]).toContain(' ');
}
});
it('does not segment a query at exactly 2001 characters, one past the cap', async () => {
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
const overCapQuery = '审'.repeat(2001);
await searchFTSFromLbug(overCapQuery);
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
expect(call[2]).toBe(overCapQuery); // passed through raw, unsegmented
}
});
});
// #2339: the query path previously never called normalizeFtsText (only
// applyCjkSegmentationIfEnabled), unlike the write path which always
// composes both — a literal tab/newline in a query wouldn't match
// whitespace-normalized indexed text.
describe('normalizeFtsText query-side composition (#2339)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('collapses a literal tab in the query to a space (mode: none)', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
await searchFTSFromLbug('审批\t流程');
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
expect(call[2]).toBe('审批 流程');
}
});
it('composes segmentation THEN normalization, matching the write path order (mode: bigram)', async () => {
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
await searchFTSFromLbug('审批流程\t自动');
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
// "审批流程" bigram-segments to "审批 批流 流程"; the tab (untouched
// by segmentCjkSpans, since neither run's boundary needs an extra
// space next to an already-whitespace neighbor) is then collapsed
// to a space by normalizeFtsText, keeping "自动" a separate token.
expect(call[2]).toBe('审批 批流 流程 自动');
}
});
it('applies normalization regardless of the 2000-char segmentation cap', async () => {
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockResolvedValue([]);
const longQueryWithTab = '审'.repeat(2001) + '\t' + '批';
await searchFTSFromLbug(longQueryWithTab);
expect(vi.mocked(queryFTS).mock.calls.length).toBeGreaterThan(0);
for (const call of vi.mocked(queryFTS).mock.calls) {
// Segmentation is skipped (over the cap), but normalizeFtsText still
// runs unconditionally — no per-character cost concern there.
expect(call[2]).toBe('审'.repeat(2001) + ' ' + '批');
}
});
});
});
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();
});
}
});