litellm/scripts/check_any_discipline.py
Mateo Wang d0c2e87810
ci: ratchet lint and type-check gates (ruff preview, ANN, mypy, basedpyright) (#30379)
* ci: enable ruff preview rules under the budgeted strict gate

Turn on ruff preview in the strict-budget lane (ruff-strict.toml) only,
leaving the clean gate (ruff.toml) untouched so make lint-ruff stays at
zero. Enumerate the 118 firing codes explicitly with
explicit-preview-rules so the gate is deterministic and stable across
ruff upgrades rather than depending on preview auto-selecting the broad
catalog.

Grandfather the existing 58438 violations into ruff-strict-budget.json
as per-rule baselines with headroom, so only net-new violations fail CI.
The existing ten rules keep their hand-tuned slack; the new rules get
slack 10 when the baseline is 50 or more and 3 otherwise.

* ci: add ANN return-type rules to the budgeted strict gate

Add ANN201/202/204/205/206 (missing return annotations) to the strict
lane and grandfather the existing counts into ruff-strict-budget.json so
the codebase ratchets toward explicit return types without breaking CI.

* ci: add mypy (disallow_untyped_defs) and basedpyright strict gates with baselines

Add two type-check gates, each grandfathering the current tree so only
net-new violations fail CI, matching the ruff strict-budget ratchet.

mypy gains disallow_untyped_defs in litellm/mypy.ini (the config the CI
invocation actually reads; the root [tool.mypy] is not picked up from the
litellm/ working dir). The 4885 existing missing-annotation errors are
captured in litellm/.mypy-baseline.txt and the run is piped through
mypy-baseline filter so new untyped defs are rejected.

basedpyright runs in strict mode over litellm/, with
enableTypeIgnoreComments disabled so it only honors '# pyright: ignore'
and never polices mypy's '# type: ignore'. The existing strict diagnostics
are grandfathered into .basedpyright/baseline.json.

Both tools are pinned in the dev group and uv.lock; the lint workflow and
Makefile run them filtered through their baselines, with
lint-mypy-baseline-update and lint-basedpyright-baseline-update to ratchet.

* ci: raise lint job timeout to 15m for the basedpyright strict pass

* ci: pin pythonVersion 3.12 and regenerate baselines against merged base

Merge litellm_internal_staging so the baselines cover code the CI merge
includes (e.g. the cisco_ai_defense guardrail), which otherwise tripped
the mypy gate with 3 ungrandfathered no-untyped-def errors. Pin
pythonVersion 3.12 in pyrightconfig so basedpyright's strict analysis is
reproducible across interpreter versions (CI runs 3.12).

* ci: regenerate basedpyright baseline against the frozen lint env

The previous baseline was generated with optional provider deps (azure,
google, anthropic, mcp, numpydoc, google-genai) installed locally, so CI's
dev-only env surfaced ~3500 reportUnknown*/reportMissingTypeStubs errors
not in the baseline. Regenerate after uv sync --frozen so the baseline
reflects the same dependency set the lint job sees.

* ci: regenerate basedpyright baseline on python 3.12 frozen env

The prior baseline still carried proxy-dev packages (e.g. prisma) that the
lint job's dev-only, python 3.12 env lacks, leaving 2 unresolved-import
errors ungrandfathered. Regenerate in a python 3.12 venv synced to the
frozen lock with default groups only, so the baseline matches exactly what
CI sees.

* ci: replace type-check baselines with per-file count budgets

The mypy and basedpyright baselines were position-sensitive (and the
basedpyright one was a 27MB file), so ordinary line shifts churned them.
Replace both with a per-file count gate: scripts/type_check_gate.py reduces
each tool's output to errors-per-file and checks it against a committed
{file: max} budget, ignoring line and column numbers. A file fails only
when it gains more errors than its ceiling; debt can't be shuffled between
files because each file has its own cap and new files default to zero.

Budgets (mypy-file-budget.json 48K, basedpyright-file-budget.json 96K) are
generated in the python 3.12 frozen lint env so they match CI. Drops the
mypy-baseline dependency; basedpyright runs without its native baseline.
ratchet via make lint-mypy-budget-update / lint-basedpyright-budget-update.

* ci: add a small per-file slack to the type-check gate

Allow each file to drift PER_FILE_SLACK (5) errors past its recorded count
before failing, so a basedpyright inference ripple in an unrelated file
doesn't break the build over a couple of errors. Budgets still record exact
counts; the tolerance is applied at check time.

* ci: move type-check slack into the budget json and trim lint timeout

Make slack declarative: the budget is now {"slack": N, "files": {path: count}}
so the tolerance is tuned in JSON without editing the script, mirroring how
ruff-strict-budget.json carries its slack. --update preserves the existing
slack. Also drop the lint job timeout from 15m to 10m; the mypy and
basedpyright passes add ~2m, leaving the job around 4-5m, so 10m is a
comfortable margin.

* ci: collapse fully-adopted ruff categories and drop inert preview flag

ANN (all nine non-removed rules) and BLE (its only rule) were spelled out
code-by-code; replace each with its category selector, which is exactly
equivalent in 0.15.3 (the removed ANN101/ANN102 are skipped by a category
selector and error when named explicitly). explicit-preview-rules was inert:
every selected rule is stable and nothing is selected by category, so the flag
had nothing to gate. Verified the strict-rule counts are identical before and
after (62379 each, zero per-rule drift), so no budget change.

* ci: drop redundant pyright dev dependency

Nothing invokes bare pyright in the Makefile, the linting workflow, or
scripts; the basedpyright gate added on this branch is the only type
checker that runs. basedpyright is a superset fork that reads the same
pyrightconfig.json and honors the same "# pyright: ignore" comments, so
pyright==1.1.408 in the ci group was dead weight. Regenerated uv.lock
under the same exclude-newer cutoff so the only change is removing
pyright and its package stanza

* ci: un-weaken mypy and error on Any in basedpyright

mypy: enable warn_return_any, drop the valid-type silencer, and stop globally ignoring missing first-party imports via [mypy-litellm.*] ignore_missing_imports = False, which surfaced eight real broken litellm.* imports the blanket ignore was hiding; third-party imports stay ignored. The per-file budget moves 4888 -> 5799 (902 no-any-return, 1 valid-type, 8 import-not-found), all grandfathered so only net-new errors fail and the ceilings ratchet down

basedpyright: error on reportExplicitAny and reportAny. The per-file budget moves 117033 -> 148946 (6931 explicit-Any, 24954 Any-typed expressions), grandfathered the same way

* ci: add Any-discipline gate on changed lines under litellm/

Add scripts/check_any_discipline.py, a type-aware gate that fails when a
changed line holds a value typed Any -- including the X | Any unions that
mypy --strict / basedpyright accept (e.g. re.Match.group() -> str | Any,
json.loads() -> Any, bare dict -> dict[Any, Any]).

It reuses the repo's mypyc-compiled mypy 1.19 via a custom generic AST
walker (mypyc precludes subclassing TraverserVisitor), loads litellm/mypy.ini
for parity with lint-mypy, and uses a dedicated incremental cache
(.mypy_cache_any) with mtime+hash invalidation to force re-checks. Scope is
changed-lines-only so editing a legacy file never forces cleaning its
existing Any debt; suppress a genuine typed/untyped boundary with
# any-ok: <reason> (ANY002 requires the reason).

Wire it into the Makefile (lint-any, lint, lint-dev), a parallel
any-discipline CI job with its own actions/cache, .gitignore, and the
CLAUDE.md / CONTRIBUTING.md docs.

* ci: move Any-gate codes into the shared LIT namespace

Renumber the Any-discipline checker into the LIT*** scheme owned by
scripts/check_type_discipline.py (PR #30500) so the two checkers share one
rule namespace and suppression convention:

  ANY001 -> LIT002  (Any-typed value; LIT002 was the retired/free slot)
  ANY002 -> LIT005  (any-ok without a reason; the shared suppression-reason code)
  ANY000 -> LIT000  (setup/build/read error; the shared error code)

Messages and behavior are unchanged; LIT005's text already matches the
"<token> requires a reason" shape used for cast-ok/guard-ok.

* ci: gate mypy and basedpyright per error rule, not per file

Switch the mypy/basedpyright budget gate from per-file error counts to
per-rule-code totals, mirroring the {rule: {baseline, slack}} shape of
ruff-strict-budget.json. A rule fails when its codebase-wide error count
exceeds baseline + slack, so violations are tracked by category rather
than by file location.

scripts/type_check_gate.py now parses mypy from its text output (trailing
[code]) and basedpyright from --outputjson (the JSON `rule` field), since
basedpyright's wrapped text diagnostics mis-attribute the rule on
continuation lines. Replace the *-file-budget.json files with freshly
captured *-code-budget.json baselines and update the Makefile, CI, and
CLAUDE.md accordingly.

* docs: prefer Pydantic validation over any-ok suppression

Point the Any-discipline guidance at validating Any with Pydantic (a model
or TypeAdapter that returns a typed value or raises) and frame
# any-ok as a last resort that should ideally never be used.

* chore: remove extraneous comment

* chore: make the CLAUDE.md more concise

* chore: clean up bloated CONTRIBUTING.md additions

* chore: make Makefile more concise

* ci: add the lint-budget-update target CLAUDE.md references

CLAUDE.md tells contributors to run make lint-budget-update, but the
target was never defined. Add it as an aggregate that re-captures the
ruff, mypy, and basedpyright budgets in one shot.

* ci: recapture mypy and basedpyright budgets in the lint env

The per-rule baselines were captured in a richer dependency env than the
CI lint job's uv sync --frozen, so CI resolved fewer types and reported
more errors than the budgets allowed (no-any-return 902 over cap 900, plus
several basedpyright reportUnknown* rules). Regenerate both in the frozen
env so they grandfather the true CI debt: mypy 5786 -> 5799 (no-any-return
890 -> 902, valid-type 1 restored), basedpyright 146213 -> 148942.

* ci: check out PR head sha in lint and any-discipline jobs

The default pull_request checkout uses refs/pull/N/merge, which folds the
latest base commits into HEAD. The diff-based gates (ruff delta, Any
discipline) then diff against the event's older base.sha and blame base's
own new commits on this branch; staging's otel-v2 and streaming changes
(#30326, #30485) tripped the Any gate on files this branch never touched.
Checking out the PR head sha makes the gates diff the real branch tip
against base, and pins the tree the mypy/basedpyright budgets were captured
against so their counts stay deterministic as the base advances.

* ci(lint): renumber Any-typed-value rule LIT002 -> LIT009

Free up LIT002 for the sibling type-discipline gate (check_type_discipline.py,
#30500), which groups its mutable-collection family at LIT001 (annotation) and
LIT002 (construction). This gate's Any-typed-value rule moves to LIT009 so the
shared LIT namespace stays contiguous with no holes; LIT000 and LIT005 are
unchanged.

* style: rename lint-strict-budget -> lint-ruff-budget

* ci: harden type-check gates against silent passes (greptile review)

type_check_gate.py: refuse to certify a vacuous run. The CI pipe swallows
the tool's exit code ('tool || true'), so a crashed mypy/basedpyright that
emits nothing would parse to zero errors, breach no ceiling, and pass.
is_vacuous_run() now fails when nothing was parsed but the budget expects
errors. Also wrap basedpyright's json.loads in a JSONDecodeError handler
that prints the offending output instead of dumping a raw traceback.

check_any_discipline.py: ALL_LINES was None, which dict.get() also returns
for a path absent from the line map, so a path-normalisation mismatch could
let a violation on an unchanged file pass the scope filter. Make ALL_LINES a
distinct sentinel object so 'whole file' and 'path missing' are unambiguous.

Adds tests for all three.
2026-06-16 12:07:46 -07:00

556 lines
20 KiB
Python

#!/usr/bin/env python3
"""Any-discipline gate: fail when a *changed* file holds a value typed `Any`.
Where ruff, `mypy --strict`, and even basedpyright's `reportAny` stop short, this
catches the case that actually bites: a *union* hiding an `Any`. For example
`re.Match.group()` -> `str | Any`, `json.loads()` -> `Any`, and bare `list`/`dict`
-> `list[Any]`/`dict[..., Any]`. Any value whose inferred type *contains* `Any`
(recursively, through unions / generics / tuples) is reported.
Scope: changed-only, changed-lines
----------------------------------
litellm already contains a large amount of pre-existing `Any` (a single legacy
file can have >100 findings), and a whole-tree scan would have to re-export types
for litellm's entire import closure on every run (~2 min, ~3 GB). So this gate is
*changed-only* and reports a finding only on a line that the diff against
`--base` actually adds or edits (untracked files count as wholly new). A brand
new file is therefore checked in full, while editing a legacy file only requires
*your* lines to be clean -- you can't introduce an `X | Any`, but you aren't
forced to clean the file's existing debt. This mirrors how `ruff_strict_gate.py`
blames a change only for the violations it introduces; cold legacy code is left
to the ratchet gates (mypy/basedpyright/ruff budgets).
How it works
------------
It loads `litellm/mypy.ini` (the same config `make lint-mypy` uses, so findings
match what developers already see), builds the changed files with mypy asking for
its exported expression->type map, and walks each file's AST applying a recursive
"contains Any" predicate -- the test `mypy --disallow-any-expr` uses internally
but applies inconsistently (python/mypy#12856).
mypy only re-exports types for modules it re-type-checks, so for each target we
invalidate just its cached hash (deps stay warm) to force a fast re-check against
a persisted incremental cache (.mypy_cache_any).
Rules
-----
Codes share the `LIT***` namespace with `scripts/check_type_discipline.py` (PR
#30500), which owns LIT001/002/003/004/006/007/008. This gate claims the rest:
LIT009 A value expression's inferred type is, or contains, `Any`.
Suppress with `# any-ok: <reason>` on the offending line.
LIT005 An `# any-ok` suppression without a reason (the shared
suppression-needs-a-reason code, same as `# cast-ok` / `# guard-ok`).
LIT000 Setup failure: mypy could not build, or a target file could not be read.
`Any`s produced purely by an already-reported error, and the special-form /
implementation-artifact internal `Any`s, are ignored. A bound method *reference*
whose signature mentions `Any` is not flagged -- only the value its call produces.
Usage
-----
# gate mode (CI / pre-push): check changed lines under litellm/
uv run --no-sync python scripts/check_any_discipline.py --changed --base origin/litellm_internal_staging
# whole-file spot-check (no line filter), paths relative to repo root
uv run --no-sync python scripts/check_any_discipline.py litellm/budget_manager.py
Exit code 1 if any Any-tainted value is found, 2 on a setup/usage error.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import tokenize
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import NamedTuple
try:
from mypy import build
from mypy.config_parser import parse_config_file
from mypy.find_sources import create_source_list
from mypy.fscache import FileSystemCache
from mypy.modulefinder import BuildSource
from mypy.nodes import AssignmentStmt, Expression, NameExpr, Node
from mypy.options import Options
from mypy.types import (
AnyType,
CallableType,
Instance,
Overloaded,
TupleType,
Type,
TypeOfAny,
UnionType,
get_proper_type,
)
except ImportError: # pragma: no cover - environment guard
sys.stderr.write(
"check_any_discipline: mypy is not importable in this interpreter.\n"
"Run it through the project environment, e.g.\n"
" uv run --no-sync python scripts/check_any_discipline.py --changed\n"
)
raise SystemExit(2)
REPO_ROOT = Path(__file__).resolve().parent.parent
LITELLM_DIR = REPO_ROOT / "litellm"
MYPY_INI = LITELLM_DIR / "mypy.ini"
CACHE_DIR = REPO_ROOT / ".mypy_cache_any"
PY_TAG = f"{sys.version_info.major}.{sys.version_info.minor}"
DEFAULT_BASE = "origin/litellm_internal_staging"
MIN_REASON_LEN = 3
ANY_OK_RE = re.compile(r"#\s*any-ok(?::\s*(?P<reason>.*))?")
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
# Files allowed to surface `Any` (the typed/untyped boundary). A finding is
# skipped if any fragment below is a substring of the file's posix path. Keep
# this tight -- prefer a line-level `# any-ok: <reason>` over a blanket exemption.
BOUNDARY_PATHS: frozenset[str] = frozenset()
# `Any` kinds that are not actionable: produced by an already-reported error, or
# an internal placeholder that never corresponds to a concrete runtime value.
# NOTE: `special_form` is deliberately NOT here. In mypy 1.19 the `Any` in
# typeshed unions like `re.Match.group() -> str | Any` is tagged `special_form`,
# and that union is the headline case this gate exists to catch.
_HARMLESS_ANY = frozenset(
kind
for kind in (
TypeOfAny.from_error,
getattr(TypeOfAny, "implementation_artifact", None),
)
if kind is not None
)
# AST attributes that point OUTSIDE the syntactic subtree (a RefExpr's resolved
# definition, a node's TypeInfo). Skipping exactly these two makes a generic
# child-walk equivalent to mypy's TraverserVisitor -- validated to the node
# against ExtendedTraverserVisitor across the full grammar (see commit notes).
_NON_SYNTACTIC_ATTRS = frozenset({"node", "info"})
class Violation(NamedTuple):
path: Path
line: int
col: int
code: str
message: str
def render(self) -> str:
return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
# --------------------------------------------------------------------------- #
# The "contains Any" predicate
# --------------------------------------------------------------------------- #
def contains_any(t: Type, _seen: set[int] | None = None) -> bool:
"""True if a *value* of type ``t`` carries `Any` anywhere meaningful."""
seen = _seen if _seen is not None else set()
p = get_proper_type(t)
if id(p) in seen:
return False
seen.add(id(p))
# A function/method *reference* whose signature mentions Any is not itself an
# unsafe value -- only its eventual call result is. Don't recurse into it.
if isinstance(p, (CallableType, Overloaded)):
return False
if isinstance(p, AnyType):
return p.type_of_any not in _HARMLESS_ANY
if isinstance(p, UnionType):
return any(contains_any(item, seen) for item in p.items)
if isinstance(p, Instance):
return any(contains_any(arg, seen) for arg in p.args)
if isinstance(p, TupleType):
return any(contains_any(item, seen) for item in p.items)
return False
# --------------------------------------------------------------------------- #
# Generic, leak-free AST walk (works under a mypyc-compiled mypy, which forbids
# subclassing TraverserVisitor)
# --------------------------------------------------------------------------- #
def _walk_file(tree: Node) -> tuple[list[Expression], set[int]]:
"""Return (every Expression in `tree`, ids of simple assignment-target names).
The walk follows only syntactic children (every attribute except the two
non-syntactic back-references), so it never escapes the module. Simple
``x = <expr>`` name targets are collected separately so we don't double-report
the assigned name as an echo of an Any rvalue.
"""
exprs: list[Expression] = []
skip_lvalues: set[int] = set()
stack: list[object] = [tree]
seen: set[int] = set()
while stack:
n = stack.pop()
if isinstance(n, Node):
if id(n) in seen:
continue
seen.add(id(n))
if isinstance(n, Expression):
exprs.append(n)
if isinstance(n, AssignmentStmt):
for lvalue in n.lvalues:
if isinstance(lvalue, NameExpr):
skip_lvalues.add(id(lvalue))
for name in dir(n):
if name.startswith("__") or name in _NON_SYNTACTIC_ATTRS:
continue
try:
val = getattr(n, name)
except Exception:
continue
if callable(val):
continue
if isinstance(val, (Node, list, tuple)):
stack.append(val)
elif isinstance(n, (list, tuple)):
stack.extend(n)
return exprs, skip_lvalues
def find_any_in_tree(tree: Node, idmap: dict[int, Type]) -> list[tuple[int, int, str]]:
exprs, skip_lvalues = _walk_file(tree)
findings: list[tuple[int, int, str]] = []
for expr in exprs:
if id(expr) in skip_lvalues:
continue
t = idmap.get(id(expr))
if t is not None and contains_any(t):
findings.append((expr.line, expr.column, str(get_proper_type(t))))
out: list[tuple[int, int, str]] = []
seen_pos: set[tuple[int, int]] = set()
for line, col, typ in sorted(findings):
if line < 1 or (line, col) in seen_pos:
continue
seen_pos.add((line, col))
out.append((line, col, typ))
return out
# --------------------------------------------------------------------------- #
# Comment scanning (LIT005 + any-ok suppression)
# --------------------------------------------------------------------------- #
def _reason_ok(reason: str | None) -> bool:
return reason is not None and len(reason.strip()) >= MIN_REASON_LEN
def scan_any_ok(
path: Path, source: str
) -> tuple[frozenset[int], tuple[Violation, ...]]:
"""Return (lines with a valid any-ok suppression, LIT005 violations)."""
try:
tokens = tokenize.generate_tokens(
iter(source.splitlines(keepends=True)).__next__
)
comments = tuple(
(t.start[0], t.string) for t in tokens if t.type == tokenize.COMMENT
)
except tokenize.TokenError:
return frozenset(), ()
ok_lines: set[int] = set()
violations: list[Violation] = []
for line, text in comments:
m = ANY_OK_RE.search(text)
if m is None:
continue
if _reason_ok(m.group("reason")):
ok_lines.add(line)
else:
violations.append(
Violation(
path,
line,
0,
"LIT005",
"any-ok requires a reason: `# any-ok: <reason>`",
)
)
return frozenset(ok_lines), tuple(violations)
# --------------------------------------------------------------------------- #
# mypy build (parity with `make lint-mypy`) + forced target re-check
# --------------------------------------------------------------------------- #
def _build_options() -> Options:
opts = Options()
if MYPY_INI.exists():
parse_config_file(opts, lambda: None, str(MYPY_INI), sys.stdout, sys.stderr)
opts.export_types = True
opts.preserve_asts = True
opts.incremental = True
opts.cache_dir = str(CACHE_DIR)
opts.show_traceback = False
return opts
def _meta_path(module: str) -> Path:
return CACHE_DIR / PY_TAG / (module.replace(".", os.sep) + ".meta.json")
def _force_recheck(sources: Sequence[BuildSource]) -> None:
"""Invalidate each target's cached entry so mypy re-type-checks (and thus
re-exports types + preserves the AST for) exactly these modules, while their
dependencies stay warm. A missing entry is a cold build for that module.
mypy trusts a cache entry whenever the source mtime matches the cached one
(it never re-hashes on that fast path), so we must break BOTH: zero the
cached mtime to force a re-hash, and corrupt the cached hash so the re-hash
mismatches and the module is treated as changed."""
for src in sources:
if not src.module:
continue
meta = _meta_path(src.module)
if not meta.exists():
continue
try:
data = json.loads(meta.read_text())
data["hash"] = "0" * 40
data["mtime"] = 0
meta.write_text(json.dumps(data))
except (OSError, ValueError):
continue
def check_files(rel_paths: Sequence[str]) -> tuple[Violation, ...]:
"""`rel_paths` are relative to the litellm package dir (the build cwd)."""
prev_cwd = Path.cwd()
os.chdir(LITELLM_DIR)
try:
opts = _build_options()
fscache = FileSystemCache()
sources = create_source_list(list(rel_paths), opts, fscache)
_force_recheck(sources)
try:
res = build.build(sources, options=opts, fscache=fscache)
except build.CompileError as exc:
joined = "; ".join(exc.messages[:3]) or "blocking error"
return (
Violation(
Path(rel_paths[0]),
0,
0,
"LIT000",
f"mypy could not build: {joined}",
),
)
idmap = {id(expr): t for expr, t in res.types.items()}
# Resolve trees to absolute source paths while cwd is the build dir, since
# mypy stores the paths it was given (relative to this cwd).
trees: dict[str, Node] = {}
for state in res.graph.values():
if state.path and state.tree is not None:
trees[os.path.realpath(state.path)] = state.tree
finally:
os.chdir(prev_cwd)
out: list[Violation] = []
for rel in rel_paths:
abs_path = (LITELLM_DIR / rel).resolve()
report_path = abs_path.relative_to(REPO_ROOT)
if _is_boundary(report_path):
continue
try:
source = abs_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
out.append(
Violation(report_path, 0, 0, "LIT000", f"could not read file: {exc}")
)
continue
ok_lines, ok_violations = scan_any_ok(report_path, source)
out.extend(ok_violations)
tree = trees.get(os.path.realpath(abs_path))
if tree is None:
continue
for line, col, typ in find_any_in_tree(tree, idmap):
if line in ok_lines:
continue
out.append(
Violation(
report_path,
line,
col,
"LIT009",
f"value type contains Any -> {typ}",
)
)
return tuple(out)
# --------------------------------------------------------------------------- #
# File selection (changed-only, changed-lines) + driver
# --------------------------------------------------------------------------- #
class _AllLines:
"""Sentinel: a wholly new / untracked file -- every line is in scope.
A distinct object, not None, so that `line_map.get(path)` returning None for
a path absent from the map is never mistaken for "whole file in scope"."""
# A changed file's in-scope lines: a specific set, or every line.
LineScope = set[int] | _AllLines
ALL_LINES = _AllLines()
def _is_boundary(path: Path) -> bool:
posix = path.as_posix()
return any(frag in posix for frag in BOUNDARY_PATHS)
def _git(*args: str) -> list[str]:
result = subprocess.run(
["git", "-C", str(REPO_ROOT), *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout.splitlines()
def _parse_added_lines(diff_text: str) -> dict[str, set[int]]:
"""Map repo-relative path -> set of new-file line numbers the diff adds/edits."""
changed: dict[str, set[int]] = {}
path: str | None = None
for line in diff_text.splitlines():
if line.startswith("+++ b/"):
path = line[6:]
elif path and (m := _HUNK_RE.match(line)):
start = int(m.group(1))
count = int(m.group(2)) if m.group(2) is not None else 1
if count:
changed.setdefault(path, set()).update(range(start, start + count))
return changed
def changed_line_map(base: str) -> dict[str, LineScope] | None:
"""Repo-relative `.py` path under litellm/ -> changed line numbers (or
ALL_LINES for untracked files). Compares the working tree to the merge-base
with `base`, so it covers committed-on-branch + unstaged edits. None if git
is unavailable / not a repo."""
try:
merge_base = _git("merge-base", base, "HEAD")
point = merge_base[0].strip() if merge_base else base
diff = "\n".join(
_git(
"diff",
"--unified=0",
"--no-color",
"--diff-filter=d",
point,
"--",
"litellm",
)
)
untracked = _git("ls-files", "--others", "--exclude-standard", "--", "litellm")
except (subprocess.CalledProcessError, FileNotFoundError):
return None
out: dict[str, LineScope] = {}
for name, lines in _parse_added_lines(diff).items():
if name.endswith(".py") and (REPO_ROOT / name).exists():
out[name] = lines
for name in untracked:
if name.endswith(".py") and (REPO_ROOT / name).exists():
out[name] = ALL_LINES
return out
def _to_litellm_relative(paths: Iterable[Path]) -> list[str]:
rels: list[str] = []
for p in sorted(paths):
try:
rels.append(p.resolve().relative_to(LITELLM_DIR).as_posix())
except ValueError:
continue
return rels
def _in_scope(v: Violation, line_map: dict[str, LineScope] | None) -> bool:
"""A finding survives if line filtering is off (explicit paths), it's a build
error, or its line is one the diff added/edited."""
if line_map is None or v.code == "LIT000":
return True
lines = line_map.get(v.path.as_posix())
return lines is ALL_LINES or (lines is not None and v.line in lines)
def main(argv: Sequence[str]) -> int:
parser = argparse.ArgumentParser(
description="Any-discipline gate (changed-only, changed-lines)."
)
parser.add_argument(
"paths",
nargs="*",
help="explicit files (repo-root relative); whole-file, no line filter",
)
parser.add_argument(
"--changed",
action="store_true",
help="check changed lines under litellm/ vs --base",
)
parser.add_argument("--base", default=os.environ.get("ANY_GATE_BASE", DEFAULT_BASE))
args = parser.parse_args(list(argv))
line_map: dict[str, LineScope] | None = None
if args.changed:
line_map = changed_line_map(args.base)
if line_map is None:
print(
"check_any_discipline: not a git repository; nothing to check",
file=sys.stderr,
)
return 0
rel_paths = _to_litellm_relative(
(REPO_ROOT / name).resolve() for name in line_map
)
elif args.paths:
rel_paths = _to_litellm_relative((REPO_ROOT / p).resolve() for p in args.paths)
else:
parser.error("pass --changed or explicit file paths")
return 2
if not rel_paths:
print("OK: no changed Python lines under litellm/ to check")
return 0
violations = tuple(v for v in check_files(rel_paths) if _in_scope(v, line_map))
for v in sorted(violations):
print(v.render())
if violations:
n = len(violations)
print(
f"\nFAIL: {n} Any-discipline violation(s) on changed lines.\n"
"Give the value a concrete type, or annotate the line `# any-ok: <reason>`.",
file=sys.stderr,
)
return 1
print(
f"OK: {len(rel_paths)} changed file(s) under litellm/ have no Any-typed values on changed lines"
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))