|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Generates a line-by-line coverage report showing uncovered lines |
| 5 | + * Reads from LCOV format and displays in terminal |
| 6 | + * Also outputs JSON file with uncovered lines for programmatic access |
| 7 | + */ |
| 8 | + |
| 9 | +const fs = require('fs'); |
| 10 | +const path = require('path'); |
| 11 | + |
| 12 | +const lcovPath = path.join(__dirname, '..', 'coverage', 'lcov.info'); |
| 13 | +const jsonOutputPath = path.join(__dirname, '..', 'coverage', 'uncovered-lines.json'); |
| 14 | + |
| 15 | +if (!fs.existsSync(lcovPath)) { |
| 16 | + console.error('LCOV coverage file not found. Run pnpm test:coverage first.'); |
| 17 | + process.exit(1); |
| 18 | +} |
| 19 | + |
| 20 | +const lcovContent = fs.readFileSync(lcovPath, 'utf8'); |
| 21 | + |
| 22 | +// Parse LCOV format |
| 23 | +const files = []; |
| 24 | +let currentFile = null; |
| 25 | + |
| 26 | +const lines = lcovContent.split('\n'); |
| 27 | +for (let i = 0; i < lines.length; i++) { |
| 28 | + const line = lines[i]; |
| 29 | + |
| 30 | + // SF: source file |
| 31 | + if (line.startsWith('SF:')) { |
| 32 | + if (currentFile) { |
| 33 | + files.push(currentFile); |
| 34 | + } |
| 35 | + const filePath = line.substring(3); |
| 36 | + // Only include src/ files (not less-browser) and bin/ |
| 37 | + // Exclude abstract base classes (they're meant to be overridden) |
| 38 | + const normalized = filePath.replace(/\\/g, '/'); |
| 39 | + const abstractClasses = ['abstract-file-manager', 'abstract-plugin-loader']; |
| 40 | + const isAbstract = abstractClasses.some(abstract => normalized.includes(abstract)); |
| 41 | + |
| 42 | + if (!isAbstract && |
| 43 | + ((normalized.includes('src/less/') && !normalized.includes('src/less-browser/')) || |
| 44 | + normalized.includes('src/less-node/') || |
| 45 | + normalized.includes('bin/'))) { |
| 46 | + // Extract relative path - match src/less/... or src/less-node/... or bin/... |
| 47 | + // Path format: src/less/tree/debug-info.js or src/less-node/file-manager.js |
| 48 | + // Match from src/ or bin/ to end of path |
| 49 | + const match = normalized.match(/(src\/[^/]+\/.+|bin\/.+)$/); |
| 50 | + const relativePath = match ? match[1] : (normalized.includes('/src/') || normalized.includes('/bin/') ? normalized.split('/').slice(-3).join('/') : path.basename(filePath)); |
| 51 | + currentFile = { |
| 52 | + path: relativePath, |
| 53 | + fullPath: filePath, |
| 54 | + uncoveredLines: [], |
| 55 | + uncoveredLineCode: {}, // line number -> source code |
| 56 | + totalLines: 0, |
| 57 | + coveredLines: 0 |
| 58 | + }; |
| 59 | + } else { |
| 60 | + currentFile = null; |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + // DA: line data (line number, execution count) |
| 65 | + if (currentFile && line.startsWith('DA:')) { |
| 66 | + const match = line.match(/^DA:(\d+),(\d+)$/); |
| 67 | + if (match) { |
| 68 | + const lineNum = parseInt(match[1], 10); |
| 69 | + const count = parseInt(match[2], 10); |
| 70 | + currentFile.totalLines++; |
| 71 | + if (count > 0) { |
| 72 | + currentFile.coveredLines++; |
| 73 | + } else { |
| 74 | + currentFile.uncoveredLines.push(lineNum); |
| 75 | + } |
| 76 | + } |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +if (currentFile) { |
| 81 | + files.push(currentFile); |
| 82 | +} |
| 83 | + |
| 84 | +// Read source code for uncovered lines |
| 85 | +files.forEach(file => { |
| 86 | + if (file.uncoveredLines.length > 0 && fs.existsSync(file.fullPath)) { |
| 87 | + try { |
| 88 | + const sourceCode = fs.readFileSync(file.fullPath, 'utf8'); |
| 89 | + const sourceLines = sourceCode.split('\n'); |
| 90 | + file.uncoveredLines.forEach(lineNum => { |
| 91 | + // LCOV uses 1-based line numbers |
| 92 | + if (lineNum > 0 && lineNum <= sourceLines.length) { |
| 93 | + file.uncoveredLineCode[lineNum] = sourceLines[lineNum - 1].trim(); |
| 94 | + } |
| 95 | + }); |
| 96 | + } catch (err) { |
| 97 | + // If we can't read the source (e.g., it's in lib/ but we want src/), that's ok |
| 98 | + // We'll just skip the source code |
| 99 | + } |
| 100 | + } |
| 101 | +}); |
| 102 | + |
| 103 | +// Filter to only files with uncovered lines and sort by coverage |
| 104 | +const filesWithGaps = files |
| 105 | + .filter(f => f.uncoveredLines.length > 0) |
| 106 | + .sort((a, b) => { |
| 107 | + const aPct = a.totalLines > 0 ? a.coveredLines / a.totalLines : 1; |
| 108 | + const bPct = b.totalLines > 0 ? b.coveredLines / b.totalLines : 1; |
| 109 | + return aPct - bPct; |
| 110 | + }); |
| 111 | + |
| 112 | +if (filesWithGaps.length === 0) { |
| 113 | + if (files.length === 0) { |
| 114 | + console.log('\n⚠️ No source files found in coverage data. This may indicate an issue with the coverage report.\n'); |
| 115 | + } else { |
| 116 | + console.log('\n✅ All analyzed files have 100% line coverage!\n'); |
| 117 | + console.log(`(Analyzed ${files.length} files from src/less/, src/less-node/, and bin/)\n`); |
| 118 | + } |
| 119 | + process.exit(0); |
| 120 | +} |
| 121 | + |
| 122 | +console.log('\n' + '='.repeat(100)); |
| 123 | +console.log('Uncovered Lines Report'); |
| 124 | +console.log('='.repeat(100) + '\n'); |
| 125 | + |
| 126 | +filesWithGaps.forEach(file => { |
| 127 | + const coveragePct = file.totalLines > 0 |
| 128 | + ? ((file.coveredLines / file.totalLines) * 100).toFixed(1) |
| 129 | + : '0.0'; |
| 130 | + |
| 131 | + console.log(`\n${file.path} (${coveragePct}% coverage)`); |
| 132 | + console.log('-'.repeat(100)); |
| 133 | + |
| 134 | + // Group consecutive lines into ranges |
| 135 | + const ranges = []; |
| 136 | + let start = file.uncoveredLines[0]; |
| 137 | + let end = file.uncoveredLines[0]; |
| 138 | + |
| 139 | + for (let i = 1; i < file.uncoveredLines.length; i++) { |
| 140 | + if (file.uncoveredLines[i] === end + 1) { |
| 141 | + end = file.uncoveredLines[i]; |
| 142 | + } else { |
| 143 | + ranges.push(start === end ? `${start}` : `${start}..${end}`); |
| 144 | + start = file.uncoveredLines[i]; |
| 145 | + end = file.uncoveredLines[i]; |
| 146 | + } |
| 147 | + } |
| 148 | + ranges.push(start === end ? `${start}` : `${start}..${end}`); |
| 149 | + |
| 150 | + // Display ranges (max 5 per line for readability) |
| 151 | + const linesPerRow = 5; |
| 152 | + for (let i = 0; i < ranges.length; i += linesPerRow) { |
| 153 | + const row = ranges.slice(i, i + linesPerRow); |
| 154 | + console.log(` Lines: ${row.join(', ')}`); |
| 155 | + } |
| 156 | + |
| 157 | + console.log(` Total uncovered: ${file.uncoveredLines.length} of ${file.totalLines} lines`); |
| 158 | +}); |
| 159 | + |
| 160 | +console.log('\n' + '='.repeat(100) + '\n'); |
| 161 | + |
| 162 | +// Write JSON output for programmatic access |
| 163 | +const jsonOutput = { |
| 164 | + generated: new Date().toISOString(), |
| 165 | + files: filesWithGaps.map(file => ({ |
| 166 | + path: file.path, |
| 167 | + fullPath: file.fullPath, |
| 168 | + sourcePath: (() => { |
| 169 | + // Try to map lib/ path to src/ path |
| 170 | + const normalized = file.fullPath.replace(/\\/g, '/'); |
| 171 | + if (normalized.includes('/lib/')) { |
| 172 | + return normalized.replace('/lib/', '/src/').replace(/\.js$/, '.ts'); |
| 173 | + } |
| 174 | + return file.fullPath; |
| 175 | + })(), |
| 176 | + coveragePercent: file.totalLines > 0 |
| 177 | + ? parseFloat(((file.coveredLines / file.totalLines) * 100).toFixed(1)) |
| 178 | + : 0, |
| 179 | + totalLines: file.totalLines, |
| 180 | + coveredLines: file.coveredLines, |
| 181 | + uncoveredLines: file.uncoveredLines, |
| 182 | + uncoveredLineCode: file.uncoveredLineCode || {}, |
| 183 | + uncoveredRanges: (() => { |
| 184 | + const ranges = []; |
| 185 | + if (file.uncoveredLines.length === 0) return ranges; |
| 186 | + |
| 187 | + let start = file.uncoveredLines[0]; |
| 188 | + let end = file.uncoveredLines[0]; |
| 189 | + |
| 190 | + for (let i = 1; i < file.uncoveredLines.length; i++) { |
| 191 | + if (file.uncoveredLines[i] === end + 1) { |
| 192 | + end = file.uncoveredLines[i]; |
| 193 | + } else { |
| 194 | + ranges.push({ start, end }); |
| 195 | + start = file.uncoveredLines[i]; |
| 196 | + end = file.uncoveredLines[i]; |
| 197 | + } |
| 198 | + } |
| 199 | + ranges.push({ start, end }); |
| 200 | + return ranges; |
| 201 | + })() |
| 202 | + })) |
| 203 | +}; |
| 204 | + |
| 205 | +fs.writeFileSync(jsonOutputPath, JSON.stringify(jsonOutput, null, 2), 'utf8'); |
| 206 | +console.log(`\n📄 Uncovered lines data written to: coverage/uncovered-lines.json\n`); |
| 207 | + |
0 commit comments