GitNexus/gitnexus/test/integration/augmentation.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

365 lines
15 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 path from 'path';
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(),
}));
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/storage-resolver.js')>();
return {
...actual,
requireRegisteredStoragePath: vi.fn(
async (entry: { storagePath: string }) => entry.storagePath,
),
};
});
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');
});
it('silently degrades when the registered storage path fails validation', async () => {
const { requireRegisteredStoragePath } =
await import('../../src/storage/storage-resolver.js');
const resolverMock = vi.mocked(requireRegisteredStoragePath);
resolverMock.mockRejectedValueOnce(new Error('foreign storage'));
const result = await augment('login', handle.dbPath);
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 () => {
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();
}
});
describe('root path matching', () => {
it('matches repo at root level and CWD in sub-directory (repo at /, CWD at /src)', async () => {
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
const rootPath = path.resolve('/');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: rootPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
try {
const subDir = path.join(rootPath, 'src');
const result = await augment('login', subDir);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('[GitNexus]');
} finally {
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
}
});
it('does not attach a nested registered checkout from a parent cwd (repo at /src, CWD at /)', async () => {
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
const rootPath = path.resolve('/');
const subDir = path.join(rootPath, 'src');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: subDir,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
try {
const result = await augment('login', rootPath);
expect(result).toBe('');
} finally {
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
}
});
if (process.platform === 'win32') {
it('matches Windows drive-root repo and sub-directory CWD (repo at C:\\, CWD at C:\\src)', async () => {
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: 'C:\\',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
try {
const result = await augment('login', 'C:\\src');
expect(result.length).toBeGreaterThan(0);
} finally {
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
}
});
it('does not attach a nested Windows checkout from a drive-root cwd (repo at C:\\src, CWD at C:\\)', async () => {
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: 'C:\\src',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
try {
const result = await augment('login', 'C:\\');
expect(result).toBe('');
} finally {
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
}
});
}
});
});
},
{
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;
},
},
);