Skip to content
GitHub
Decisions

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 ANN rule family (including ANN401 disallowing Any) 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] cover src/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 accept
  • pydantic_ty_sanity.py — pydantic-specific cases verifying dataclass_transform handling (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 check against strict-max (rules.all = "error"); zero diagnostics required to pass.
  • pyproject.toml adds [tool.ty] + [tool.ty.rules] with all = "error" and source roots covering library + apps.
  • tools/dev/precheck.sh runs 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.yaml updated: ty not added at tier 1; mypy entries removed; tier 1 stays fast.
  • CI workflow .github/workflows/ci.yml swaps mypy --strict invocation for uv 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 check rather than mypy --strict).
  • ADR-012 is partially superseded on the mypy portion. Biome, git-cliff, tiered commit hooks, ruff TD rules 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-065spectral.core admission discipline (ty enforcement protects core surface)
  • ADR-053ci.yml ty 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