gabriel / muse public
typing_audit.py python
801 lines 29.3 KB
Raw
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