fix(search): index description field for FTS so doc comments are keyword-searchable (#2300)

* fix(search): index description column for FTS so doc comments are keyword-searchable

Closes #2299. descriptionExtractor (#2286) populates the `description`
column for every symbol table, but FTS only indexed name+content on 5
tables, so doc-comment keywords (Javadoc/KDoc/godoc/Rust ///) were
invisible to BM25 keyword search.

- Add `description` to the Function/Class/Method/Interface FTS indexes
  (File has no description column, left as name+content).
- Add FTS indexes for the remaining EMBEDDABLE_LABELS symbol tables
  (Struct, Enum, Trait, Impl, Macro, Namespace, Constructor, TypeAlias,
  Typedef, Const, Property, Record, Union, Static, Variable).
- createSearchFTSIndexes now drops-then-creates each index so the schema
  change reaches existing DBs on incremental re-analyze and --repair-fts
  (createFTSIndex is idempotent-by-name and would otherwise skip stale
  indexes).

Tests: fts-schema column-subset + coverage guards; drop-before-create
order; e2e doc-comment keyword search (Java class + Rust struct found by
description-only terms). bm25-search assertions derive from FTS_INDEXES.

* fix(review): apply autofix feedback

- Guard the --repair-fts path on FTS-extension availability before
  createSearchFTSIndexes drops-then-creates indexes (P1 regression:
  without the gate, an unavailable extension could drop existing indexes
  then fail to recreate them, leaving the DB index-less). Mirrors the
  analyze path's ftsAvailable gate and fails loudly first.
- Add a re-analyze upgrade integration test: seed an old name+content-only
  DB (no Struct index), run the real createSearchFTSIndexes(), and assert
  description keyword search + the previously un-indexed Struct now resolve.
  Proves drop-then-create upgrades a live stale index end-to-end.

* fix(ci): add loadFTSExtension to --repair-fts test mocks

The R3 review fix added a loadFTSExtension availability gate to the
--repair-fts path, but run-analyze-fts-repair.test.ts mocked the lbug
adapter without that export, so both repair tests threw `No
"loadFTSExtension" export`. Add loadFTSExtension to the two mocks
(returning true to preserve their original intent) and add a dedicated
test proving the guard fails loudly — and does NOT drop any index —
when the extension is unavailable.

* test(fts): run fts-description-search in the sequential lbug-db project

It was the only FTS-index-creating integration test left in the parallel
`default` vitest project; every other ftsIndexes-using test (search-core,
search-pool, augmentation, …) runs in the `lbug-db` project, which forces
fileParallelism: false to avoid LadybugDB native mmap file-lock conflicts
in parallel forks (Windows). Add it to the lbug-db include list and the
default exclude list to match the convention and remove the flake risk.

* test(ci): fail loudly when FTS extension is unavailable, never silently skip

FTS-dependent lbug integration suites (search-core, search-pool,
augmentation, fts-description-search, …) self-skip via ctx.skip() when the
LadybugDB FTS extension can't load, emitting only a console.warn while the
job stays green. That means a broken/missing FTS extension in CI would make
these integration tests silently vanish with no signal — false confidence.

withTestLbugDB now honors GITNEXUS_REQUIRE_FTS=1: when set and the extension
is unavailable, setup() throws instead of skipping, so the suite fails
loudly. The CI test jobs (ubuntu coverage + windows/macOS cross-platform)
set the flag; local/offline runs leave it unset and keep skipping
gracefully. (Verified the extension currently loads on all three runners,
so this is a guard against regression, not a behavior change today.)

* test(ci): run fts-description-search on macOS/Windows cross-platform jobs

The new FTS description-search suite was registered in the sequential
lbug-db vitest project (ubuntu/coverage) but absent from LBUG_NATIVE, so
the macOS/Windows platform-sensitive jobs (which run only the explicit
ALL_CROSS_PLATFORM allowlist via run-cross-platform.ts) never executed it.
The GITNEXUS_REQUIRE_FTS=1 hardening on those jobs guarded the old FTS
fixtures but not the new 20-index/description path. Add the suite to
LBUG_NATIVE so the new path is validated cross-platform too.

Refs #2299.

* fix(search): verify FTS indexes cover description, not just queryability

verifySearchFTSIndexes probed each index with QUERY_FTS_INDEX and treated
'queryable' as 'present'. A stale name+content-only index left on a
pre-#2299 DB stays queryable yet silently misses the description column, so
verification would pass green while doc-comment search stayed broken.

Switch to a single CALL SHOW_INDEXES() that exposes property_names per
index, and report an index as missing when it is absent OR does not cover
its configured columns. Return contract (string[] of table.indexName) is
unchanged, so both run-analyze.ts call sites are untouched. The per-index
string interpolation is gone, so the now-dead safeIdentifier helper is
removed.

The real caller of the live function in tests is bm25-search.test.ts (the
repair test mocks verifySearchFTSIndexes wholesale); its two probe-shaped
cases are rewritten to feed SHOW_INDEXES rows and now assert column
coverage, plus an absent-index case.

Refs #2299.

* test(search): assert description search via the public query surface

The #2299 integration suite only exercised the searchFTSFromLbug helper.
Add a third block that drives the public LocalBackend.callTool('query')
path — which resolves the repo via the registry and routes BM25 through the
pool adapter (a different connection context than the core-adapter helper) —
and asserts a description-only keyword returns the seeded class. Reuses the
existing description-only SEED and production FTS_INDEXES; partial-mocks
repo-manager so listRegisteredRepos points at the test DB while
cleanupOldKuzuFiles and the rest stay real.

Refs #2299.

* test(search): make lbug-core-adapter FTS gate honor GITNEXUS_REQUIRE_FTS

lbug-core-adapter.test.ts has its own per-test FTS gate (skipUnlessFtsAvailable)
that called ctx.skip() whenever the extension could not load — bypassing the
GITNEXUS_REQUIRE_FTS=1 hardening that withTestLbugDB already honors. Since this
file is in LBUG_NATIVE it runs on the ubuntu/macOS/windows jobs that all set
GITNEXUS_REQUIRE_FTS=1, so an FTS regression on a runner would have let these
FTS-primitive tests silently vanish from a green run — the exact gap #2299's
test-infra hardening set out to close.

Make the helper mirror withTestLbugDB: when GITNEXUS_REQUIRE_FTS=1 and the
extension is unavailable, throw (hard fail) instead of skipping. Offline/local
runs (no env var) still skip gracefully.

Refs #2299.
This commit is contained in:
Gergő Magyar 2026-06-25 14:21:44 +01:00 • committed by GitHub
parent 51716acb48
commit 82f1e48f32
13 changed files with 499 additions and 55 deletions

View file

@ -11,6 +11,10 @@ jobs:
name: ubuntu / coverage
runs-on: ubuntu-latest
timeout-minutes: 25
# Fail loudly (don't silently skip) if the FTS extension is unavailable, so
# FTS-dependent lbug integration suites are guaranteed to run in CI.
env:
GITNEXUS_REQUIRE_FTS: '1'
steps:
# persist-credentials: false — this job runs tests and uploads a
# test-reports artifact (if: always()). The default-persisted token in
@ -77,6 +81,10 @@ jobs:
os: [windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
# Same guarantee on the platform-sensitive runners: FTS-dependent suites in
# the cross-platform subset must run, not silently skip.
env:
GITNEXUS_REQUIRE_FTS: '1'
steps:
# persist-credentials: false — runs tests only, never pushes (zizmor
# credential-persistence / artipacked audit).

View file

@ -70,6 +70,7 @@ const LBUG_NATIVE = [
'test/integration/local-backend-calltool.test.ts',
'test/integration/search-core.test.ts',
'test/integration/search-pool.test.ts',
'test/integration/fts-description-search.test.ts',
'test/integration/staleness-and-stability.test.ts',
'test/integration/analyze-wal-checkpoint-failure.test.ts',
];

View file

@ -658,6 +658,22 @@ export async function runFullAnalysis(
}
try {
await initLbug(lbugPath);
// Gate on FTS availability BEFORE touching any index. createSearchFTSIndexes
// now DROPs each index before recreating it (so schema changes reach existing
// DBs); if the extension were unavailable, the drops would run and leave the
// DB index-less, only failing at the create step. Fail loudly first — mirrors
// the analyze path's `if (ftsAvailable)` gate below — so an unavailable
// extension never destroys the existing indexes.
const repairFtsAvailable = await loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
if (!repairFtsAvailable) {
throw new Error(
'Cannot repair FTS indexes: the LadybugDB FTS extension is unavailable ' +
'(not pre-installed and could not be installed on this machine). ' +
'Run `gitnexus doctor` to install it, then retry `--repair-fts`.',
);
}
progress('fts', 85, 'Repairing search indexes...');
await createSearchFTSIndexes({
onIndexStart: options.verbose

View file

@ -1,4 +1,4 @@
import { createFTSIndex } from '../lbug/lbug-adapter.js';
import { createFTSIndex, dropFTSIndex } from '../lbug/lbug-adapter.js';
import { FTS_INDEXES } from './fts-schema.js';
export interface CreateSearchFTSIndexesOptions {
@ -11,6 +11,18 @@ export async function createSearchFTSIndexes(
): Promise<void> {
for (const { table, indexName, properties } of FTS_INDEXES) {
options?.onIndexStart?.(table, indexName);
// Drop first so the live `properties` always win. `createFTSIndex` is
// idempotent-by-name (skips when the index already exists), so without the
// drop a schema change — e.g. adding `description` (#2299) — would never
// reach an existing `.lbug` DB on an incremental re-analyze or `--repair-fts`;
// the old name+content index would silently persist. `dropFTSIndex` no-ops
// when the index is absent (first-ever analyze) and clears the per-connection
// memo so the create below actually runs.
// ponytail: this rebuilds every FTS index on every analyze instead of
// skipping when present; FTS build is proportional to symbol-table size and
// runs inside the existing FTS phase. Gate on a stored schema fingerprint if
// this rebuild cost ever shows up in analyze profiles.
await dropFTSIndex(table, indexName);
await createFTSIndex(table, indexName, [...properties]);
options?.onIndexReady?.(table, indexName);
}
@ -19,25 +31,32 @@ export async function createSearchFTSIndexes(
export async function verifySearchFTSIndexes(
executeQuery: (cypher: string) => Promise<unknown[]>,
): Promise<string[]> {
const safeIdentifier = (value: string): string => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
throw new Error(`Invalid FTS identifier: ${value}`);
}
return value;
};
// Read the catalog once and check each configured index both EXISTS and
// covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
// ... catch) is not enough: a stale `name+content`-only index left on a
// pre-#2299 DB stays queryable yet silently misses `description`, so the probe
// would pass while doc-comment search is still broken (#2299). SHOW_INDEXES
// exposes `property_names` (STRING[]) per index, so we assert coverage directly.
const rows = await executeQuery('CALL SHOW_INDEXES() RETURN *');
const propsByIndex = new Map<string, readonly string[]>();
for (const row of rows) {
if (typeof row !== 'object' || row === null) continue;
const record = row as Record<string, unknown>;
const indexName = record.index_name;
const propertyNames = record.property_names;
if (typeof indexName !== 'string' || !Array.isArray(propertyNames)) continue;
propsByIndex.set(
indexName,
propertyNames.filter((p): p is string => typeof p === 'string'),
);
}
const missing: string[] = [];
for (const { table, indexName } of FTS_INDEXES) {
const safeTable = safeIdentifier(table);
const safeIndex = safeIdentifier(indexName);
const probe = `
CALL QUERY_FTS_INDEX('${safeTable}', '${safeIndex}', '__gitnexus_fts_probe__', conjunctive := false)
RETURN score
LIMIT 1
`;
try {
await executeQuery(probe);
} catch {
for (const { table, indexName, properties } of FTS_INDEXES) {
const actual = propsByIndex.get(indexName);
// Absent from the catalog, or present but not covering every expected column.
if (!actual || !properties.every((p) => actual.includes(p))) {
missing.push(`${table}.${indexName}`);
}
}

View file

@ -4,10 +4,44 @@ export interface FTSIndexDefinition {
readonly properties: readonly string[];
}
// Shared by both index creation (`createSearchFTSIndexes`) and querying
// (`searchFTSFromLbug` / `verifySearchFTSIndexes`) — the single source of truth
// for which tables/columns are full-text searchable. Adding `description` here
// makes doc comments (Javadoc/KDoc/JSDoc/Doxygen/godoc/RDoc) keyword-searchable
// once they are populated by `descriptionExtractor` (#2270/#2286, issue #2299).
//
// IMPORTANT: every property must be a real column on its table (see
// `core/lbug/schema.ts`). `File` has no `description` column, so it stays
// name+content. All other entries below carry a `description` column.
//
// Tables beyond the original 5 mirror `EMBEDDABLE_LABELS` (embeddings/types.ts):
// indexing the same set keeps a symbol's doc comment both keyword- and
// semantically-searchable.
const FTS_PROPERTIES = ['name', 'content', 'description'] as const;
export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
// File has no `description` column — keep it name+content only.
{ table: 'File', indexName: 'file_fts', properties: ['name', 'content'] },
{ table: 'Function', indexName: 'function_fts', properties: ['name', 'content'] },
{ table: 'Class', indexName: 'class_fts', properties: ['name', 'content'] },
{ table: 'Method', indexName: 'method_fts', properties: ['name', 'content'] },
{ table: 'Interface', indexName: 'interface_fts', properties: ['name', 'content'] },
// Original 5 (minus File) gain `description`.
{ table: 'Function', indexName: 'function_fts', properties: FTS_PROPERTIES },
{ table: 'Class', indexName: 'class_fts', properties: FTS_PROPERTIES },
{ table: 'Method', indexName: 'method_fts', properties: FTS_PROPERTIES },
{ table: 'Interface', indexName: 'interface_fts', properties: FTS_PROPERTIES },
// Remaining EMBEDDABLE_LABELS symbol tables — all CODE_ELEMENT_BASE-shaped
// (or a superset), so all carry name + content + description columns.
{ table: 'Constructor', indexName: 'constructor_fts', properties: FTS_PROPERTIES },
{ table: 'Struct', indexName: 'struct_fts', properties: FTS_PROPERTIES },
{ table: 'Enum', indexName: 'enum_fts', properties: FTS_PROPERTIES },
{ table: 'Trait', indexName: 'trait_fts', properties: FTS_PROPERTIES },
{ table: 'Impl', indexName: 'impl_fts', properties: FTS_PROPERTIES },
{ table: 'Macro', indexName: 'macro_fts', properties: FTS_PROPERTIES },
{ table: 'Namespace', indexName: 'namespace_fts', properties: FTS_PROPERTIES },
{ table: 'TypeAlias', indexName: 'type_alias_fts', properties: FTS_PROPERTIES },
{ table: 'Typedef', indexName: 'typedef_fts', properties: FTS_PROPERTIES },
{ table: 'Const', indexName: 'const_fts', properties: FTS_PROPERTIES },
{ table: 'Property', indexName: 'property_fts', properties: FTS_PROPERTIES },
{ table: 'Record', indexName: 'record_fts', properties: FTS_PROPERTIES },
{ table: 'Union', indexName: 'union_fts', properties: FTS_PROPERTIES },
{ table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
];

View file

@ -74,12 +74,15 @@ export function withTestLbugDB(
// init on Windows CI regularly exceeds 30s due to native resource setup.
const timeout = options?.timeout ?? 120_000;
// Suites that seed FTS indexes need the optional FTS extension. It is not
// guaranteed on every machine (e.g. the macOS platform-sensitive CI runner,
// where it is neither pre-installed nor installable). Track availability so
// setup can skip FTS seeding instead of throwing, and so every test in the
// suite is skipped rather than failing against a missing index. (PR #1161.)
// Suites that seed FTS indexes need the optional FTS extension. On a dev
// machine it may be neither pre-installed nor installable (offline), so we
// track availability and skip the suite rather than fail against a missing
// index (PR #1161). In CI this graceful skip is dangerous: an FTS-dependent
// integration suite would silently vanish while the job stays green. When
// GITNEXUS_REQUIRE_FTS=1 (set by the CI test jobs), an unavailable extension
// is a HARD FAILURE so these tests can never silently stop protecting.
const ftsRequired = !!options?.ftsIndexes?.length;
const ftsMustBeAvailable = process.env.GITNEXUS_REQUIRE_FTS === '1';
let ftsAvailable = true;
let ftsSkipWarned = false;
@ -97,11 +100,21 @@ export function withTestLbugDB(
// 1b. Probe the FTS extension for suites that need it, mirroring the
// analyze write path (`auto`: LOAD-first, then one bounded INSTALL).
// When it still cannot load, the suite is skipped (see beforeEach)
// and FTS seeding below is bypassed so setup never throws.
// and FTS seeding below is bypassed so setup never throws — UNLESS
// GITNEXUS_REQUIRE_FTS=1, in which case CI fails loudly instead of
// letting an FTS-dependent integration suite silently disappear.
if (ftsRequired) {
ftsAvailable = await adapter.loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
if (!ftsAvailable && ftsMustBeAvailable) {
throw new Error(
`[withTestLbugDB(${prefix})] FTS extension is required (GITNEXUS_REQUIRE_FTS=1) ` +
'but could not be loaded or installed. FTS-dependent integration tests must not ' +
'be silently skipped in CI — install/repair the LadybugDB FTS extension ' +
'(see `gitnexus doctor`) or unset GITNEXUS_REQUIRE_FTS for offline/local runs.',
);
}
}
// 2. Drop stale FTS indexes from previous test file

View file

@ -0,0 +1,162 @@
/**
* Integration test for issue #2299: doc-comment text stored in the `description`
* column must be reachable via keyword (BM25/FTS) search.
*
* Drives the *production* `FTS_INDEXES` through the test harness (not a bespoke
* fixture list), so removing `description` from a symbol table's index — or
* dropping a table from FTS coverage — fails this test. Seed rows place the
* searched keywords ONLY in `description` (never in `name`/`content`) to prove
* the description column is what matches.
*/
import { describe, it, expect, vi } from 'vitest';
import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js';
import { searchFTSFromLbug } from '../../src/core/search/bm25-index.js';
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
// The public query surface (LocalBackend.callTool('query')) resolves the repo
// via the registry and routes BM25 through the pool adapter, so the third block
// below needs listRegisteredRepos mocked to point at the test DB. Inert for the
// two core-adapter blocks — they call searchFTSFromLbug directly (no registry).
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
};
});
const SEED = [
// Java class: Javadoc keywords live in `description`, NOT in name/content.
`CREATE (n:Class {id: 'class:RetryScheduler', name: 'RetryScheduler', filePath: 'src/RetryScheduler.java', startLine: 5, endLine: 40, isExported: true, content: 'public class RetryScheduler schedules retries', description: 'Implements the circuit breaker pattern for distributed service mesh fault tolerance, isolating failing downstream dependencies to prevent cascade failures'})`,
// Rust struct: a table with NO FTS index before this change. Keywords live in
// `description` only — proves the new-table coverage half of the fix.
`CREATE (n:Struct {id: 'struct:LruShard', name: 'LruShard', filePath: 'src/cache.rs', startLine: 1, endLine: 20, content: 'struct LruShard holds entries', description: 'least recently used eviction policy for a bounded capacity cache'})`,
];
const PRODUCTION_FTS_INDEXES = FTS_INDEXES.map((i) => ({
table: i.table,
indexName: i.indexName,
columns: [...i.properties],
}));
withTestLbugDB(
'fts-description-search',
() => {
describe('description column is keyword-searchable (#2299)', () => {
it('finds a class by Javadoc keywords present only in description', async () => {
const { results } = await searchFTSFromLbug('circuit breaker fault tolerance', 20);
expect(results.map((r) => r.filePath)).toContain('src/RetryScheduler.java');
});
it('still finds the same class by name (no regression)', async () => {
const { results } = await searchFTSFromLbug('RetryScheduler', 20);
expect(results.map((r) => r.filePath)).toContain('src/RetryScheduler.java');
});
it('finds a Struct (previously un-indexed table) by its doc-comment keywords', async () => {
const { results } = await searchFTSFromLbug('least recently used eviction', 20);
expect(results.map((r) => r.filePath)).toContain('src/cache.rs');
});
});
},
{
seed: SEED,
ftsIndexes: PRODUCTION_FTS_INDEXES,
},
);
// Second scenario: simulate a pre-#2299 database — FTS indexes built with the old
// name+content-only schema, and no Struct index at all — then run the real
// createSearchFTSIndexes() exactly as an incremental re-analyze / --repair-fts
// would. This proves the drop-then-create behavior actually UPGRADES a live stale
// index (the whole reason U2 exists): the old code's idempotent-by-name create
// would skip the existing class_fts and description search would stay broken.
const OLD_SCHEMA_FTS_INDEXES = [
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
{ table: 'Class', indexName: 'class_fts', columns: ['name', 'content'] },
// Struct intentionally omitted — pre-#2299 had no FTS index for it.
];
withTestLbugDB(
'fts-description-reindex-upgrade',
() => {
describe('re-analyze upgrades stale FTS indexes so description becomes searchable (#2299)', () => {
it('finds a class by description keywords after re-indexing an old name+content-only DB', async () => {
const { results } = await searchFTSFromLbug('circuit breaker fault tolerance', 20);
expect(results.map((r) => r.filePath)).toContain('src/RetryScheduler.java');
});
it('makes a previously un-indexed table (Struct) searchable after re-indexing', async () => {
const { results } = await searchFTSFromLbug('least recently used eviction', 20);
expect(results.map((r) => r.filePath)).toContain('src/cache.rs');
});
});
},
{
seed: SEED,
ftsIndexes: OLD_SCHEMA_FTS_INDEXES,
afterSetup: async () => {
// Real production index build over the now-stale DB — drops then recreates.
const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
await createSearchFTSIndexes();
},
},
);
// Third scenario: prove the description-only keyword is reachable through the
// PUBLIC query surface (LocalBackend.callTool('query')), not just the
// searchFTSFromLbug helper the blocks above exercise. callTool resolves the repo
// via the registry and routes BM25 through the pool adapter — a different
// connection context than the core-adapter helper — so this block needs
// poolAdapter + a listRegisteredRepos mock + a LocalBackend built in afterSetup.
// Reuses the same description-only SEED and production FTS_INDEXES.
withTestLbugDB(
'fts-description-search-public-query',
(handle) => {
describe('public query surface returns description-only matches (#2299)', () => {
it('finds a class by description keywords via callTool("query")', async () => {
const ext = handle as IndexedDBHandle & { _backend?: LocalBackend };
expect(ext._backend).toBeDefined();
const backend = ext._backend!;
type QuerySymbol = { id: string };
type QueryResult = {
error?: unknown;
definitions?: QuerySymbol[];
process_symbols?: QuerySymbol[];
};
const result: QueryResult = await backend.callTool('query', {
query: 'circuit breaker fault tolerance',
});
expect(result.error).toBeUndefined();
const ids = [...(result.process_symbols ?? []), ...(result.definitions ?? [])].map(
(s) => s.id,
);
expect(ids).toContain('class:RetryScheduler');
});
});
},
{
seed: SEED,
ftsIndexes: PRODUCTION_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as IndexedDBHandle & { _backend?: LocalBackend })._backend = backend;
},
},
);

View file

@ -37,10 +37,25 @@ const FTS_UNAVAILABLE_NOTE =
/**
* Dynamically skip an FTS-primitive test when the extension cannot load.
* `ctx.skip()` aborts the test, so callers should `await` this first thing.
*
* Honors GITNEXUS_REQUIRE_FTS=1 the same way `withTestLbugDB` does (see
* test/helpers/test-indexed-db.ts): when CI sets it, an unavailable extension is
* a HARD FAILURE, never a silent skip — otherwise these FTS-primitive tests
* (this file is in LBUG_NATIVE, so it runs on the ubuntu/macOS/windows jobs that
* all set GITNEXUS_REQUIRE_FTS=1) could vanish from a green run. Offline/local
* runs (no env var) still skip gracefully (#2299).
*/
const skipUnlessFtsAvailable = async (ctx: { skip: (note?: string) => void }): Promise<void> => {
const { loadFTSExtension } = await import('../../src/core/lbug/lbug-adapter.js');
if (!(await loadFTSExtension())) ctx.skip(FTS_UNAVAILABLE_NOTE);
if (await loadFTSExtension()) return;
if (process.env.GITNEXUS_REQUIRE_FTS === '1') {
throw new Error(
'FTS extension is required (GITNEXUS_REQUIRE_FTS=1) but could not be loaded or installed. ' +
'FTS-dependent tests must not be silently skipped in CI — install/repair the LadybugDB ' +
'FTS extension (see `gitnexus doctor`) or unset GITNEXUS_REQUIRE_FTS for offline/local runs.',
);
}
ctx.skip(FTS_UNAVAILABLE_NOTE);
};
// ─── Core LadybugDB Adapter ─────────────────────────────────────────────

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { searchFTSFromLbug, type BM25SearchResult } from '../../src/core/search/bm25-index.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')>();
@ -7,6 +8,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => {
...actual,
queryFTS: vi.fn().mockResolvedValue([]),
createFTSIndex: vi.fn().mockResolvedValue(undefined),
dropFTSIndex: vi.fn().mockResolvedValue(undefined),
};
});
@ -26,45 +28,63 @@ describe('BM25 search', () => {
vi.clearAllMocks();
});
it('creates the configured indexes on the writable analysis path', async () => {
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([
['File', 'file_fts', ['name', 'content']],
['Function', 'function_fts', ['name', 'content']],
['Class', 'class_fts', ['name', 'content']],
['Method', 'method_fts', ['name', 'content']],
['Interface', 'interface_fts', ['name', 'content']],
]);
expect(vi.mocked(createFTSIndex).mock.calls).toEqual(
FTS_INDEXES.map((i) => [i.table, i.indexName, [...i.properties]]),
);
});
it('verifies all configured FTS indexes are queryable', async () => {
const executeQuery = vi.fn().mockResolvedValue([]);
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) => ({
index_name: i.indexName,
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(5);
expect(executeQuery).toHaveBeenCalledTimes(1);
});
it('reports missing indexes when an FTS probe fails', async () => {
const executeQuery = vi
.fn()
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new Error('index does not exist'))
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
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) => ({
index_name: i.indexName,
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) => ({
index_name: i.indexName,
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', () => {
@ -289,13 +309,9 @@ describe('BM25 search', () => {
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([
'File',
'Function',
'Class',
'Method',
'Interface',
]);
expect(queryCalls.map((c) => String(c[1]).match(/QUERY_FTS_INDEX\('([^']+)'/)?.[1])).toEqual(
FTS_INDEXES.map((i) => i.table),
);
});
});
});

View file

@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
// Hoisted so the vi.mock factory can close over the shared call log.
const { calls } = vi.hoisted(() => ({ calls: [] as string[] }));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
dropFTSIndex: vi.fn(async (table: string, indexName: string) => {
calls.push(`drop:${table}.${indexName}`);
}),
createFTSIndex: vi.fn(async (table: string, indexName: string) => {
calls.push(`create:${table}.${indexName}`);
}),
}));
const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
const { FTS_INDEXES } = await import('../../src/core/search/fts-schema.js');
afterEach(() => {
calls.length = 0;
vi.clearAllMocks();
});
describe('createSearchFTSIndexes', () => {
it('drops each index before (re)creating it, in order, for every entry', async () => {
await createSearchFTSIndexes();
const expected = FTS_INDEXES.flatMap((i) => [
`drop:${i.table}.${i.indexName}`,
`create:${i.table}.${i.indexName}`,
]);
expect(calls).toEqual(expected);
});
it('invokes onIndexStart/onIndexReady once per index', async () => {
const started: string[] = [];
const ready: string[] = [];
await createSearchFTSIndexes({
onIndexStart: (_t, name) => started.push(name),
onIndexReady: (_t, name) => ready.push(name),
});
const expectedNames = FTS_INDEXES.map((i) => i.indexName);
expect(started).toEqual(expectedNames);
expect(ready).toEqual(expectedNames);
});
});

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
import { NODE_SCHEMA_QUERIES } from '../../src/core/lbug/schema.js';
import { EMBEDDABLE_LABELS } from '../../src/core/embeddings/types.js';
/**
* Parse a `CREATE NODE TABLE` DDL string into its table name and declared
* column names. Kept `if`-free so it never introduces a vacuous assertion path.
*/
const parseTableColumns = (ddl: string): { table: string; columns: string[] } => {
const table = ddl.match(/CREATE NODE TABLE\s+`?([A-Za-z_][A-Za-z0-9_]*)`?/)?.[1] ?? '';
const body = ddl.slice(ddl.indexOf('(') + 1, ddl.lastIndexOf(')'));
const columns = body
.split('\n')
.map((line) => line.trim())
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+[A-Za-z]/)?.[1] ?? '')
.filter((col) => col.length > 0 && col !== 'PRIMARY');
return { table, columns };
};
const COLUMNS_BY_TABLE = new Map<string, Set<string>>(
NODE_SCHEMA_QUERIES.map(parseTableColumns).map(({ table, columns }) => [table, new Set(columns)]),
);
describe('FTS_INDEXES', () => {
it('keeps File at name+content only (File has no description column)', () => {
const file = FTS_INDEXES.find((i) => i.table === 'File');
expect(file).toMatchObject({ properties: ['name', 'content'] });
});
it('indexes description for the four original symbol tables', () => {
const tables = ['Function', 'Class', 'Method', 'Interface'];
const withoutDescription = tables.filter(
(t) => !FTS_INDEXES.find((i) => i.table === t)?.properties.includes('description'),
);
expect(withoutDescription).toEqual([]);
});
it('only indexes columns that actually exist on each table', () => {
const violations = FTS_INDEXES.flatMap((index) => {
const columns = COLUMNS_BY_TABLE.get(index.table) ?? new Set<string>();
return index.properties
.filter((prop) => !columns.has(prop))
.map((prop) => `${index.table}.${prop}`);
});
expect(violations).toEqual([]);
});
it('covers every embeddable symbol table', () => {
const indexed = new Set(FTS_INDEXES.map((i) => i.table));
const missing = EMBEDDABLE_LABELS.filter((label) => !indexed.has(label));
expect(missing).toEqual([]);
});
it('uses unique, valid index names', () => {
const names = FTS_INDEXES.map((i) => i.indexName);
expect(new Set(names).size).toBe(names.length);
const invalid = names.filter((n) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(n));
expect(invalid).toEqual([]);
});
});

