Skip to content

ci(lint): enforce type-discipline budget for casts and type guards - #30498

Closed
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
type-discipline-gate
Closed

ci(lint): enforce type-discipline budget for casts and type guards#30498
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
type-discipline-gate

Conversation

@mateo-berri

Copy link
Copy Markdown
Contributor

Summary

Adds a ratcheted CI gate that blocks net-new typing.cast() usage and bans TypeGuard/TypeIs outright, layered on top of the existing ruff-strict budget infrastructure. Nothing existing is forced to change — the gate freezes today's counts as a baseline and fails only on additions beyond baseline + slack.

Two enforcement layers:

  1. Coarse import-level freeze (Ruff banned-api / TID251). cast, TypeGuard, and TypeIs (from both typing and typing_extensions) are added to flake8-tidy-imports.banned-api in ruff-strict.toml. Because TID251 is a single rule code covering all banned-api matches, the ruff-strict-budget.json baseline is bumped 2404 → 2662 to absorb the ~258 pre-existing usages these entries now match (otherwise the gate would breach immediately on existing code).
  2. Per-call-site granularity (custom AST checker / LIT006–LIT007). scripts/check_type_discipline.py gains:
    • LIT006 — flags cast(...) call sites. Suppress with # cast-ok: <reason>.
    • LIT007 — flags TypeGuard[...] / TypeIs[...] annotations. Suppress with # guard-ok: <reason>.
      scripts/type_discipline_gate.py runs the checker with a baseline + slack budget and delta-vs-base comparison, mirroring ruff_strict_gate.py. Budgets in type-discipline-budget.json: LIT006 baseline 1013 / slack 10, LIT007 0 / 0 (hard ban — there are none today).

CI wiring: test-linting.yml runs scripts/type_discipline_gate.py --base "$BASE_SHA" alongside the existing ruff_strict_gate.py step.

Rationale: typing.cast() is the moral equivalent of TypeScript's as — an unchecked assertion with zero runtime guarantee — and TypeGuard/TypeIs predicate bodies are never verified by the checker. The intent is to validate into concrete frozen types at boundaries instead, and to ratchet the cast count down over time (python scripts/type_discipline_gate.py --update re-captures a lower baseline as casts are removed).

Files

  • ruff-strict.toml — ban cast/TypeGuard/TypeIs via banned-api
  • ruff-strict-budget.json — TID251 baseline 2404 → 2662
  • scripts/check_type_discipline.py — new vendored AST checker (LIT006/LIT007)
  • scripts/type_discipline_gate.py — new baseline+slack gate
  • type-discipline-budget.json — LIT006/LIT007 budgets
  • .github/workflows/test-linting.yml — run the gate in CI

Test plan

  • test-linting workflow passes on this PR (no net-new casts/guards introduced here).
  • Confirm the gate trips on a deliberately-added cast() (verified locally).
  • Confirm # cast-ok: <reason> / # guard-ok: <reason> suppressions are honored.
  • Confirm a missing reason on a suppression is rejected (LIT005).

Add a ratcheted gate that blocks net-new typing.cast() usage and bans
TypeGuard/TypeIs outright, layered on the existing ruff-strict budget setup.

- ruff-strict.toml: ban cast/TypeGuard/TypeIs (typing + typing_extensions)
  via flake8-tidy-imports banned-api (TID251) for a coarse import-level freeze.
- ruff-strict-budget.json: bump TID251 baseline 2404 -> 2662 to absorb the
  ~258 pre-existing usages now matched by the new banned-api entries.
- scripts/check_type_discipline.py: AST checker adding LIT006 (cast call sites,
  suppress with `# cast-ok: <reason>`) and LIT007 (TypeGuard/TypeIs annotations,
  suppress with `# guard-ok: <reason>`) for per-call-site granularity.
- scripts/type_discipline_gate.py: baseline+slack gate with delta-vs-base,
  mirroring ruff_strict_gate.py.
- type-discipline-budget.json: LIT006 baseline 1013 (slack 10), LIT007 0/0.
- test-linting.yml: run the gate in CI against the PR base SHA.
@mateo-berri
mateo-berri requested a review from a team June 16, 2026 02:49
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Superseded by #30500 (branch renamed to litellm_type_discipline_gate).

