#!/usr/bin/env python3 """mypy ratchet gate. ``ruff`` became a CI gate for ``server/`` on 2026-07-30 (round 1 of the code review). ``mypy`` was left out and recorded as open work for one reason: the standard-mode run is not clean — **1652 errors across 190 files**. Fixing all of them in one PR would be a huge, risky diff; leaving mypy ungated means a type regression lands green. So: a ratchet, the same shape as the i18n one. ``mypy-baseline.json`` records the current error count per ``(file, error-code)``. This script runs mypy and fails when a pair is **new** or its count **grew** — i.e. it stops error 1653 without pretending the first 1652 are fixed. Asymmetry with the i18n ratchet, on purpose: that one also fails when a baselined entry stops offending, because translating a key is a deliberate act and a stale baseline would rot. Here a refactor can incidentally fix a dozen type errors, and failing the author's CI for *improving* things would be hostile. Instead a shrink is reported as ``IMPROVED`` (with the exact command to refresh the file) and the run still passes — while the **total** is asserted never to grow, so the baseline cannot rot upward. Usage: python scripts/check_mypy_baseline.py # gate (CI) python scripts/check_mypy_baseline.py --update # refresh the baseline Keyed on (file, code) rather than (file, line, code) deliberately: line numbers churn on every unrelated edit above the error, which would make the baseline unmergeable across concurrent branches. """ from __future__ import annotations import argparse import collections import json import re import subprocess import sys from pathlib import Path _SERVER = Path(__file__).resolve().parents[1] _BASELINE = _SERVER / "mypy-baseline.json" _TARGET = "app" #: ``path/to/file.py:LINE: error: message [code]`` _LINE_RE = re.compile(r"^(?P[^:]+):\d+: error: .*\[(?P[a-z-]+)\]\s*$") def run_mypy() -> tuple[dict[str, int], int]: """Return ``{"file::code": count}`` plus the total error count.""" proc = subprocess.run( [sys.executable, "-m", "mypy", _TARGET], cwd=_SERVER, capture_output=True, text=True, check=False, ) if proc.returncode not in (0, 1): # 2 = mypy itself failed (bad config, crash). Never silently pass. sys.stderr.write(proc.stdout + proc.stderr) raise SystemExit(f"mypy exited {proc.returncode} — not a type error") counts: collections.Counter[str] = collections.Counter() total = 0 for line in proc.stdout.splitlines(): m = _LINE_RE.match(line.strip()) if m is None: continue counts[f"{m['file']}::{m['code']}"] += 1 total += 1 return dict(counts), total def load_baseline() -> tuple[dict[str, int], int]: if not _BASELINE.exists(): return {}, 0 data = json.loads(_BASELINE.read_text(encoding="utf-8")) entries = data["entries"] return entries, int(data["total"]) def write_baseline(counts: dict[str, int], total: int) -> None: _BASELINE.write_text( json.dumps( { "$comment": ( "mypy ratchet baseline — see scripts/check_mypy_baseline.py. " "Keyed on file::error-code so line churn does not make this " "file unmergeable. Regenerate with " "`python scripts/check_mypy_baseline.py --update`; the total " "must never grow." ), "total": total, "entries": dict(sorted(counts.items())), }, indent=2, ) + "\n", encoding="utf-8", ) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--update", action="store_true", help="refresh the baseline") args = ap.parse_args() counts, total = run_mypy() if args.update: write_baseline(counts, total) print(f"baseline refreshed: {total} errors across {len(counts)} file/code pairs") return 0 baseline, baseline_total = load_baseline() regressions: list[str] = [] for key, n in sorted(counts.items()): was = baseline.get(key, 0) if n > was: file, code = key.split("::", 1) regressions.append( f" {file}: [{code}] {was} -> {n} (+{n - was})" ) improvements = sum( max(0, was - counts.get(key, 0)) for key, was in baseline.items() ) if regressions: print("mypy ratchet FAILED — new or increased type errors:\n") print("\n".join(regressions)) print( "\nFix them, or (only if the new error is genuinely unavoidable and" "\nreviewed) refresh the baseline with:" "\n cd server && python scripts/check_mypy_baseline.py --update" ) return 1 if total > baseline_total: # Defence in depth: per-pair counts can be flat while the total moves # if a file was renamed. Never let the total drift upward silently. print( f"mypy ratchet FAILED — total errors grew {baseline_total} -> {total} " f"with no per-pair regression (a rename?). Refresh the baseline." ) return 1 msg = f"mypy ratchet passed: {total} errors (baseline {baseline_total})" if improvements: msg += ( f"\nIMPROVED: {improvements} baselined error(s) are gone. Please " f"shrink the baseline:\n" f" cd server && python scripts/check_mypy_baseline.py --update" ) print(msg) return 0 if __name__ == "__main__": raise SystemExit(main())