From fb388ad8bf89ff3a29fa2b77514ce9f73a2f0ce3 Mon Sep 17 00:00:00 2001 From: Shamim Rehman Date: Thu, 16 Jul 2026 10:52:47 -0400 Subject: [PATCH 1/2] ci: add bounded Forge mypy gate --- .github/workflows/ci.yml | 7 + forge_cli/incident_store.py | 16 ++- pyproject.toml | 2 + requirements/dev.lock | 72 ++++++++++ scripts/verify_type_gate.py | 270 +++++++++++++++++++++++++++++++++++ tests/test_type_gate.py | 258 +++++++++++++++++++++++++++++++++ typing/mypy-baseline-v1.json | 75 ++++++++++ uv.lock | 103 +++++++++++++ 8 files changed, 796 insertions(+), 7 deletions(-) create mode 100755 scripts/verify_type_gate.py create mode 100644 tests/test_type_gate.py create mode 100644 typing/mypy-baseline-v1.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4257a03..6c61c28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -36,6 +38,11 @@ jobs: python -m pip install --no-deps --no-build-isolation -e . python -m pip check + - name: Type check critical surfaces and monotonic repository baseline + env: + TRUSTED_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python scripts/verify_type_gate.py --base "${TRUSTED_BASE_SHA}" + - name: Lint run: ruff check forge_cli/ tests/ diff --git a/forge_cli/incident_store.py b/forge_cli/incident_store.py index bd49dd4..7768ed3 100644 --- a/forge_cli/incident_store.py +++ b/forge_cli/incident_store.py @@ -80,6 +80,8 @@ class QuarantineSourceChangedError(OSError): class RecoverablePartialStateError(OSError): """Indicates recovery state remains after an incomplete operation.""" + primary_error: OSError | None + def __init__( self, *, @@ -1335,22 +1337,22 @@ def publish(self) -> Incident: primary_error = exc raise finally: - cleanup_error: OSError | None = None + final_cleanup_error: OSError | None = None if temporary_fd is not None: try: os.close(temporary_fd) except OSError as exc: - cleanup_error = exc + final_cleanup_error = exc if temporary_name is not None: try: os.unlink(temporary_name, dir_fd=self._directory_fd) except OSError as exc: - if cleanup_error is None: - cleanup_error = exc - if cleanup_error is not None: + if final_cleanup_error is None: + final_cleanup_error = exc + if final_cleanup_error is not None: raise RecoverablePartialStateError( - primary_error=primary_error or cleanup_error - ) from cleanup_error + primary_error=primary_error or final_cleanup_error + ) from final_cleanup_error @contextmanager diff --git a/pyproject.toml b/pyproject.toml index b822626..f5eed05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,10 @@ anthropic = ["anthropic>=0.39.0"] openai = ["openai>=1.0.0"] mcp = ["mcp>=1.0.0", "uvicorn>=0.30.0"] dev = [ + "mypy>=1.19.1,<1.20", "pytest>=7.4.0", "ruff>=0.1.0", + "types-pyyaml>=6.0.12.20241230", ] [dependency-groups] diff --git a/requirements/dev.lock b/requirements/dev.lock index fd05779..f26a8c5 100644 --- a/requirements/dev.lock +++ b/requirements/dev.lock @@ -118,6 +118,48 @@ jsonschema==4.26.0 \ jsonschema-specifications==2025.9.1 \ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d +librt==0.13.0 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b \ + --hash=sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd \ + --hash=sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6 \ + --hash=sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7 \ + --hash=sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1 \ + --hash=sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082 \ + --hash=sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781 \ + --hash=sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0 \ + --hash=sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c \ + --hash=sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14 \ + --hash=sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f \ + --hash=sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628 \ + --hash=sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c \ + --hash=sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db \ + --hash=sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650 \ + --hash=sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b \ + --hash=sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348 \ + --hash=sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588 \ + --hash=sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd \ + --hash=sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927 \ + --hash=sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89 \ + --hash=sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3 \ + --hash=sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5 \ + --hash=sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9 \ + --hash=sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176 \ + --hash=sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a \ + --hash=sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3 \ + --hash=sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7 \ + --hash=sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c \ + --hash=sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03 \ + --hash=sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d \ + --hash=sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1 \ + --hash=sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9 \ + --hash=sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b \ + --hash=sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566 \ + --hash=sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82 \ + --hash=sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1 \ + --hash=sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa \ + --hash=sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79 \ + --hash=sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e \ + --hash=sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21 markdown-it-py==4.2.0 \ --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a @@ -127,9 +169,36 @@ mcp==1.28.1 \ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba +mypy==1.19.1 \ + --hash=sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd \ + --hash=sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba \ + --hash=sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6 \ + --hash=sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a \ + --hash=sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042 \ + --hash=sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331 \ + --hash=sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1 \ + --hash=sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13 \ + --hash=sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2 \ + --hash=sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b \ + --hash=sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8 \ + --hash=sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef \ + --hash=sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288 \ + --hash=sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75 \ + --hash=sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250 \ + --hash=sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab \ + --hash=sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247 \ + --hash=sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925 \ + --hash=sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e \ + --hash=sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e +mypy-extensions==1.1.0 \ + --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ + --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 @@ -357,6 +426,9 @@ starlette==1.3.1 \ typer==0.26.8 \ --hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c \ --hash=sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e +types-pyyaml==6.0.12.20260518 \ + --hash=sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd \ + --hash=sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466 typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 diff --git a/scripts/verify_type_gate.py b/scripts/verify_type_gate.py new file mode 100755 index 0000000..ba0ada1 --- /dev/null +++ b/scripts/verify_type_gate.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Enforce Forge's versioned critical typing policy and monotonic debt baseline.""" + +from __future__ import annotations + +import argparse +from collections import Counter +from collections.abc import Callable, Iterable, Mapping, Sequence +import json +from pathlib import Path +import re +import subprocess +import sys +from typing import TypeAlias, cast + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_VERSION = 1 +BASELINE_PATH = "typing/mypy-baseline-v1.json" +CRITICAL_SURFACES = ( + "forge_cli/models.py", + "forge_cli/incident_store.py", +) +MYPY_ARGS = ( + "--check-untyped-defs", + "--no-implicit-optional", + "--warn-redundant-casts", + "--warn-unused-ignores", + "--show-error-codes", + "--output=json", + "--no-error-summary", + "--no-pretty", + "--no-incremental", +) +SHA_PATTERN = re.compile(r"[0-9a-f]{40}") +DiagnosticKey: TypeAlias = tuple[str, str, str] +DiagnosticCounts: TypeAlias = Counter[DiagnosticKey] +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +class TypeGateError(RuntimeError): + """Raised when the type gate cannot prove the requested invariant.""" + + +def diagnostic_counts(diagnostics: Iterable[object]) -> DiagnosticCounts: + """Count stable diagnostic keys while excluding source positions and hints.""" + counts: DiagnosticCounts = Counter() + for diagnostic in diagnostics: + if not isinstance(diagnostic, Mapping): + raise TypeGateError("each mypy diagnostic must be a JSON object") + diagnostic_map = cast(Mapping[str, object], diagnostic) + path = diagnostic_map.get("file") + code = diagnostic_map.get("code") + message = diagnostic_map.get("message") + if ( + not isinstance(path, str) + or not path + or not isinstance(code, str) + or not code + or not isinstance(message, str) + or not message + ): + raise TypeGateError( + "each mypy diagnostic requires non-empty file, code, and message" + ) + counts[(path, code, message)] += 1 + return counts + + +def _parse_mypy_output(output: str) -> DiagnosticCounts: + diagnostics: list[object] = [] + for line_number, line in enumerate(output.splitlines(), start=1): + if not line.strip(): + continue + try: + diagnostics.append(cast(object, json.loads(line))) + except json.JSONDecodeError as exc: + raise TypeGateError( + f"invalid mypy JSON output on line {line_number}: {exc.msg}" + ) from exc + return diagnostic_counts(diagnostics) + + +def collect_mypy_diagnostics( + paths: Sequence[str], + *, + run: Runner = subprocess.run, +) -> DiagnosticCounts: + """Run the selected JSON mypy policy and return normalized diagnostic counts.""" + try: + result = run( + [sys.executable, "-m", "mypy", *MYPY_ARGS, *paths], + cwd=ROOT, + capture_output=True, + text=True, + ) + except OSError as exc: + raise TypeGateError(f"mypy execution failed: {exc}") from exc + if result.returncode not in {0, 1}: + detail = result.stderr.strip() or result.stdout.strip() or "no diagnostic output" + raise TypeGateError(f"mypy execution failed ({result.returncode}): {detail}") + if result.stderr.strip(): + raise TypeGateError(f"mypy wrote unexpected stderr: {result.stderr.strip()}") + try: + diagnostics = _parse_mypy_output(result.stdout) + except TypeGateError as exc: + if result.returncode == 1: + raise TypeGateError( + f"mypy exited 1 without parseable JSON diagnostics: {exc}" + ) from exc + raise + if result.returncode == 1 and not diagnostics: + raise TypeGateError("mypy exited 1 without parseable JSON diagnostics") + return diagnostics + + +def parse_baseline(text: str) -> DiagnosticCounts: + """Parse one versioned JSON baseline into stable diagnostic counts.""" + try: + payload = cast(object, json.loads(text)) + except json.JSONDecodeError as exc: + raise TypeGateError(f"baseline is not valid JSON: {exc.msg}") from exc + if not isinstance(payload, dict): + raise TypeGateError("baseline must be a version 1 JSON object") + payload_map = cast(dict[str, object], payload) + if type(payload_map.get("version")) is not int or payload_map["version"] != POLICY_VERSION: + raise TypeGateError("baseline must be a version 1 JSON object") + diagnostics = payload_map.get("diagnostics") + if not isinstance(diagnostics, list): + raise TypeGateError("baseline diagnostics must be a JSON list") + return diagnostic_counts(cast(list[object], diagnostics)) + + +def render_baseline(diagnostics: DiagnosticCounts) -> str: + """Render deterministic versioned JSON containing only stable diagnostic keys.""" + entries = [ + {"file": path, "code": code, "message": message} + for (path, code, message), count in sorted(diagnostics.items()) + for _ in range(count) + ] + return json.dumps( + {"version": POLICY_VERSION, "diagnostics": entries}, + indent=2, + sort_keys=True, + ) + "\n" + + +def _format_counter(diagnostics: DiagnosticCounts) -> str: + rendered: list[str] = [] + for (path, code, message), count in sorted(diagnostics.items()): + diagnostic = f"{path}: [{code}] {message}" + rendered.append(f"{count}x {diagnostic}" if count > 1 else diagnostic) + return "\n".join(rendered) or "" + + +def validate_candidate_baseline( + current: DiagnosticCounts, + candidate: DiagnosticCounts, + trusted: DiagnosticCounts | None, +) -> None: + """Require an exact current baseline that cannot exceed trusted-base debt.""" + if current != candidate: + missing = candidate - current + added = current - candidate + raise TypeGateError( + "\n".join( + ( + "candidate baseline does not exactly match current diagnostics", + f"stale/inflated entries:\n{_format_counter(missing)}", + f"unbaselined entries:\n{_format_counter(added)}", + ) + ) + ) + if trusted is not None: + growth = candidate - trusted + if growth: + raise TypeGateError( + "candidate baseline exceeds trusted base diagnostic counts\n" + + f"growth:\n{_format_counter(growth)}" + ) + + +def _run_git(run: Runner, arguments: Sequence[str]) -> subprocess.CompletedProcess[str]: + try: + return run( + ["git", *arguments], + cwd=ROOT, + capture_output=True, + text=True, + ) + except OSError as exc: + raise TypeGateError(f"git execution failed: {exc}") from exc + + +def load_trusted_baseline( + base_sha: str, + *, + run: Runner = subprocess.run, +) -> DiagnosticCounts | None: + """Load the baseline from a proven git tree, or prove it is absent for bootstrap.""" + if SHA_PATTERN.fullmatch(base_sha) is None: + raise TypeGateError("base SHA must be a full 40-character lowercase hexadecimal SHA") + + tree = _run_git(run, ("cat-file", "-e", f"{base_sha}^{{tree}}")) + if tree.returncode != 0: + detail = tree.stderr.strip() or "unknown git error" + raise TypeGateError(f"trusted base tree lookup failed for {base_sha}: {detail}") + + listing = _run_git(run, ("ls-tree", "-z", "--full-tree", base_sha, "--", BASELINE_PATH)) + if listing.returncode != 0: + raise TypeGateError( + f"trusted base path lookup failed: {listing.stderr.strip() or 'unknown git error'}" + ) + entries = [entry for entry in listing.stdout.split("\0") if entry] + if not entries: + return None + if len(entries) != 1 or "\t" not in entries[0]: + raise TypeGateError("trusted base path lookup returned malformed content") + _, path = entries[0].split("\t", 1) + if path != BASELINE_PATH: + raise TypeGateError(f"trusted base path lookup returned unexpected path: {path}") + + shown = _run_git(run, ("show", f"{base_sha}:{BASELINE_PATH}")) + if shown.returncode != 0: + detail = shown.stderr.strip() or "unknown git error" + raise TypeGateError( + f"trusted base baseline content retrieval failed: {detail}" + ) + return parse_baseline(shown.stdout) + + +def verify_type_gate(base_sha: str) -> tuple[int, int]: + """Run the blocking critical check and the monotonic repository debt check.""" + critical = collect_mypy_diagnostics(CRITICAL_SURFACES) + if critical: + raise TypeGateError( + f"critical-surface mypy policy failed\n{_format_counter(critical)}" + ) + + current = collect_mypy_diagnostics(("forge_cli",)) + candidate_path = ROOT / BASELINE_PATH + try: + candidate = parse_baseline(candidate_path.read_text(encoding="utf-8")) + except OSError as exc: + raise TypeGateError(f"candidate baseline read failed: {exc}") from exc + trusted = load_trusted_baseline(base_sha) + validate_candidate_baseline(current, candidate, trusted) + return sum(critical.values()), sum(current.values()) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + _ = parser.add_argument("--base", required=True, help="full trusted PR or push base SHA") + args = parser.parse_args(argv) + base_sha = cast(str, args.base) + try: + critical_count, debt_count = verify_type_gate(base_sha) + except TypeGateError as exc: + print(f"type gate failed: {exc}", file=sys.stderr) + return 1 + message = ( + f"Forge mypy policy v{POLICY_VERSION}: critical diagnostics={critical_count}; " + f"repository debt diagnostics={debt_count}" + ) + print(message) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_type_gate.py b/tests/test_type_gate.py new file mode 100644 index 0000000..3c9ccee --- /dev/null +++ b/tests/test_type_gate.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from collections import Counter +import json +from pathlib import Path +import subprocess + +import pytest + +from scripts.verify_type_gate import ( + BASELINE_PATH, + CRITICAL_SURFACES, + MYPY_ARGS, + POLICY_VERSION, + TypeGateError, + collect_mypy_diagnostics, + diagnostic_counts, + load_trusted_baseline, + parse_baseline, + render_baseline, + validate_candidate_baseline, +) + + +ROOT = Path(__file__).resolve().parents[1] +DiagnosticKey = tuple[str, str, str] + + +def _diagnostic( + *, + path: str = "forge_cli/example.py", + code: str = "assignment", + message: str = "incompatible assignment", + line: int = 1, + column: int = 0, +) -> dict[str, object]: + return { + "file": path, + "line": line, + "column": column, + "message": message, + "hint": None, + "code": code, + "severity": "error", + } + + +def _key( + *, + path: str = "forge_cli/example.py", + code: str = "assignment", + message: str = "incompatible assignment", +) -> DiagnosticKey: + return (path, code, message) + + +def test_policy_is_versioned_and_wired_to_exact_critical_surfaces() -> None: + assert POLICY_VERSION == 1 + assert BASELINE_PATH == "typing/mypy-baseline-v1.json" + assert CRITICAL_SURFACES == ( + "forge_cli/models.py", + "forge_cli/incident_store.py", + ) + assert MYPY_ARGS == ( + "--check-untyped-defs", + "--no-implicit-optional", + "--warn-redundant-casts", + "--warn-unused-ignores", + "--show-error-codes", + "--output=json", + "--no-error-summary", + "--no-pretty", + "--no-incremental", + ) + + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + assert "fetch-depth: 0" in workflow + assert "github.event.pull_request.base.sha" in workflow + assert "github.event.before" in workflow + assert "python scripts/verify_type_gate.py --base" in workflow + + +def test_actual_mypy_policy_rejects_wrong_return(tmp_path: Path) -> None: + module = tmp_path / "wrong_return.py" + module.write_text("def wrong() -> str:\n return 1\n", encoding="utf-8") + + diagnostics = collect_mypy_diagnostics((str(module),)) + + assert any("Incompatible return value type" in key[2] for key in diagnostics) + assert any(key[1] == "return-value" for key in diagnostics) + + +def test_line_and_column_movement_does_not_change_normalized_diagnostics() -> None: + before = _diagnostic(line=10, column=4) + after = _diagnostic(line=200, column=19) + + assert diagnostic_counts([before]) == diagnostic_counts([after]) + + +def test_rendered_baseline_contains_only_versioned_stable_keys() -> None: + diagnostic = _key() + + payload = json.loads(render_baseline(Counter({diagnostic: 1}))) + + assert payload == { + "diagnostics": [ + { + "code": "assignment", + "file": "forge_cli/example.py", + "message": "incompatible assignment", + } + ], + "version": 1, + } + + +def test_candidate_baseline_rejects_stale_diagnostics() -> None: + candidate = Counter({_key(): 1}) + + with pytest.raises(TypeGateError, match="does not exactly match"): + validate_candidate_baseline(Counter(), candidate, candidate) + + +def test_candidate_baseline_rejects_replaced_diagnostic_without_count_growth() -> None: + original = _key(message="old assignment") + replacement = _key(code="return-value", message="wrong return") + + with pytest.raises(TypeGateError, match="does not exactly match"): + validate_candidate_baseline( + Counter({replacement: 1}), + Counter({original: 1}), + Counter({original: 1}), + ) + + +def test_candidate_baseline_rejects_inflation() -> None: + diagnostic = _key() + + with pytest.raises(TypeGateError, match="does not exactly match"): + validate_candidate_baseline( + Counter({diagnostic: 1}), + Counter({diagnostic: 2}), + Counter({diagnostic: 2}), + ) + + +def test_trusted_base_subset_comparison_counts_duplicates() -> None: + diagnostic = _key() + + with pytest.raises(TypeGateError, match="exceeds trusted base"): + validate_candidate_baseline( + Counter({diagnostic: 2}), + Counter({diagnostic: 2}), + Counter({diagnostic: 1}), + ) + + +def test_baseline_parser_preserves_duplicate_counts() -> None: + diagnostic = _diagnostic() + text = json.dumps({"version": 1, "diagnostics": [diagnostic, diagnostic]}) + + assert parse_baseline(text) == Counter({_key(): 2}) + + +@pytest.mark.parametrize( + "text", + [ + "{", + "[]", + json.dumps({"version": 2, "diagnostics": []}), + json.dumps({"version": 1, "diagnostics": {}}), + ], +) +def test_malformed_baseline_json_fails_closed(text: str) -> None: + with pytest.raises(TypeGateError): + parse_baseline(text) + + +@pytest.mark.parametrize("field", ["file", "code", "message"]) +@pytest.mark.parametrize("value", ["", None]) +def test_empty_or_missing_required_diagnostic_fields_fail_closed( + field: str, + value: object, +) -> None: + diagnostic = _diagnostic() + diagnostic[field] = value + text = json.dumps({"version": 1, "diagnostics": [diagnostic]}) + + with pytest.raises(TypeGateError, match="non-empty file, code, and message"): + parse_baseline(text) + + +@pytest.mark.parametrize("stdout", ["", "not-json\n"]) +def test_mypy_exit_one_without_parseable_diagnostics_fails_closed(stdout: str) -> None: + def run(command, **kwargs): + return subprocess.CompletedProcess(command, 1, stdout, "") + + with pytest.raises(TypeGateError, match="without parseable JSON diagnostics"): + collect_mypy_diagnostics(("forge_cli",), run=run) + + +def _git_runner( + *, + tree_returncode: int = 0, + listing_returncode: int = 0, + tree_output: str = "", + show_returncode: int = 0, + show_output: str = '{"version": 1, "diagnostics": []}\n', +): + def run(command, **kwargs): + if command[1:3] == ["cat-file", "-e"]: + return subprocess.CompletedProcess(command, tree_returncode, "", "bad tree") + if command[1] == "ls-tree": + return subprocess.CompletedProcess(command, listing_returncode, tree_output, "bad listing") + if command[1] == "show": + return subprocess.CompletedProcess(command, show_returncode, show_output, "bad show") + raise AssertionError(f"unexpected command: {command}") + + return run + + +def test_invalid_base_sha_fails_closed() -> None: + with pytest.raises(TypeGateError, match="full 40-character"): + load_trusted_baseline("not-a-sha", run=_git_runner()) + + +def test_invalid_base_tree_fails_closed() -> None: + with pytest.raises(TypeGateError, match="tree lookup failed"): + load_trusted_baseline("a" * 40, run=_git_runner(tree_returncode=1)) + + +def test_base_path_lookup_failure_fails_closed() -> None: + with pytest.raises(TypeGateError, match="path lookup failed"): + load_trusted_baseline("a" * 40, run=_git_runner(listing_returncode=1)) + + +def test_missing_base_baseline_bootstraps_only_after_successful_tree_lookup() -> None: + assert load_trusted_baseline("a" * 40, run=_git_runner(tree_output="")) is None + + +def test_base_baseline_show_failure_fails_closed() -> None: + tree_output = f"100644 blob {'b' * 40}\t{BASELINE_PATH}\0" + + with pytest.raises(TypeGateError, match="content retrieval failed"): + load_trusted_baseline( + "a" * 40, + run=_git_runner(tree_output=tree_output, show_returncode=1), + ) + + +def test_malformed_trusted_baseline_fails_closed() -> None: + tree_output = f"100644 blob {'b' * 40}\t{BASELINE_PATH}\0" + + with pytest.raises(TypeGateError): + load_trusted_baseline( + "a" * 40, + run=_git_runner(tree_output=tree_output, show_output="{"), + ) diff --git a/typing/mypy-baseline-v1.json b/typing/mypy-baseline-v1.json new file mode 100644 index 0000000..ac9ac84 --- /dev/null +++ b/typing/mypy-baseline-v1.json @@ -0,0 +1,75 @@ +{ + "diagnostics": [ + { + "code": "arg-type", + "file": "forge_cli/cli.py", + "message": "Argument 24 to \"Incident\" has incompatible type \"**dict[str, dict[Any, Any] | None]\"; expected \"list[dict[str, Any]]\"" + }, + { + "code": "arg-type", + "file": "forge_cli/cli.py", + "message": "Argument 24 to \"Incident\" has incompatible type \"**dict[str, dict[Any, Any] | None]\"; expected \"list[str]\"" + }, + { + "code": "assignment", + "file": "forge_cli/cli.py", + "message": "Incompatible types in assignment (expression has type \"list[dict[Any, Any]]\", target has type \"dict[Any, Any] | None\")" + }, + { + "code": "operator", + "file": "forge_cli/config.py", + "message": "Unsupported left operand type for / (\"None\")" + }, + { + "code": "operator", + "file": "forge_cli/config.py", + "message": "Unsupported left operand type for / (\"None\")" + }, + { + "code": "operator", + "file": "forge_cli/config.py", + "message": "Unsupported left operand type for / (\"None\")" + }, + { + "code": "arg-type", + "file": "forge_cli/display.py", + "message": "Argument \"style\" to \"Text\" has incompatible type \"str | None\"; expected \"str | Style\"" + }, + { + "code": "arg-type", + "file": "forge_cli/display.py", + "message": "Argument \"style\" to \"Text\" has incompatible type \"str | None\"; expected \"str | Style\"" + }, + { + "code": "arg-type", + "file": "forge_cli/mcp_server.py", + "message": "Argument 25 to \"Incident\" has incompatible type \"**dict[str, list[dict[str, Any]] | dict[str, Any] | None]\"; expected \"dict[str, Any] | None\"" + }, + { + "code": "arg-type", + "file": "forge_cli/mcp_server.py", + "message": "Argument 25 to \"Incident\" has incompatible type \"**dict[str, list[dict[str, Any]] | dict[str, Any] | None]\"; expected \"list[dict[str, Any]]\"" + }, + { + "code": "import-not-found", + "file": "forge_cli/providers.py", + "message": "Cannot find implementation or library stub for module named \"anthropic\"" + }, + { + "code": "import-not-found", + "file": "forge_cli/providers.py", + "message": "Cannot find implementation or library stub for module named \"openai\"" + }, + { + "code": "return-value", + "file": "forge_cli/providers.py", + "message": "Incompatible return value type (got \"object\", expected \"LLMProvider\")" + }, + { + "code": "return-value", + "file": "forge_cli/providers.py", + "message": "Incompatible return value type (got \"object\", expected \"LLMProvider\")" + } + ], + "version": 1 +} diff --git a/uv.lock b/uv.lock index 47334e3..c4f5f3c 100644 --- a/uv.lock +++ b/uv.lock @@ -368,8 +368,10 @@ anthropic = [ { name = "anthropic" }, ] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] mcp = [ { name = "mcp" }, @@ -394,12 +396,14 @@ build = [ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.39.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1,<1.20" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "typer", specifier = ">=0.9.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12.20241230" }, { name = "uvicorn", marker = "extra == 'mcp'", specifier = ">=0.30.0" }, ] provides-extras = ["anthropic", "openai", "mcp", "dev"] @@ -631,6 +635,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, +] + [[package]] name = "license-expression" version = "30.4.4" @@ -730,6 +782,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "openai" version = "2.45.0" @@ -1419,6 +1513,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 13825e825e0811f7947afe437038e2b2b0d74818 Mon Sep 17 00:00:00 2001 From: Shamim Rehman Date: Thu, 16 Jul 2026 11:07:06 -0400 Subject: [PATCH 2/2] test: cover type debt reduction --- tests/test_type_gate.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_type_gate.py b/tests/test_type_gate.py index 3c9ccee..760e043 100644 --- a/tests/test_type_gate.py +++ b/tests/test_type_gate.py @@ -121,6 +121,17 @@ def test_candidate_baseline_rejects_stale_diagnostics() -> None: validate_candidate_baseline(Counter(), candidate, candidate) +def test_candidate_baseline_accepts_real_debt_reduction() -> None: + retained = _key() + fixed = _key(code="return-value", message="wrong return") + + validate_candidate_baseline( + Counter({retained: 1}), + Counter({retained: 1}), + Counter({retained: 1, fixed: 1}), + ) + + def test_candidate_baseline_rejects_replaced_diagnostic_without_count_growth() -> None: original = _key(message="old assignment") replacement = _key(code="return-value", message="wrong return")