From 79d5fa9fee34099f16959eac03976d81bb2c8b0a Mon Sep 17 00:00:00 2001 From: Vadim Malanov Date: Tue, 7 Jul 2026 10:19:08 +0300 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 3 + .gitignore | 3 + AGENTS.md | 1 + docs/structure-map.md | 13 +++ scripts/report_module_sizes.py | 152 +++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+) create mode 100644 scripts/report_module_sizes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3afe1ab..36e5837 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Compile run: python -m compileall -q app scripts tests + - name: Module size report (report-only guardrail) + run: python scripts/report_module_sizes.py + - name: Pytest (unit only — heavy deps excluded) # test_chunker.py skips here: it needs the pinned document recognition # engine (private TeamHUB_Engines repo, no PAT secret configured). diff --git a/.gitignore b/.gitignore index 7a836be..c6707d4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ data/input/* data/work/* !data/input/.gitkeep !data/work/.gitkeep +# target home for new local runtime state (18_REPO_LAYOUT_STANDARD); +# data/input + data/work stay as the documented compatibility surface +var/ *.log .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 19c07be..87011a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,7 @@ are non-runtime. There is no archived/legacy code yet. python -m pip check python -m compileall -q app scripts tests python -m pytest tests/ -q +python scripts/report_module_sizes.py # report-only size guardrail # Frontend cd frontend diff --git a/docs/structure-map.md b/docs/structure-map.md index 2e28d4a..86b1ae0 100644 --- a/docs/structure-map.md +++ b/docs/structure-map.md @@ -51,6 +51,19 @@ legacy-knowledge-indexer/ or rename only for symmetry. - Shared OCR/document-recognition logic should move through `Engines`, leaving thin app adapters. +- `data/input/` and `data/work/` are the accepted ignored runtime-state dirs + (deliberate exception to the `var/` rule of `18_REPO_LAYOUT_STANDARD.md`): + docker-compose mounts `./data/input:/data/input` and `./data/work:/data/work`, + and `APP_INPUT_DIR`/`APP_WORK_DIR` point there. Moving them under `var/` + needs a scoped migration touching compose, config defaults and RUNBOOK. + New kinds of local runtime output (logs, exports, reports) go to the + ignored `var/` directory. + +## Module-size guardrail + +`python scripts/report_module_sizes.py` prints the report-only LOC bands per +layer (CONVENTIONS.md module-size guardrail). It runs in CI as a non-blocking +step; split-review stays a human decision. ## Next safe steps diff --git a/scripts/report_module_sizes.py b/scripts/report_module_sizes.py new file mode 100644 index 0000000..f002cd9 --- /dev/null +++ b/scripts/report_module_sizes.py @@ -0,0 +1,152 @@ +"""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())