import { mkdir, readFile, writeFile } from 'fs/promises' import { dirname, resolve } from 'path' type FileCoverage = { path: string found: number hit: number chunks: number[] } type DirectoryCoverage = { path: string found: number hit: number } const LCOV_PATH = resolve(process.cwd(), 'coverage/lcov.info') const HTML_PATH = resolve(process.cwd(), 'coverage/index.html') const CHUNK_COUNT = 20 function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } function bucketColor(ratio: number): string { if (ratio >= 0.9) return '#166534' if (ratio >= 0.75) return '#15803d' if (ratio >= 0.5) return '#65a30d' if (ratio > 0) return '#a3a3a3' return '#262626' } function coverageLabel(ratio: number): string { return `${Math.round(ratio * 100)}%` } function coverageRatio(found: number, hit: number): number { return found === 0 ? 0 : hit / found } function bucketGlyph(ratio: number): string { if (ratio >= 0.9) return '█' if (ratio >= 0.75) return '▓' if (ratio >= 0.5) return '▒' if (ratio > 0) return '░' return '·' } function terminalBar(chunks: number[]): string { return chunks.map(bucketGlyph).join('') } function summarizeDirectories(files: FileCoverage[]): DirectoryCoverage[] { const dirs = new Map() for (const file of files) { const dir = file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : '.' const current = dirs.get(dir) ?? { path: dir, found: 0, hit: 0 } current.found += file.found current.hit += file.hit dirs.set(dir, current) } return [...dirs.values()].sort((a, b) => { const left = coverageRatio(a.found, a.hit) const right = coverageRatio(b.found, b.hit) if (right !== left) return right - left return b.found - a.found }) } function buildTerminalReport(files: FileCoverage[]): string { const totalFound = files.reduce((sum, file) => sum + file.found, 0) const totalHit = files.reduce((sum, file) => sum + file.hit, 0) const totalRatio = coverageRatio(totalFound, totalHit) const overallChunks = new Array(CHUNK_COUNT).fill(totalRatio) const topDirectories = summarizeDirectories(files) .filter(dir => dir.found > 0) .slice(0, 8) const lowestFiles = [...files] .filter(file => file.found >= 20) .sort((a, b) => { const left = coverageRatio(a.found, a.hit) const right = coverageRatio(b.found, b.hit) if (left !== right) return left - right return b.found - a.found }) .slice(0, 10) const lines = [ '', 'Coverage Activity', `${terminalBar(overallChunks)} ${coverageLabel(totalRatio)} ${totalHit}/${totalFound} lines ${files.length} files`, '', 'Top Directories', ] for (const dir of topDirectories) { const ratio = coverageRatio(dir.found, dir.hit) lines.push( `${terminalBar(new Array(12).fill(ratio))} ${coverageLabel(ratio).padStart(4)} ${String(dir.hit).padStart(5)}/${String(dir.found).padEnd(5)} ${dir.path}`, ) } lines.push('', 'Lowest Coverage Files') for (const file of lowestFiles) { const ratio = coverageRatio(file.found, file.hit) lines.push( `${terminalBar(file.chunks).padEnd(CHUNK_COUNT)} ${coverageLabel(ratio).padStart(4)} ${String(file.hit).padStart(5)}/${String(file.found).padEnd(5)} ${file.path}`, ) } lines.push('', `HTML report: ${HTML_PATH}`) return lines.join('\n') } function parseLcov(content: string): FileCoverage[] { const files: FileCoverage[] = [] const sections = content.split('end_of_record') for (const rawSection of sections) { const section = rawSection.trim() if (!section) continue const lines = section.split('\n') let filePath = '' const lineHits = new Map() for (const line of lines) { if (line.startsWith('SF:')) { filePath = line.slice(3).trim() } else if (line.startsWith('DA:')) { const [lineNumberText, hitText] = line.slice(3).split(',') const lineNumber = Number(lineNumberText) const hits = Number(hitText) if (Number.isFinite(lineNumber) && Number.isFinite(hits)) { lineHits.set(lineNumber, hits) } } } if (!filePath || lineHits.size === 0) continue const ordered = [...lineHits.entries()].sort((a, b) => a[0] - b[0]) const found = ordered.length const hit = ordered.filter(([, hits]) => hits > 0).length const chunkSize = Math.max(1, Math.ceil(found / CHUNK_COUNT)) const chunks: number[] = [] for (let index = 0; index < found; index += chunkSize) { const slice = ordered.slice(index, index + chunkSize) const covered = slice.filter(([, hits]) => hits > 0).length chunks.push(slice.length === 0 ? 0 : covered / slice.length) } while (chunks.length < CHUNK_COUNT) { chunks.push(0) } files.push({ path: filePath, found, hit, chunks: chunks.slice(0, CHUNK_COUNT), }) } return files.sort((a, b) => { const left = a.found === 0 ? 0 : a.hit / a.found const right = b.found === 0 ? 0 : b.hit / b.found if (right !== left) return right - left return a.path.localeCompare(b.path) }) } function buildHtml(files: FileCoverage[]): string { const totalFound = files.reduce((sum, file) => sum + file.found, 0) const totalHit = files.reduce((sum, file) => sum + file.hit, 0) const totalRatio = totalFound === 0 ? 0 : totalHit / totalFound const cards = [ ['Files', String(files.length)], ['Covered Lines', `${totalHit}/${totalFound}`], ['Line Coverage', coverageLabel(totalRatio)], ] const rows = files .map(file => { const ratio = file.found === 0 ? 0 : file.hit / file.found const squares = file.chunks .map( (chunk, index) => ``, ) .join('') return ` ${escapeHtml(file.path)} ${coverageLabel(ratio)} ${file.hit}/${file.found} ${squares} ` }) .join('') const summary = cards .map( ([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`, ) .join('') return ` OpenClaude Coverage

Coverage Activity

Git-style heatmap generated from coverage/lcov.info

${summary}
${rows}
File Coverage Lines Activity
Less
More
` } async function main() { const content = await readFile(LCOV_PATH, 'utf8') const files = parseLcov(content) const html = buildHtml(files) await mkdir(dirname(HTML_PATH), { recursive: true }) await writeFile(HTML_PATH, html, 'utf8') console.log(buildTerminalReport(files)) console.log(`coverage heatmap written to ${HTML_PATH}`) } await main()