ADR-051: Python type checker — ty primary, ruff ANN backfill, mypy informational-transitional
Status: Accepted (2026-04-24) Supersedes: ADR-012 on the mypy portion of the dev-tooling stack (the rest of ADR-012 — Biome, git-cliff, tiered commit hooks, ruff TD rules — stands; the mypy gate is replaced by ty)
Context
ADR-012 established the dev-tooling stack and named mypy as the Python type-check gate at the pre-push tier. Two pressures motivated revisiting the choice:
- Ty (Astral) reached strict-max coverage parity for our codebase. Ty is faster than mypy (Rust-implemented), shares the Astral toolchain (uv, ruff), and supports
rules.all = "error"for a strict-max gate that matches the discipline ADR-012 wanted from mypy. - Mypy’s missing-annotation family is now better covered by ruff. The
ANNrule family (includingANN401disallowingAny) catches the same class of errors at lint time, faster, with better diagnostics, and without requiring a separate type-check pass for the simplest cases.
The pre-push tier needs one canonical type-check gate. Running mypy + ty + ruff ANN at the same time produces redundant work and noisy reports. The right shape is: ty as the primary gate (strict-max), ruff ANN backfilling the missing-annotation family, and mypy retained only as an informational warning step that runs in tools/dev/precheck.sh for diagnostic classes ty does not yet cover.
Decision
D1 — Ty is the primary Python type checker
Configuration in pyproject.toml under [tool.ty] and [tool.ty.rules]:
rules.all = "error"— strict-max posture; every diagnostic class is an error- Source roots in
[tool.ty.src]coversrc/spectral,apps/api/src,apps/workers/src
Pre-push tier invokes uv run ty check. CI invokes the same. A passing run = zero diagnostics.
D2 — Ruff ANN family backfills missing-annotation coverage
Ruff config enables the ANN rule set, including ANN401 (no Any in public function signatures). Catches the missing-annotation family that mypy-strict would have caught, at lint time. Runs in ruff check at every tier (pre-commit, pre-push, CI).
D3 — Mypy retained as informational-transitional warning
tools/dev/precheck.sh runs mypy as a non-blocking step that surfaces diagnostic classes ty does not yet cover (currently a small set; tracking via Astral’s ty roadmap). Mypy results print as warnings; they do not fail the precheck gate. Pre-commit and CI gates do not invoke mypy.
Mypy removal trigger: when ty’s diagnostic coverage matches mypy’s for our codebase (verified by running both gates on a representative branch and observing parity), remove the precheck.sh mypy step entirely. The trigger is documented in continue.md and tracked here.
D4 — Tripwire tests under tests/_tooling/
tests/_tooling/test_ty_tripwires.py plus fixtures in tests/_tooling/fixtures/ guard against ty misconfiguration:
ty_sanity.py— minimal annotated module that ty must acceptpydantic_ty_sanity.py— pydantic-specific cases verifyingdataclass_transformhandling (unknown kwargs + wrong field types are caught)
If a future ty upgrade silently regresses rules.all = "error" or breaks pydantic field-typing detection, the tripwires fail loudly.
D5 — Pre-commit hook for ty is intentionally NOT added
Pre-commit (tier 1) stays fast (< 3s) per ADR-012. Ty is fast but still slower than ruff lint; full ty runs at pre-push (tier 2) and in CI. Tier 1 catches the missing-annotation family via ruff ANN; full type-check semantics are pre-push tier responsibility.
Alternatives considered
Stay on mypy. Rejected. Slower; no longer Astral-toolchain-aligned; missing-annotation family already covered better by ruff ANN.
Pyright. Considered. Microsoft-backed, well-maintained, but adds a Node-runtime dependency to the Python type-check pipeline; ty is Rust + part of Astral’s stack, which is already an uv dependency.
Ty without mypy backstop. Considered. Rejected for the transitional period while ty’s coverage continues to fill in. The informational-warning posture in precheck.sh costs little and provides a safety net during the transition.
Ty + ruff ANN with no ANN401. Rejected. ANN401 (no Any in public signatures) is the rule that catches the worst missing-annotation class; without it the backfill is not complete.
Consequences
- Pre-push gate is
uv run ty checkagainst strict-max (rules.all = "error"); zero diagnostics required to pass. pyproject.tomladds[tool.ty]+[tool.ty.rules]withall = "error"and source roots covering library + apps.tools/dev/precheck.shruns mypy informationally (warnings only); removal trigger documented.tests/_tooling/test_ty_tripwires.py+ fixtures catch ty misconfiguration loudly. Tripwires were not in place under mypy; this is a net gain..pre-commit-config.yamlupdated: ty not added at tier 1; mypy entries removed; tier 1 stays fast.- CI workflow
.github/workflows/ci.ymlswapsmypy --strictinvocation foruv run ty check(per ADR-053 D20). - AGENTS.md updated to reference ty as the canonical Python type-check gate (the file that lists “Before declaring anything done” includes
ty checkrather thanmypy --strict). - ADR-012 is partially superseded on the mypy portion. Biome, git-cliff, tiered commit hooks, ruff
TDrules all stand. ADR-012’s status line is updated to reflect this. - Removal of mypy is a single-step cleanup once the removal trigger fires: drop the precheck.sh step, drop the dev dependency, remove the tripwires that target mypy-specific behavior (none currently do).
References
- ADR-012 — partially superseded by this ADR (mypy → ty)
- ADR-065 —
spectral.coreadmission discipline (ty enforcement protects core surface) - ADR-053 —
ci.ymlty swap as part of D20 substrate cleanup - Ty adoption commit
d775046— initial landing - Pre-push gate runner —
tools/dev/precheck.sh - Codex
developer-guide/type-checking.mdx— close-pass new page pyproject.toml—[tool.ty]and[tool.ty.rules]tests/_tooling/test_ty_tripwires.py— tripwires