// CoverageAnalyzer: Parses Jest coverage reports and identifies gaps. // Includes size limits, retry logic, and robust error recovery. import * as fs from "node:fs"; import type { CoverageGap } from "./types"; import { readFileWithRetry } from "./util"; import { logWarn, logError } from "./logger"; const MAX_COVERAGE_SIZE = 5_242_880; // 5MB cap for LCOV files. export class CoverageAnalyzer { /** * Find the coverage summary file in the given directory. * @param cwd - The directory to search in (defaults to process.cwd()) * @returns The path to the coverage report, or null if not found or too large. */ public findCoverageReport(cwd: string = process.cwd()): string | null { try { const candidates = [ `${cwd}/coverage/coverage-summary.json`, `${cwd}/coverage/lcov.info`, ]; for (const p of candidates) { try { if (fs.existsSync(p)) { const stat = fs.statSync(p); if (stat.size > MAX_COVERAGE_SIZE) { logWarn( `Coverage report too large: ${stat.size} bytes. Skipping.`, ); return null; } return p; } } catch { // Stat failed (ENOTDIR, etc.) — skip this candidate. } } } catch { // Unexpected error — graceful degradation. logError("Error searching for coverage report."); } return null; } /** * Parse Jest's coverage-summary.json. * Uses retry for transient I/O errors and validates structure. * @param reportPath - Path to the coverage-summary.json file. * @returns Array of coverage gaps, or empty array on failure. */ public parseJsonCoverage(reportPath: string): CoverageGap[] { const content = readFileWithRetry(reportPath, 2, 30); if (content === null) return []; let data: any; try { data = JSON.parse(content); } catch (err) { logError(`Failed to parse coverage JSON: ${(err as Error).message}`); return []; } if (typeof data !== "object" || data === null) { logError("Coverage JSON is not an object."); return []; } const gaps: CoverageGap[] = []; for (const key of Object.keys(data)) { if (key === "total") continue; try { const entry = data[key]; if (!entry || !entry.lines || !entry.branches || !entry.functions) continue; gaps.push({ file: key, lines: { covered: entry.lines.covered ?? 0, total: entry.lines.total ?? 0, pct: entry.lines.pct ?? 0, }, branches: { covered: entry.branches.covered ?? 0, total: entry.branches.total ?? 0, pct: entry.branches.pct ?? 0, }, functions: { covered: entry.functions.covered ?? 0, total: entry.functions.total ?? 0, pct: entry.functions.pct ?? 0, }, }); } catch { // Skip malformed entries rather than aborting. logWarn(`Skipping malformed coverage entry: ${key}`); } } return gaps; } /** * Parse LCOV format with robust error recovery. * @param reportPath - Path to the lcov.info file. * @returns Array of coverage gaps, or empty array on failure. */ public parseLcovCoverage(reportPath: string): CoverageGap[] { const content = readFileWithRetry(reportPath, 2, 30); if (content === null) return []; const gaps: CoverageGap[] = []; const lines = content.split("\n").slice(0, 50_000); // Line cap let current: Partial | null = null; for (const line of lines) { try { if (line.startsWith("SF:")) { current = { file: line.substring(3).trim(), lines: { covered: 0, total: 0, pct: 0 }, branches: { covered: 0, total: 0, pct: 0 }, functions: { covered: 0, total: 0, pct: 0 }, }; } else if (line === "end_of_record" && current) { gaps.push(current as CoverageGap); current = null; } else if (current) { if (line.startsWith("LF:")) current.lines!.total = parseInt(line.substring(3), 10) || 0; else if (line.startsWith("LH:")) current.lines!.covered = parseInt(line.substring(3), 10) || 0; else if (line.startsWith("BRF:")) current.branches!.total = parseInt(line.substring(4), 10) || 0; else if (line.startsWith("BRH:")) current.branches!.covered = parseInt(line.substring(4), 10) || 0; else if (line.startsWith("FNF:")) current.functions!.total = parseInt(line.substring(4), 10) || 0; else if (line.startsWith("FNH:")) current.functions!.covered = parseInt(line.substring(4), 10) || 0; } } catch { // Skip malformed lines rather than aborting. logWarn(`Skipping malformed LCOV line: ${line.substring(0, 50)}`); } } // Compute percentages for (const gap of gaps) { try { if (gap.lines.total > 0) gap.lines.pct = Math.round( (gap.lines.covered / gap.lines.total) * 100, ); if (gap.branches.total > 0) gap.branches.pct = Math.round( (gap.branches.covered / gap.branches.total) * 100, ); if (gap.functions.total > 0) gap.functions.pct = Math.round( (gap.functions.covered / gap.functions.total) * 100, ); } catch { logWarn(`Failed to compute coverage percentages for: ${gap.file}`); } } return gaps; } /** * Get coverage gaps below a threshold. * @param gaps - Array of coverage gaps. * @param threshold - Minimum coverage percentage (default 80). * @returns Gaps where lines, branches, or functions are below threshold. */ public getGaps(gaps: CoverageGap[], threshold: number = 80): CoverageGap[] { return gaps.filter( (g) => g.lines.pct < threshold || g.branches.pct < threshold || g.functions.pct < threshold, ); } }