test: a size and complexity ratchet; coverage reported on both sides
The "class size" item from the quality checklist, measured: - A shipped file over 800 lines may not grow past its line count today (13 frontend, 11 backend). No other file may cross 800. - A function over the complexity limit may not rise, and no new one may appear. The limit is eslint `complexity` 20 on the frontend (64 functions over it today) and ruff C901 10 on the backend (32). - Each is a per-file ceiling. Shrinking is free; an entry back under the limit must be deleted, so the lists only shrink. C901 is measured in a test rather than switched on in ruff.toml: ruff has no per-function baseline, and a noqa would let a function grow without limit. Coverage (owner decision D4) is printed with no threshold: `vitest --coverage` (v8) gives 80% of lines, and the pytest lane ends with the total for app/, 78%. The bar stays "every regression test seen red". .coverage is ignored by git and kept out of the image's build context.
This commit is contained in:
@@ -35,4 +35,9 @@ services:
|
||||
volumes:
|
||||
- ./:/app
|
||||
working_dir: /app
|
||||
command: python -m pytest
|
||||
# The suite, then the coverage total (E32 D4: reported, no threshold). pytest's exit code is the
|
||||
# lane's — the report is printed either way and never decides it.
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'python -m pytest --cov=app --cov-report= ; rc=$$?; echo ">> backend coverage (app/) $$(python -m coverage report --format=total)% of lines"; exit $$rc'
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
# basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type
|
||||
# checker whose version floats silently changes what "clean" means, and the ratchet baseline it
|
||||
# writes is keyed to the diagnostics THIS version produces.
|
||||
# pytest-cov: the coverage REPORT the gate prints (E32 D4) — a number to read, deliberately with no
|
||||
# threshold; "every regression test seen red" is the bar, not a percentage.
|
||||
basedpyright==1.40.1
|
||||
pytest==8.4.2
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.15.21
|
||||
httpx2==2.13.0
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""The size / complexity RATCHET for the backend (E32, owner 2026-10-03 — "class size" from the quality
|
||||
checklist, measured rather than argued). The frontend's twin is src/lib/sourceSize.contract.test.ts.
|
||||
|
||||
Two limits, each with today's offenders as a CEILING:
|
||||
* a module over MAX_LINES lines — a listed one may not grow past its line count; any other module
|
||||
may not cross the limit;
|
||||
* a function over ruff's C901 limit (cyclomatic complexity, MAX_COMPLEXITY) — per module, the
|
||||
offenders' values highest-first may not rise, and no new offender may appear.
|
||||
Shrinking is free; a module or function that drops UNDER the limit must be taken off its list (red
|
||||
until it is), so the lists only get shorter. Raising a ceiling is a decision for the owner, with the
|
||||
reason in docs/gates.md.
|
||||
|
||||
C901 is measured here rather than switched on in ruff.toml because ruff has no per-function
|
||||
baseline: on, it would need a `noqa` on each of today's offenders, and a `noqa` lets the function
|
||||
grow without limit. This keeps the ceiling on the number.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
MAX_LINES = 800
|
||||
MAX_COMPLEXITY = 10 # ruff's C901 default (mccabe max-complexity)
|
||||
|
||||
BIG_MODULES: dict[str, int] = {
|
||||
"app/downloads/edit.py": 833,
|
||||
"app/downloads/pipeline.py": 870,
|
||||
"app/logs.py": 839,
|
||||
"app/models.py": 1607,
|
||||
"app/plex/stream.py": 1128,
|
||||
"app/plex/subtitles.py": 1092,
|
||||
"app/plex/sync.py": 841,
|
||||
"app/routes/admin.py": 864,
|
||||
"app/routes/auth.py": 1353,
|
||||
"app/routes/downloads.py": 1237,
|
||||
"app/sync/playlists.py": 843,
|
||||
}
|
||||
|
||||
COMPLEX: dict[str, list[int]] = {
|
||||
"app/bodylimit.py": [11],
|
||||
"app/discover/hero.py": [14],
|
||||
"app/discover/pins.py": [12],
|
||||
"app/downloads/edit.py": [16, 15],
|
||||
"app/downloads/formats.py": [11],
|
||||
"app/downloads/gc.py": [15, 11],
|
||||
"app/downloads/og.py": [12, 11],
|
||||
"app/downloads/pipeline.py": [14, 12],
|
||||
"app/feedquery.py": [28, 14],
|
||||
"app/plex/query.py": [13],
|
||||
"app/plex/stream.py": [14],
|
||||
"app/plex/subtitles.py": [12],
|
||||
"app/plex/sync.py": [13],
|
||||
"app/plex/tmdb.py": [11],
|
||||
"app/plex/watch_sync.py": [12],
|
||||
"app/routes/auth.py": [19],
|
||||
"app/routes/debug_sessions.py": [12],
|
||||
"app/routes/downloads.py": [11],
|
||||
"app/routes/plex/browse.py": [25, 18, 13],
|
||||
"app/routes/plex/detail.py": [13],
|
||||
"app/routes/search.py": [15],
|
||||
"app/sync/playlists.py": [19],
|
||||
"app/sync/runner.py": [13],
|
||||
"app/sync/tagsuggest.py": [14],
|
||||
"app/worker.py": [18],
|
||||
}
|
||||
|
||||
|
||||
def _line_counts() -> dict[str, int]:
|
||||
return {
|
||||
p.relative_to(BACKEND).as_posix(): len(p.read_text(encoding="utf-8").split("\n"))
|
||||
for p in sorted((BACKEND / "app").rglob("*.py"))
|
||||
}
|
||||
|
||||
|
||||
def _complexity() -> dict[str, list[int]]:
|
||||
out = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ruff",
|
||||
"check",
|
||||
"--config",
|
||||
str(BACKEND / "ruff.toml"),
|
||||
"--select",
|
||||
"C901",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--exit-zero",
|
||||
str(BACKEND / "app"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
found: dict[str, list[int]] = {}
|
||||
for m in json.loads(out):
|
||||
value = re.search(r"\((\d+) > \d+\)", m["message"])
|
||||
assert value, m["message"]
|
||||
rel = Path(m["filename"]).resolve().relative_to(BACKEND).as_posix()
|
||||
found.setdefault(rel, []).append(int(value.group(1)))
|
||||
return {f: sorted(v, reverse=True) for f, v in found.items()}
|
||||
|
||||
|
||||
def test_no_module_crosses_the_line_limit_and_a_listed_one_does_not_grow():
|
||||
over = [
|
||||
f"{f}: {n} lines (ceiling {BIG_MODULES.get(f, MAX_LINES)})"
|
||||
for f, n in _line_counts().items()
|
||||
if n > BIG_MODULES.get(f, MAX_LINES)
|
||||
]
|
||||
assert over == []
|
||||
|
||||
|
||||
def test_lists_no_module_that_is_back_under_the_limit():
|
||||
lines = _line_counts()
|
||||
assert [f for f in BIG_MODULES if lines.get(f, 0) <= MAX_LINES] == []
|
||||
|
||||
|
||||
def test_no_function_crosses_the_complexity_limit_and_a_listed_one_does_not_rise():
|
||||
worse = []
|
||||
for f, values in _complexity().items():
|
||||
ceiling = COMPLEX.get(f, [])
|
||||
for i, v in enumerate(values):
|
||||
cap = ceiling[i] if i < len(ceiling) else MAX_COMPLEXITY
|
||||
if v > cap:
|
||||
worse.append(f"{f}: #{i + 1} is {v} (ceiling {cap})")
|
||||
assert worse == []
|
||||
|
||||
|
||||
def test_lists_no_offender_that_is_back_under_the_limit():
|
||||
actual = _complexity()
|
||||
stale = [
|
||||
f"{f}: listed {len(v)}, now {len(actual.get(f, []))}"
|
||||
for f, v in COMPLEX.items()
|
||||
if len(actual.get(f, [])) < len(v)
|
||||
]
|
||||
assert stale == []
|
||||
Reference in New Issue
Block a user