Add scripts/report_module_sizes.py - report-only LOC bands per layer from the TeamHUB module-size guardrail (CONVENTIONS.md, module contract p.6). Always exits 0: split-review stays a human decision. Wire it into CI as a non-blocking step and into the AGENTS.md baseline checks. Document data/input + data/work as the deliberate compatibility exception to the var/ rule of 18_REPO_LAYOUT_STANDARD.md in docs/structure-map.md (compose mounts and APP_*_DIR depend on the paths) and ignore var/ for new local runtime output. Verified: script runs clean on the repo (159 files in scope, zero flagged); ruff and compileall pass; adversarial review fixed two classification bugs (frontend shell under frontend/src/app/, app/api/security.py as infra helper). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
5.3 KiB
Python
153 lines
5.3 KiB
Python
"""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())
|