test-impact-analyzer.tsβ’12.7 kB
import { execSync } from 'child_process';
import { createScopedLogger } from './logger.js';
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
import { resolve, relative, dirname, join } from 'path';
import { glob } from 'glob';
interface TestImpactMapping {
sourceFiles: Record<string, string[]>; // source file -> test files that cover it
testFiles: Record<string, string[]>; // test file -> source files it covers
categories: Record<string, string[]>; // category -> test files
}
interface ChangedFiles {
modified: string[];
added: string[];
deleted: string[];
}
interface TestSelection {
affected: string[];
category: 'smoke' | 'core' | 'extended' | 'integration';
estimatedTime: string;
reason: string;
}
class TestImpactAnalyzer {
private projectRoot: string;
private mapping: TestImpactMapping;
constructor(projectRoot: string = process.cwd()) {
this.projectRoot = projectRoot;
this.mapping = this.loadOrGenerateMapping();
}
/**
* Get tests affected by current git changes
*/
public getAffectedTests(baseBranch: string = 'main'): TestSelection {
const changedFiles = this.getChangedFiles(baseBranch);
const affectedTests = this.calculateAffectedTests(changedFiles);
return this.categorizeTestSelection(affectedTests, changedFiles);
}
/**
* Get changed files from git diff
*/
private getChangedFiles(baseBranch: string): ChangedFiles {
try {
// Get changed files compared to base branch
const diffOutput = execSync(
`git diff --name-status ${baseBranch}...HEAD`,
{
encoding: 'utf-8',
cwd: this.projectRoot,
}
);
const modified: string[] = [];
const added: string[] = [];
const deleted: string[] = [];
diffOutput.split('\n').forEach((line) => {
if (!line.trim()) return;
const [status, ...fileParts] = line.split('\t');
const file = fileParts.join('\t');
switch (status[0]) {
case 'M':
modified.push(file);
break;
case 'A':
added.push(file);
break;
case 'D':
deleted.push(file);
break;
}
});
return { modified, added, deleted };
} catch (error) {
createScopedLogger('utils/test-impact-analyzer', 'getChangedFiles').warn(
'Failed to get git diff, analyzing all tests',
{ error: error instanceof Error ? error.message : String(error) }
);
return { modified: [], added: [], deleted: [] };
}
}
/**
* Calculate which tests are affected by changed files
*/
private calculateAffectedTests(changedFiles: ChangedFiles): string[] {
const affectedTests = new Set<string>();
const allChangedFiles = [...changedFiles.modified, ...changedFiles.added];
// Find tests that directly cover changed source files
allChangedFiles.forEach((file) => {
if (this.mapping.sourceFiles[file]) {
this.mapping.sourceFiles[file].forEach((testFile) => {
affectedTests.add(testFile);
});
}
});
// If changed files include test files, add them directly
allChangedFiles.forEach((file) => {
if (file.includes('.test.') || file.includes('.spec.')) {
affectedTests.add(file);
}
});
return Array.from(affectedTests);
}
/**
* Categorize test selection based on impact and changed files
*/
private categorizeTestSelection(
affectedTests: string[],
changedFiles: ChangedFiles
): TestSelection {
const allChangedFiles = [
...changedFiles.modified,
...changedFiles.added,
...changedFiles.deleted,
];
// If only documentation changes
if (this.isOnlyDocumentationChanges(allChangedFiles)) {
return {
affected: [],
category: 'smoke',
estimatedTime: '30s',
reason: 'Documentation-only changes, running smoke tests for safety',
};
}
// If no affected tests found, run smoke tests
if (affectedTests.length === 0) {
return {
affected: this.mapping.categories.smoke || [],
category: 'smoke',
estimatedTime: '30s',
reason: 'No specific tests affected, running smoke tests',
};
}
// If only a few tests affected, run them + smoke
if (affectedTests.length <= 5) {
const smokeTests = this.mapping.categories.smoke || [];
const allTests = Array.from(new Set([...affectedTests, ...smokeTests]));
return {
affected: allTests,
category: 'core',
estimatedTime: '2m',
reason: `${affectedTests.length} tests affected, including smoke tests`,
};
}
// If many tests affected or core service changes, run extended
if (
affectedTests.length > 10 ||
this.hasCoreServiceChanges(allChangedFiles)
) {
return {
affected: this.mapping.categories.extended || [],
category: 'extended',
estimatedTime: '5m',
reason: 'Significant changes detected, running extended test suite',
};
}
// Default to core tests
const coreTests = this.mapping.categories.core || [];
const combinedTests = Array.from(new Set([...affectedTests, ...coreTests]));
return {
affected: combinedTests,
category: 'core',
estimatedTime: '2m',
reason: `${affectedTests.length} tests affected, running core suite`,
};
}
/**
* Check if changes are only documentation
*/
private isOnlyDocumentationChanges(files: string[]): boolean {
const docPatterns = [
'.md',
'.txt',
'docs/',
'README',
'CHANGELOG',
'LICENSE',
];
return (
files.length > 0 &&
files.every((file) =>
docPatterns.some((pattern) => file.includes(pattern))
)
);
}
/**
* Check if changes include core services
*/
private hasCoreServiceChanges(files: string[]): boolean {
const corePatterns = [
'src/services/',
'src/handlers/',
'src/api/',
'src/index.ts',
];
return files.some((file) =>
corePatterns.some((pattern) => file.includes(pattern))
);
}
/**
* Load or generate test-to-source file mapping
*/
private loadOrGenerateMapping(): TestImpactMapping {
const mappingFile = join(
this.projectRoot,
'config',
'test-impact-mapping.json'
);
if (existsSync(mappingFile)) {
try {
return JSON.parse(readFileSync(mappingFile, 'utf-8'));
} catch (error) {
createScopedLogger('utils/test-impact-analyzer', 'loadMapping').warn(
'Failed to load test mapping, regenerating',
{ error: error instanceof Error ? error.message : String(error) }
);
}
}
return this.generateMapping();
}
/**
* Generate test impact mapping by analyzing imports and file relationships
*/
private generateMapping(): TestImpactMapping {
const mapping: TestImpactMapping = {
sourceFiles: {},
testFiles: {},
categories: {
smoke: this.getCriticalPathTests(),
core: [],
extended: [],
integration: [],
},
};
// Get all test files
const testFiles = glob.sync('test/**/*.test.{ts,js}', {
cwd: this.projectRoot,
});
testFiles.forEach((testFile) => {
const sourceFiles = this.analyzeTestImports(testFile);
mapping.testFiles[testFile] = sourceFiles;
// Reverse mapping: source file -> test files
sourceFiles.forEach((sourceFile) => {
if (!mapping.sourceFiles[sourceFile]) {
mapping.sourceFiles[sourceFile] = [];
}
mapping.sourceFiles[sourceFile].push(testFile);
});
// Categorize tests
this.categorizeTest(testFile, mapping.categories);
});
return mapping;
}
/**
* Analyze imports in a test file to determine which source files it covers
*/
private analyzeTestImports(testFile: string): string[] {
const testPath = join(this.projectRoot, testFile);
const sourceFiles: string[] = [];
try {
const content = readFileSync(testPath, 'utf-8');
const imports = content.match(/from\s+['"`]([^'"`]+)['"`]/g) || [];
imports.forEach((importLine) => {
const match = importLine.match(/from\s+['"`]([^'"`]+)['"`]/);
if (match) {
let importPath = match[1];
// Convert relative imports to actual file paths
if (importPath.startsWith('.')) {
const testDir = dirname(testFile);
const resolvedPath = resolve(
join(this.projectRoot, testDir),
importPath
);
importPath = relative(this.projectRoot, resolvedPath);
}
// Add .ts/.js extensions if missing
if (!importPath.includes('.')) {
const tsFile = `${importPath}.ts`;
const jsFile = `${importPath}.js`;
if (existsSync(join(this.projectRoot, tsFile))) {
importPath = tsFile;
} else if (existsSync(join(this.projectRoot, jsFile))) {
importPath = jsFile;
}
}
// Only include source files (not test files or node_modules)
if (importPath.startsWith('src/') && !importPath.includes('test')) {
sourceFiles.push(importPath);
}
}
});
} catch (error) {
createScopedLogger('utils/test-impact-analyzer', 'analyzeImports').warn(
`Failed to analyze imports in ${testFile}`,
{ error: error instanceof Error ? error.message : String(error) }
);
}
return sourceFiles;
}
/**
* Get critical path tests for smoke testing
*/
private getCriticalPathTests(): string[] {
// These are the most critical tests that must pass
const criticalTests = [
'test/services/UniversalCreateService.test.ts',
'test/services/UniversalSearchService.test.ts',
'test/handlers/tools.test.ts',
'test/api/advanced-search.test.ts',
];
return criticalTests.filter((test) =>
existsSync(join(this.projectRoot, test))
);
}
/**
* Categorize a test based on its path and content
*/
private categorizeTest(
testFile: string,
categories: Record<string, string[]>
): void {
// Integration tests
if (testFile.includes('integration/') || testFile.includes('e2e/')) {
categories.integration.push(testFile);
return;
}
// Core service tests
if (testFile.includes('services/') || testFile.includes('handlers/')) {
categories.core.push(testFile);
return;
}
// Extended tests (API, objects, utils)
if (
testFile.includes('api/') ||
testFile.includes('objects/') ||
testFile.includes('utils/')
) {
categories.extended.push(testFile);
return;
}
// Default to extended
categories.extended.push(testFile);
}
/**
* Save the mapping to disk for future use
*/
public saveMapping(): void {
const mappingFile = join(
this.projectRoot,
'config',
'test-impact-mapping.json'
);
const mappingDir = dirname(mappingFile);
if (!existsSync(mappingDir)) {
mkdirSync(mappingDir, { recursive: true });
}
writeFileSync(mappingFile, JSON.stringify(this.mapping, null, 2));
}
/**
* Generate a report of the test impact analysis
*/
public generateReport(baseBranch: string = 'main'): string {
const selection = this.getAffectedTests(baseBranch);
const changedFiles = this.getChangedFiles(baseBranch);
let report = '# Test Impact Analysis Report\n\n';
report += `**Base Branch**: ${baseBranch}\n`;
report += `**Category**: ${selection.category}\n`;
report += `**Estimated Time**: ${selection.estimatedTime}\n`;
report += `**Reason**: ${selection.reason}\n\n`;
report += '## Changed Files\n';
report += `- Modified: ${changedFiles.modified.length}\n`;
report += `- Added: ${changedFiles.added.length}\n`;
report += `- Deleted: ${changedFiles.deleted.length}\n\n`;
if (changedFiles.modified.length > 0) {
report += '### Modified Files\n';
changedFiles.modified.forEach((file) => {
report += `- ${file}\n`;
});
report += '\n';
}
report += '## Affected Tests\n';
if (selection.affected.length === 0) {
report += 'No specific tests affected.\n';
} else {
selection.affected.forEach((test) => {
report += `- ${test}\n`;
});
}
return report;
}
}
// Export for use as a module
export { TestImpactAnalyzer };