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..479fe393f 100644 --- a/gitnexus/test/integration/augmentation.test.ts +++ b/gitnexus/test/integration/augmentation.test.ts @@ -21,6 +21,11 @@ const AUGMENT_SEED_DATA = [ `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login authenticates user credentials', description: 'user login'})`, `CREATE (n:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate checks user input', description: 'input validation'})`, `CREATE (n:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash computes bcrypt hash', description: 'password hashing'})`, + ...Array.from( + { length: 5 }, + (_, i) => + `CREATE (n:Function {id: 'a:login-${i}', name: 'loginPartial${i}', filePath: 'src/login-partial-${i}.ts', startLine: 1, endLine: 2, isExported: true, content: 'partial login match ${i}', description: 'login partial'})`, + ), // Class / Method / Interface nodes `CREATE (n:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService handles authentication', description: 'auth service'})`, @@ -130,23 +135,40 @@ 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).toMatch(/^login \(src\/auth\.ts\)$/m); + } 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, + rank: i + 1, + nodeIds: [`file:caller-${i}`], + })), + ftsAvailable: true, + }); + try { + // Five partial login matches have IDs that sort before func:login, so + // the five-row fallback only retains the exact node when exact names + // rank ahead of partial matches. + const result = await augment('login', handle.dbPath); + expect(result).toContain('[GitNexus]'); + expect(result).toMatch(/^login \(src\/auth\.ts\)$/m); } finally { spy.mockRestore(); }