"""Report-only module size guardrail (LOC bands per layer). Prints physical line counts for runtime, script and test modules grouped by the TeamHUB module-size bands (TeamHUB-Platform docs/CONVENTIONS.md and docs/submodules/02_MODULE_CONTRACT.md §6). Report-only by design: the exit code is always 0 — split-review is a human decision, not a build gate. Usage: python scripts/report_module_sizes.py [--top N] """ from __future__ import annotations import argparse from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] # (band, target_low, target_high, warning, split_review). split_review=None # means "owner note" (declarative/schema files are flexible by contract). @dataclass(frozen=True) class Band: title: str target: tuple[int, int] warning: int split_review: int | None BANDS: dict[str, Band] = { "backend-shell": Band("Backend composition shell", (100, 300), 500, 800), "router": Band("Router", (150, 400), 600, 800), "service": Band("Service / use case", (200, 600), 800, 1000), "infra-helper": Band("Repository / infra / helper", (150, 500), 700, 900), "schema": Band("Declarative constants / schema", (0, 1000), 1000, None), "frontend-shell": Band("Frontend composition shell", (300, 800), 1200, 1500), "view": Band("View module", (200, 500), 700, 900), "frontend-helper": Band("Frontend helper / model", (100, 350), 500, 700), "script": Band("Script", (200, 600), 900, 1200), "test": Band("Test file", (200, 700), 1000, 1500), } SCAN_GLOBS = ("app/**/*.py", "scripts/*.py", "tests/**/*.py", "frontend/src/**/*.ts", "frontend/src/**/*.tsx") # Exact-path bands checked before the prefix rules. _EXACT_BANDS: dict[str, str] = { "app/main.py": "backend-shell", "frontend/src/main.tsx": "frontend-shell", # app/api/security.py holds auth dependencies/middleware, not routes. "app/api/security.py": "infra-helper", } # Ordered prefix rules; first match wins. _PREFIX_BANDS: tuple[tuple[str, str], ...] = ( ("app/api/", "router"), ("app/ingestion/", "service"), ("app/indexing/", "service"), ("app/workers/", "service"), ("app/", "infra-helper"), ("scripts/", "script"), ("tests/", "test"), ("frontend/src/app/", "frontend-shell"), ("frontend/src/pages/", "view"), ("frontend/src/layouts/", "view"), ("frontend/src/", "frontend-helper"), ) def classify(rel: str) -> str | None: """Map a repo-relative path to a size band; None = out of guardrail scope.""" p = rel.replace("\\", "/") # Alembic migrations are generated/controlled files, tracked separately. if "__pycache__" in p or p.startswith("app/db/migrations/"): return None exact = _EXACT_BANDS.get(p) if exact is not None: return exact if p.startswith("app/api/") and "schemas" in p: return "schema" for prefix, band in _PREFIX_BANDS: if p.startswith(prefix): return band return None def count_lines(path: Path) -> int: with path.open(encoding="utf-8", errors="replace") as fh: return sum(1 for _ in fh) def status_for(lines: int, band: Band) -> str: if band.split_review is not None and lines > band.split_review: return "SPLIT-REVIEW" if lines > band.warning: return "WARNING" if band.split_review is not None else "OWNER-NOTE" return "ok" def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--top", type=int, default=15, help="largest files to list (default 15)") args = parser.parse_args() rows: list[tuple[int, str, str, str]] = [] # (lines, rel, band_key, status) for pattern in SCAN_GLOBS: for path in sorted(REPO_ROOT.glob(pattern)): if not path.is_file(): continue rel = path.relative_to(REPO_ROOT).as_posix() band_key = classify(rel) if band_key is None: continue lines = count_lines(path) rows.append((lines, rel, band_key, status_for(lines, BANDS[band_key]))) rows.sort(reverse=True) flagged = [r for r in rows if r[3] != "ok"] print(f"Module size report (report-only) - {len(rows)} files in scope") print() print("Band summary (files / max LOC / warning / split-review):") for key, band in BANDS.items(): in_band = [r for r in rows if r[2] == key] if not in_band: continue split = band.split_review if band.split_review is not None else "owner-note" print( f" {band.title:<32} {len(in_band):>3} files, max {in_band[0][0]:>5} LOC" f" (warn >{band.warning}, split >{split})" ) print() if flagged: print("Flagged files (human split-review decision required):") for lines, rel, band_key, status in flagged: print(f" {status:<12} {lines:>6} {rel} [{BANDS[band_key].title}]") else: print("Flagged files: none - all files within warning thresholds.") print() print(f"Top {min(args.top, len(rows))} largest files:") for lines, rel, band_key, status in rows[: args.top]: print(f" {lines:>6} {status:<12} {rel} [{BANDS[band_key].title}]") # Report-only: never fail the build. Split-review is an owner decision # (TeamHUB CONVENTIONS.md, module-size guardrail). return 0 if __name__ == "__main__": raise SystemExit(main())