2026-04-10 17:06:53 -03:00
|
|
|
"""Per-file coverage threshold validation for quality-managed modules."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_percentages(xml_path: Path, root: Path) -> dict[str, float]:
|
2026-04-20 21:39:53 -03:00
|
|
|
"""Load per-file line-rate percentages from a Cobertura XML report."""
|
2026-04-10 17:06:53 -03:00
|
|
|
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
|
|
|
|
|
]
|
|
|
|
|
percentages: dict[str, float] = {}
|
|
|
|
|
for class_node in xml_root.findall(".//class"):
|
|
|
|
|
filename = class_node.attrib.get("filename")
|
|
|
|
|
line_rate = class_node.attrib.get("line-rate")
|
|
|
|
|
if not filename or line_rate is None:
|
|
|
|
|
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
|
|
|
|
|
percentages[key] = float(line_rate) * 100.0
|
|
|
|
|
return percentages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_check(contract: dict[str, Any], root: Path, xml_path: Path) -> list[str]:
|
2026-04-20 21:39:53 -03:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-04-10 17:06:53 -03:00
|
|
|
if not xml_path.exists():
|
|
|
|
|
return [f"coverage xml missing: {xml_path.relative_to(root)}"]
|
|
|
|
|
|
|
|
|
|
percentages = _load_percentages(xml_path, root)
|
|
|
|
|
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("\\", "/")
|
|
|
|
|
percent = percentages.get(normalized)
|
|
|
|
|
if percent is None:
|
|
|
|
|
issues.append(f"coverage missing for tracked file: {relative_path}")
|
|
|
|
|
continue
|
|
|
|
|
if percent + 1e-9 < minimum:
|
|
|
|
|
issues.append(
|
|
|
|
|
f"coverage below {minimum:.1f}%: {relative_path} ({percent:.1f}%)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return issues
|
2026-04-20 21:39:53 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
percentages = _load_percentages(xml_path, root)
|
|
|
|
|
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)
|