forge-mutation-tester
Version:
Mutation testing tool for Solidity smart contracts using Gambit
422 lines • 19.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CoverageService = void 0;
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const chalk_1 = __importDefault(require("chalk"));
const child_process_1 = require("child_process");
const util_1 = require("util");
const execAsync = (0, util_1.promisify)(child_process_1.exec);
class CoverageService {
async analyzeCoverage(projectPath) {
console.log(chalk_1.default.dim(' Detecting project type...'));
// Check if it's a Forge project
const foundryTomlPath = path_1.default.join(projectPath, 'foundry.toml');
const isForgeProject = await fs_1.promises.access(foundryTomlPath).then(() => true).catch(() => false);
if (isForgeProject) {
return await this.analyzeForgeProjectCoverage(projectPath);
}
else {
return await this.analyzeHardhatProjectCoverage(projectPath);
}
}
async analyzeForgeProjectCoverage(projectPath) {
console.log(chalk_1.default.dim(' Running forge coverage analysis...'));
try {
// Run forge coverage with LCOV output for detailed analysis
const { stdout: coverageOutput } = await execAsync('forge coverage --report lcov --report summary', {
cwd: projectPath,
maxBuffer: 1024 * 1024 * 10, // 10MB
timeout: 120000 // 2 minutes
});
// Parse the summary output for overall coverage - try multiple formats
let overallCoverage = 0;
// Try different forge coverage output formats
const patterns = [
/Overall coverage: ([\d.]+)%/i, // "Overall coverage: 85.5%"
/Total.*?\|\s*([\d.]+)%/i, // Table format "| Total | 85.5% |"
/\|\s*Total\s*\|\s*([\d.]+)%/i, // "| Total | 85.5% |"
/Lines:\s*([\d.]+)%/i, // "Lines: 85.5%"
/Statement coverage:\s*([\d.]+)%/i, // Statement coverage format
];
for (const pattern of patterns) {
const match = coverageOutput.match(pattern);
if (match) {
overallCoverage = parseFloat(match[1]);
console.log(chalk_1.default.dim(` Parsed coverage: ${overallCoverage}% using pattern: ${pattern.source}`));
break;
}
}
// If still 0, log the output for debugging
if (overallCoverage === 0) {
console.log(chalk_1.default.yellow(' Warning: Could not parse coverage percentage from output:'));
console.log(chalk_1.default.dim(coverageOutput.substring(0, 500) + '...'));
// Try to extract any percentage from the output as fallback
const anyPercentage = coverageOutput.match(/([\d.]+)%/);
if (anyPercentage) {
overallCoverage = parseFloat(anyPercentage[1]);
console.log(chalk_1.default.yellow(` Using fallback percentage: ${overallCoverage}%`));
}
}
// Read LCOV file for detailed analysis
const lcovPath = path_1.default.join(projectPath, 'lcov.info');
let lcovData = '';
try {
lcovData = await fs_1.promises.readFile(lcovPath, 'utf-8');
}
catch {
console.log(chalk_1.default.yellow(' Warning: LCOV file not found, using basic analysis'));
}
return await this.parseCoverageData(projectPath, coverageOutput, lcovData, overallCoverage);
}
catch (error) {
throw new Error(`Failed to analyze Forge coverage: ${error.message}`);
}
}
async analyzeHardhatProjectCoverage(projectPath) {
console.log(chalk_1.default.dim(' Running Hardhat coverage analysis...'));
try {
// Run hardhat coverage
const { stdout: coverageOutput } = await execAsync('npx hardhat coverage', {
cwd: projectPath,
maxBuffer: 1024 * 1024 * 10,
timeout: 300000 // 5 minutes
});
// Parse coverage output
const overallMatch = coverageOutput.match(/All files\s+\|\s+([\d.]+)/);
const overallCoverage = overallMatch ? parseFloat(overallMatch[1]) : 0;
// Read coverage JSON if available
const coverageJsonPath = path_1.default.join(projectPath, 'coverage/coverage-final.json');
let coverageJson = '';
try {
coverageJson = await fs_1.promises.readFile(coverageJsonPath, 'utf-8');
}
catch {
console.log(chalk_1.default.yellow(' Warning: Coverage JSON not found, using basic analysis'));
}
return await this.parseCoverageData(projectPath, coverageOutput, coverageJson, overallCoverage);
}
catch (error) {
throw new Error(`Failed to analyze Hardhat coverage: ${error.message}`);
}
}
async parseCoverageData(projectPath, coverageOutput, additionalData, overallCoverage) {
console.log(chalk_1.default.dim(' Parsing coverage data and identifying gaps...'));
// Find source files
const sourceFiles = await this.findSourceFiles(projectPath);
const uncoveredLines = [];
const uncoveredFunctions = [];
const uncoveredBranches = [];
const fileReports = [];
// Parse LCOV data if available
if (additionalData && additionalData.includes('TN:')) {
const lcovReport = this.parseLCOVData(additionalData);
// Process each source file
for (const file of sourceFiles) {
const fileReport = await this.analyzeFileForUncoveredCode(file, projectPath, lcovReport.get(file));
fileReports.push(fileReport);
// Add uncovered items from this file
if (fileReport.uncoveredLines)
uncoveredLines.push(...fileReport.uncoveredLines);
if (fileReport.uncoveredFunctions)
uncoveredFunctions.push(...fileReport.uncoveredFunctions);
if (fileReport.uncoveredBranches)
uncoveredBranches.push(...fileReport.uncoveredBranches);
}
}
else {
// Fallback to basic analysis
for (const file of sourceFiles) {
const fileReport = await this.analyzeFileForUncoveredCode(file, projectPath);
fileReports.push(fileReport);
if (fileReport.uncoveredLines)
uncoveredLines.push(...fileReport.uncoveredLines);
if (fileReport.uncoveredFunctions)
uncoveredFunctions.push(...fileReport.uncoveredFunctions);
if (fileReport.uncoveredBranches)
uncoveredBranches.push(...fileReport.uncoveredBranches);
}
}
return {
overallCoverage,
lineCoverage: this.calculateAverageLineCoverage(fileReports),
functionCoverage: this.calculateAverageFunctionCoverage(fileReports),
branchCoverage: this.calculateAverageBranchCoverage(fileReports),
uncoveredLines,
uncoveredFunctions,
uncoveredBranches,
fileReports
};
}
parseLCOVData(lcovData) {
const fileMap = new Map();
const sections = lcovData.split('TN:').slice(1); // Remove empty first element
for (const section of sections) {
const lines = section.trim().split('\n');
let currentFile = '';
const uncoveredLines = [];
const uncoveredFunctions = [];
for (const line of lines) {
if (line.startsWith('SF:')) {
currentFile = line.substring(3);
}
else if (line.startsWith('DA:')) {
// DA:line_number,hit_count
const [lineNum, hitCount] = line.substring(3).split(',');
if (parseInt(hitCount) === 0) {
uncoveredLines.push(parseInt(lineNum));
}
}
else if (line.startsWith('FN:')) {
// FN:line_number,function_name
const [lineNum, funcName] = line.substring(3).split(',');
uncoveredFunctions.push(funcName);
}
}
if (currentFile) {
fileMap.set(currentFile, { uncoveredLines, uncoveredFunctions });
}
}
return fileMap;
}
async findSourceFiles(projectPath) {
const glob = require('glob');
let sourceFiles = [];
try {
sourceFiles = glob.sync('src/**/*.sol', { cwd: projectPath });
if (sourceFiles.length === 0) {
sourceFiles = glob.sync('contracts/**/*.sol', { cwd: projectPath });
}
}
catch {
sourceFiles = [];
}
// Filter out test files
return sourceFiles.filter(file => {
const fileName = path_1.default.basename(file);
const filePath = file.toLowerCase();
return !(filePath.includes('/test/') ||
filePath.includes('/tests/') ||
fileName.endsWith('.test.sol') ||
fileName.endsWith('.t.sol') ||
fileName.startsWith('Test') ||
fileName.endsWith('Test.sol') ||
filePath.includes('mock') ||
filePath.includes('Mock'));
});
}
async analyzeFileForUncoveredCode(file, projectPath, lcovFileData) {
const filePath = path_1.default.join(projectPath, file);
const sourceCode = await fs_1.promises.readFile(filePath, 'utf-8');
const lines = sourceCode.split('\n');
const uncoveredLines = [];
const uncoveredFunctions = [];
const uncoveredBranches = [];
// Use LCOV data if available
if (lcovFileData) {
// Process uncovered lines from LCOV
for (const lineNum of lcovFileData.uncoveredLines || []) {
if (lineNum <= lines.length) {
const code = lines[lineNum - 1].trim();
if (this.isExecutableLine(code)) {
uncoveredLines.push({
file,
lineNumber: lineNum,
code,
importance: this.assessLineImportance(code)
});
}
}
}
// Process uncovered functions from LCOV
for (const funcName of lcovFileData.uncoveredFunctions || []) {
// Find the function in the source code
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes(`function ${funcName}`) && !line.includes('//')) {
uncoveredFunctions.push({
file,
functionName: funcName,
lineNumber: i + 1,
signature: line.trim(),
importance: this.assessFunctionImportance(line)
});
break;
}
}
}
}
else {
// Fallback to heuristic analysis
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const lineNumber = i + 1;
// Skip empty lines and comments
if (!line || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) {
continue;
}
// Identify function signatures
if (line.includes('function ') && !line.includes('//')) {
const functionMatch = line.match(/function\s+(\w+)\s*\(/);
if (functionMatch) {
uncoveredFunctions.push({
file,
functionName: functionMatch[1],
lineNumber,
signature: line,
importance: this.assessFunctionImportance(line)
});
}
}
// Identify branch conditions
if (line.includes('if ') || line.includes('require(') || line.includes('assert(') ||
line.includes('for ') || line.includes('while ')) {
const branchType = this.identifyBranchType(line);
if (branchType) {
uncoveredBranches.push({
file,
lineNumber,
condition: line,
branchType,
importance: this.assessBranchImportance(line)
});
}
}
// Identify potentially uncovered lines
if (this.isExecutableLine(line)) {
uncoveredLines.push({
file,
lineNumber,
code: line,
importance: this.assessLineImportance(line)
});
}
}
}
// Calculate coverage metrics
const totalLines = lines.filter(line => this.isExecutableLine(line.trim())).length;
const totalFunctions = sourceCode.match(/function\s+\w+\s*\(/g)?.length || 0;
return {
file,
lineCoverage: Math.max(0, 100 - (uncoveredLines.length / Math.max(totalLines, 1)) * 100),
functionCoverage: Math.max(0, 100 - (uncoveredFunctions.length / Math.max(totalFunctions, 1)) * 100),
branchCoverage: Math.max(0, 100 - (uncoveredBranches.length / 10) * 100), // Estimate
totalLines,
coveredLines: totalLines - uncoveredLines.length,
totalFunctions,
coveredFunctions: totalFunctions - uncoveredFunctions.length,
uncoveredLines,
uncoveredFunctions,
uncoveredBranches
};
}
async prioritizeUncoveredCode(coverageReport) {
console.log(chalk_1.default.dim(' Prioritizing uncovered code for test generation...'));
// Sort by importance and limit to manageable numbers
const prioritizedLines = coverageReport.uncoveredLines
.sort((a, b) => this.getImportanceScore(b.importance) - this.getImportanceScore(a.importance))
.slice(0, 50); // Top 50 lines
const prioritizedFunctions = coverageReport.uncoveredFunctions
.sort((a, b) => this.getImportanceScore(b.importance) - this.getImportanceScore(a.importance))
.slice(0, 20); // Top 20 functions
const prioritizedBranches = coverageReport.uncoveredBranches
.sort((a, b) => this.getImportanceScore(b.importance) - this.getImportanceScore(a.importance))
.slice(0, 30); // Top 30 branches
// Identify high-priority files
const fileMap = new Map();
[...prioritizedLines, ...prioritizedFunctions, ...prioritizedBranches].forEach(item => {
const count = fileMap.get(item.file) || 0;
fileMap.set(item.file, count + 1);
});
const highPriorityFiles = Array.from(fileMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([file]) => file);
return {
lines: prioritizedLines,
functions: prioritizedFunctions,
branches: prioritizedBranches,
files: highPriorityFiles
};
}
// Helper methods
isExecutableLine(line) {
return line.length > 0 &&
!line.startsWith('//') &&
!line.startsWith('/*') &&
!line.startsWith('*') &&
!line.startsWith('}') &&
!line.startsWith('{') &&
(line.includes(';') || line.includes('{') || line.includes('require') || line.includes('revert'));
}
identifyBranchType(line) {
if (line.includes('if '))
return 'if';
if (line.includes('for '))
return 'for';
if (line.includes('while '))
return 'while';
if (line.includes('require('))
return 'require';
if (line.includes('assert('))
return 'assert';
return null;
}
assessLineImportance(line) {
if (line.includes('revert') || line.includes('require') || line.includes('assert'))
return 'high';
if (line.includes('emit') || line.includes('transfer') || line.includes('call') || line.includes('send'))
return 'high';
if (line.includes('delete') || line.includes('selfdestruct'))
return 'high';
if (line.includes('storage') || line.includes('mapping') || line.includes('='))
return 'medium';
return 'low';
}
assessFunctionImportance(line) {
if (line.includes('external') || line.includes('public'))
return 'high';
if (line.includes('payable') || line.includes('onlyOwner') || line.includes('modifier'))
return 'high';
if (line.includes('internal'))
return 'medium';
return 'low';
}
assessBranchImportance(line) {
if (line.includes('require') || line.includes('assert') || line.includes('revert'))
return 'high';
if (line.includes('if') && (line.includes('msg.sender') || line.includes('owner') || line.includes('balance')))
return 'high';
if (line.includes('for') || line.includes('while'))
return 'medium';
return 'medium';
}
calculateAverageLineCoverage(fileReports) {
if (fileReports.length === 0)
return 0;
return fileReports.reduce((sum, report) => sum + report.lineCoverage, 0) / fileReports.length;
}
calculateAverageFunctionCoverage(fileReports) {
if (fileReports.length === 0)
return 0;
return fileReports.reduce((sum, report) => sum + report.functionCoverage, 0) / fileReports.length;
}
calculateAverageBranchCoverage(fileReports) {
if (fileReports.length === 0)
return 0;
return fileReports.reduce((sum, report) => sum + report.branchCoverage, 0) / fileReports.length;
}
getImportanceScore(importance) {
switch (importance) {
case 'high': return 3;
case 'medium': return 2;
case 'low': return 1;
default: return 0;
}
}
}
exports.CoverageService = CoverageService;
//# sourceMappingURL=coverage.service.js.map