@mateo-berri
mateo-berri deleted the type-discipline-gate branch June 16, 2026 02:50
@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a two-layer CI ratchet that freezes net-new typing.cast() calls (LIT006) and hard-bans TypeGuard/TypeIs (LIT007) without requiring any existing code to change. The ruff-strict.toml banned-api entries handle import-level detection, while the new check_type_discipline.py AST checker and type_discipline_gate.py budget gate handle per-call-site counting with baseline + slack budgets.

  • check_type_discipline.py is a new vendored stdlib-only AST checker; the gate wires it into CI via a worktree-based base-vs-head comparison that mirrors the existing ruff_strict_gate.py.
  • The --changed-only flag mentioned in the checker's usage docstring is silently ignored by main(), and multi-line cast() calls where # cast-ok appears on a line other than the call's opening line will not be suppressed — both are usability footguns worth addressing.

Confidence Score: 4/5

Safe to merge — the gate tooling is additive CI infrastructure that doesn't touch production code paths and fails closed on error.

The core gate logic (worktree-based base comparison, budget evaluation, breach reporting) is solid and correctly mirrors the existing ruff gate. Two usability issues in the checker script — the no-op --changed-only flag and the single-line-only suppression window for multi-line casts — could frustrate contributors but won't silently pass violations through the gate.

scripts/check_type_discipline.py — the suppression comment line-matching and the undocumented flag behavior deserve a second look before the checker is widely used.

Important Files Changed

Filename Overview
scripts/check_type_discipline.py New AST-based checker for LIT006/LIT007 rules; has two usability issues: --changed-only flag is documented but silently ignored, and multi-line cast suppression comments not on the call's opening line have no effect.
scripts/type_discipline_gate.py New budget gate mirroring ruff_strict_gate.py; logic for base worktree comparison, cap evaluation, and introduced-violation display is correct.
ruff-strict.toml Adds cast/TypeGuard/TypeIs to banned-api with explanatory messages; missing trailing newline.
ruff-strict-budget.json TID251 baseline bumped 2404→2662 to absorb the ~258 pre-existing import-level violations introduced by the new banned-api entries.
type-discipline-budget.json New budget file with LIT006 baseline 1013/slack 10 and LIT007 at 0/0 (hard ban); values align with the PR description.
.github/workflows/test-linting.yml Adds the type-discipline gate step in the same pattern as the existing ruff_strict_gate step, using BASE_SHA from pull_request.base.sha.

Reviews (1): Last reviewed commit: "ci(lint): enforce type-discipline budget..." | Re-trigger Greptile

Comment on lines +19 to +20
Validate into a concrete frozen type at the boundary instead.
Suppress with `# cast-ok: <reason>` on the call's first line.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --changed-only flag documented but not implemented

The module docstring shows python check_type_discipline.py --changed-only file1.py file2.py as a valid usage, but main() discards all arguments starting with - via paths = [a for a in argv if not a.startswith("-")]. The flag is silently ignored — passing --changed-only has no effect beyond being dropped. Callers who believe the flag restricts output to changed files will be surprised when all passed files are checked unconditionally. Either implement the flag or remove it from the docstring.

Comment on lines +254 to +262
def iter_cast_violations(path: Path, tree: ast.AST, comments: Comments) -> Iterator[Violation]:
for node in ast.walk(tree):
if isinstance(node, ast.Call) and _is_cast_call(node) and node.lineno not in comments.cast_ok_lines:
yield Violation(
path, node.lineno, "LIT006",
"cast() is an unchecked assertion (the type checker takes it on faith); "
"validate into a frozen dataclass/NamedTuple/ReadOnly TypedDict at the "
"boundary instead (suppress: `# cast-ok: <reason>`)",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Multi-line cast() suppression silently has no effect

iter_cast_violations checks node.lineno not in comments.cast_ok_lines, where node.lineno is the first line of the ast.Call node. A suppression comment placed on any line other than the cast('s opening line is not matched and the violation is still counted. The module docstring mentions "on the call's first line" but this constraint is easy to miss for long multi-line casts. Consider checking all lines spanned by the call (node.lineno through node.end_lineno) for a suppression comment.

Comment thread ruff-strict.toml
"typing.TypeGuard".msg = "Unverified narrowing. Parse into a concrete type, or use isinstance for a runtime-checked narrowing."
"typing_extensions.TypeGuard".msg = "Same as typing.TypeGuard."
"typing.TypeIs".msg = "Unverified narrowing (the body is trusted). Parse into a concrete type instead."
"typing_extensions.TypeIs".msg = "Same as typing.TypeIs." No newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The file still has no trailing newline after the additions, which is inconsistent with most editors and can cause noisy diffs.

Suggested change
"typing_extensions.TypeIs".msg = "Same as typing.TypeIs."
"typing_extensions.TypeIs".msg = "Same as typing.TypeIs."

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant