test(gates): the complexity ratchet holds each function by name, and its scope is registered

The ceilings were a per-file ranked list, so one function could grow by what another in the same
file lost: Home.tsx's [34, 32] passed with Home at 32 and Hero at 34. Both twins now key every
offender as module::function (an unnamed one is (anonymous); a repeated name gets 2, 3 in source
order) and re-baseline at today's measured values. Swapping two ceilings is red on both sides
(Home 34 > 32; feedquery filtered_query 28 > 14).

The ratchet measures shipped code only. docs/gates.md said C901 covered backend + scripts; it
covers backend/app. The exclusion of tests, test kits, e2e and scripts/ is now a row with its
measured hits, and the stale counts are replaced (9 BE modules over 800, not 11).
This commit is contained in:
2026-10-04 02:55:07 +02:00
parent 3741d1a46d
commit e432dc2780
4 changed files with 151 additions and 137 deletions
+56 -48
View File
@@ -4,8 +4,10 @@ checklist, measured rather than argued). The frontend's twin is src/lib/sourceSi
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.
* a function over ruff's C901 limit (cyclomatic complexity, MAX_COMPLEXITY) — a listed one, by its
NAME (`module::function`), may not rise; no other function may cross the limit. (Ranked per
module until the E32 review: one function could grow by what another in the same module lost.)
A rename reads as a new offender plus a stale entry: move its row.
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.
@@ -37,32 +39,39 @@ BIG_MODULES: dict[str, int] = {
"app/routes/downloads.py": 1237,
}
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],
COMPLEX: dict[str, int] = {
"app/bodylimit.py::__call__": 11,
"app/discover/hero.py::pick": 14,
"app/discover/pins.py::_shelf_card": 12,
"app/downloads/edit.py::normalize_edit_spec": 16,
"app/downloads/edit.py::run_ffmpeg": 15,
"app/downloads/formats.py::build_ydl_opts": 11,
"app/downloads/gc.py::_sweep_orphan_files": 15,
"app/downloads/gc.py::run_download_gc": 11,
"app/downloads/og.py::_jpeg_size": 12,
"app/downloads/og.py::_watch_tags": 11,
"app/downloads/pipeline.py::_download": 14,
"app/downloads/pipeline.py::_process_edit": 12,
"app/feedquery.py::feed_summary": 14,
"app/feedquery.py::filtered_query": 28,
"app/plex/query.py::meta_filter_conds": 13,
"app/plex/stream.py::start_session": 14,
"app/plex/subtitles.py::_ffmpeg_extract": 12,
"app/plex/sync.py::sync": 13,
"app/plex/tmdb.py::parse_tmdb": 11,
"app/plex/watch_sync.py::run_plex_watch_sync": 12,
"app/routes/auth.py::callback": 19,
"app/routes/debug_sessions.py::send_issue": 12,
"app/routes/downloads.py::resolve_source": 11,
"app/routes/plex/browse.py::facets": 25,
"app/routes/plex/browse.py::per_table": 13,
"app/routes/plex/browse.py::unified_library": 18,
"app/routes/plex/detail.py::item_detail": 13,
"app/routes/search.py::search_youtube": 15,
"app/sync/playlists.py::merge_playlist": 19,
"app/sync/runner.py::run_rss_poll": 13,
"app/sync/tagsuggest.py::compute_tag_suggestions": 14,
"app/worker.py::_run_supervised": 18,
}
@@ -73,7 +82,9 @@ def _line_counts() -> dict[str, int]:
}
def _complexity() -> dict[str, list[int]]:
def _complexity() -> dict[str, int]:
"""`module::function` -> its complexity; a name taken twice in a module gets ` 2`, ` 3` in
source order."""
out = subprocess.run(
[
sys.executable,
@@ -93,13 +104,17 @@ def _complexity() -> dict[str, list[int]]:
text=True,
check=True,
).stdout
found: dict[str, list[int]] = {}
for m in json.loads(out):
found: dict[str, int] = {}
for m in sorted(json.loads(out), key=lambda m: (m["filename"], m["location"]["row"])):
value = re.search(r"\((\d+) > \d+\)", m["message"])
assert value, m["message"]
name = re.search(r"`([^`]+)`", m["message"])
assert value and name, 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()}
key, n = f"{rel}::{name.group(1)}", 2
while key in found:
key, n = f"{rel}::{name.group(1)} {n}", n + 1
found[key] = int(value.group(1))
return found
def test_no_module_crosses_the_line_limit_and_a_listed_one_does_not_grow():
@@ -117,21 +132,14 @@ def test_lists_no_module_that_is_back_under_the_limit():
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})")
worse = [
f"{fn}: {v} (ceiling {COMPLEX.get(fn, MAX_COMPLEXITY)})"
for fn, v in _complexity().items()
if v > COMPLEX.get(fn, MAX_COMPLEXITY)
]
assert worse == []
def test_lists_no_offender_that_is_back_under_the_limit():
def test_lists_no_function_that_is_back_under_the_limit_or_gone():
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 == []
assert [fn for fn in COMPLEX if fn not in actual] == []