From 4f520ea18dbbc89e01d33a85ff33c619c74cca04 Mon Sep 17 00:00:00 2001 From: kigland Date: Wed, 30 Sep 2026 09:10:15 +0800 Subject: [PATCH] fix(augment): recover symbols missed by FTS file ranking --- gitnexus/src/core/augmentation/engine.ts | 14 ++++--- .../test/integration/augmentation.test.ts | 37 +++++++++++++------ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index f72223b5c..ef6fe0c8c 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -129,7 +129,7 @@ export async function augment(pattern: string, cwd?: string): Promise { } // Step 1: BM25 search (fast, no embeddings) - const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId); + const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId); // Step 2: Map BM25 file results to symbols const symbolMatches: Array<{ @@ -167,16 +167,18 @@ export async function augment(pattern: string, cwd?: string): Promise { } } - // When FTS indexes are unavailable (read-only DB, first run before indexes are built), - // fall back to a direct name CONTAINS query so enrichment still works. - if (symbolMatches.length === 0 && !ftsAvailable) { + // FTS ranks files by mentions, so a widely referenced symbol's definition + // may not be present in the top file results. Fall back to graph names + // whenever those files produce no symbol match, regardless of FTS health. + if (symbolMatches.length === 0) { const fallbackRows = await executeQuery( repoId, ` MATCH (n) WHERE n.name CONTAINS '${patternFirstWord}' - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - ORDER BY id + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, + CASE WHEN n.name = '${patternFirstWord}' THEN 0 ELSE 1 END AS exactRank + ORDER BY exactRank, id LIMIT 5 `, ).catch(() => []); diff --git a/gitnexus/test/integration/augmentation.test.ts b/gitnexus/test/integration/augmentation.test.ts index 460df12a6..21dbe7602 100644 --- a/gitnexus/test/integration/augmentation.test.ts +++ b/gitnexus/test/integration/augmentation.test.ts @@ -130,23 +130,38 @@ withTestLbugDB( expect(result).toBe(''); }); - // ─── Negative-safety: fallback must stay gated on !ftsAvailable ─── - // - // When FTS is available but happens to return zero BM25 hits, the - // CONTAINS fallback must NOT fire — preserving the original early-return - // semantics. If anyone later loosens the gate to `symbolMatches.length - // === 0` alone, this test fails. - - it('does NOT fire CONTAINS fallback when FTS is available but BM25 returns empty', async () => { + it('falls back to graph names when FTS is available but BM25 returns empty', async () => { const bm25 = await import('../../src/core/search/bm25-index.js'); const spy = vi .spyOn(bm25, 'searchFTSFromLbug') .mockResolvedValue({ results: [], ftsAvailable: true }); try { - // 'login' WOULD match a graph node via CONTAINS, but FTS is available - // and empty → fallback gate must hold → result must be ''. + // FTS health does not imply its top file results contain the symbol's + // definition. The graph-name fallback must recover the exact node. const result = await augment('login', handle.dbPath); - expect(result).toBe(''); + expect(result).toContain('[GitNexus]'); + expect(result).toContain('login'); + } finally { + spy.mockRestore(); + } + }); + + it('finds an exact-name symbol when FTS ranks only mentioning files', async () => { + const bm25 = await import('../../src/core/search/bm25-index.js'); + const spy = vi.spyOn(bm25, 'searchFTSFromLbug').mockResolvedValue({ + results: Array.from({ length: 5 }, (_, i) => ({ + filePath: `src/caller-${i}.ts`, + score: 100 - i, + nodeIds: [`file:caller-${i}`], + })), + ftsAvailable: true, + }); + try { + // The graph contains func:login in src/auth.ts, but none of the + // FTS-ranked files defines it. Exact-name lookup must still recover it. + const result = await augment('login', handle.dbPath); + expect(result).toContain('[GitNexus]'); + expect(result).toContain('login'); } finally { spy.mockRestore(); }