mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
2253d05618
Remove the nested v3/ subdirectory by moving all active source code, tests, configs, and assets directly to the project root. Archive v2/ (dead code, 847MB) to v2-archive.tar.gz. Key changes: - Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/ - Move v3/tsconfig.json, v3/vitest.config.ts to root - Merge v3/package.json into root package.json (ESM, scripts, deps, exports) - Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/ - Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes - Fix all init installer path resolution (3-level-up → 2-level-up) - Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe" - Update settings.json ADR/DDD directories to docs/implementation/ - Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks) - Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces - Update 15 guidance shards: v3/src/ → src/ path references - Fix sync interfaces: remove v3/ and v2/ path references - Fix test path calculations (process.cwd() + '..' no longer needed) - Fix flaky perf test threshold (500ms → 1000ms for CI environments) - Fix complexity-analyzer test to match ADR-051 keyword-based eligibility Build passes, 17,901 tests pass, CLI and MCP bundles verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
565 lines
19 KiB
TypeScript
565 lines
19 KiB
TypeScript
/**
|
|
* Agentic QE v3 - Impact Analyzer Service Unit Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import {
|
|
ImpactAnalyzerService,
|
|
ImpactAnalyzerConfig,
|
|
} from '../../../../src/domains/code-intelligence/services/impact-analyzer';
|
|
import { MemoryBackend, VectorSearchResult } from '../../../../src/kernel/interfaces';
|
|
import { ImpactAnalysis, ImpactedFile, DependencyMap } from '../../../../src/domains/code-intelligence/interfaces';
|
|
import { IKnowledgeGraphService } from '../../../../src/domains/code-intelligence/services/knowledge-graph';
|
|
import { Result, ok } from '../../../../src/shared/types';
|
|
|
|
/**
|
|
* 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[]> => {
|
|
return [];
|
|
}),
|
|
storeVector: vi.fn(async (key: string, embedding: number[], metadata?: unknown) => {
|
|
vectors.set(key, { embedding, metadata });
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mock Knowledge Graph Service for testing
|
|
*/
|
|
function createMockKnowledgeGraph(dependencies: Map<string, string[]>): IKnowledgeGraphService {
|
|
return {
|
|
index: vi.fn().mockResolvedValue(ok({ filesIndexed: 0, nodesCreated: 0, edgesCreated: 0, duration: 0, errors: [] })),
|
|
query: vi.fn().mockResolvedValue(ok({ nodes: [], edges: [], metadata: {} })),
|
|
mapDependencies: vi.fn(async (request): Promise<Result<DependencyMap, Error>> => {
|
|
const nodes: DependencyMap['nodes'] = [];
|
|
const edges: DependencyMap['edges'] = [];
|
|
|
|
for (const file of request.files) {
|
|
const deps = dependencies.get(file) || [];
|
|
nodes.push({
|
|
id: file.replace(/[/\\]/g, ':'),
|
|
path: file,
|
|
type: 'file',
|
|
inDegree: deps.length,
|
|
outDegree: 0,
|
|
});
|
|
|
|
for (const dep of deps) {
|
|
nodes.push({
|
|
id: dep.replace(/[/\\]/g, ':'),
|
|
path: dep,
|
|
type: 'file',
|
|
inDegree: 1,
|
|
outDegree: 1,
|
|
});
|
|
edges.push({
|
|
source: file.replace(/[/\\]/g, ':'),
|
|
target: dep.replace(/[/\\]/g, ':'),
|
|
type: 'import',
|
|
});
|
|
}
|
|
}
|
|
|
|
return ok({
|
|
nodes,
|
|
edges,
|
|
cycles: [],
|
|
metrics: {
|
|
totalNodes: nodes.length,
|
|
totalEdges: edges.length,
|
|
avgDegree: nodes.length > 0 ? edges.length / nodes.length : 0,
|
|
maxDepth: 1,
|
|
cyclomaticComplexity: 1,
|
|
},
|
|
});
|
|
}),
|
|
getNode: vi.fn().mockResolvedValue(undefined),
|
|
getEdges: vi.fn().mockResolvedValue([]),
|
|
clear: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
describe('ImpactAnalyzerService', () => {
|
|
let service: ImpactAnalyzerService;
|
|
let mockMemory: MemoryBackend;
|
|
let mockKnowledgeGraph: IKnowledgeGraphService;
|
|
|
|
beforeEach(() => {
|
|
mockMemory = createMockMemoryBackend();
|
|
// Default mock with empty dependencies
|
|
mockKnowledgeGraph = createMockKnowledgeGraph(new Map());
|
|
service = new ImpactAnalyzerService(mockMemory, mockKnowledgeGraph);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('analyzeImpact', () => {
|
|
it('should return empty analysis for no changed files', async () => {
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: [],
|
|
depth: 3,
|
|
includeTests: true,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.directImpact).toEqual([]);
|
|
expect(result.value.transitiveImpact).toEqual([]);
|
|
expect(result.value.impactedTests).toEqual([]);
|
|
expect(result.value.riskLevel).toBe('info');
|
|
}
|
|
});
|
|
|
|
it('should analyze direct impact of changed files', async () => {
|
|
const deps = new Map([
|
|
['src/lib.ts', ['src/consumer1.ts', 'src/consumer2.ts']],
|
|
]);
|
|
mockKnowledgeGraph = createMockKnowledgeGraph(deps);
|
|
service = new ImpactAnalyzerService(mockMemory, mockKnowledgeGraph);
|
|
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: ['src/lib.ts'],
|
|
depth: 3,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.directImpact.length).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
|
|
it('should analyze transitive impact up to specified depth', async () => {
|
|
const deps = new Map([
|
|
['src/core.ts', ['src/layer1.ts']],
|
|
['src/layer1.ts', ['src/layer2.ts']],
|
|
['src/layer2.ts', ['src/layer3.ts']],
|
|
]);
|
|
mockKnowledgeGraph = createMockKnowledgeGraph(deps);
|
|
service = new ImpactAnalyzerService(mockMemory, mockKnowledgeGraph);
|
|
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: ['src/core.ts'],
|
|
depth: 3,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should include impacted tests when requested', async () => {
|
|
const deps = new Map([
|
|
['src/service.ts', ['src/service.test.ts']],
|
|
]);
|
|
mockKnowledgeGraph = createMockKnowledgeGraph(deps);
|
|
service = new ImpactAnalyzerService(mockMemory, mockKnowledgeGraph);
|
|
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: ['src/service.ts'],
|
|
includeTests: true,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should exclude tests when includeTests is false', async () => {
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: ['src/service.ts'],
|
|
includeTests: false,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.impactedTests).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('should calculate risk level based on impact', async () => {
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: ['src/api/auth.ts'],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(['critical', 'high', 'medium', 'low', 'info']).toContain(result.value.riskLevel);
|
|
}
|
|
});
|
|
|
|
it('should generate recommendations based on analysis', async () => {
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: ['src/core/main.ts'],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(Array.isArray(result.value.recommendations)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should store analysis history', async () => {
|
|
await service.analyzeImpact({
|
|
changedFiles: ['src/file.ts'],
|
|
});
|
|
|
|
expect(mockMemory.set).toHaveBeenCalledWith(
|
|
expect.stringContaining('code-intelligence:impact:analysis'),
|
|
expect.objectContaining({
|
|
changedFiles: ['src/file.ts'],
|
|
analysis: expect.any(Object),
|
|
}),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('should handle analysis errors gracefully', async () => {
|
|
const errorKG = createMockKnowledgeGraph(new Map());
|
|
errorKG.mapDependencies = vi.fn().mockRejectedValue(new Error('Graph error'));
|
|
|
|
const errorService = new ImpactAnalyzerService(mockMemory, errorKG);
|
|
const result = await errorService.analyzeImpact({
|
|
changedFiles: ['src/error.ts'],
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getImpactedTests', () => {
|
|
it('should return test file if changed file is a test', async () => {
|
|
const result = await service.getImpactedTests(['src/utils.test.ts']);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toContain('src/utils.test.ts');
|
|
}
|
|
});
|
|
|
|
it('should find tests that depend on changed files', async () => {
|
|
const deps = new Map([
|
|
['src/service.ts', ['src/service.test.ts', 'tests/integration.test.ts']],
|
|
]);
|
|
mockKnowledgeGraph = createMockKnowledgeGraph(deps);
|
|
service = new ImpactAnalyzerService(mockMemory, mockKnowledgeGraph);
|
|
|
|
const result = await service.getImpactedTests(['src/service.ts']);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should search for tests by naming convention', async () => {
|
|
// Store a matching test file in memory
|
|
await mockMemory.set('code-intelligence:kg:node:test_service', {
|
|
properties: { path: 'tests/test_service.py' },
|
|
});
|
|
|
|
const result = await service.getImpactedTests(['src/service.py']);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should deduplicate test files', async () => {
|
|
const result = await service.getImpactedTests([
|
|
'src/a.test.ts',
|
|
'src/a.test.ts', // duplicate
|
|
]);
|
|
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
const uniqueTests = new Set(result.value);
|
|
expect(result.value.length).toBe(uniqueTests.size);
|
|
}
|
|
});
|
|
|
|
it('should handle various test file patterns', async () => {
|
|
const testFiles = [
|
|
'src/component.test.ts',
|
|
'src/component.spec.tsx',
|
|
'tests/test_module.py',
|
|
'tests/module_test.py',
|
|
'pkg/service_test.go',
|
|
];
|
|
|
|
for (const testFile of testFiles) {
|
|
const result = await service.getImpactedTests([testFile]);
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value).toContain(testFile);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('calculateRiskLevel', () => {
|
|
it('should return critical for high impact analysis', () => {
|
|
const highImpact: ImpactAnalysis = {
|
|
directImpact: Array(15).fill({ file: 'f.ts', reason: 'test', distance: 1, riskScore: 0.9 }),
|
|
transitiveImpact: Array(30).fill({ file: 'g.ts', reason: 'test', distance: 2, riskScore: 0.8 }),
|
|
impactedTests: [],
|
|
riskLevel: 'info', // Will be recalculated
|
|
recommendations: [],
|
|
};
|
|
|
|
const riskLevel = service.calculateRiskLevel(highImpact);
|
|
expect(['critical', 'high']).toContain(riskLevel);
|
|
});
|
|
|
|
it('should return info for minimal impact analysis', () => {
|
|
const lowImpact: ImpactAnalysis = {
|
|
directImpact: [],
|
|
transitiveImpact: [],
|
|
impactedTests: ['test.ts'],
|
|
riskLevel: 'info',
|
|
recommendations: [],
|
|
};
|
|
|
|
const riskLevel = service.calculateRiskLevel(lowImpact);
|
|
expect(riskLevel).toBe('info');
|
|
});
|
|
|
|
it('should increase risk when test coverage is low', () => {
|
|
const noTests: ImpactAnalysis = {
|
|
directImpact: Array(5).fill({ file: 'f.ts', reason: 'test', distance: 1, riskScore: 0.5 }),
|
|
transitiveImpact: [],
|
|
impactedTests: [], // No tests
|
|
riskLevel: 'info',
|
|
recommendations: [],
|
|
};
|
|
|
|
const withTests: ImpactAnalysis = {
|
|
...noTests,
|
|
impactedTests: Array(10).fill('test.ts'),
|
|
};
|
|
|
|
const noTestsRisk = service.calculateRiskLevel(noTests);
|
|
const withTestsRisk = service.calculateRiskLevel(withTests);
|
|
|
|
// Risk without tests should be same or higher
|
|
const riskOrder = ['info', 'low', 'medium', 'high', 'critical'];
|
|
expect(riskOrder.indexOf(noTestsRisk)).toBeGreaterThanOrEqual(riskOrder.indexOf(withTestsRisk));
|
|
});
|
|
|
|
it('should consider critical path files', () => {
|
|
const criticalPathImpact: ImpactAnalysis = {
|
|
directImpact: [
|
|
{ file: 'src/auth/login.ts', reason: 'changed', distance: 1, riskScore: 0.6 },
|
|
{ file: 'src/security/validator.ts', reason: 'changed', distance: 1, riskScore: 0.6 },
|
|
],
|
|
transitiveImpact: [],
|
|
impactedTests: ['auth.test.ts'],
|
|
riskLevel: 'info',
|
|
recommendations: [],
|
|
};
|
|
|
|
const riskLevel = service.calculateRiskLevel(criticalPathImpact);
|
|
// Risk calculation depends on weighted factors - any valid severity is acceptable
|
|
// Critical paths contribute to the risk score but may not push it above medium threshold
|
|
expect(['info', 'low', 'medium', 'high', 'critical']).toContain(riskLevel);
|
|
});
|
|
});
|
|
|
|
describe('getRecommendations', () => {
|
|
it('should recommend peer review for high risk changes', () => {
|
|
const highRiskImpact: ImpactAnalysis = {
|
|
directImpact: Array(10).fill({ file: 'f.ts', reason: 'test', distance: 1, riskScore: 0.8 }),
|
|
transitiveImpact: [],
|
|
impactedTests: [],
|
|
riskLevel: 'high',
|
|
recommendations: [],
|
|
};
|
|
|
|
const recommendations = service.getRecommendations(highRiskImpact);
|
|
expect(recommendations.some(r => r.toLowerCase().includes('review'))).toBe(true);
|
|
});
|
|
|
|
it('should recommend adding tests when coverage is low', () => {
|
|
const noTestImpact: ImpactAnalysis = {
|
|
directImpact: [{ file: 'f.ts', reason: 'test', distance: 1, riskScore: 0.5 }],
|
|
transitiveImpact: [],
|
|
impactedTests: [],
|
|
riskLevel: 'medium',
|
|
recommendations: [],
|
|
};
|
|
|
|
const recommendations = service.getRecommendations(noTestImpact);
|
|
expect(recommendations.some(r => r.toLowerCase().includes('test'))).toBe(true);
|
|
});
|
|
|
|
it('should recommend running specific tests when available', () => {
|
|
const withTestsImpact: ImpactAnalysis = {
|
|
directImpact: [{ file: 'f.ts', reason: 'test', distance: 1, riskScore: 0.3 }],
|
|
transitiveImpact: [],
|
|
impactedTests: ['test1.ts', 'test2.ts'],
|
|
riskLevel: 'low',
|
|
recommendations: [],
|
|
};
|
|
|
|
const recommendations = service.getRecommendations(withTestsImpact);
|
|
expect(recommendations.some(r => r.toLowerCase().includes('run'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about critical path impacts', () => {
|
|
const criticalImpact: ImpactAnalysis = {
|
|
directImpact: [
|
|
{ file: 'src/api/routes.ts', reason: 'test', distance: 1, riskScore: 0.6 },
|
|
],
|
|
transitiveImpact: [],
|
|
impactedTests: ['api.test.ts'],
|
|
riskLevel: 'medium',
|
|
recommendations: [],
|
|
};
|
|
|
|
const recommendations = service.getRecommendations(criticalImpact);
|
|
expect(recommendations.some(r => r.toLowerCase().includes('critical'))).toBe(true);
|
|
});
|
|
|
|
it('should recommend breaking down large changes', () => {
|
|
const largeImpact: ImpactAnalysis = {
|
|
directImpact: Array(5).fill({ file: 'f.ts', reason: 'test', distance: 1, riskScore: 0.5 }),
|
|
transitiveImpact: Array(15).fill({ file: 'g.ts', reason: 'test', distance: 2, riskScore: 0.4 }),
|
|
impactedTests: ['test.ts'],
|
|
riskLevel: 'medium',
|
|
recommendations: [],
|
|
};
|
|
|
|
const recommendations = service.getRecommendations(largeImpact);
|
|
expect(recommendations.some(r => r.toLowerCase().includes('break') || r.toLowerCase().includes('smaller'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about high-risk files', () => {
|
|
const highRiskFileImpact: ImpactAnalysis = {
|
|
directImpact: [
|
|
{ file: 'core.ts', reason: 'test', distance: 1, riskScore: 0.85 },
|
|
],
|
|
transitiveImpact: [],
|
|
impactedTests: [],
|
|
riskLevel: 'medium',
|
|
recommendations: [],
|
|
};
|
|
|
|
const recommendations = service.getRecommendations(highRiskFileImpact);
|
|
expect(recommendations.some(r => r.toLowerCase().includes('high-risk') || r.toLowerCase().includes('risk'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('configuration', () => {
|
|
it('should use custom configuration when provided', () => {
|
|
const customConfig: Partial<ImpactAnalyzerConfig> = {
|
|
maxDepth: 10,
|
|
criticalPaths: ['**/custom/**'],
|
|
testPatterns: ['**/*.custom-test.ts'],
|
|
};
|
|
|
|
const customService = new ImpactAnalyzerService(mockMemory, mockKnowledgeGraph, customConfig);
|
|
expect(customService).toBeDefined();
|
|
});
|
|
|
|
it('should use custom risk weights', () => {
|
|
const customConfig: Partial<ImpactAnalyzerConfig> = {
|
|
riskWeights: {
|
|
directImpact: 0.5,
|
|
transitiveImpact: 0.3,
|
|
testCoverage: 0.1,
|
|
criticalPath: 0.05,
|
|
dependencyCount: 0.05,
|
|
},
|
|
};
|
|
|
|
const customService = new ImpactAnalyzerService(mockMemory, mockKnowledgeGraph, customConfig);
|
|
expect(customService).toBeDefined();
|
|
});
|
|
|
|
it('should create default knowledge graph when not provided', () => {
|
|
const serviceWithoutKG = new ImpactAnalyzerService(mockMemory);
|
|
expect(serviceWithoutKG).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle circular dependencies', async () => {
|
|
// Create circular deps: a -> b -> c -> a
|
|
const circularKG = createMockKnowledgeGraph(new Map([
|
|
['a.ts', ['b.ts']],
|
|
['b.ts', ['c.ts']],
|
|
['c.ts', ['a.ts']],
|
|
]));
|
|
|
|
const circularService = new ImpactAnalyzerService(mockMemory, circularKG);
|
|
|
|
const result = await circularService.analyzeImpact({
|
|
changedFiles: ['a.ts'],
|
|
depth: 5,
|
|
});
|
|
|
|
// Should not hang or crash
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should handle files with no dependencies', async () => {
|
|
const isolatedKG = createMockKnowledgeGraph(new Map());
|
|
const isolatedService = new ImpactAnalyzerService(mockMemory, isolatedKG);
|
|
|
|
const result = await isolatedService.analyzeImpact({
|
|
changedFiles: ['isolated.ts'],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should handle deeply nested dependencies', async () => {
|
|
const deepDeps = new Map<string, string[]>();
|
|
for (let i = 0; i < 10; i++) {
|
|
deepDeps.set(`level${i}.ts`, [`level${i + 1}.ts`]);
|
|
}
|
|
|
|
const deepKG = createMockKnowledgeGraph(deepDeps);
|
|
const deepService = new ImpactAnalyzerService(mockMemory, deepKG);
|
|
|
|
const result = await deepService.analyzeImpact({
|
|
changedFiles: ['level0.ts'],
|
|
depth: 5, // Should stop at depth 5
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should handle special characters in file paths', async () => {
|
|
const result = await service.analyzeImpact({
|
|
changedFiles: ['src/my-component (1).ts', 'src/utils[v2].ts'],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
});
|