claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
529 lines • 21.5 kB
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
*/
// ============================================================================
// 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) {
const { resolve, normalize, isAbsolute } = require('path');
// 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 { existsSync } = require('fs');
const { readFile } = require('fs/promises');
const { join, normalize } = require('path');
// 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