GitNexus/gitnexus/test/integration/augmentation.test.ts
Antheurus fcab1e2e82
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
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(augment): add CONTAINS fallback when FTS indexes unavailable (#1476)
* fix(augment): add CONTAINS fallback when FTS indexes unavailable

When the MCP server holds the KuzuDB write lock, the augment CLI opens
the DB read-only. FTS indexes cannot be created in read-only mode, so
searchFTSFromLbug returns ftsAvailable=false and an empty results array.
The existing early-return path silently produced no enrichment.

Add a Cypher name CONTAINS fallback that fires only when ftsAvailable is
false and BM25 produced no symbol matches. This covers the read-only DB
case (concurrent MCP server) and the first-run case (indexes not yet
built). The fallback is wrapped in .catch(() => []) and cannot throw.

When FTS indexes exist, this branch is never reached — behaviour is
unchanged for users without a concurrent MCP server.

* fix(augment): guard against CONTAINS '' and add no-FTS test coverage

Blocker 1 — CONTAINS '' on whitespace-leading patterns:
pattern.split(/\s+/)[0] returns "" when the input has leading whitespace
(e.g. "   ".split(/\s+/) → ["", ""]). In Kuzu, CONTAINS '' matches every
node with a name property, injecting arbitrary graph nodes into LLM context.

Fix: trim() before split, then guard on !firstWord || firstWord.length < 2.
No behaviour change for normal non-empty patterns.

Blocker 2 — zero test coverage on the FTS-unavailable code path:
The new CONTAINS fallback block (engine.ts lines 146-166) was exercised by
no existing test — all existing tests run with FTS indexes built. A second
withTestLbugDB fixture is added with no ftsIndexes, forcing searchFTSFromLbug
to return ftsAvailable: false, and asserts:
1. augment('login', ...) returns non-empty enrichment (fallback works)
2. augment('   ', ...) returns '' (CONTAINS '' guard holds)
3. augment('nxyz_notfound', ...) returns '' (no matching nodes)
4. executeQuery throwing returns '' (.catch(() => []) path)

* fix(augment): extend CONTAINS '' guard to FTS happy path and consolidate

The same split(/\s+/)[0] bug existed at line 125 (BM25 symbol filter,
FTS-available path) — a leading-whitespace pattern produced CONTAINS ''
there too, matching every node in BM25-matched files.

Fix: hoist patternFirstWord computation with trim() and the length guard
to the top of augment(), before any DB interaction. Both CONTAINS sites
(BM25 symbol filter and CONTAINS fallback) now use the single pre-validated
value. No behaviour change for normal patterns; the guard fires once for
all callers instead of being duplicated.

Also tighten the whitespace test in the no-FTS suite from 3 spaces to
4 spaces so it unambiguously exercises the patternFirstWord guard rather
than straddling the outer pattern.length < 3 boundary.

* test(augment): negative-safety test for ftsAvailable=true gate

Asserts the CONTAINS fallback does NOT fire when FTS is available but
BM25 returns zero results. Pins the safety property promised by the PR
description: behavior is unchanged for users without the read-only-DB
condition.

If anyone later loosens the gate to `symbolMatches.length === 0` alone,
this test fails.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-11 16:39:10 +01:00

218 lines
10 KiB
TypeScript

/**
* Integration Tests: Augmentation Engine
*
* augment() against a real indexed LadybugDB
* - Matching pattern returns non-empty string with callers/callees
* - Non-matching pattern returns empty string
* - Pattern shorter than 3 chars returns empty string
*/
import { describe, it, expect, vi } from 'vitest';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
// ─── Seed data & FTS indexes for augmentation ────────
const AUGMENT_SEED_DATA = [
// File nodes
`CREATE (n:File {id: 'file:auth.ts', name: 'auth.ts', filePath: 'src/auth.ts', content: 'authentication module for user login'})`,
`CREATE (n:File {id: 'file:utils.ts', name: 'utils.ts', filePath: 'src/utils.ts', content: 'utility functions for hashing'})`,
// Function nodes
`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'})`,
// 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'})`,
`CREATE (n:Method {id: 'method:AuthService.login', name: 'loginMethod', filePath: 'src/auth.ts', startLine: 35, endLine: 50, isExported: false, content: 'method login in AuthService', description: 'login method'})`,
`CREATE (n:Interface {id: 'iface:Creds', name: 'Credentials', filePath: 'src/auth.ts', startLine: 1, endLine: 5, isExported: true, content: 'interface Credentials for login authentication', description: 'credentials type'})`,
// Community & Process nodes
`CREATE (n:Community {id: 'comm:auth', label: 'Auth', heuristicLabel: 'Authentication', keywords: ['auth'], description: 'Auth cluster', enrichedBy: 'heuristic', cohesion: 0.8, symbolCount: 3})`,
`CREATE (n:Process {id: 'proc:login-flow', label: 'LoginFlow', heuristicLabel: 'User Login', processType: 'intra_community', stepCount: 2, communities: ['auth'], entryPointId: 'func:login', terminalId: 'func:validate'})`,
// Relationships
`MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:validate'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:hash'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'import-resolved', step: 0}]->(b)`,
`MATCH (a:Function), (c:Community) WHERE a.id = 'func:login' AND c.id = 'comm:auth'
CREATE (a)-[:CodeRelation {type: 'MEMBER_OF', confidence: 1.0, reason: '', step: 0}]->(c)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:login' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)`,
];
const AUGMENT_FTS_INDEXES = [
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
{ 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: 'Interface', indexName: 'interface_fts', columns: ['name', 'content', 'description'] },
];
// Mock repo-manager so augment() finds our test DB
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn(),
}));
let augment: (pattern: string, cwd?: string) => Promise<string>;
let augmentNoFts: (pattern: string, cwd?: string) => Promise<string>;
withTestLbugDB(
'augment',
(handle) => {
describe('augment()', () => {
it('returns non-empty string with relationship info for a matching pattern', async () => {
const result = await augment('login', handle.dbPath);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('[GitNexus]');
expect(result).toContain('login');
});
it('returns empty string for a non-matching pattern', async () => {
const result = await augment('nonexistent_xyz', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string for patterns shorter than 3 characters', async () => {
const result = await augment('ab', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string for empty pattern', async () => {
const result = await augment('', handle.dbPath);
expect(result).toBe('');
});
// ─── Unhappy paths ────────────────────────────────────────────────
it('returns empty string for whitespace-only pattern', async () => {
const result = await augment(' ', handle.dbPath);
expect(result).toBe('');
});
it('handles special regex characters in pattern without throwing', async () => {
const result = await augment('func()', handle.dbPath);
expect(typeof result).toBe('string');
});
it('handles very long pattern without throwing', async () => {
const result = await augment('a'.repeat(500), handle.dbPath);
expect(typeof result).toBe('string');
});
it('handles unicode pattern without throwing', async () => {
const result = await augment('日本語テスト', handle.dbPath);
expect(typeof result).toBe('string');
});
// ─── 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 () => {
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 ''.
const result = await augment('login', handle.dbPath);
expect(result).toBe('');
} finally {
spy.mockRestore();
}
});
});
},
{
seed: AUGMENT_SEED_DATA,
ftsIndexes: AUGMENT_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
// Configure mock to return our test DB so augment() can find it
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
// Dynamically import augment after mocks are in place
const engine = await import('../../src/core/augmentation/engine.js');
augment = engine.augment;
},
},
);
// ─── FTS-unavailable suite: exercises the CONTAINS fallback branch ────────────
//
// No ftsIndexes → searchFTSFromLbug returns ftsAvailable: false → fallback fires.
// Same seed data so 'login' still exists as a graph node.
withTestLbugDB(
'augment-no-fts',
(handle) => {
describe('augment() — FTS indexes unavailable (CONTAINS fallback)', () => {
it('falls back to CONTAINS query and returns enrichment when FTS is unavailable', async () => {
const result = await augmentNoFts('login', handle.dbPath);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('[GitNexus]');
});
it("returns empty string for whitespace-only pattern (CONTAINS '' guard)", async () => {
const result = await augmentNoFts(' ', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string when no nodes match the CONTAINS query', async () => {
const result = await augmentNoFts('nxyz_notfound', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string when fallback CONTAINS query throws', async () => {
const poolAdapter = await import('../../src/core/lbug/pool-adapter.js');
const spy = vi
.spyOn(poolAdapter, 'executeQuery')
.mockRejectedValue(new Error('simulated DB error'));
try {
const result = await augmentNoFts('login', handle.dbPath);
expect(result).toBe('');
} finally {
spy.mockRestore();
}
});
});
},
{
seed: AUGMENT_SEED_DATA,
// Intentionally no ftsIndexes — forces searchFTSFromLbug to return ftsAvailable: false
poolAdapter: true,
afterSetup: async (handle) => {
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
const engine = await import('../../src/core/augmentation/engine.js');
augmentNoFts = engine.augment;
},
},
);