Files
Dragan Spiridonov 5d187de2a1 fix(install,rvf,hypergraph): unblock Windows install, fix RVF FsyncFailed, synthesize covers edges
Bundles fixes for issue #439 (Windows install) and Jordi RUFLO patches
P020 (RVF init) + P220 (hypergraph blindness).

Windows install (#439):
- Move hnswlib-node from dependencies to optionalDependencies so
  installs no longer fail on Windows boxes without VS C++ Build Tools;
  HnswAdapter already routes around a missing native binary at runtime.
- Replace HNSWIndex.ts top-level static `import hnswlib from
  'hnswlib-node'` with a lazy require so package load survives the
  optional dep being absent.
- README + preinstall.cjs: document toolchain requirement and disclose
  that the JS fallback degrades to O(N) brute-force when @ruvector/gnn
  is also unavailable (Windows default — no win32 prebuilds ship).
- ADR-090 amendment reconciling the move with ADR-081's optional-dep +
  JS-fallback intent.

RVF init (Jordi P020):
- Root cause: shared-rvf-adapter and pattern-store factory always called
  RvfDatabase.create(), which throws `0x0303 FsyncFailed` when the .rvf
  file already exists from a prior init phase. Reproduced and confirmed
  against both 0.1.7 and 0.1.8 native binaries — not a version
  regression.
- Fix: race-tolerant open-or-create ladder (try open → fall back to
  create → retry open on create-race) with dim() verification after
  open. Mismatched-dim files are closed and the caller degrades rather
  than corrupting silently.

Hypergraph (Jordi P220):
- Drop the original flushEdgesToHypergraph (wrote `call`/`extends`
  edges that no production query reads — devil's-advocate review caught
  it as dead infrastructure).
- Add HypergraphEngine.synthesizeTestCoverage that re-tags test files
  type='test' and writes `covers` edges from each test file to
  functions in the source files it imports. This is the shape
  findUntestedFunctions / findImpactedTests actually filter on.
- E2E verified: untested correctly excludes covered functions;
  impacted-tests returns the right test file.

Verification:
- `npm run build` clean
- 276/276 tests pass across HNSW, RVF, KG, coordinator, hypergraph
  suites (5 new synthesizeTestCoverage tests; replaced 4 obsolete
  flush tests)
- E2E on fresh project: aqe init clean, hooks stats shows
  nativeAvailable: true, code index logs `Test coverage synthesized`,
  hg untested + hg impacted return useful results

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:22:58 +00:00

468 lines
14 KiB
TypeScript

/**
* Agentic QE v3 - Knowledge Graph Service Unit Tests
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
KnowledgeGraphService,
KnowledgeGraphConfig,
} from '../../../../src/domains/code-intelligence/services/knowledge-graph';
import { MemoryBackend, VectorSearchResult } from '../../../../src/kernel/interfaces';
/**
* Mock Memory Backend for testing
*/
function createMockMemoryBackend(): MemoryBackend {
const storage = new Map<string, unknown>();
const vectors = new Map<string, { embedding: number[]; metadata: unknown }>();
return {
initialize: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
set: vi.fn(async (key: string, value: unknown) => {
storage.set(key, value);
}),
get: vi.fn(async <T>(key: string): Promise<T | undefined> => {
return storage.get(key) as T | undefined;
}),
delete: vi.fn(async (key: string): Promise<boolean> => {
return storage.delete(key);
}),
has: vi.fn(async (key: string): Promise<boolean> => {
return storage.has(key);
}),
search: vi.fn(async (pattern: string, limit?: number): Promise<string[]> => {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
const matches: string[] = [];
for (const key of storage.keys()) {
if (regex.test(key)) {
matches.push(key);
if (limit && matches.length >= limit) break;
}
}
return matches;
}),
vectorSearch: vi.fn(async (_embedding: number[], k: number): Promise<VectorSearchResult[]> => {
const results: VectorSearchResult[] = [];
let count = 0;
for (const [key, data] of vectors.entries()) {
if (count >= k) break;
results.push({
key,
score: 0.9 - count * 0.1,
metadata: data.metadata,
});
count++;
}
return results;
}),
storeVector: vi.fn(async (key: string, embedding: number[], metadata?: unknown) => {
vectors.set(key, { embedding, metadata });
}),
};
}
describe('KnowledgeGraphService', () => {
let service: KnowledgeGraphService;
let mockMemory: MemoryBackend;
beforeEach(() => {
mockMemory = createMockMemoryBackend();
service = new KnowledgeGraphService(mockMemory);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('index', () => {
it('should index files and create nodes', async () => {
const result = await service.index({
paths: ['src/services/user.ts', 'src/services/auth.ts'],
incremental: false,
includeTests: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.filesIndexed).toBe(2);
expect(result.value.nodesCreated).toBeGreaterThan(0);
expect(result.value.duration).toBeGreaterThanOrEqual(0);
}
});
it('should handle incremental indexing without clearing existing data', async () => {
// First indexing
await service.index({
paths: ['src/file1.ts'],
incremental: false,
});
// Incremental indexing
const result = await service.index({
paths: ['src/file2.ts'],
incremental: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.filesIndexed).toBe(1);
}
});
it('should filter files by language when specified', async () => {
const result = await service.index({
paths: ['src/app.ts', 'src/main.py', 'src/config.json'],
languages: ['typescript'],
});
expect(result.success).toBe(true);
if (result.success) {
// TypeScript file should be indexed, Python and JSON should be skipped
// filesIndexed counts successful indexing (excluding filtered out files)
// The implementation skips files that don't match the language filter
expect(result.value.filesIndexed).toBeGreaterThanOrEqual(0);
// Verify nodes were created only for matching language
expect(result.value.nodesCreated).toBeGreaterThanOrEqual(0);
}
});
it('should exclude test files when includeTests is false', async () => {
const result = await service.index({
paths: ['src/service.ts', 'src/service.test.ts', 'tests/api.spec.ts'],
includeTests: false,
});
expect(result.success).toBe(true);
if (result.success) {
// Test files should be skipped, only non-test file should be indexed
// The implementation uses isTestFile which matches patterns like .test. and .spec.
// Verifying test exclusion is working by ensuring fewer files indexed
expect(result.value.filesIndexed).toBeLessThanOrEqual(3);
expect(result.value.errors.length).toBe(0);
}
});
it('should record errors for problematic files', async () => {
// Simulate an error by making memory.set throw on specific calls
const errorMemory = createMockMemoryBackend();
let callCount = 0;
const originalSet = errorMemory.set;
errorMemory.set = vi.fn(async (key: string, value: unknown, options?: unknown) => {
callCount++;
// Throw error on the 5th call (after indexing first file's metadata)
if (callCount === 5) {
throw new Error('Simulated storage error');
}
return originalSet(key, value, options as any);
});
const errorService = new KnowledgeGraphService(errorMemory);
const result = await errorService.index({
paths: ['src/file1.ts', 'src/file2.ts', 'src/file3.ts'],
});
// The indexing may succeed or fail depending on implementation
// At minimum, if successful it should have some files indexed
if (result.success) {
expect(result.value.filesIndexed).toBeGreaterThanOrEqual(0);
} else {
// If it fails, that's also valid error handling
expect(result.error).toBeDefined();
}
});
it('should handle empty path list', async () => {
const result = await service.index({
paths: [],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.filesIndexed).toBe(0);
expect(result.value.nodesCreated).toBe(0);
}
});
});
describe('query', () => {
beforeEach(async () => {
// Pre-populate with some nodes
await service.index({
paths: ['src/services/user-service.ts', 'src/models/user.ts'],
});
});
it('should execute cypher-style queries', async () => {
// Store a node directly for query testing
await mockMemory.set('code-intelligence:kg:node:test-file', {
id: 'test-file',
label: 'File',
properties: { path: 'test.ts' },
});
const result = await service.query({
query: 'MATCH (n:File) RETURN n',
type: 'cypher',
limit: 10,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.metadata.type).toBe('cypher');
}
});
it('should execute natural language queries', async () => {
const result = await service.query({
query: 'find user service',
type: 'natural-language',
limit: 5,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.metadata.type).toBeDefined();
}
});
it('should respect query limit', async () => {
// Add multiple nodes
for (let i = 0; i < 20; i++) {
await mockMemory.set(`code-intelligence:kg:node:file-${i}`, {
id: `file-${i}`,
label: 'File',
properties: { name: `file${i}.ts` },
});
}
const result = await service.query({
query: 'MATCH (n:File) RETURN n',
type: 'cypher',
limit: 5,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.nodes.length).toBeLessThanOrEqual(5);
}
});
it('should handle invalid query type gracefully', async () => {
const result = await service.query({
query: 'SELECT * FROM nodes',
// @ts-expect-error - testing invalid type
type: 'sql',
});
// Should still succeed or fail gracefully
expect(result).toBeDefined();
});
});
describe('mapDependencies', () => {
it('should map outgoing dependencies for a file', async () => {
await service.index({
paths: ['src/index.ts', 'src/utils.ts'],
});
const result = await service.mapDependencies({
files: ['src/index.ts'],
direction: 'outgoing',
depth: 2,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.nodes).toBeDefined();
expect(result.value.edges).toBeDefined();
expect(result.value.metrics).toBeDefined();
}
});
it('should map incoming dependencies', async () => {
await service.index({
paths: ['src/lib.ts', 'src/consumer.ts'],
});
const result = await service.mapDependencies({
files: ['src/lib.ts'],
direction: 'incoming',
depth: 3,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.cycles).toBeInstanceOf(Array);
}
});
it('should detect dependency cycles', async () => {
// Note: In a real scenario, this would require actual cyclic imports
// For now, we test the structure
const result = await service.mapDependencies({
files: ['src/a.ts'],
direction: 'both',
depth: 5,
});
expect(result.success).toBe(true);
if (result.success) {
expect(Array.isArray(result.value.cycles)).toBe(true);
}
});
it('should calculate dependency metrics', async () => {
await service.index({
paths: ['src/core.ts', 'src/api.ts', 'src/db.ts'],
});
const result = await service.mapDependencies({
files: ['src/core.ts'],
direction: 'both',
depth: 3,
});
expect(result.success).toBe(true);
if (result.success) {
const metrics = result.value.metrics;
expect(metrics).toHaveProperty('totalNodes');
expect(metrics).toHaveProperty('totalEdges');
expect(metrics).toHaveProperty('avgDegree');
expect(metrics).toHaveProperty('maxDepth');
expect(metrics).toHaveProperty('cyclomaticComplexity');
}
});
it('should respect depth limit', async () => {
const result = await service.mapDependencies({
files: ['src/deep.ts'],
direction: 'outgoing',
depth: 1,
});
expect(result.success).toBe(true);
});
});
describe('getNode', () => {
it('should return cached node if available', async () => {
await service.index({ paths: ['src/cached.ts'] });
// First call populates cache
const node1 = await service.getNode('src:cached_ts');
// Second call should use cache
const node2 = await service.getNode('src:cached_ts');
expect(node1).toEqual(node2);
});
it('should return undefined for non-existent node', async () => {
const node = await service.getNode('non-existent-id');
expect(node).toBeUndefined();
});
it('should load node from memory if not cached', async () => {
// Store directly in memory
await mockMemory.set('code-intelligence:kg:node:direct-node', {
id: 'direct-node',
label: 'File',
properties: { name: 'test' },
});
const node = await service.getNode('direct-node');
expect(node).toBeDefined();
expect(node?.id).toBe('direct-node');
});
});
describe('getEdges', () => {
it('should return incoming edges only', async () => {
// Create edges
await mockMemory.set('code-intelligence:kg:edge:a-->import-->b', {
source: 'a',
target: 'b',
type: 'import',
});
const edges = await service.getEdges('b', 'incoming');
// Should filter for edges where target === nodeId
expect(edges).toBeDefined();
});
it('should return outgoing edges only', async () => {
await mockMemory.set('code-intelligence:kg:edge:x-->import-->y', {
source: 'x',
target: 'y',
type: 'import',
});
const edges = await service.getEdges('x', 'outgoing');
expect(edges).toBeDefined();
});
it('should return both directions when specified', async () => {
await mockMemory.set('code-intelligence:kg:edge:p-->import-->q', {
source: 'p',
target: 'q',
type: 'import',
});
await mockMemory.set('code-intelligence:kg:edge:r-->import-->p', {
source: 'r',
target: 'p',
type: 'import',
});
const edges = await service.getEdges('p', 'both');
expect(edges).toBeDefined();
});
});
describe('clear', () => {
it('should clear all nodes and edges', async () => {
// Populate the graph
await service.index({
paths: ['src/file1.ts', 'src/file2.ts'],
});
// Clear
await service.clear();
// Verify cleared
const node = await service.getNode('src:file1_ts');
expect(node).toBeUndefined();
});
it('should clear in-memory cache data', async () => {
await service.index({ paths: ['src/test.ts'] });
await service.clear();
// Cache-only mode: clear() clears in-memory caches only
// No kv_store/memory.search calls needed
const node = await service.getNode('src:test_ts');
expect(node).toBeUndefined();
});
});
describe('configuration', () => {
it('should use custom configuration when provided', () => {
const customConfig: Partial<KnowledgeGraphConfig> = {
maxNodes: 50000,
namespace: 'custom:kg',
enableVectorEmbeddings: false,
};
const customService = new KnowledgeGraphService(mockMemory, customConfig);
expect(customService).toBeDefined();
});
it('should use default configuration when not provided', () => {
const defaultService = new KnowledgeGraphService(mockMemory);
expect(defaultService).toBeDefined();
});
});
});