#!/usr/bin/env python3 """ Coverage Analyzer Parses Jest/Istanbul coverage reports and identifies gaps, uncovered branches, and provides actionable recommendations for improving test coverage. Usage: python coverage_analyzer.py coverage/coverage-final.json --threshold 80 python coverage_analyzer.py coverage/ --format html --output report.html python coverage_analyzer.py coverage/ --critical-paths """ import os import sys import json import argparse import re from pathlib import Path from typing import Dict, List, Optional, Tuple, Any from dataclasses import dataclass, field, asdict from datetime import datetime from collections import defaultdict @dataclass class FileCoverage: """Coverage data for a single file""" path: str statements: Tuple[int, int] # (covered, total) branches: Tuple[int, int] functions: Tuple[int, int] lines: Tuple[int, int] uncovered_lines: List[int] = field(default_factory=list) uncovered_branches: List[str] = field(default_factory=list) @property def statement_pct(self) -> float: return (self.statements[0] / self.statements[1] * 100) if self.statements[1] > 0 else 100 @property def branch_pct(self) -> float: return (self.branches[0] / self.branches[1] * 100) if self.branches[1] > 0 else 100 @property def function_pct(self) -> float: return (self.functions[0] / self.functions[1] * 100) if self.functions[1] > 0 else 100 @property def line_pct(self) -> float: return (self.lines[0] / self.lines[1] * 100) if self.lines[1] > 0 else 100 @dataclass class CoverageGap: """An identified coverage gap""" file: str gap_type: str # 'statements', 'branches', 'functions', 'lines' lines: List[int] severity: str # 'critical', 'high', 'medium', 'low' description: str recommendation: str @dataclass class CoverageSummary: """Overall coverage summary""" statements: Tuple[int, int] branches: Tuple[int, int] functions: Tuple[int, int] lines: Tuple[int, int] files_analyzed: int files_below_threshold: int = 0 class CoverageParser: """Parses various coverage report formats""" def __init__(self, verbose: bool = False): self.verbose = verbose def parse(self, path: Path) -> Tuple[Dict[str, FileCoverage], CoverageSummary]: """Parse coverage data from file or directory""" if path.is_file(): if path.suffix == '.json': return self._parse_istanbul_json(path) elif path.suffix == '.info' or 'lcov' in path.name: return self._parse_lcov(path) elif path.is_dir(): # Look for common coverage files for filename in ['coverage-final.json', 'coverage-summary.json', 'lcov.info']: candidate = path / filename if candidate.exists(): return self.parse(candidate) # Check for coverage-final.json in coverage directory coverage_json = path / 'coverage-final.json' if coverage_json.exists(): return self._parse_istanbul_json(coverage_json) raise ValueError(f"Could not find or parse coverage data at: {path}") def _parse_istanbul_json(self, path: Path) -> Tuple[Dict[str, FileCoverage], CoverageSummary]: """Parse Istanbul/Jest JSON coverage format""" with open(path, 'r') as f: data = json.load(f) files = {} total_statements = [0, 0] total_branches = [0, 0] total_functions = [0, 0] total_lines = [0, 0] for file_path, file_data in data.items(): # Skip node_modules if 'node_modules' in file_path: continue # Parse statement coverage s_map = file_data.get('statementMap', {}) s_hits = file_data.get('s', {}) covered_statements = sum(1 for h in s_hits.values() if h > 0) total_statements[0] += covered_statements total_statements[1] += len(s_map) # Parse branch coverage b_map = file_data.get('branchMap', {}) b_hits = file_data.get('b', {}) covered_branches = sum( sum(1 for h in hits if h > 0) for hits in b_hits.values() ) total_branch_count = sum(len(b['locations']) for b in b_map.values()) total_branches[0] += covered_branches total_branches[1] += total_branch_count # Parse function coverage fn_map = file_data.get('fnMap', {}) fn_hits = file_data.get('f', {}) covered_functions = sum(1 for h in fn_hits.values() if h > 0) total_functions[0] += covered_functions total_functions[1] += len(fn_map) # Determine uncovered lines uncovered_lines = [] for stmt_id, hits in s_hits.items(): if hits == 0 and stmt_id in s_map: stmt = s_map[stmt_id] start_line = stmt.get('start', {}).get('line', 0) if start_line not in uncovered_lines: uncovered_lines.append(start_line) # Count lines line_coverage = self._calculate_line_coverage(s_map, s_hits) total_lines[0] += line_coverage[0] total_lines[1] += line_coverage[1] # Identify uncovered branches uncovered_branches = [] for branch_id, hits in b_hits.items(): for idx, hit in enumerate(hits): if hit == 0: uncovered_branches.append(f"{branch_id}:{idx}") files[file_path] = FileCoverage( path=file_path, statements=(covered_statements, len(s_map)), branches=(covered_branches, total_branch_count), functions=(covered_functions, len(fn_map)), lines=line_coverage, uncovered_lines=sorted(uncovered_lines)[:50], # Limit uncovered_branches=uncovered_branches[:20] ) summary = CoverageSummary( statements=tuple(total_statements), branches=tuple(total_branches), functions=tuple(total_functions), lines=tuple(total_lines), files_analyzed=len(files) ) return files, summary def _calculate_line_coverage(self, s_map: Dict, s_hits: Dict) -> Tuple[int, int]: """Calculate line coverage from statement data""" lines = set() covered_lines = set() for stmt_id, stmt in s_map.items(): start_line = stmt.get('start', {}).get('line', 0) end_line = stmt.get('end', {}).get('line', start_line) for line in range(start_line, end_line + 1): lines.add(line) if s_hits.get(stmt_id, 0) > 0: covered_lines.add(line) return (len(covered_lines), len(lines)) def _parse_lcov(self, path: Path) -> Tuple[Dict[str, FileCoverage], CoverageSummary]: """Parse LCOV format coverage data""" with open(path, 'r') as f: content = f.read() files = {} current_file = None current_data = {} total = { 'statements': [0, 0], 'branches': [0, 0], 'functions': [0, 0], 'lines': [0, 0] } for line in content.split('\n'): line = line.strip() if line.startswith('SF:'): current_file = line[3:] current_data = { 'lines_hit': 0, 'lines_total': 0, 'functions_hit': 0, 'functions_total': 0, 'branches_hit': 0, 'branches_total': 0, 'uncovered_lines': [] } elif line.startswith('DA:'): parts = line[3:].split(',') if len(parts) >= 2: line_num = int(parts[0]) hits = int(parts[1]) current_data['lines_total'] += 1 if hits > 0: current_data['lines_hit'] += 1 else: current_data['uncovered_lines'].append(line_num) elif line.startswith('FN:'): current_data['functions_total'] += 1 elif line.startswith('FNDA:'): parts = line[5:].split(',') if len(parts) >= 1 and int(parts[0]) > 0: current_data['functions_hit'] += 1 elif line.startswith('BRDA:'): parts = line[5:].split(',') current_data['branches_total'] += 1 if len(parts) >= 4 and parts[3] != '-' and int(parts[3]) > 0: current_data['branches_hit'] += 1 elif line == 'end_of_record' and current_file: # Skip node_modules if 'node_modules' not in current_file: files[current_file] = FileCoverage( path=current_file, statements=(current_data['lines_hit'], current_data['lines_total']), branches=(current_data['branches_hit'], current_data['branches_total']), functions=(current_data['functions_hit'], current_data['functions_total']), lines=(current_data['lines_hit'], current_data['lines_total']), uncovered_lines=current_data['uncovered_lines'][:50] ) for key in total: if key == 'statements' or key == 'lines': total[key][0] += current_data['lines_hit'] total[key][1] += current_data['lines_total'] elif key == 'branches': total[key][0] += current_data['branches_hit'] total[key][1] += current_data['branches_total'] elif key == 'functions': total[key][0] += current_data['functions_hit'] total[key][1] += current_data['functions_total'] current_file = None summary = CoverageSummary( statements=tuple(total['statements']), branches=tuple(total['branches']), functions=tuple(total['functions']), lines=tuple(total['lines']), files_analyzed=len(files) ) return files, summary class CoverageAnalyzer: """Analyzes coverage data and generates recommendations""" CRITICAL_PATTERNS = [ r'auth', r'payment', r'security', r'login', r'register', r'checkout', r'order', r'transaction', r'billing' ] SERVICE_PATTERNS = [ r'service', r'api', r'handler', r'controller', r'middleware' ] def __init__( self, threshold: int = 80, critical_paths: bool = False, verbose: bool = False ): self.threshold = threshold self.critical_paths = critical_paths self.verbose = verbose def analyze( self, files: Dict[str, FileCoverage], summary: CoverageSummary ) -> Tuple[List[CoverageGap], Dict[str, Any]]: """Analyze coverage and return gaps and recommendations""" gaps = [] recommendations = { 'critical': [], 'high': [], 'medium': [], 'low': [] } # Analyze each file for file_path, coverage in files.items(): file_gaps = self._analyze_file(file_path, coverage) gaps.extend(file_gaps) # Sort gaps by severity severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} gaps.sort(key=lambda g: (severity_order[g.severity], -len(g.lines))) # Generate recommendations for gap in gaps: recommendations[gap.severity].append({ 'file': gap.file, 'type': gap.gap_type, 'lines': gap.lines[:10], # Limit 'description': gap.description, 'recommendation': gap.recommendation }) # Add summary stats stats = { 'overall_statement_pct': (summary.statements[0] / summary.statements[1] * 100) if summary.statements[1] > 0 else 100, 'overall_branch_pct': (summary.branches[0] / summary.branches[1] * 100) if summary.branches[1] > 0 else 100, 'overall_function_pct': (summary.functions[0] / summary.functions[1] * 100) if summary.functions[1] > 0 else 100, 'overall_line_pct': (summary.lines[0] / summary.lines[1] * 100) if summary.lines[1] > 0 else 100, 'files_analyzed': summary.files_analyzed, 'files_below_threshold': sum( 1 for f in files.values() if f.line_pct < self.threshold ), 'total_gaps': len(gaps), 'critical_gaps': len(recommendations['critical']), 'threshold': self.threshold, 'meets_threshold': (summary.lines[0] / summary.lines[1] * 100) >= self.threshold if summary.lines[1] > 0 else True } return gaps, { 'recommendations': recommendations, 'stats': stats } def _analyze_file(self, file_path: str, coverage: FileCoverage) -> List[CoverageGap]: """Analyze a single file for coverage gaps""" gaps = [] # Determine if file is critical is_critical = any( re.search(pattern, file_path.lower()) for pattern in self.CRITICAL_PATTERNS ) is_service = any( re.search(pattern, file_path.lower()) for pattern in self.SERVICE_PATTERNS ) # Determine severity based on file type and coverage level if is_critical: base_severity = 'critical' target_threshold = 95 elif is_service: base_severity = 'high' target_threshold = 85 else: base_severity = 'medium' target_threshold = self.threshold # Check line coverage if coverage.line_pct < target_threshold: severity = base_severity if coverage.line_pct < 50 else self._lower_severity(base_severity) gaps.append(CoverageGap( file=file_path, gap_type='lines', lines=coverage.uncovered_lines[:20], severity=severity, description=f"Line coverage at {coverage.line_pct:.1f}% (target: {target_threshold}%)", recommendation=self._get_line_recommendation(coverage) )) # Check branch coverage if coverage.branch_pct < target_threshold - 5: # Allow 5% less for branches severity = base_severity if coverage.branch_pct < 40 else self._lower_severity(base_severity) gaps.append(CoverageGap( file=file_path, gap_type='branches', lines=[], severity=severity, description=f"Branch coverage at {coverage.branch_pct:.1f}%", recommendation=f"Add tests for conditional logic. {len(coverage.uncovered_branches)} uncovered branches." )) # Check function coverage if coverage.function_pct < target_threshold: severity = self._lower_severity(base_severity) gaps.append(CoverageGap( file=file_path, gap_type='functions', lines=[], severity=severity, description=f"Function coverage at {coverage.function_pct:.1f}%", recommendation="Add tests for uncovered functions/methods." )) return gaps def _lower_severity(self, severity: str) -> str: """Lower severity by one level""" mapping = { 'critical': 'high', 'high': 'medium', 'medium': 'low', 'low': 'low' } return mapping[severity] def _get_line_recommendation(self, coverage: FileCoverage) -> str: """Generate recommendation for line coverage gaps""" if coverage.line_pct < 30: return "This file has very low coverage. Consider adding basic render/unit tests first." elif coverage.line_pct < 60: return "Add tests covering the main functionality and happy paths." else: return "Focus on edge cases and error handling paths." class ReportGenerator: """Generates coverage reports in various formats""" def __init__(self, verbose: bool = False): self.verbose = verbose def generate_text_report( self, files: Dict[str, FileCoverage], summary: CoverageSummary, analysis: Dict[str, Any], threshold: int ) -> str: """Generate a text report""" lines = [] # Header lines.append("=" * 60) lines.append("COVERAGE ANALYSIS REPORT") lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") lines.append("=" * 60) lines.append("") # Overall summary stats = analysis['stats'] lines.append("OVERALL COVERAGE:") lines.append(f" Statements: {stats['overall_statement_pct']:.1f}%") lines.append(f" Branches: {stats['overall_branch_pct']:.1f}%") lines.append(f" Functions: {stats['overall_function_pct']:.1f}%") lines.append(f" Lines: {stats['overall_line_pct']:.1f}%") lines.append("") # Threshold check threshold_status = "PASS" if stats['meets_threshold'] else "FAIL" lines.append(f"Threshold ({threshold}%): {threshold_status}") lines.append(f"Files analyzed: {stats['files_analyzed']}") lines.append(f"Files below threshold: {stats['files_below_threshold']}") lines.append("") # Critical gaps recs = analysis['recommendations'] if recs['critical']: lines.append("-" * 60) lines.append("CRITICAL GAPS (requires immediate attention):") for rec in recs['critical'][:5]: lines.append(f" - {rec['file']}") lines.append(f" {rec['description']}") if rec['lines']: lines.append(f" Uncovered lines: {', '.join(map(str, rec['lines'][:5]))}") lines.append("") # High priority gaps if recs['high']: lines.append("-" * 60) lines.append("HIGH PRIORITY GAPS:") for rec in recs['high'][:5]: lines.append(f" - {rec['file']}") lines.append(f" {rec['description']}") lines.append("") # Files below threshold below_threshold = [ (path, cov) for path, cov in files.items() if cov.line_pct < threshold ] below_threshold.sort(key=lambda x: x[1].line_pct) if below_threshold: lines.append("-" * 60) lines.append(f"FILES BELOW {threshold}% THRESHOLD:") for path, cov in below_threshold[:10]: short_path = path.split('/')[-1] if '/' in path else path lines.append(f" {cov.line_pct:5.1f}% {short_path}") if len(below_threshold) > 10: lines.append(f" ... and {len(below_threshold) - 10} more files") lines.append("") # Recommendations lines.append("-" * 60) lines.append("RECOMMENDATIONS:") all_recs = ( recs['critical'][:2] + recs['high'][:2] + recs['medium'][:2] ) for i, rec in enumerate(all_recs[:5], 1): lines.append(f" {i}. {rec['recommendation']}") lines.append(f" File: {rec['file']}") lines.append("") lines.append("=" * 60) return '\n'.join(lines) def generate_html_report( self, files: Dict[str, FileCoverage], summary: CoverageSummary, analysis: Dict[str, Any], threshold: int ) -> str: """Generate an HTML report""" stats = analysis['stats'] recs = analysis['recommendations'] html = f"""
Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
Target: {threshold}% | Files Analyzed: {stats['files_analyzed']} | Below Threshold: {stats['files_below_threshold']}
| Severity | File | Issue | Recommendation |
|---|---|---|---|
| {severity.upper()} | {gap['file'].split('/')[-1]} | {gap['description']} | {gap['recommendation']} |
| File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|
| {short_path} | {cov.statement_pct:.1f}% | {cov.branch_pct:.1f}% | {cov.function_pct:.1f}% | {cov.line_pct:.1f}% |