Files
Dragan Spiridonov dc0ac612e2 fix(rvf): resolve project root via static import, not require() (#516 follow-up)
The v3.10.4 publish failed: rvf-pattern-store.test.ts expected RvfPatternStore
but got PatternStore. createPatternStore's #516 change used
`require('../kernel/unified-memory.js')` to get findProjectRoot; esbuild inlines
that in the shipped bundle, but under vitest (the CI publish gate) it throws
"Cannot find module", so the whole RVF block fell through to the plain store —
i.e. RVF pattern store was silently disabled under the test runner.

Fix: extract findProjectRoot/clearProjectRootCache into a lightweight,
sqlite-free module (src/kernel/project-root.ts) and import it STATICALLY from
the RVF hot paths. unified-memory.ts now imports + re-exports them, so existing
`import { findProjectRoot } from '.../unified-memory.js'` sites are unchanged.
A static import avoids both the vitest require-resolution failure and dragging
unified-memory's eager better-sqlite3 load into these modules.

- src/kernel/project-root.ts: new module (fs/path only)
- src/kernel/unified-memory.ts: import + re-export (internal callers keep a binding)
- src/learning/pattern-store.ts: static import; drop require()
- src/integrations/ruvector/shared-rvf-adapter.ts: same (latent same bug)
- rvf-pattern-store.test.ts: pin AQE_PROJECT_ROOT to a hermetic temp root with
  .agentic-qe so the RVF branch is taken deterministically (was relying on a
  prior test creating ./.agentic-qe in cwd)

Verified locally (native RVF available): rvf-pattern-store 16/16,
find-project-root 4/4, 181 non-sqlite ruvector tests pass, typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:10:16 +00:00

434 lines
14 KiB
TypeScript

/**
* Tests for RvfPatternStore (ADR-066)
*
* Tests the RVF-backed pattern store adapter, factory routing,
* and migration utility.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RvfPatternStore } from '../../../src/learning/rvf-pattern-store.js';
import type { RvfNativeAdapter } from '../../../src/integrations/ruvector/rvf-native-adapter.js';
import type { QEPattern } from '../../../src/learning/qe-patterns.js';
import { migratePatterns } from '../../../src/learning/rvf-pattern-migration.js';
import {
setRuVectorFeatureFlags,
resetRuVectorFeatureFlags,
} from '../../../src/integrations/ruvector/feature-flags.js';
// ============================================================================
// Mock RVF Adapter
// ============================================================================
function createMockAdapter(opts: { dimension?: number } = {}): RvfNativeAdapter {
const dimension = opts.dimension ?? 384;
const vectors = new Map<string, Float32Array>();
return {
ingest: vi.fn((entries: Array<{ id: string; vector: Float32Array | number[] }>) => {
for (const e of entries) {
const vec = e.vector instanceof Float32Array ? e.vector : new Float32Array(e.vector);
vectors.set(e.id, vec);
}
return { accepted: entries.length, rejected: 0 };
}),
search: vi.fn((query: Float32Array | number[], k: number) => {
// Simple brute-force cosine similarity
const qVec = query instanceof Float32Array ? query : new Float32Array(query);
const results: Array<{ id: string; distance: number; score: number }> = [];
for (const [id, vec] of vectors) {
let dot = 0, magA = 0, magB = 0;
for (let i = 0; i < dimension; i++) {
dot += qVec[i] * vec[i];
magA += qVec[i] * qVec[i];
magB += vec[i] * vec[i];
}
const similarity = dot / (Math.sqrt(magA) * Math.sqrt(magB) + 1e-9);
results.push({ id, distance: 1 - similarity, score: similarity });
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, k);
}),
delete: vi.fn((ids: string[]) => {
let deleted = 0;
for (const id of ids) {
if (vectors.delete(id)) deleted++;
}
return deleted;
}),
status: vi.fn(() => ({
totalVectors: vectors.size,
totalSegments: 1,
fileSizeBytes: vectors.size * dimension * 4,
epoch: 1,
witnessValid: true,
witnessEntries: 0,
})),
close: vi.fn(),
isOpen: vi.fn(() => true),
path: vi.fn(() => '/tmp/test-patterns.rvf'),
dimension: vi.fn(() => dimension),
size: vi.fn(() => vectors.size),
compact: vi.fn(),
fork: vi.fn(),
derive: vi.fn(),
embedKernel: vi.fn(),
extractKernel: vi.fn(() => null),
verifyWitness: vi.fn(() => ({ valid: true, totalEntries: 0, errors: [] })),
sign: vi.fn(() => null),
fileId: vi.fn(() => 'test-file-id'),
parentId: vi.fn(() => null),
lineageDepth: vi.fn(() => 0),
indexStats: vi.fn(() => ({
totalVectors: vectors.size,
dimension,
totalSegments: 1,
fileSizeBytes: 0,
epoch: 1,
witnessValid: true,
witnessEntries: 0,
idMapSize: vectors.size,
})),
freeze: vi.fn(() => 1),
} as unknown as RvfNativeAdapter;
}
// ============================================================================
// Test Helpers
// ============================================================================
function makePattern(overrides: Partial<QEPattern> = {}): QEPattern {
return {
id: `pattern-${Math.random().toString(36).slice(2, 8)}`,
patternType: 'test-template',
qeDomain: 'test-generation',
domain: 'test-generation',
name: 'Test Pattern',
description: 'A test pattern for unit testing',
confidence: 0.8,
usageCount: 5,
successRate: 0.9,
qualityScore: 0.75,
context: { tags: ['test'] },
template: { type: 'code', content: 'test()', variables: [] },
embedding: Array.from({ length: 384 }, () => Math.random()),
tier: 'short-term',
createdAt: new Date(),
lastUsedAt: new Date(),
successfulUses: 4,
reusable: false,
reuseCount: 0,
averageTokenSavings: 0,
...overrides,
} as QEPattern;
}
// ============================================================================
// RvfPatternStore Tests
// ============================================================================
describe('RvfPatternStore', () => {
let store: RvfPatternStore;
let adapter: RvfNativeAdapter;
beforeEach(async () => {
adapter = createMockAdapter();
store = new RvfPatternStore(
() => adapter,
{ rvfPath: '/tmp/test.rvf', base: undefined as any },
);
await store.initialize();
});
afterEach(async () => {
await store.dispose();
});
describe('Initialization', () => {
it('should initialize and create adapter', async () => {
expect(store.getAdapter()).toBe(adapter);
});
it('should skip re-initialization', async () => {
const factoryFn = vi.fn(() => adapter);
const s = new RvfPatternStore(factoryFn, { rvfPath: '/tmp/test.rvf', base: undefined as any });
await s.initialize();
await s.initialize(); // second call
expect(factoryFn).toHaveBeenCalledTimes(1);
await s.dispose();
});
});
describe('Store', () => {
it('should store a pattern and ingest its vector', async () => {
const pattern = makePattern();
const result = await store.store(pattern);
expect(result.success).toBe(true);
if (result.success) {
expect(result.value).toBe(pattern.id);
}
expect(adapter.ingest).toHaveBeenCalledOnce();
});
it('should reject pattern below confidence threshold', async () => {
const pattern = makePattern({ confidence: 0.1 });
const result = await store.store(pattern);
expect(result.success).toBe(false);
expect(adapter.ingest).not.toHaveBeenCalled();
});
it('should skip vector ingest when embedding is missing', async () => {
const pattern = makePattern({ embedding: undefined });
const result = await store.store(pattern);
expect(result.success).toBe(true);
expect(adapter.ingest).not.toHaveBeenCalled();
});
});
describe('Search', () => {
it('should search by vector via RVF adapter', async () => {
// Store a pattern first
const pattern = makePattern();
await store.store(pattern);
// Search with its own embedding
const result = await store.search(pattern.embedding!, { limit: 5 });
expect(result.success).toBe(true);
if (result.success) {
expect(adapter.search).toHaveBeenCalled();
}
});
it('should filter results by domain', async () => {
const p1 = makePattern({ qeDomain: 'test-generation' });
const p2 = makePattern({ qeDomain: 'coverage-analysis' });
// Mock sqliteStore for metadata lookup
const mockSqlite = {
getPattern: vi.fn((id: string) => {
if (id === p1.id) return p1;
if (id === p2.id) return p2;
return null;
}),
};
(store as any).sqliteStore = mockSqlite;
await store.store(p1);
await store.store(p2);
const result = await store.search(p1.embedding!, {
domain: 'test-generation',
limit: 10,
});
expect(result.success).toBe(true);
if (result.success) {
for (const r of result.value) {
expect(r.pattern.qeDomain).toBe('test-generation');
}
}
});
});
describe('Delete', () => {
it('should delete vector from RVF adapter', async () => {
const pattern = makePattern();
await store.store(pattern);
const result = await store.delete(pattern.id);
expect(result.success).toBe(true);
expect(adapter.delete).toHaveBeenCalledWith([pattern.id]);
});
});
describe('Stats', () => {
it('should return stats from RVF adapter', async () => {
const pattern = makePattern();
await store.store(pattern);
const stats = await store.getStats();
expect(stats.totalPatterns).toBe(1);
expect(stats.hnswStats.nativeAvailable).toBe(true);
expect(stats.hnswStats.vectorCount).toBe(1);
});
});
describe('Create', () => {
it('should create a pattern with defaults', async () => {
const result = await store.create({
patternType: 'test-template',
name: 'New Pattern',
description: 'Created via create()',
template: { type: 'code', content: 'test()', variables: [] },
embedding: Array.from({ length: 384 }, () => Math.random()),
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.name).toBe('New Pattern');
expect(result.value.tier).toBe('short-term');
expect(result.value.confidence).toBe(0.5); // default
}
});
});
});
// ============================================================================
// Factory Routing Tests
// ============================================================================
describe('createPatternStore factory routing', () => {
afterEach(() => {
resetRuVectorFeatureFlags();
});
it('should return PatternStore when useRVFPatternStore is false', async () => {
setRuVectorFeatureFlags({ useRVFPatternStore: false });
const { createPatternStore } = await import('../../../src/learning/pattern-store.js');
const mockMemory = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
list: vi.fn(() => []),
clear: vi.fn(),
};
const store = createPatternStore(mockMemory as any);
// Should be the original PatternStore (not RvfPatternStore)
expect(store.constructor.name).toBe('PatternStore');
await store.dispose();
});
it('should return RvfPatternStore when useRVFPatternStore is true and native is available', async () => {
// Check if native binary is actually available in this environment
let nativeAvailable = false;
try {
const { isRvfNativeAvailable } = await import(
'../../../src/integrations/ruvector/rvf-native-adapter.js'
);
nativeAvailable = isRvfNativeAvailable();
} catch { /* not available */ }
if (!nativeAvailable) {
console.log('[TEST] Skipping true-path test — @ruvector/rvf-node not available');
return;
}
// Issue #516: createPatternStore now anchors the RVF dir to the project root
// (AQE_PROJECT_ROOT ?? findProjectRoot()) instead of a cwd-relative
// '.agentic-qe'. Pin a hermetic project root with an existing .agentic-qe so
// the RVF branch is taken deterministically regardless of test order or cwd
// side-effects (previously this relied on another test having created
// ./.agentic-qe in the process cwd).
const { mkdtempSync, mkdirSync, rmSync } = await import('fs');
const { tmpdir } = await import('os');
const { join } = await import('path');
const projectRoot = mkdtempSync(join(tmpdir(), 'aqe-rvf-factory-'));
mkdirSync(join(projectRoot, '.agentic-qe'), { recursive: true });
const savedRoot = process.env.AQE_PROJECT_ROOT;
process.env.AQE_PROJECT_ROOT = projectRoot;
setRuVectorFeatureFlags({ useRVFPatternStore: true });
try {
const { createPatternStore } = await import('../../../src/learning/pattern-store.js');
const mockMemory = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
list: vi.fn(() => []),
clear: vi.fn(),
};
const store = createPatternStore(mockMemory as any);
expect(store.constructor.name).toBe('RvfPatternStore');
await store.dispose();
} finally {
if (savedRoot === undefined) delete process.env.AQE_PROJECT_ROOT;
else process.env.AQE_PROJECT_ROOT = savedRoot;
rmSync(projectRoot, { recursive: true, force: true });
}
});
});
// ============================================================================
// Migration Utility Tests
// ============================================================================
describe('migratePatterns', () => {
it('should migrate embeddings from SQLite to RVF', () => {
const adapter = createMockAdapter();
const mockSqlite = {
getAllEmbeddings: vi.fn(() => [
{ patternId: 'p1', embedding: Array.from({ length: 384 }, () => 0.5) },
{ patternId: 'p2', embedding: Array.from({ length: 384 }, () => 0.3) },
{ patternId: 'p3', embedding: Array.from({ length: 384 }, () => 0.7) },
]),
};
const result = migratePatterns(
mockSqlite as any,
adapter,
{ batchSize: 2, dimension: 384 },
);
expect(result.totalMigrated).toBe(3);
expect(result.totalSkipped).toBe(0);
expect(result.errors).toHaveLength(0);
expect(result.durationMs).toBeGreaterThan(0);
// Two batches: [p1,p2] and [p3]
expect(adapter.ingest).toHaveBeenCalledTimes(2);
});
it('should skip embeddings with wrong dimension', () => {
const adapter = createMockAdapter();
const mockSqlite = {
getAllEmbeddings: vi.fn(() => [
{ patternId: 'p1', embedding: Array.from({ length: 384 }, () => 0.5) },
{ patternId: 'p2', embedding: Array.from({ length: 128 }, () => 0.3) }, // wrong dim
]),
};
const result = migratePatterns(
mockSqlite as any,
adapter,
{ dimension: 384 },
);
expect(result.totalMigrated).toBe(1);
expect(result.totalSkipped).toBe(1);
});
it('should handle empty embeddings list', () => {
const adapter = createMockAdapter();
const mockSqlite = {
getAllEmbeddings: vi.fn(() => []),
};
const result = migratePatterns(mockSqlite as any, adapter);
expect(result.totalMigrated).toBe(0);
expect(result.totalSkipped).toBe(0);
expect(adapter.ingest).not.toHaveBeenCalled();
});
it('should handle SQLite read failure gracefully', () => {
const adapter = createMockAdapter();
const mockSqlite = {
getAllEmbeddings: vi.fn(() => { throw new Error('DB locked'); }),
};
const result = migratePatterns(mockSqlite as any, adapter);
expect(result.totalMigrated).toBe(0);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('DB locked');
});
});