@dataclass
@register_rule("testing")
class TestCoverageRule(ProjectRule):
"""Check test coverage via pytest-cov.
Scoring: coverage percentage directly (e.g., 90% → score 90).
Pass threshold: 90%.
"""
min_coverage: float = 90.0
@property
def rule_id(self) -> str:
"""Unique identifier for this rule."""
return "QUALITY_COVERAGE"
def check(self, project_path: Path) -> CheckResult:
"""Check test coverage and capture failures with pytest-cov.
Delegates to ``run_tests(mode='compact')`` from the shared
test runner for structured output, then converts the result
to a ``CheckResult``.
The per-file gap list is derived from ``parse_coverage``'s
filtered ``per_file`` map, which excludes ``__main__.py``
entries by default (aligned with coverage.py's
``exclude_also`` convention).
"""
from axm_audit.core.test_runner import run_tests
effective = read_coverage_config(project_path)
report = run_tests(project_path, mode="compact", stop_on_first=False)
return self._report_to_result(report, min_coverage=effective)
def _no_coverage_result(self, failures: list[dict[str, str]]) -> CheckResult:
"""Build the ``CheckResult`` returned when pytest-cov is not configured."""
return CheckResult(
rule_id=self.rule_id,
passed=False,
message="No coverage data (pytest-cov not configured)",
severity=Severity.WARNING,
score=0,
details={"coverage": 0.0, "failures": failures},
fix_hint="Add pytest-cov: uv add --dev pytest-cov",
)
def _timeout_result(self, failures: list[dict[str, str]]) -> CheckResult:
"""Build the ``CheckResult`` returned when the test run timed out.
Coverage is reported as unmeasured (not a fabricated percentage
derived from a truncated report) so a slow/contended run never
masquerades as a real coverage gap.
"""
return CheckResult(
rule_id=self.rule_id,
passed=False,
message="Test run timed out — coverage not measured",
severity=Severity.WARNING,
score=0,
details={"coverage": None, "failures": failures, "timed_out": True},
fix_hint=(
"Test suite exceeded the coverage-run timeout; rerun in "
"isolation or raise the timeout. Coverage was not measured."
),
)
@staticmethod
def _build_text_parts(
coverage_pct: float, failures: list[dict[str, str]]
) -> list[str]:
"""Build compact bullet lines for the coverage gap and up to 10 failures."""
text_parts: list[str] = []
if coverage_pct < _FULL_COVERAGE:
text_parts.append(
f"\u2022 cov {coverage_pct:.0f}% \u2192 {_FULL_COVERAGE}%"
)
for f in failures[:10]:
short = f["test"].rsplit("::", 1)[-1]
text_parts.append(f"\u2022 FAIL {short}")
return text_parts
@staticmethod
def _build_message(
coverage_pct: float, score: int, has_failures: bool, total_fails: int
) -> str:
"""Format the human-readable coverage message, with or without failures."""
if has_failures:
return f"Test coverage: {coverage_pct:.0f}% ({total_fails} test(s) failed)"
return f"Test coverage: {coverage_pct:.0f}% ({score}/100)"
def _report_to_result(
self, report: TestReport, min_coverage: float | None = None
) -> CheckResult:
"""Convert a ``TestReport`` to a ``CheckResult``.
``min_coverage`` is the effective pass threshold resolved by ``check``
from ``[tool.axm-audit.coverage]`` (defaults to ``self.min_coverage``
when not supplied, preserving the no-config behavior). ``self`` is
never mutated, so a shared rule instance never leaks one package's
threshold into the next.
Builds a compact text summary with bullet lines for coverage gap
(``• cov N% → 100%``) and up to 10 short failure names
(``• FAIL test_name``). Returns ``text=None`` when coverage is
full and no failures exist.
"""
m = _CoverageMetrics.from_report(report, self.min_coverage, min_coverage)
if report.timed_out:
return self._timeout_result(m.failures)
if report.coverage is None:
return self._no_coverage_result(m.failures)
text_parts = self._build_text_parts(m.coverage_pct, m.failures)
return CheckResult(
rule_id=self.rule_id,
passed=m.passed,
message=self._build_message(
m.coverage_pct, m.score, m.has_failures, m.total_fails
),
severity=Severity.WARNING if not m.passed else Severity.INFO,
score=m.score,
details={"coverage": m.coverage_pct, "failures": m.failures},
text="\n".join(text_parts) if text_parts else None,
fix_hint=self._generate_fix_hints(
m.has_failures, m.coverage_pct, m.effective
),
)
def _generate_fix_hints(
self, has_failures: bool, coverage_pct: float, min_coverage: float
) -> str | None:
"""Generate fix hints based on failures and the effective threshold."""
fix_hints: list[str] = []
if has_failures:
fix_hints.append("Fix failing tests")
if coverage_pct < min_coverage:
fix_hints.append(f"Increase test coverage to >= {min_coverage:.0f}%")
return "; ".join(fix_hints) if fix_hints else None