""" Pytest-based code quality gates using radon. These tests enforce complexity thresholds so regressions are caught during normal ``pytest`` runs, not just in the CI quality job. """ from __future__ import annotations import json import subprocess import sys from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] BACKEND_PACKAGES = ["mjr_am_backend", "mjr_am_shared"] # Maximum cyclomatic complexity per function/method. # Some existing parser/service functions reach CC ~21-25; keep the gate # at the current max to prevent regressions without failing on # already-shipped code. Gradually lower this as complex functions are # refactored. MAX_CC_PER_FUNCTION = 25 # Maximum average complexity across all analysed modules. MAX_AVG_COMPLEXITY = 5.0 # Minimum maintainability index (radon mi) — "A" grade is >= 20. MIN_MAINTAINABILITY_INDEX = 15.0 def _existing_packages() -> list[str]: return [p for p in BACKEND_PACKAGES if (REPO_ROOT / p).is_dir()] def _run_radon(subcommand: str, *extra_args: str) -> str: packages = _existing_packages() if not packages: pytest.skip("No backend packages found") cmd = [sys.executable, "-m", "radon", subcommand, *extra_args, *packages] result = subprocess.run( cmd, cwd=str(REPO_ROOT), capture_output=True, text=True, timeout=120, ) return result.stdout.strip() # -- Cyclomatic Complexity --------------------------------------------------- def _parse_cc_json() -> dict: raw = _run_radon("cc", "--json", "--no-assert") if not raw: return {} return json.loads(raw) def test_no_function_exceeds_max_cc(): """Every function/method must have CC <= MAX_CC_PER_FUNCTION.""" data = _parse_cc_json() violations: list[str] = [] for _filepath, items in data.items(): if not isinstance(items, list): continue for item in items: if not isinstance(item, dict): continue cc = int(item.get("complexity") or 0) if cc > MAX_CC_PER_FUNCTION: name = item.get("name", "") lineno = item.get("lineno", "?") violations.append(f" {_filepath}:{lineno} {name} (CC={cc})") assert not violations, ( f"Functions exceeding CC>{MAX_CC_PER_FUNCTION}:\n" + "\n".join(violations) ) def test_average_complexity_within_threshold(): """Average CC across all backend modules must stay below threshold.""" data = _parse_cc_json() total_cc = 0 count = 0 for _filepath, items in data.items(): if not isinstance(items, list): continue for item in items: if not isinstance(item, dict): continue total_cc += int(item.get("complexity") or 0) count += 1 if count == 0: pytest.skip("No functions analysed") avg = total_cc / count assert avg <= MAX_AVG_COMPLEXITY, ( f"Average CC is {avg:.2f}, exceeds threshold {MAX_AVG_COMPLEXITY}" ) # -- Maintainability Index --------------------------------------------------- def _parse_mi_json() -> dict: raw = _run_radon("mi", "--json") if not raw: return {} return json.loads(raw) def test_no_module_below_min_maintainability(): """Every module must have a maintainability index >= MIN_MAINTAINABILITY_INDEX.""" data = _parse_mi_json() violations: list[str] = [] for filepath, mi_score in data.items(): if not isinstance(mi_score, (int, float)): continue if mi_score < MIN_MAINTAINABILITY_INDEX: violations.append(f" {filepath} (MI={mi_score:.1f})") assert not violations, ( f"Modules below MI<{MIN_MAINTAINABILITY_INDEX}:\n" + "\n".join(violations) ) # -- Raw Metrics Summary (informational, always passes) ---------------------- def test_raw_metrics_summary(): """Collect and print raw metrics for visibility — always passes.""" data = _parse_cc_json() total_functions = 0 total_cc = 0 max_cc = 0 max_cc_name = "" for filepath, items in data.items(): if not isinstance(items, list): continue for item in items: if not isinstance(item, dict): continue cc = int(item.get("complexity") or 0) total_functions += 1 total_cc += cc if cc > max_cc: max_cc = cc max_cc_name = f"{filepath}:{item.get('lineno', '?')} {item.get('name', '?')}" if total_functions: avg = total_cc / total_functions print(f"\n[Quality] {total_functions} functions, avg CC={avg:.2f}, max CC={max_cc} ({max_cc_name})") else: print("\n[Quality] No functions analysed")