"""TypeScript typing audit — zero-tolerance enforcement for mission-critical code. Complements ``npx tsc --noEmit`` (the primary type gate) by catching patterns that TypeScript's compiler allows but that undermine type safety in practice. Patterns checked ---------------- *any* — ``as any``, ``: any``, ``Array``, ``Promise``, ``Record``, ``Map``, ```` type parameters. Every ``any`` escapes the type system and makes the surrounding code unverifiable. Use ``unknown`` and narrow. *@ts-ignore / @ts-nocheck* — suppresses real errors. Absolute ban. *Function type* — ``(fn: Function)`` carries no signature information; the compiler cannot verify call sites. Use ``() => void`` or a named callable interface. *Non-null assertion on DOM queries* — ``document.getElementById(...)!`` (and ``querySelector!``, ``querySelectorAll!``). These crash silently when the element is absent. Narrow with ``if (!el) return;`` instead. *JSON.parse without explicit cast* — ``JSON.parse(...)`` returns ``any``. Every call site must either narrow via ``as T`` or validate through a type guard. *as unknown as X* is explicitly NOT flagged — it is the idiomatic safe double-cast for DOM / window extension patterns where the TypeScript type system has no better mechanism. Usage:: python tools/ts_audit.py # src/ts/ python tools/ts_audit.py --dirs src/ts/ python tools/ts_audit.py --json artifacts/ts_audit.json """ from __future__ import annotations import argparse import json import operator import re import sys from collections import defaultdict from pathlib import Path from typing import TypedDict # --------------------------------------------------------------------------- # Type aliases # --------------------------------------------------------------------------- type PatternCounts = dict[str, int] type PatternLines = dict[str, list[int]] type PatternMap = dict[str, re.Pattern[str]] type PerFileViolations = dict[str, PatternCounts] # --------------------------------------------------------------------------- # Data shapes # --------------------------------------------------------------------------- class Violation(TypedDict): """One pattern match at one source location.""" file: str line: int kind: str class FileResult(TypedDict): """Typing-violation summary for one TypeScript source file.""" file: str patterns: PatternCounts pattern_lines: PatternLines class Offender(TypedDict): """A file with at least one violation, ranked by count.""" file: str total: int patterns: PatternCounts class ReportSummary(TypedDict): """High-level aggregate counts for the entire scan.""" total_files_scanned: int total_violations: int class Report(TypedDict): """Full audit report produced by :func:`generate_report`.""" summary: ReportSummary pattern_totals: PatternCounts top_offenders: list[Offender] per_file: PerFileViolations violations: list[Violation] # --------------------------------------------------------------------------- # Pattern registry # --------------------------------------------------------------------------- #: Patterns that count toward the violation total. #: Each key is a stable identifier used in JSON output. _PATTERNS: PatternMap = { # any-as-type ───────────────────────────────────────────────────────── # ``as any`` — explicit escape; conceals broken upstream type. # Negative lookbehind excludes ``as unknown as`` (the safe double-cast). "as_any": re.compile(r"(?, Promise, etc. "generic_any": re.compile( r"\b(?:Array|Promise|Record|Map|Set|Readonly|Partial|Required|" r"NonNullable|Awaited|ReturnType|Parameters)\s*<[^>]*\bany\b" ), # inline type argument — e.g. foo(...) "type_arg_any": re.compile(r""), # @ts-ignore / @ts-nocheck ──────────────────────────────────────────── "ts_ignore": re.compile(r"//\s*@ts-ignore"), "ts_nocheck": re.compile(r"//\s*@ts-nocheck"), # Function type ─────────────────────────────────────────────────────── # Matches ``: Function`` and ```` but not ``Function.prototype``, # ``Function.bind``, or import-style usages. "function_type": re.compile(r"(?::\s*|<)Function\b(?!\.|\s*prototype)"), # Non-null assertion on DOM queries ─────────────────────────────────── # ``getElementById(...)!`` crashes when the element is absent. # Narrow with ``if (!el) return;`` instead. "nonnull_dom": re.compile( r"\b(?:getElementById|querySelector|querySelectorAll|" r"closest|parentElement)\s*\([^)]*\)\s*!" ), # JSON.parse without narrowing ──────────────────────────────────────── # ``JSON.parse(...)`` returns ``any``. Every call site must cast or guard. # Pattern fires on the bare call; ``as T`` or ``as unknown`` on the same # line is sufficient to suppress — those lines are skipped in the loop. "json_parse_any": re.compile(r"\bJSON\.parse\s*\("), } #: Display order for the human-readable report. _CATEGORY_ORDER: list[tuple[str, list[str]]] = [ ("any escapes", ["as_any", "annot_any", "generic_any", "type_arg_any"]), ("type: suppression", ["ts_ignore", "ts_nocheck"]), ("unsafe types", ["function_type"]), ("DOM safety", ["nonnull_dom"]), ("JSON safety", ["json_parse_any"]), ] # --------------------------------------------------------------------------- # File scanning # --------------------------------------------------------------------------- def _scan_file(filepath: Path) -> FileResult: """Scan one TypeScript file for violations. Args: filepath: Absolute or relative path to a ``.ts`` file. Returns: :class:`FileResult` with per-pattern counts and line numbers. """ source = filepath.read_text(encoding="utf-8", errors="replace") lines = source.splitlines() patterns: defaultdict[str, int] = defaultdict(int) pattern_lines: defaultdict[str, list[int]] = defaultdict(list) for lineno, line in enumerate(lines, 1): stripped = line.strip() # Skip blank lines and pure comment lines. if not stripped or stripped.startswith("//") or stripped.startswith("*"): continue # ``JSON.parse`` is fine when the same line narrows the result # with ``as T`` or ``as unknown`` anywhere after the call. # Simple heuristic: ``as `` appears on the line after ``JSON.parse``. json_parse_narrowed = bool( re.search(r"\bJSON\.parse\b", line) and re.search(r"\bas\s+\w", line) ) for name, pattern in _PATTERNS.items(): if name == "json_parse_any" and json_parse_narrowed: continue if pattern.search(line): patterns[name] += 1 pattern_lines[name].append(lineno) return FileResult( file=str(filepath), patterns=dict(patterns), pattern_lines=dict(pattern_lines), ) # --------------------------------------------------------------------------- # Report generation # --------------------------------------------------------------------------- def generate_report(dirs: list[str]) -> Report: """Scan all ``.ts`` files under *dirs* and return a full audit report. Args: dirs: Directory paths to scan recursively. Returns: :class:`Report` with summary, per-file breakdowns, and flat violations. """ files: list[Path] = [] for d in dirs: files.extend(sorted(Path(d).rglob("*.ts"))) totals: defaultdict[str, int] = defaultdict(int) per_file: PerFileViolations = {} top_offenders: list[Offender] = [] all_violations: list[Violation] = [] for fp in files: r = _scan_file(fp) if not r["patterns"]: continue per_file[r["file"]] = r["patterns"] file_total = sum(r["patterns"].values()) top_offenders.append(Offender( file=r["file"], total=file_total, patterns=r["patterns"], )) for name, count in r["patterns"].items(): totals[name] += count for lineno in r["pattern_lines"].get(name, []): all_violations.append(Violation( file=r["file"], line=lineno, kind=name, )) top_offenders.sort(key=operator.itemgetter("total"), reverse=True) all_violations.sort(key=lambda v: (v["file"], v["line"])) total_violations = sum(totals.values()) return Report( summary=ReportSummary( total_files_scanned=len(files), total_violations=total_violations, ), pattern_totals=dict(totals), top_offenders=top_offenders, per_file=per_file, violations=all_violations, ) # --------------------------------------------------------------------------- # Human-readable printer # --------------------------------------------------------------------------- def print_report(report: Report) -> None: """Print the audit report to stdout in a human-readable format.""" w = 70 print("=" * w) print(" TS TYPING AUDIT — Violation Report") print("=" * w) s = report["summary"] print(f" Files scanned: {s['total_files_scanned']}") print(f" Total violations: {s['total_violations']}") print() totals = report["pattern_totals"] if totals: print(" Pattern breakdown:") for category, names in _CATEGORY_ORDER: cat_counts = {n: totals[n] for n in names if n in totals} if cat_counts: print(f" {category}:") for name, count in cat_counts.items(): print(f" {name:<30} {count}") print() print(" Violations (file:line [kind]):") for v in report["violations"]: print(f" {v['file']}:{v['line']} [{v['kind']}]") print() top = report["top_offenders"][:15] if top: print(f" Top {len(top)} offenders:") for o in top: print(f" {o['total']:>4} {o['file']}") else: print(" Pattern breakdown: (none)") print() print(" Top 15 offenders:") print("=" * w) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description="TypeScript typing audit — zero-tolerance type-safety enforcement.", formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument( "--dirs", nargs="+", default=["src/ts/"], metavar="DIR", help="Directories to scan recursively (default: src/ts/).", ) p.add_argument( "--json", metavar="PATH", help="Write full JSON report to PATH in addition to stdout.", ) p.add_argument( "--max-violations", type=int, default=0, metavar="N", help="Exit 1 if total violations exceed N (default: 0).", ) return p def main() -> None: """Entry point.""" args = _build_parser().parse_args() report = generate_report(args.dirs) print_report(report) if args.json: Path(args.json).write_text( json.dumps(report, indent=2), encoding="utf-8", ) if report["summary"]["total_violations"] > args.max_violations: sys.exit(1) if __name__ == "__main__": main()