- Removed unnecessary imports from various test files to improve readability and maintainability. - Added new settings for the Generated Feed display in the settingsFeed.js file. - Introduced a new configuration file for Pyright type checking.
148 lines
4.7 KiB
Python
148 lines
4.7 KiB
Python
"""
|
|
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", "<unknown>")
|
|
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")
|