"""Per-file coverage threshold validation for quality-managed modules.""" from __future__ import annotations import xml.etree.ElementTree as ET import math from dataclasses import dataclass from pathlib import Path from typing import Any @dataclass(frozen=True) class CoverageRates: """Validated line and branch percentages for one source file.""" line: float branch: float def _percentage(class_node: ET.Element, attribute: str) -> float: """Parse one finite Cobertura rate and fail closed outside its domain.""" raw = class_node.attrib.get(attribute) if raw is None: raise ValueError(f"coverage class missing {attribute}") value = float(raw) if not math.isfinite(value) or value < 0.0 or value > 1.0: raise ValueError(f"coverage class has invalid {attribute}: {raw}") return value * 100.0 def _load_rates(xml_path: Path, root: Path) -> dict[str, CoverageRates]: """Load validated per-file line and branch rates from Cobertura XML.""" tree = ET.parse(xml_path) xml_root = tree.getroot() source_roots = [ Path(node.text) for node in xml_root.findall("./sources/source") if node.text ] rates: dict[str, CoverageRates] = {} for class_node in xml_root.findall(".//class"): filename = class_node.attrib.get("filename") if not filename: continue normalized = filename.replace("\\", "/") if normalized.startswith("/"): key = Path(normalized).relative_to(root).as_posix() else: key = normalized for source_root in source_roots: candidate = source_root / filename if candidate.exists(): key = candidate.relative_to(root).as_posix() break rates[key] = CoverageRates( line=_percentage(class_node, "line-rate"), branch=_percentage(class_node, "branch-rate"), ) return rates def _load_percentages(xml_path: Path, root: Path) -> dict[str, float]: """Load per-file line percentages for backward-compatible callers.""" return {path: rates.line for path, rates in _load_rates(xml_path, root).items()} def run_check(contract: dict[str, Any], root: Path, xml_path: Path) -> list[str]: """Return human-readable issues for tracked files below the coverage floor. The report is intentionally per-file so a single weak module cannot hide behind aggregate suite coverage. """ if not xml_path.exists(): return [f"coverage xml missing: {xml_path.relative_to(root)}"] try: rates_by_path = _load_rates(xml_path, root) except (ET.ParseError, OSError, UnicodeError, ValueError) as error: return [f"coverage xml invalid: {error}"] minimum = float(contract.get("coverage", {}).get("minimum_percent", 95.0)) issues: list[str] = [] for relative_path in contract.get("coverage", {}).get("tracked_files", []): normalized = relative_path.replace("\\", "/") rates = rates_by_path.get(normalized) if rates is None: issues.append(f"coverage missing for tracked file: {relative_path}") continue if rates.line + 1e-9 < minimum: issues.append( f"line coverage below {minimum:.1f}%: {relative_path} " f"({rates.line:.1f}%)" ) if rates.branch + 1e-9 < minimum: issues.append( f"branch coverage below {minimum:.1f}%: {relative_path} " f"({rates.branch:.1f}%)" ) return issues def compute_workspace_line_coverage( contract: dict[str, Any], root: Path, xml_path: Path, ) -> float: """Compute mean line coverage across tracked files present in the XML.""" if not xml_path.exists(): return 0.0 try: percentages = _load_percentages(xml_path, root) except (ET.ParseError, OSError, UnicodeError, ValueError): return 0.0 samples: list[float] = [] for relative_path in contract.get("coverage", {}).get("tracked_files", []): normalized = relative_path.replace("\\", "/") percent = percentages.get(normalized) if percent is not None: samples.append(percent) if not samples: return 0.0 return round(sum(samples) / len(samples), 3)