531 lines
21 KiB
JavaScript
531 lines
21 KiB
JavaScript
/**
|
|
* Coverage Router for Test Routing
|
|
*
|
|
* Optimizations:
|
|
* - Async file I/O for non-blocking coverage loading
|
|
* - TTL-based caching of coverage data
|
|
* - Singleton router instance
|
|
*/
|
|
import * as pathMod from 'node:path';
|
|
import { existsSync } from 'node:fs';
|
|
import { readFile } from 'node:fs/promises';
|
|
// ============================================================================
|
|
// Caching for Performance
|
|
// ============================================================================
|
|
/**
|
|
* Cache for coverage data (1 minute TTL)
|
|
*/
|
|
const coverageDataCache = new Map();
|
|
const COVERAGE_CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
/**
|
|
* Clear coverage cache
|
|
*/
|
|
export function clearCoverageCache() {
|
|
coverageDataCache.clear();
|
|
}
|
|
/**
|
|
* Get coverage cache stats
|
|
*/
|
|
export function getCoverageCacheStats() {
|
|
return { size: coverageDataCache.size };
|
|
}
|
|
const DEFAULT_CONFIG = {
|
|
minCoverage: 70,
|
|
targetCoverage: 85,
|
|
incremental: true,
|
|
coverageTypes: ['line', 'branch', 'function', 'statement'],
|
|
};
|
|
export class CoverageRouter {
|
|
config;
|
|
ruvectorEngine = null;
|
|
useNative = false;
|
|
coverageHistory = [];
|
|
constructor(config = {}) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
}
|
|
async initialize() {
|
|
try {
|
|
// @ruvector/coverage is optional - gracefully fallback if not installed
|
|
const ruvector = await import('@ruvector/coverage').catch(() => null);
|
|
if (ruvector) {
|
|
this.ruvectorEngine = ruvector.createCoverageRouter?.(this.config);
|
|
this.useNative = !!this.ruvectorEngine;
|
|
}
|
|
}
|
|
catch {
|
|
this.useNative = false;
|
|
}
|
|
}
|
|
parseCoverage(data, format = 'json') {
|
|
switch (format) {
|
|
case 'lcov': return this.parseLcov(data);
|
|
case 'istanbul': return this.parseIstanbul(data);
|
|
case 'cobertura': return this.parseCobertura(data);
|
|
default: return this.parseJson(data);
|
|
}
|
|
}
|
|
route(coverage, changedFiles) {
|
|
const gaps = this.calculateGaps(coverage);
|
|
const targetFiles = this.prioritizeFiles(coverage, changedFiles);
|
|
const action = this.determineAction(coverage, gaps);
|
|
const priority = this.calculatePriority(coverage, changedFiles);
|
|
const testTypes = this.recommendTestTypes(gaps);
|
|
const estimatedEffort = this.estimateEffort(gaps);
|
|
const impactScore = this.calculateImpact(coverage, targetFiles);
|
|
return { action, priority, targetFiles, testTypes, gaps, estimatedEffort, impactScore };
|
|
}
|
|
getTrend() {
|
|
if (this.coverageHistory.length < 2)
|
|
return { direction: 'stable', change: 0 };
|
|
const recent = this.coverageHistory[this.coverageHistory.length - 1];
|
|
const previous = this.coverageHistory[this.coverageHistory.length - 2];
|
|
const change = recent.overall - previous.overall;
|
|
return { direction: change > 0.5 ? 'up' : change < -0.5 ? 'down' : 'stable', change };
|
|
}
|
|
addToHistory(report) {
|
|
this.coverageHistory.push(report);
|
|
if (this.coverageHistory.length > 10)
|
|
this.coverageHistory.shift();
|
|
}
|
|
getStats() {
|
|
return { useNative: this.useNative, historySize: this.coverageHistory.length, minCoverage: this.config.minCoverage, targetCoverage: this.config.targetCoverage };
|
|
}
|
|
parseLcov(data) {
|
|
const files = [];
|
|
let currentFile = null;
|
|
const lines = data.split('\n');
|
|
for (const line of lines) {
|
|
if (line.startsWith('SF:')) {
|
|
if (currentFile?.path)
|
|
files.push(this.finalizeFileCoverage(currentFile));
|
|
currentFile = { path: line.substring(3), uncoveredLines: [], totalLines: 0, coveredLines: 0 };
|
|
}
|
|
else if (line.startsWith('LF:')) {
|
|
if (currentFile)
|
|
currentFile.totalLines = parseInt(line.substring(3), 10);
|
|
}
|
|
else if (line.startsWith('LH:')) {
|
|
if (currentFile)
|
|
currentFile.coveredLines = parseInt(line.substring(3), 10);
|
|
}
|
|
else if (line.startsWith('DA:')) {
|
|
const [lineNum, hits] = line.substring(3).split(',').map(Number);
|
|
if (currentFile && hits === 0)
|
|
currentFile.uncoveredLines?.push(lineNum);
|
|
}
|
|
else if (line === 'end_of_record') {
|
|
if (currentFile?.path)
|
|
files.push(this.finalizeFileCoverage(currentFile));
|
|
currentFile = null;
|
|
}
|
|
}
|
|
return this.buildReport(files);
|
|
}
|
|
parseIstanbul(data) {
|
|
const files = [];
|
|
for (const [path, coverage] of Object.entries(data)) {
|
|
const cov = coverage;
|
|
const statements = cov.s;
|
|
const functions = cov.f;
|
|
const branches = cov.b;
|
|
const statementCovered = Object.values(statements).filter(v => v > 0).length;
|
|
const statementTotal = Object.values(statements).length;
|
|
const functionCovered = Object.values(functions).filter(v => v > 0).length;
|
|
const functionTotal = Object.values(functions).length;
|
|
const branchCovered = Object.values(branches).flat().filter(v => v > 0).length;
|
|
const branchTotal = Object.values(branches).flat().length;
|
|
files.push({
|
|
path, lineCoverage: statementTotal > 0 ? (statementCovered / statementTotal) * 100 : 100,
|
|
branchCoverage: branchTotal > 0 ? (branchCovered / branchTotal) * 100 : 100,
|
|
functionCoverage: functionTotal > 0 ? (functionCovered / functionTotal) * 100 : 100,
|
|
statementCoverage: statementTotal > 0 ? (statementCovered / statementTotal) * 100 : 100,
|
|
uncoveredLines: [], totalLines: statementTotal, coveredLines: statementCovered,
|
|
});
|
|
}
|
|
return this.buildReport(files);
|
|
}
|
|
parseCobertura(data) {
|
|
const files = [];
|
|
const classMatches = data.matchAll(/<class[^>]*filename="([^"]+)"[^>]*line-rate="([^"]+)"[^>]*branch-rate="([^"]+)"[^>]*>/g);
|
|
for (const match of classMatches) {
|
|
files.push({
|
|
path: match[1], lineCoverage: parseFloat(match[2]) * 100, branchCoverage: parseFloat(match[3]) * 100,
|
|
functionCoverage: parseFloat(match[2]) * 100, statementCoverage: parseFloat(match[2]) * 100,
|
|
uncoveredLines: [], totalLines: 0, coveredLines: 0,
|
|
});
|
|
}
|
|
return this.buildReport(files);
|
|
}
|
|
parseJson(data) {
|
|
if (Array.isArray(data))
|
|
return this.buildReport(data);
|
|
const files = [];
|
|
for (const [path, coverage] of Object.entries(data)) {
|
|
const cov = coverage;
|
|
files.push({
|
|
path, lineCoverage: cov.lineCoverage || 0, branchCoverage: cov.branchCoverage || 0,
|
|
functionCoverage: cov.functionCoverage || 0, statementCoverage: cov.statementCoverage || 0,
|
|
uncoveredLines: cov.uncoveredLines || [], totalLines: cov.totalLines || 0, coveredLines: cov.coveredLines || 0,
|
|
});
|
|
}
|
|
return this.buildReport(files);
|
|
}
|
|
finalizeFileCoverage(partial) {
|
|
const lineCoverage = partial.totalLines && partial.totalLines > 0 ? (partial.coveredLines || 0) / partial.totalLines * 100 : 100;
|
|
return { path: partial.path || 'unknown', lineCoverage, branchCoverage: lineCoverage, functionCoverage: lineCoverage, statementCoverage: lineCoverage, uncoveredLines: partial.uncoveredLines || [], totalLines: partial.totalLines || 0, coveredLines: partial.coveredLines || 0 };
|
|
}
|
|
buildReport(files) {
|
|
const totalLines = files.reduce((sum, f) => sum + f.totalLines, 0);
|
|
const coveredLines = files.reduce((sum, f) => sum + f.coveredLines, 0);
|
|
const overall = totalLines > 0 ? (coveredLines / totalLines) * 100 : 100;
|
|
const avgLine = files.length > 0 ? files.reduce((sum, f) => sum + f.lineCoverage, 0) / files.length : 100;
|
|
const avgBranch = files.length > 0 ? files.reduce((sum, f) => sum + f.branchCoverage, 0) / files.length : 100;
|
|
const avgFunction = files.length > 0 ? files.reduce((sum, f) => sum + f.functionCoverage, 0) / files.length : 100;
|
|
const avgStatement = files.length > 0 ? files.reduce((sum, f) => sum + f.statementCoverage, 0) / files.length : 100;
|
|
const sortedByLine = [...files].sort((a, b) => a.lineCoverage - b.lineCoverage);
|
|
return { overall, byType: { line: avgLine, branch: avgBranch, function: avgFunction, statement: avgStatement }, byFile: files, lowestCoverage: sortedByLine.slice(0, 5), highestCoverage: sortedByLine.slice(-5).reverse(), uncoveredCritical: this.findCriticalUncovered(files), timestamp: Date.now() };
|
|
}
|
|
findCriticalUncovered(files) {
|
|
const critical = [];
|
|
const criticalPatterns = [/auth/, /security/, /payment/, /core/, /main/, /index/];
|
|
for (const file of files) {
|
|
if (file.lineCoverage < this.config.minCoverage) {
|
|
for (const pattern of criticalPatterns) {
|
|
if (pattern.test(file.path)) {
|
|
critical.push(file.path);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return critical.slice(0, 10);
|
|
}
|
|
calculateGaps(coverage) {
|
|
const gaps = [];
|
|
for (const file of coverage.byFile) {
|
|
if (file.lineCoverage < this.config.targetCoverage) {
|
|
const gap = this.config.targetCoverage - file.lineCoverage;
|
|
gaps.push({ file: file.path, currentCoverage: file.lineCoverage, targetCoverage: this.config.targetCoverage, gap, suggestedTests: this.suggestTests(file) });
|
|
}
|
|
}
|
|
return gaps.sort((a, b) => b.gap - a.gap).slice(0, 10);
|
|
}
|
|
suggestTests(file) {
|
|
const suggestions = [];
|
|
if (file.uncoveredLines.length > 10)
|
|
suggestions.push('Add unit tests for uncovered code paths');
|
|
if (file.branchCoverage < 50)
|
|
suggestions.push('Add branch coverage tests (if/else paths)');
|
|
if (file.functionCoverage < 80)
|
|
suggestions.push('Add tests for untested functions');
|
|
if (/api|endpoint|route|handler/.test(file.path))
|
|
suggestions.push('Add integration tests for API endpoints');
|
|
return suggestions.slice(0, 3);
|
|
}
|
|
prioritizeFiles(coverage, changedFiles) {
|
|
let targetFiles = coverage.lowestCoverage.map(f => f.path);
|
|
if (changedFiles && changedFiles.length > 0) {
|
|
const changedWithLowCoverage = coverage.byFile.filter(f => changedFiles.some(cf => f.path.includes(cf))).filter(f => f.lineCoverage < this.config.targetCoverage).map(f => f.path);
|
|
targetFiles = [...new Set([...changedWithLowCoverage, ...targetFiles])];
|
|
}
|
|
return targetFiles.slice(0, 10);
|
|
}
|
|
determineAction(coverage, gaps) {
|
|
if (coverage.overall < this.config.minCoverage)
|
|
return 'prioritize';
|
|
if (gaps.length > 5)
|
|
return 'add-tests';
|
|
if (coverage.overall < this.config.targetCoverage)
|
|
return 'review-coverage';
|
|
return 'skip';
|
|
}
|
|
calculatePriority(coverage, changedFiles) {
|
|
let priority = 5;
|
|
if (coverage.overall < 50)
|
|
priority += 4;
|
|
else if (coverage.overall < 70)
|
|
priority += 2;
|
|
else if (coverage.overall < 85)
|
|
priority += 1;
|
|
priority += Math.min(3, coverage.uncoveredCritical.length);
|
|
if (changedFiles && changedFiles.length > 0) {
|
|
const changedLowCoverage = coverage.byFile.filter(f => changedFiles.some(cf => f.path.includes(cf))).filter(f => f.lineCoverage < this.config.minCoverage);
|
|
priority += Math.min(2, changedLowCoverage.length);
|
|
}
|
|
return Math.min(10, priority);
|
|
}
|
|
recommendTestTypes(gaps) {
|
|
const types = new Set(['unit']);
|
|
for (const gap of gaps) {
|
|
if (/api|endpoint|route|handler|service/.test(gap.file))
|
|
types.add('integration');
|
|
if (/page|component|view|ui/.test(gap.file))
|
|
types.add('e2e');
|
|
}
|
|
return Array.from(types);
|
|
}
|
|
estimateEffort(gaps) {
|
|
let effort = 0;
|
|
for (const gap of gaps)
|
|
effort += (gap.gap / 10) * 0.5;
|
|
return Math.round(effort * 10) / 10;
|
|
}
|
|
calculateImpact(coverage, targetFiles) {
|
|
const potentialGain = targetFiles.reduce((sum, file) => {
|
|
const fileCov = coverage.byFile.find(f => f.path === file);
|
|
return fileCov ? sum + (this.config.targetCoverage - fileCov.lineCoverage) : sum;
|
|
}, 0);
|
|
return Math.min(100, Math.round(potentialGain / targetFiles.length || 0));
|
|
}
|
|
}
|
|
export function createCoverageRouter(config) {
|
|
return new CoverageRouter(config);
|
|
}
|
|
/**
|
|
* Route a task based on coverage analysis
|
|
*/
|
|
export async function coverageRoute(task, options = {}) {
|
|
const router = new CoverageRouter({
|
|
targetCoverage: options.threshold || 80,
|
|
});
|
|
// Try to load coverage data
|
|
const coverage = await loadProjectCoverage(options.projectRoot);
|
|
if (!coverage) {
|
|
return {
|
|
action: 'skip',
|
|
priority: 1,
|
|
targetFiles: [],
|
|
testTypes: ['unit'],
|
|
gaps: [],
|
|
estimatedEffort: 0,
|
|
impactScore: 0,
|
|
};
|
|
}
|
|
return router.route(coverage);
|
|
}
|
|
/**
|
|
* Suggest coverage improvements for a path
|
|
*/
|
|
export async function coverageSuggest(path, options = {}) {
|
|
const limit = options.limit || 20;
|
|
const threshold = options.threshold || 80;
|
|
const coverage = await loadProjectCoverage(options.projectRoot);
|
|
if (!coverage) {
|
|
return {
|
|
path,
|
|
suggestions: [],
|
|
totalGap: 0,
|
|
estimatedEffort: 0,
|
|
};
|
|
}
|
|
// Filter files matching the path
|
|
const matchingFiles = coverage.byFile.filter(f => f.path.includes(path));
|
|
const belowThreshold = matchingFiles.filter(f => f.lineCoverage < threshold);
|
|
const suggestions = belowThreshold
|
|
.map(f => ({
|
|
file: f.path,
|
|
currentCoverage: f.lineCoverage,
|
|
targetCoverage: threshold,
|
|
gap: threshold - f.lineCoverage,
|
|
priority: calculateFilePriority(f.path, f.lineCoverage, threshold),
|
|
suggestedTests: suggestTestsForFile(f),
|
|
}))
|
|
.sort((a, b) => b.priority - a.priority)
|
|
.slice(0, limit);
|
|
const totalGap = suggestions.reduce((sum, s) => sum + s.gap, 0);
|
|
const estimatedEffort = totalGap * 0.1; // Rough estimate: 0.1 hours per % gap
|
|
return { path, suggestions, totalGap, estimatedEffort };
|
|
}
|
|
/**
|
|
* List all coverage gaps with agent assignments
|
|
*/
|
|
export async function coverageGaps(options = {}) {
|
|
const threshold = options.threshold || 80;
|
|
const groupByAgent = options.groupByAgent !== false;
|
|
const coverage = await loadProjectCoverage(options.projectRoot);
|
|
if (!coverage) {
|
|
return {
|
|
totalGaps: 0,
|
|
gaps: [],
|
|
byAgent: {},
|
|
summary: 'No coverage data found',
|
|
};
|
|
}
|
|
const belowThreshold = coverage.byFile.filter(f => f.lineCoverage < threshold);
|
|
const gaps = belowThreshold.map(f => ({
|
|
file: f.path,
|
|
currentCoverage: f.lineCoverage,
|
|
targetCoverage: threshold,
|
|
gap: threshold - f.lineCoverage,
|
|
priority: calculateFilePriority(f.path, f.lineCoverage, threshold),
|
|
suggestedAgent: suggestAgentForFile(f.path),
|
|
}));
|
|
const byAgent = {};
|
|
if (groupByAgent) {
|
|
for (const gap of gaps) {
|
|
if (!byAgent[gap.suggestedAgent]) {
|
|
byAgent[gap.suggestedAgent] = [];
|
|
}
|
|
byAgent[gap.suggestedAgent].push(gap.file);
|
|
}
|
|
}
|
|
return {
|
|
totalGaps: gaps.length,
|
|
gaps,
|
|
byAgent,
|
|
summary: `${gaps.length} files below ${threshold}% coverage threshold`,
|
|
};
|
|
}
|
|
/**
|
|
* Validate and normalize path to prevent directory traversal
|
|
* Returns null if path is invalid or attempts traversal
|
|
*/
|
|
function validateProjectPath(inputPath) {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires -- sync function, top-level import at file head
|
|
const { resolve, normalize, isAbsolute } = pathMod;
|
|
// Default to cwd if not provided
|
|
const basePath = inputPath || process.cwd();
|
|
// Normalize and resolve the path
|
|
const normalizedPath = normalize(basePath);
|
|
const resolvedPath = isAbsolute(normalizedPath) ? normalizedPath : resolve(process.cwd(), normalizedPath);
|
|
// Check for path traversal attempts
|
|
if (normalizedPath.includes('..') && !resolvedPath.startsWith(process.cwd())) {
|
|
// Only allow .. if it resolves within or above cwd
|
|
// For safety, reject any path with .. that goes outside project
|
|
return null;
|
|
}
|
|
// Additional validation: no null bytes or control characters
|
|
if (/[\x00-\x1f]/.test(resolvedPath)) {
|
|
return null;
|
|
}
|
|
// Limit path length to prevent DoS
|
|
if (resolvedPath.length > 4096) {
|
|
return null;
|
|
}
|
|
return resolvedPath;
|
|
}
|
|
/**
|
|
* Load project coverage data (async with caching)
|
|
*/
|
|
async function loadProjectCoverage(projectRoot, skipCache) {
|
|
// Validate and normalize the project root path
|
|
const root = validateProjectPath(projectRoot);
|
|
if (!root) {
|
|
// Invalid path detected, return null safely
|
|
return null;
|
|
}
|
|
// Check cache first
|
|
if (!skipCache) {
|
|
const cached = coverageDataCache.get(root);
|
|
if (cached && Date.now() - cached.timestamp < COVERAGE_CACHE_TTL_MS) {
|
|
return cached.report;
|
|
}
|
|
}
|
|
const { join, normalize } = pathMod;
|
|
// Try common coverage locations (all relative to validated root)
|
|
const coverageLocations = [
|
|
['coverage', 'coverage-final.json'],
|
|
['coverage', 'lcov.info'],
|
|
['.nyc_output', 'coverage.json'],
|
|
['coverage.json'],
|
|
];
|
|
for (const pathParts of coverageLocations) {
|
|
// Join and normalize to prevent traversal in coverage paths
|
|
const coveragePath = normalize(join(root, ...pathParts));
|
|
// Ensure the coverage path is still within or under root
|
|
if (!coveragePath.startsWith(root)) {
|
|
continue;
|
|
}
|
|
if (existsSync(coveragePath)) {
|
|
try {
|
|
// Use async file read for non-blocking I/O
|
|
const content = await readFile(coveragePath, 'utf-8');
|
|
const router = new CoverageRouter();
|
|
let report = null;
|
|
if (coveragePath.endsWith('.json')) {
|
|
report = router.parseCoverage(JSON.parse(content), 'istanbul');
|
|
}
|
|
else if (coveragePath.endsWith('.info')) {
|
|
report = router.parseCoverage(content, 'lcov');
|
|
}
|
|
// Cache the result
|
|
if (report) {
|
|
coverageDataCache.set(root, { report, timestamp: Date.now() });
|
|
return report;
|
|
}
|
|
}
|
|
catch {
|
|
// Continue to next path
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
/**
|
|
* Calculate priority for a file based on path and coverage
|
|
*/
|
|
function calculateFilePriority(path, coverage, threshold) {
|
|
let priority = 5;
|
|
// Gap-based priority
|
|
const gap = threshold - coverage;
|
|
if (gap > 50)
|
|
priority += 3;
|
|
else if (gap > 30)
|
|
priority += 2;
|
|
else if (gap > 15)
|
|
priority += 1;
|
|
// Path-based priority
|
|
const lowerPath = path.toLowerCase();
|
|
if (/core|main|index/.test(lowerPath))
|
|
priority += 2;
|
|
if (/auth|security|payment/.test(lowerPath))
|
|
priority += 3;
|
|
if (/api|service|controller/.test(lowerPath))
|
|
priority += 1;
|
|
if (/util|helper/.test(lowerPath))
|
|
priority -= 1;
|
|
if (/test|spec|mock/.test(lowerPath))
|
|
priority -= 2;
|
|
return Math.max(1, Math.min(10, priority));
|
|
}
|
|
/**
|
|
* Suggest tests for a file based on its coverage
|
|
*/
|
|
function suggestTestsForFile(file) {
|
|
const suggestions = [];
|
|
if (file.uncoveredLines.length > 10) {
|
|
suggestions.push('Add unit tests for uncovered code paths');
|
|
}
|
|
if (file.branchCoverage < 50) {
|
|
suggestions.push('Add branch coverage tests (if/else paths)');
|
|
}
|
|
if (file.functionCoverage < 80) {
|
|
suggestions.push('Add tests for untested functions');
|
|
}
|
|
const lowerPath = file.path.toLowerCase();
|
|
if (/api|endpoint|route|handler/.test(lowerPath)) {
|
|
suggestions.push('Add integration tests for API endpoints');
|
|
}
|
|
if (/component|view|ui/.test(lowerPath)) {
|
|
suggestions.push('Add component tests with user interactions');
|
|
}
|
|
return suggestions.slice(0, 3);
|
|
}
|
|
/**
|
|
* Suggest an agent type for a file
|
|
*/
|
|
function suggestAgentForFile(path) {
|
|
const lowerPath = path.toLowerCase();
|
|
if (/api|endpoint|route|controller/.test(lowerPath))
|
|
return 'api-tester';
|
|
if (/component|view|ui|page/.test(lowerPath))
|
|
return 'ui-tester';
|
|
if (/service|repository|model/.test(lowerPath))
|
|
return 'unit-tester';
|
|
if (/integration|e2e/.test(lowerPath))
|
|
return 'e2e-tester';
|
|
if (/util|helper|lib/.test(lowerPath))
|
|
return 'unit-tester';
|
|
return 'tester';
|
|
}
|
|
//# sourceMappingURL=coverage-router.js.map
|