UNPKG

forge-mutation-tester

Version:

Mutation testing tool for Solidity smart contracts using Gambit

422 lines 19.2 kB
"use strict"; 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