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>
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -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).
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
152
scripts/report_module_sizes.py
Normal file
152
scripts/report_module_sizes.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user