typing_audit.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
| 1 | #!/usr/bin/env python3 |
| 2 | """Typing audit — zero-tolerance type-safety enforcement for mission-critical code. |
| 3 | |
| 4 | Every banned pattern maps to a future Rust port liability: if Python cannot |
| 5 | name a type, ``rustc`` cannot either. The ratchet keeps the rule enforced |
| 6 | continuously so violations never accumulate. |
| 7 | |
| 8 | Patterns checked |
| 9 | ---------------- |
| 10 | *Any-as-type* — ``dict[str, Any]``, ``list[Any]``, ``type[Any]``, |
| 11 | ``Any | X``, ``X | Any``, ``Mapping[str, Any]``, etc. |
| 12 | |
| 13 | *object-as-type* — same severity as Any; erases all structural information. |
| 14 | |
| 15 | *cast()* — all usage banned; it conceals a broken callee return type. |
| 16 | |
| 17 | *# type: ignore* — every suppressed error is an unaudited assumption. |
| 18 | |
| 19 | *Bare collections* — ``list``, ``dict``, ``set``, ``tuple`` without ``[T]``. |
| 20 | |
| 21 | *Optional[X]* and *Union[X, Y]* — use ``X | None`` and ``X | Y`` (PEP 604). |
| 22 | |
| 23 | *Legacy typing imports* — ``List``, ``Dict``, ``Set``, ``Tuple``. |
| 24 | |
| 25 | *Bare Callable / Callable returning Any* — must carry a full signature. |
| 26 | |
| 27 | *Untyped varargs* — ``*args: Any``, ``**kwargs: Any``, and unannotated |
| 28 | ``*args`` / ``**kwargs`` (annotation absent entirely). |
| 29 | |
| 30 | *Untyped function definitions* — missing return or parameter annotation. |
| 31 | |
| 32 | *Unconstrained TypeVar* — ``TypeVar(...)`` with no ``bound=`` and no |
| 33 | constraint arguments; behaves identically to ``Any`` in practice. |
| 34 | |
| 35 | *Naked dict at boundary* — ``dict[str, X]`` as a parameter or return type |
| 36 | is banned at function/method boundaries. Every dict with known keys must |
| 37 | be a ``TypedDict``; every dict with dynamic keys must justify its key space. |
| 38 | The only valid ``dict[str, ...]`` at a boundary is an explicitly named |
| 39 | ``TypedDict`` subclass. This rule exists because ``rustc`` cannot infer |
| 40 | struct fields from a ``HashMap<String, X>`` — named fields must be declared. |
| 41 | Pattern ``boundary_dict`` fires on ``: dict[str,`` and ``-> dict[str,``. |
| 42 | |
| 43 | Usage:: |
| 44 | |
| 45 | python tools/typing_audit.py # muse/ + tests/ |
| 46 | python tools/typing_audit.py --dirs muse/ tests/ |
| 47 | python tools/typing_audit.py --dirs muse/ --max-any 0 --max-untyped 0 |
| 48 | python tools/typing_audit.py --json artifacts/typing_audit.json |
| 49 | """ |
| 50 | |
| 51 | from __future__ import annotations |
| 52 | |
| 53 | import argparse |
| 54 | import ast |
| 55 | import io |
| 56 | import json |
| 57 | import operator |
| 58 | import re |
| 59 | import sys |
| 60 | import tokenize |
| 61 | from collections import defaultdict |
| 62 | from pathlib import Path |
| 63 | from typing import TypedDict |
| 64 | |
| 65 | # --------------------------------------------------------------------------- |
| 66 | # Type aliases — avoid dict[str, X] at function/class-field boundaries. |
| 67 | # --------------------------------------------------------------------------- |
| 68 | |
| 69 | type PatternCounts = dict[str, int] |
| 70 | type PatternLines = dict[str, list[int]] |
| 71 | type PatternMap = dict[str, re.Pattern[str]] |
| 72 | type PerFileViolations = dict[str, PatternCounts] |
| 73 | |
| 74 | # --------------------------------------------------------------------------- |
| 75 | # Data shapes — TypedDicts replace every dict[str, Any] in the old script. |
| 76 | # All shapes mirror the Rust struct that will eventually own them. |
| 77 | # --------------------------------------------------------------------------- |
| 78 | |
| 79 | |
| 80 | class UntypedDef(TypedDict): |
| 81 | """A function or method that is missing a required type annotation. |
| 82 | |
| 83 | ``issue`` is one of: |
| 84 | |
| 85 | - ``"missing_return_type"`` — no return annotation. |
| 86 | - ``"missing_param_type"`` — a non-self/cls parameter lacks annotation. |
| 87 | - ``"untyped_args"`` — ``*args`` is annotated as ``Any`` or has |
| 88 | no annotation at all. |
| 89 | - ``"untyped_kwargs"`` — ``**kwargs`` is annotated as ``Any`` or has |
| 90 | no annotation at all. |
| 91 | - ``"unconstrained_typevar"``— a ``TypeVar`` with no ``bound=`` and no |
| 92 | positional constraints. |
| 93 | """ |
| 94 | |
| 95 | file: str |
| 96 | line: int |
| 97 | name: str |
| 98 | issue: str |
| 99 | |
| 100 | |
| 101 | class FileResult(TypedDict): |
| 102 | """Typing-violation summary for a single Python source file.""" |
| 103 | |
| 104 | file: str |
| 105 | imports_any: bool |
| 106 | patterns: PatternCounts |
| 107 | pattern_lines: PatternLines |
| 108 | type_ignore_variants: PatternCounts |
| 109 | untyped_defs: list[UntypedDef] |
| 110 | |
| 111 | |
| 112 | class Offender(TypedDict): |
| 113 | """A file with at least one typing violation, ranked by total count.""" |
| 114 | |
| 115 | file: str |
| 116 | total: int |
| 117 | patterns: PatternCounts |
| 118 | |
| 119 | |
| 120 | class ReportSummary(TypedDict): |
| 121 | """High-level aggregate counts for the entire scan.""" |
| 122 | |
| 123 | total_files_scanned: int |
| 124 | files_importing_any: int |
| 125 | total_any_patterns: int |
| 126 | untyped_defs: int |
| 127 | |
| 128 | |
| 129 | class Report(TypedDict): |
| 130 | """Full typing-audit report produced by :func:`generate_report`.""" |
| 131 | |
| 132 | summary: ReportSummary |
| 133 | pattern_totals: PatternCounts |
| 134 | type_ignore_variants: PatternCounts |
| 135 | top_offenders: list[Offender] |
| 136 | per_file: PerFileViolations |
| 137 | untyped_defs: list[UntypedDef] |
| 138 | |
| 139 | |
| 140 | # --------------------------------------------------------------------------- |
| 141 | # String-literal masking |
| 142 | # --------------------------------------------------------------------------- |
| 143 | |
| 144 | |
| 145 | def _mask_string_literals(source: str) -> str: |
| 146 | """Replace string-literal content with spaces, preserving newlines. |
| 147 | |
| 148 | Pattern matching runs on the masked source so that raw regex strings, |
| 149 | docstrings, and string constants never produce false positives. All |
| 150 | newlines are preserved so that line numbers stay accurate. |
| 151 | |
| 152 | Tokenisation errors (e.g. incomplete source snippets) are silently |
| 153 | ignored — the original source is returned unchanged so the caller still |
| 154 | produces *some* output rather than silently dropping the file. |
| 155 | |
| 156 | Args: |
| 157 | source: Full UTF-8 source text of a Python file. |
| 158 | |
| 159 | Returns: |
| 160 | A copy of *source* with the content of every string token replaced |
| 161 | by space characters (newlines within multi-line strings preserved). |
| 162 | """ |
| 163 | chars = list(source) |
| 164 | lines = source.splitlines(keepends=True) |
| 165 | |
| 166 | # Pre-compute cumulative line offsets for O(1) (row, col) → offset. |
| 167 | offsets: list[int] = [0] |
| 168 | for ln in lines: |
| 169 | offsets.append(offsets[-1] + len(ln)) |
| 170 | |
| 171 | def _abs(row: int, col: int) -> int: |
| 172 | return offsets[row - 1] + col |
| 173 | |
| 174 | # Token types that contain string literal content — including f-string |
| 175 | # middle segments which are FSTRING_MIDDLE (not STRING) in Python 3.12+. |
| 176 | _FSTRING_MIDDLE = getattr(tokenize, "FSTRING_MIDDLE", None) |
| 177 | _STRING_TYPES = {tokenize.STRING} |
| 178 | if _FSTRING_MIDDLE is not None: |
| 179 | _STRING_TYPES.add(_FSTRING_MIDDLE) |
| 180 | |
| 181 | try: |
| 182 | gen = tokenize.generate_tokens(io.StringIO(source).readline) |
| 183 | for tok_type, _tok_str, (srow, scol), (erow, ecol), _ in gen: |
| 184 | if tok_type not in _STRING_TYPES: |
| 185 | continue |
| 186 | start = _abs(srow, scol) |
| 187 | end = _abs(erow, ecol) |
| 188 | for i in range(start, end): |
| 189 | if chars[i] not in {"\n", "\r"}: |
| 190 | chars[i] = " " |
| 191 | except tokenize.TokenError: |
| 192 | pass |
| 193 | |
| 194 | return "".join(chars) |
| 195 | |
| 196 | |
| 197 | # --------------------------------------------------------------------------- |
| 198 | # Pattern registry |
| 199 | # --------------------------------------------------------------------------- |
| 200 | |
| 201 | #: All patterns that count toward the violation total. |
| 202 | #: Keys are stable identifiers used in JSON output and tests. |
| 203 | #: |
| 204 | #: NOTE: do NOT use re.IGNORECASE — Python type annotations are case-sensitive. |
| 205 | #: ``List`` and ``list`` are distinct identifiers; matching ``list[any]`` |
| 206 | #: (where ``any`` is the built-in function) would be a false positive. |
| 207 | _PATTERNS: PatternMap = { |
| 208 | # Any-as-type ───────────────────────────────────────────────────────── |
| 209 | "dict_str_any": re.compile(r"\bdict\[str,\s*Any\]|\bDict\[str,\s*Any\]"), |
| 210 | "list_any": re.compile(r"\blist\[Any\]|\bList\[Any\]"), |
| 211 | "type_any": re.compile(r"\btype\[Any\]"), |
| 212 | "any_in_union": re.compile(r"\bAny\s*\||\|\s*Any\b"), |
| 213 | "return_any": re.compile(r"->\s*Any\b"), |
| 214 | "param_any": re.compile(r":\s*Any\b"), |
| 215 | "mapping_any": re.compile(r"\bMapping\[str,\s*Any\]"), |
| 216 | "optional_any": re.compile(r"\bOptional\[Any\]"), |
| 217 | "sequence_any": re.compile(r"\bSequence\[Any\]|\bIterable\[Any\]"), |
| 218 | "tuple_any": re.compile(r"\btuple\[[^\n]*Any[^\n]*\]|\bTuple\[[^\n]*Any[^\n]*\]"), |
| 219 | # object-as-type ────────────────────────────────────────────────────── |
| 220 | "param_object": re.compile(r":\s*object\b"), |
| 221 | "return_object": re.compile(r"->\s*object\b"), |
| 222 | # Handles one level of nesting, e.g. dict[str, list[object]]. |
| 223 | "collection_object": re.compile( |
| 224 | r"\b(?:dict|list|set|tuple|Sequence|Mapping)" |
| 225 | r"\[[^\n\[\]]*(?:\[[^\n\[\]]*\][^\n\[\]]*)*\bobject\b" |
| 226 | ), |
| 227 | # cast() — banned ───────────────────────────────────────────────────── |
| 228 | "cast_usage": re.compile(r"\bcast\("), |
| 229 | # type: ignore — suppresses real errors ─────────────────────────────── |
| 230 | "type_ignore": re.compile(r"#\s*type:\s*ignore"), |
| 231 | # Bare collections (no type parameters) ─────────────────────────────── |
| 232 | # Negative lookaheads exclude parameterised forms and prose. |
| 233 | "bare_list": re.compile(r"(?::\s*|->\s*)list\b(?!\[|\(|\s+[a-z])"), |
| 234 | "bare_dict": re.compile(r"(?::\s*|->\s*)dict\b(?!\[|\(|\s+[a-z])"), |
| 235 | "bare_set": re.compile(r"(?::\s*|->\s*)set\b(?!\[|\(|\s+[a-z])"), |
| 236 | "bare_tuple": re.compile(r"(?::\s*|->\s*)tuple\b(?!\[|\(|\s+[a-z])"), |
| 237 | # Optional[X] — use X | None (PEP 604) ──────────────────────────────── |
| 238 | "optional_usage": re.compile(r"\bOptional\[(?!Any\b)"), |
| 239 | # Union[X, Y] — use X | Y (PEP 604) ────────────────────────────────── |
| 240 | "union_usage": re.compile(r"\bUnion\["), |
| 241 | # Legacy typing imports (use lowercase builtins) ────────────────────── |
| 242 | "legacy_List": re.compile(r"\bList\["), |
| 243 | "legacy_Dict": re.compile(r"\bDict\["), |
| 244 | "legacy_Set": re.compile(r"\bSet\["), |
| 245 | "legacy_Tuple": re.compile(r"\bTuple\["), |
| 246 | # Callable — must carry full signature ──────────────────────────────── |
| 247 | "bare_callable": re.compile(r"(?::\s*|->\s*)Callable\b(?!\[)"), |
| 248 | "callable_any": re.compile(r"\bCallable\[[^\n]*,\s*Any\s*\]"), |
| 249 | # Untyped varargs — *args: Any / **kwargs: Any ──────────────────────── |
| 250 | # Unannotated *args/**kwargs are caught by the AST walker instead. |
| 251 | "varargs_any": re.compile(r"\*{1,2}\w+:\s*Any\b"), |
| 252 | # Naked dict at boundary — dict[str, X] as param/return type is banned. |
| 253 | # Every structured boundary must use a TypedDict (or dataclass/enum). |
| 254 | # Matches ": dict[str," and "-> dict[str," — the two annotation positions. |
| 255 | "boundary_dict": re.compile(r"(?::\s*|->\s*)dict\[str\s*,"), |
| 256 | } |
| 257 | |
| 258 | # Category groupings for the human-readable report, in display order. |
| 259 | _CATEGORY_ORDER: list[tuple[str, list[str]]] = [ |
| 260 | ("Any-as-type", [ |
| 261 | "dict_str_any", "list_any", "type_any", "any_in_union", |
| 262 | "return_any", "param_any", |
| 263 | "mapping_any", "optional_any", "sequence_any", "tuple_any", |
| 264 | ]), |
| 265 | ("object-as-type", ["param_object", "return_object", "collection_object"]), |
| 266 | ("cast() usage", ["cast_usage"]), |
| 267 | ("type: ignore", ["type_ignore"]), |
| 268 | ("Bare collections", ["bare_list", "bare_dict", "bare_set", "bare_tuple"]), |
| 269 | ("Optional (use X | None)", ["optional_usage"]), |
| 270 | ("Union (use X | Y)", ["union_usage"]), |
| 271 | ("Legacy typing imports", ["legacy_List", "legacy_Dict", "legacy_Set", "legacy_Tuple"]), |
| 272 | ("Callable (must carry full signature)", ["bare_callable", "callable_any"]), |
| 273 | ("Untyped varargs", ["varargs_any"]), |
| 274 | ("Naked dict at boundary (use TypedDict)", ["boundary_dict"]), |
| 275 | ] |
| 276 | |
| 277 | # Directories that are never source code and must be skipped during scanning. |
| 278 | _SKIP_DIRS: frozenset[str] = frozenset({ |
| 279 | "venv", ".venv", "env", ".env", |
| 280 | "__pycache__", |
| 281 | ".git", ".muse", ".mypy_cache", ".ruff_cache", ".pytest_cache", ".tox", |
| 282 | "dist", "build", "site-packages", "__pypackages__", |
| 283 | }) |
| 284 | |
| 285 | |
| 286 | # --------------------------------------------------------------------------- |
| 287 | # Pattern helpers |
| 288 | # --------------------------------------------------------------------------- |
| 289 | |
| 290 | |
| 291 | def _count_pattern_in_line(line: str, pattern: re.Pattern[str]) -> int: |
| 292 | """Return the number of non-overlapping matches of *pattern* in *line*.""" |
| 293 | return len(pattern.findall(line)) |
| 294 | |
| 295 | |
| 296 | def _imports_any(source: str) -> bool: |
| 297 | """Return ``True`` if the source file imports ``Any`` from ``typing`` |
| 298 | or ``typing_extensions``. |
| 299 | |
| 300 | Excludes commented-out import lines (lines where ``from`` is preceded only |
| 301 | by ``#`` and optional whitespace). |
| 302 | """ |
| 303 | return bool(re.search( |
| 304 | r"^[ \t]*from\s+typing(?:_extensions)?\s+import\s+.*\bAny\b", |
| 305 | source, |
| 306 | re.MULTILINE, |
| 307 | )) |
| 308 | |
| 309 | |
| 310 | def _classify_type_ignore(line: str) -> str: |
| 311 | """Classify the style of a ``# type: ignore`` comment. |
| 312 | |
| 313 | Returns ``"type_ignore[code]"`` for code-specific ignores, or |
| 314 | ``"type_ignore[blanket]"`` for bare ``# type: ignore``. |
| 315 | |
| 316 | Args: |
| 317 | line: A single source line that contains ``# type: ignore``. |
| 318 | |
| 319 | Returns: |
| 320 | A string label for the variant. |
| 321 | """ |
| 322 | m = re.search(r"#\s*type:\s*ignore\[([^\]]+)\]", line) |
| 323 | if m: |
| 324 | return f"type_ignore[{m.group(1)}]" |
| 325 | return "type_ignore[blanket]" |
| 326 | |
| 327 | |
| 328 | # --------------------------------------------------------------------------- |
| 329 | # AST-based detection |
| 330 | # --------------------------------------------------------------------------- |
| 331 | |
| 332 | |
| 333 | def _is_any_annotation(node: ast.expr | None) -> bool: |
| 334 | """Return ``True`` if *node* is the bare ``Any`` name.""" |
| 335 | return isinstance(node, ast.Name) and node.id == "Any" |
| 336 | |
| 337 | |
| 338 | def _find_untyped_defs(source: str, filepath: str) -> list[UntypedDef]: |
| 339 | """Walk the AST and collect every function with a missing annotation. |
| 340 | |
| 341 | Checks: |
| 342 | |
| 343 | - Missing return type (``node.returns is None``). |
| 344 | - Missing parameter annotation (excluding ``self`` and ``cls``). |
| 345 | - ``*args`` annotated as ``Any`` **or** with no annotation at all. |
| 346 | - ``**kwargs`` annotated as ``Any`` **or** with no annotation at all. |
| 347 | - ``TypeVar(...)`` assignments with no ``bound=`` and no constraints. |
| 348 | |
| 349 | Line numbers for parameter violations use the argument's own line number |
| 350 | (``arg.lineno``) rather than the function definition line, so the report |
| 351 | points directly at the problematic parameter. |
| 352 | |
| 353 | Skips files that cannot be parsed. |
| 354 | |
| 355 | Args: |
| 356 | source: Full source text of the file. |
| 357 | filepath: Path string used in the returned records. |
| 358 | |
| 359 | Returns: |
| 360 | A list of :class:`UntypedDef` records, one per violation found. |
| 361 | """ |
| 362 | results: list[UntypedDef] = [] |
| 363 | try: |
| 364 | tree = ast.parse(source) |
| 365 | except SyntaxError: |
| 366 | return results |
| 367 | |
| 368 | for node in ast.walk(tree): |
| 369 | if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): |
| 370 | continue |
| 371 | |
| 372 | if node.returns is None: |
| 373 | results.append(UntypedDef( |
| 374 | file=filepath, |
| 375 | line=node.lineno, |
| 376 | name=node.name, |
| 377 | issue="missing_return_type", |
| 378 | )) |
| 379 | |
| 380 | all_args = ( |
| 381 | node.args.args |
| 382 | + node.args.posonlyargs |
| 383 | + node.args.kwonlyargs |
| 384 | ) |
| 385 | for arg in all_args: |
| 386 | if arg.arg in {"self", "cls"}: |
| 387 | continue |
| 388 | if arg.annotation is None: |
| 389 | results.append(UntypedDef( |
| 390 | file=filepath, |
| 391 | line=arg.lineno, |
| 392 | name=f"{node.name}.{arg.arg}", |
| 393 | issue="missing_param_type", |
| 394 | )) |
| 395 | |
| 396 | vararg = node.args.vararg |
| 397 | if vararg is not None: |
| 398 | if vararg.annotation is None or _is_any_annotation(vararg.annotation): |
| 399 | results.append(UntypedDef( |
| 400 | file=filepath, |
| 401 | line=vararg.lineno, |
| 402 | name=f"{node.name}.*{vararg.arg}", |
| 403 | issue="untyped_args", |
| 404 | )) |
| 405 | |
| 406 | kwarg = node.args.kwarg |
| 407 | if kwarg is not None: |
| 408 | if kwarg.annotation is None or _is_any_annotation(kwarg.annotation): |
| 409 | results.append(UntypedDef( |
| 410 | file=filepath, |
| 411 | line=kwarg.lineno, |
| 412 | name=f"{node.name}.**{kwarg.arg}", |
| 413 | issue="untyped_kwargs", |
| 414 | )) |
| 415 | |
| 416 | # TypeVar without constraints or bound — behaves identically to Any. |
| 417 | results.extend(_find_unconstrained_typevars(tree, filepath)) |
| 418 | |
| 419 | return results |
| 420 | |
| 421 | |
| 422 | def _find_unconstrained_typevars(tree: ast.Module, filepath: str) -> list[UntypedDef]: |
| 423 | """Return a record for every ``TypeVar(...)`` with no bound or constraints. |
| 424 | |
| 425 | A bare ``T = TypeVar("T")`` is semantically equivalent to ``T: Any``. |
| 426 | The Rust port requires every generic to carry an explicit trait bound. |
| 427 | |
| 428 | Args: |
| 429 | tree: Parsed AST of the file. |
| 430 | filepath: Path string used in the returned records. |
| 431 | |
| 432 | Returns: |
| 433 | A list of :class:`UntypedDef` records for unconstrained ``TypeVar`` |
| 434 | definitions. |
| 435 | """ |
| 436 | results: list[UntypedDef] = [] |
| 437 | for node in ast.walk(tree): |
| 438 | # Match: T = TypeVar("T") or T = TypeVar("T", bound=...) |
| 439 | if not isinstance(node, ast.Assign): |
| 440 | continue |
| 441 | value = node.value |
| 442 | if not isinstance(value, ast.Call): |
| 443 | continue |
| 444 | func = value.func |
| 445 | if not (isinstance(func, ast.Name) and func.id == "TypeVar"): |
| 446 | continue |
| 447 | # A TypeVar is constrained when it has: |
| 448 | # - positional args beyond the name (constraint types), OR |
| 449 | # - a keyword arg named "bound" |
| 450 | extra_args = value.args[1:] # args[0] is the name string |
| 451 | kw_names = {kw.arg for kw in value.keywords} |
| 452 | if extra_args or "bound" in kw_names: |
| 453 | continue # constrained — OK |
| 454 | # Unconstrained TypeVar. |
| 455 | target_name = ( |
| 456 | node.targets[0].id |
| 457 | if isinstance(node.targets[0], ast.Name) |
| 458 | else "<TypeVar>" |
| 459 | ) |
| 460 | results.append(UntypedDef( |
| 461 | file=filepath, |
| 462 | line=node.lineno, |
| 463 | name=target_name, |
| 464 | issue="unconstrained_typevar", |
| 465 | )) |
| 466 | return results |
| 467 | |
| 468 | |
| 469 | # --------------------------------------------------------------------------- |
| 470 | # File and directory scanner |
| 471 | # --------------------------------------------------------------------------- |
| 472 | |
| 473 | |
| 474 | def scan_file(filepath: Path) -> FileResult | None: |
| 475 | """Scan a single Python file and return its violation summary. |
| 476 | |
| 477 | String literals are masked before pattern matching so that raw regex |
| 478 | strings and docstring prose never produce false positives. The |
| 479 | ``# type: ignore`` check runs on the *original* source because those |
| 480 | comments are not string literals. |
| 481 | |
| 482 | Returns ``None`` when the file cannot be read (I/O or encoding error). |
| 483 | |
| 484 | Args: |
| 485 | filepath: Absolute or relative path to the Python file. |
| 486 | |
| 487 | Returns: |
| 488 | A :class:`FileResult` on success, ``None`` on I/O failure. |
| 489 | """ |
| 490 | try: |
| 491 | source = filepath.read_text(encoding="utf-8") |
| 492 | except (OSError, UnicodeDecodeError): |
| 493 | return None |
| 494 | |
| 495 | masked = _mask_string_literals(source) |
| 496 | |
| 497 | original_lines = source.splitlines() |
| 498 | masked_lines = masked.splitlines() |
| 499 | |
| 500 | patterns: defaultdict[str, int] = defaultdict(int) |
| 501 | pattern_lines: defaultdict[str, list[int]] = defaultdict(list) |
| 502 | type_ignore_variants: defaultdict[str, int] = defaultdict(int) |
| 503 | |
| 504 | for lineno, (orig_line, masked_line) in enumerate( |
| 505 | zip(original_lines, masked_lines), 1 |
| 506 | ): |
| 507 | stripped = masked_line.strip() |
| 508 | if not stripped or stripped.startswith("#"): |
| 509 | continue |
| 510 | |
| 511 | for name, pattern in _PATTERNS.items(): |
| 512 | # All patterns run on the masked line — string literals are blanked |
| 513 | # so raw regex strings and docstring prose never trigger false |
| 514 | # positives. Comments are NOT masked (they are not string tokens) |
| 515 | # so "# type: ignore" on real code lines is still detected. |
| 516 | count = _count_pattern_in_line(masked_line, pattern) |
| 517 | if count > 0: |
| 518 | patterns[name] += count |
| 519 | pattern_lines[name].append(lineno) |
| 520 | |
| 521 | if name == "type_ignore": |
| 522 | # Classify against the original line so we can distinguish |
| 523 | # blanket ignores from code-specific ones. |
| 524 | variant = _classify_type_ignore(orig_line) |
| 525 | type_ignore_variants[variant] += 1 |
| 526 | |
| 527 | return FileResult( |
| 528 | file=str(filepath), |
| 529 | imports_any=_imports_any(source), |
| 530 | patterns=dict(patterns), |
| 531 | pattern_lines=dict(pattern_lines), |
| 532 | type_ignore_variants=dict(type_ignore_variants), |
| 533 | untyped_defs=_find_untyped_defs(source, str(filepath)), |
| 534 | ) |
| 535 | |
| 536 | |
| 537 | def scan_directory(directory: Path) -> list[FileResult]: |
| 538 | """Recursively scan all Python files in *directory*. |
| 539 | |
| 540 | Skips virtual environments, caches, build artefacts, and VCS/tool |
| 541 | metadata directories (see ``_SKIP_DIRS``). |
| 542 | |
| 543 | Args: |
| 544 | directory: Root of the directory tree to scan. |
| 545 | |
| 546 | Returns: |
| 547 | A list of :class:`FileResult` objects, one per successfully scanned file. |
| 548 | """ |
| 549 | results: list[FileResult] = [] |
| 550 | for py_file in sorted(directory.rglob("*.py")): |
| 551 | if any(part in _SKIP_DIRS for part in py_file.parts): |
| 552 | continue |
| 553 | file_result = scan_file(py_file) |
| 554 | if file_result is not None: |
| 555 | results.append(file_result) |
| 556 | return results |
| 557 | |
| 558 | |
| 559 | # --------------------------------------------------------------------------- |
| 560 | # Report generation |
| 561 | # --------------------------------------------------------------------------- |
| 562 | |
| 563 | |
| 564 | def _offender_sort_key(entry: Offender) -> int: |
| 565 | """Return the sort key for an :class:`Offender` (total violation count).""" |
| 566 | return entry["total"] |
| 567 | |
| 568 | |
| 569 | def generate_report(results: list[FileResult]) -> Report: |
| 570 | """Aggregate per-file scan results into a :class:`Report`. |
| 571 | |
| 572 | Args: |
| 573 | results: List of :class:`FileResult` objects from :func:`scan_file` |
| 574 | or :func:`scan_directory`. |
| 575 | |
| 576 | Returns: |
| 577 | A :class:`Report` ready for human display or JSON serialisation. |
| 578 | """ |
| 579 | totals: defaultdict[str, int] = defaultdict(int) |
| 580 | files_with_any_import = 0 |
| 581 | per_file: PerFileViolations = {} |
| 582 | top_offenders: list[Offender] = [] |
| 583 | all_type_ignore_variants: defaultdict[str, int] = defaultdict(int) |
| 584 | all_untyped_defs: list[UntypedDef] = [] |
| 585 | |
| 586 | for r in results: |
| 587 | filepath = r["file"] |
| 588 | if r["imports_any"]: |
| 589 | files_with_any_import += 1 |
| 590 | |
| 591 | file_total = 0 |
| 592 | file_patterns: PatternCounts = {} |
| 593 | for pattern, count in r["patterns"].items(): |
| 594 | totals[pattern] += count |
| 595 | file_patterns[pattern] = count |
| 596 | file_total += count |
| 597 | |
| 598 | if file_total > 0: |
| 599 | per_file[filepath] = file_patterns |
| 600 | top_offenders.append(Offender( |
| 601 | file=filepath, |
| 602 | total=file_total, |
| 603 | patterns=file_patterns, |
| 604 | )) |
| 605 | |
| 606 | for variant, count in r["type_ignore_variants"].items(): |
| 607 | all_type_ignore_variants[variant] += count |
| 608 | |
| 609 | all_untyped_defs.extend(r["untyped_defs"]) |
| 610 | |
| 611 | top_offenders.sort(key=_offender_sort_key, reverse=True) |
| 612 | |
| 613 | return Report( |
| 614 | summary=ReportSummary( |
| 615 | total_files_scanned=len(results), |
| 616 | files_importing_any=files_with_any_import, |
| 617 | total_any_patterns=sum(totals.values()), |
| 618 | untyped_defs=len(all_untyped_defs), |
| 619 | ), |
| 620 | pattern_totals=dict(totals), |
| 621 | type_ignore_variants=dict(all_type_ignore_variants), |
| 622 | # Store all offenders in JSON; display is capped separately in the |
| 623 | # human-readable printer. |
| 624 | top_offenders=top_offenders, |
| 625 | per_file=per_file, |
| 626 | # Store the full list — callers that need all records can use --json. |
| 627 | untyped_defs=all_untyped_defs, |
| 628 | ) |
| 629 | |
| 630 | |
| 631 | # --------------------------------------------------------------------------- |
| 632 | # Human-readable report printer |
| 633 | # --------------------------------------------------------------------------- |
| 634 | |
| 635 | |
| 636 | def print_human_summary(report: Report, top_n: int = 15) -> None: |
| 637 | """Print a formatted, human-readable summary of *report* to stdout. |
| 638 | |
| 639 | Args: |
| 640 | report: A :class:`Report` produced by :func:`generate_report`. |
| 641 | top_n: How many offenders to display in the top-offenders list. |
| 642 | """ |
| 643 | s = report["summary"] |
| 644 | totals = report["pattern_totals"] |
| 645 | |
| 646 | print("\n" + "=" * 70) |
| 647 | print(" TYPING AUDIT — Violation Report") |
| 648 | print("=" * 70) |
| 649 | print(f" Files scanned: {s['total_files_scanned']}") |
| 650 | print(f" Files importing Any: {s['files_importing_any']}") |
| 651 | print(f" Total violations: {s['total_any_patterns']}") |
| 652 | print(f" Untyped defs: {s['untyped_defs']}") |
| 653 | print() |
| 654 | |
| 655 | has_violations = False |
| 656 | for category, pattern_names in _CATEGORY_ORDER: |
| 657 | category_total = sum(totals.get(p, 0) for p in pattern_names) |
| 658 | if category_total == 0: |
| 659 | continue |
| 660 | has_violations = True |
| 661 | print(f" {category}:") |
| 662 | for p in pattern_names: |
| 663 | count = totals.get(p, 0) |
| 664 | if count > 0: |
| 665 | print(f" {p:38s} {count:5d}") |
| 666 | print() |
| 667 | |
| 668 | if not has_violations: |
| 669 | print(" Pattern breakdown: (none)") |
| 670 | print() |
| 671 | |
| 672 | if report["type_ignore_variants"]: |
| 673 | print(" # type: ignore variants:") |
| 674 | for variant, count in sorted( |
| 675 | report["type_ignore_variants"].items(), |
| 676 | key=operator.itemgetter(1), |
| 677 | reverse=True, |
| 678 | ): |
| 679 | print(f" {variant:44s} {count:5d}") |
| 680 | print() |
| 681 | |
| 682 | print(f" Top {top_n} offenders:") |
| 683 | for entry in report["top_offenders"][:top_n]: |
| 684 | print(f" {entry['total']:4d} {entry['file']}") |
| 685 | print("=" * 70 + "\n") |
| 686 | |
| 687 | |
| 688 | # --------------------------------------------------------------------------- |
| 689 | # CLI |
| 690 | # --------------------------------------------------------------------------- |
| 691 | |
| 692 | |
| 693 | def main() -> None: |
| 694 | """Entry point: parse CLI flags, run the scan, and enforce the ratchet. |
| 695 | |
| 696 | Scans the specified directories (or individual files), prints a human |
| 697 | summary, optionally writes a JSON report, and exits non-zero when either |
| 698 | the pattern violation count exceeds ``--max-any`` or the untyped-def |
| 699 | count exceeds ``--max-untyped``. |
| 700 | """ |
| 701 | parser = argparse.ArgumentParser( |
| 702 | description=( |
| 703 | "Audit typing violations: Any, object, cast, bare collections, " |
| 704 | "Optional/Union (legacy), Callable without signature, untyped " |
| 705 | "varargs, type: ignore, untyped defs, unconstrained TypeVars." |
| 706 | ), |
| 707 | ) |
| 708 | parser.add_argument( |
| 709 | "--dirs", |
| 710 | nargs="+", |
| 711 | default=["muse/", "tests/"], |
| 712 | help="Directories or individual .py files to scan. Default: muse/ tests/", |
| 713 | ) |
| 714 | parser.add_argument( |
| 715 | "--json", |
| 716 | type=str, |
| 717 | metavar="PATH", |
| 718 | help="Write the JSON report to PATH.", |
| 719 | ) |
| 720 | parser.add_argument( |
| 721 | "--max-any", |
| 722 | type=int, |
| 723 | default=None, |
| 724 | metavar="N", |
| 725 | help="Exit non-zero if total pattern violations exceed N (ratchet mode).", |
| 726 | ) |
| 727 | parser.add_argument( |
| 728 | "--max-untyped", |
| 729 | type=int, |
| 730 | default=None, |
| 731 | metavar="N", |
| 732 | help="Exit non-zero if total untyped-def count exceeds N (ratchet mode).", |
| 733 | ) |
| 734 | parser.add_argument( |
| 735 | "--top-n", |
| 736 | type=int, |
| 737 | default=15, |
| 738 | metavar="N", |
| 739 | help="Number of offenders to display in the human summary. Default: 15.", |
| 740 | ) |
| 741 | args = parser.parse_args() |
| 742 | |
| 743 | all_results: list[FileResult] = [] |
| 744 | for d in args.dirs: |
| 745 | p = Path(d) |
| 746 | if p.is_file() and p.suffix == ".py": |
| 747 | result = scan_file(p) |
| 748 | if result is not None: |
| 749 | all_results.append(result) |
| 750 | elif p.is_dir(): |
| 751 | all_results.extend(scan_directory(p)) |
| 752 | else: |
| 753 | print(f"WARNING: {d} does not exist, skipping", file=sys.stderr) |
| 754 | |
| 755 | report = generate_report(all_results) |
| 756 | print_human_summary(report, top_n=args.top_n) |
| 757 | |
| 758 | if args.json: |
| 759 | out = Path(args.json) |
| 760 | out.parent.mkdir(parents=True, exist_ok=True) |
| 761 | out.write_text(json.dumps(report, indent=2), encoding="utf-8") |
| 762 | print(f" JSON report written to {args.json}") |
| 763 | |
| 764 | failed = False |
| 765 | |
| 766 | if args.max_any is not None: |
| 767 | total = report["summary"]["total_any_patterns"] |
| 768 | if total > args.max_any: |
| 769 | print( |
| 770 | f"\n❌ RATCHET FAILED (patterns): {total} violations exceed " |
| 771 | f"threshold of {args.max_any}", |
| 772 | file=sys.stderr, |
| 773 | ) |
| 774 | failed = True |
| 775 | else: |
| 776 | print( |
| 777 | f"\n✅ RATCHET OK (patterns): {total} violations within " |
| 778 | f"threshold of {args.max_any}", |
| 779 | ) |
| 780 | |
| 781 | if args.max_untyped is not None: |
| 782 | untyped = report["summary"]["untyped_defs"] |
| 783 | if untyped > args.max_untyped: |
| 784 | print( |
| 785 | f"\n❌ RATCHET FAILED (untyped defs): {untyped} exceed " |
| 786 | f"threshold of {args.max_untyped}", |
| 787 | file=sys.stderr, |
| 788 | ) |
| 789 | failed = True |
| 790 | else: |
| 791 | print( |
| 792 | f"\n✅ RATCHET OK (untyped defs): {untyped} within " |
| 793 | f"threshold of {args.max_untyped}", |
| 794 | ) |
| 795 | |
| 796 | if failed: |
| 797 | sys.exit(1) |
| 798 | |
| 799 | |
| 800 | if __name__ == "__main__": |
| 801 | main() |
File History
5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e
Merge branch 'dev' into main
Human
29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce
Merge branch 'dev' into main
Human
48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
100 days ago