Files
LegacyHUB/scripts/report_module_sizes.py
Vadim Malanov 79d5fa9fee chore(guardrail): report-only size check and var/ compatibility note
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>
2026-07-07 10:19:08 +03:00

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())