View file

@ -117,6 +117,8 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
deleteNodesForFile: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
// Repair path now gates on FTS availability before drop-then-create.
loadFTSExtension: vi.fn(async () => true),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
createSearchFTSIndexes: vi.fn(async () => undefined),
@ -164,6 +166,8 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
deleteNodesForFile: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
// Extension loads; the throw under test comes from index creation itself.
loadFTSExtension: vi.fn(async () => true),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
createSearchFTSIndexes: vi.fn(async () => {
@ -200,6 +204,55 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
}
});
it('fails repair mode loudly WITHOUT dropping indexes when the FTS extension is unavailable', async () => {
// Regression guard (#2299): createSearchFTSIndexes now drops each index
// before recreating it. If the extension is unavailable, the repair path must
// bail before any drop runs — otherwise it would destroy the existing indexes
// and then fail to recreate them, leaving the DB worse off.
const createSearchFTSIndexes = vi.fn(async () => undefined);
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
initLbug: vi.fn(async () => undefined),
loadGraphToLbug: vi.fn(async () => undefined),
getLbugStats: vi.fn(async () => ({})),
executeQuery: vi.fn(async () => []),
executeWithReusedStatement: vi.fn(async () => []),
closeLbug: vi.fn(async () => undefined),
loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })),
deleteNodesForFile: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
// Extension cannot load — the guard must fail BEFORE any index is touched.
loadFTSExtension: vi.fn(async () => false),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
createSearchFTSIndexes,
verifySearchFTSIndexes: vi.fn(async () => []),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-fts-unavailable-');
try {
const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
await fs.mkdir(storagePath, { recursive: true });
await saveMeta(storagePath, {
repoPath: tmpRepo.dbPath,
lastCommit: '',
indexedAt: new Date().toISOString(),
stats: {},
});
await createPlaceholderGraphStore(lbugPath);
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await expect(
runFullAnalysis(tmpRepo.dbPath, { repairFts: true }, { onProgress: () => {} }),
).rejects.toThrow(/FTS extension is unavailable[\s\S]*gitnexus doctor/i);
// The guard fires before drop-then-create, so no index is dropped.
expect(createSearchFTSIndexes).not.toHaveBeenCalled();
} finally {
await tmpRepo.cleanup();
}
});
it('fails full analyze when FTS verification reports missing indexes after creation', async () => {
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
initLbug: vi.fn(async () => undefined),

View file

@ -57,6 +57,7 @@ export default defineConfig({
'test/integration/local-backend-calltool.test.ts',
'test/integration/search-core.test.ts',
'test/integration/search-pool.test.ts',
'test/integration/fts-description-search.test.ts',
'test/integration/augmentation.test.ts',
'test/integration/staleness-and-stability.test.ts',
'test/integration/lbug-lock-retry.test.ts',
@ -91,6 +92,7 @@ export default defineConfig({
'test/integration/local-backend-calltool.test.ts',
'test/integration/search-core.test.ts',
'test/integration/search-pool.test.ts',
'test/integration/fts-description-search.test.ts',
'test/integration/augmentation.test.ts',
'test/integration/staleness-and-stability.test.ts',
'test/integration/lbug-lock-retry.test.ts',