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:
Vadim Malanov
2026-07-07 10:19:08 +03:00
parent 3eba9c0f61
commit 79d5fa9fee
5 changed files with 172 additions and 0 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

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