test(gates)!: the size/complexity ratchet leaves the gate; audit_metrics reports it

sourceSize.contract.test.ts, its test kit and backend/tests/test_source_size.py
held 13 FE / 9 BE file ceilings and 64 FE / 32 BE function ceilings. No real
hit (E34 S2/S4): its history is its own birth (751e1ae5), two rounds of
fixes to its own logic (e432dc27, d1df76e8), the snapshot move (182ec311)
and ceiling edits inside feature fixes (5806adda, df4a79a1, and the S2/S3
cuts here). It caught no defect, went red 8 times on its own limits and drew
5 review findings about itself. The numbers stay visible: audit_metrics
reports file size and complexity, and an epic compares before and after.
docs/gates.md: the two size rows go; the C901 row says where it is read now.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent 3642397f63
commit 7e681e112e
8 changed files with 5 additions and 442 deletions
-144
View File
@@ -1,144 +0,0 @@
"""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) — 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.
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": 1525,
"app/plex/stream.py": 1128,
"app/plex/subtitles.py": 1092,
"app/routes/auth.py": 1273,
"app/routes/downloads.py": 1232,
}
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,
}
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, int]:
"""`module::function` -> its complexity; a name taken twice in a module gets ` 2`, ` 3` in
source order."""
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, int] = {}
for m in sorted(json.loads(out), key=lambda m: (m["filename"], m["location"]["row"])):
value = re.search(r"\((\d+) > \d+\)", m["message"])
name = re.search(r"`([^`]+)`", m["message"])
assert value and name, m["message"]
rel = Path(m["filename"]).resolve().relative_to(BACKEND).as_posix()
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():
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 = [
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_function_that_is_back_under_the_limit_or_gone():
actual = _complexity()
assert [fn for fn in COMPLEX if fn not in actual] == []
+1 -1
View File
@@ -98,7 +98,7 @@ npm run typecheck # tsc x3 (app / node / e2e)
npm run lint # eslint (typed rules included; warnings fail)
npm run format:check # prettier
npm run knip # unused files/exports/deps — and unused by what SHIPS (--production)
npm run test # vitest (incl. the compiler, size/complexity and gate-register contracts)
npm run test # vitest (incl. the compiler and gate-register contracts)
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code)
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
```
+1 -3
View File
@@ -87,7 +87,7 @@ project checks needs a row.
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| backend + scripts | `B008` — narrowed: `fastapi.Depends`, `fastapi.Query` declared immutable | FastAPI's dependency injection IS a call in a default argument, on every route. Every other call in a default still fails. | 474 sites, all of them FastAPI |
| backend/app | `C901` — not in ruff; measured by `tests/test_source_size.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. The test holds each offender, by name, at its current value. Tests and scripts are outside it: see the size / complexity rows under Lanes. | 32 functions over 10 |
| backend/app | `C901` — not in ruff; reported by `scripts/audit_metrics.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. Complexity is a number an epic reads before and after (E35), not a gate: the ratchet that held it caught no defect in E32–E34, went red 8 times on its own ceilings and drew 5 review findings about itself. | 32 functions over 10 (E34) |
## basedpyright (backend)
@@ -121,8 +121,6 @@ project checks needs a row.
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
| size / complexity | today's offenders as ceilings | `src/lib/sourceSize.contract.test.ts`, `backend/tests/test_source_size.py`: listed files and functions may not grow, nothing new may cross the limit, an entry back under it must be deleted. A function is held by its name, not its rank in the file. | files >800 lines: 13 FE / 9 BE; functions over the limit: 64 FE / 32 BE (E32 review) |
| size / complexity | shipped code only — not tests, test kits, e2e or `scripts/` | The ratchet guards what runs in production. A test file is long because it is a list of cases, and a test kit is test code; splitting either moves lines without making anything easier to change. `scripts/` is release and seed tooling, not in the image. | outside it: tests >800 lines 2 FE / 5 BE; functions over the limit 2 FE test code (`gates.contract.test.ts` `eslintNarrowings` 23, E32 review round 3; `compilerSkips.testkit.ts` 25), 1 BE test (11), 1 script (`e2e_seed.py` `main` 13) |
## Accepted findings
-2
View File
@@ -1505,7 +1505,6 @@
"src/lib/saveLanguage.dom.test.ts",
"src/lib/savedViews.test.ts",
"src/lib/sortableA11y.contract.test.ts",
"src/lib/sourceSize.contract.test.ts",
"src/lib/staleBuild.dom.test.ts",
"src/lib/store.dom.test.tsx",
"src/lib/subscriptionCache.test.ts",
@@ -1948,7 +1947,6 @@
"src/lib/savedViews.ts",
"src/lib/seekableEnd.ts",
"src/lib/sortOptions.ts",
"src/lib/sourceSize.testkit.ts",
"src/lib/staleBuild.ts",
"src/lib/storage.ts",
"src/lib/store.ts",
@@ -1,148 +0,0 @@
import { describe, expect, it } from "vitest";
import {
boundNamesIn,
complexityOffenders,
lineCounts,
MAX_COMPLEXITY,
MAX_LINES,
shippedFiles,
} from "./sourceSize.testkit";
// The size / complexity RATCHET for the shipped frontend (E32, owner 2026-10-03 — "class size" from
// the quality checklist, measured rather than argued). Two limits, each with today's offenders as a
// CEILING:
// • a file over 800 lines — the listed ones may not grow past their line count; any other
// file may not cross the limit;
// • a function over complexity 20 (eslint `complexity`, cyclomatic) — a listed one, by its
// NAME, may not rise; no other function may cross the limit. (Ranked per file until the E32
// review: one function could grow by what another in the same file lost.) A rename reads as a
// new offender plus a stale entry: move its row.
// Shrinking is free; a file 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. JSX branches count toward complexity, so a component scores high by
// construction — the limit catches the outliers, it does not grade style.
const BIG_FILES: Record<string, number> = {
"components/AdminUsers.tsx": 993,
"components/Channels.tsx": 904,
"components/Feed.tsx": 934,
"components/Home.tsx": 1136,
"components/IssueComposer.tsx": 861,
"components/PlayerModal.tsx": 1142,
"components/Playlists.tsx": 1069,
"components/PlexPlayer.tsx": 1984,
"components/PlexPlaylistView.tsx": 830,
"components/plexViews.tsx": 941,
"components/SettingsPanel.tsx": 885,
"components/Welcome.tsx": 944,
"lib/plexPlayerHooks.ts": 922,
};
const COMPLEX: Record<string, number> = {
"App.tsx::App": 28,
"components/AccountPanel.tsx::AccountPanel": 29,
"components/AdminLogs.tsx::AdminLogs": 35,
"components/ChannelPage.tsx::ChannelPage": 91,
"components/Channels.tsx::Channels": 53,
"components/ConfigPanel.tsx::ConfigPanel": 24,
"components/ConfigPanel.tsx::Field": 28,
"components/DownloadCenter.tsx::DownloadCenter": 36,
"components/Feed.tsx::Feed": 64,
"components/Home.tsx::Hero": 32,
"components/Home.tsx::Home": 34,
"components/Home.tsx::useHomeCardMenu": 21,
"components/IssueComposer.tsx::ComposerBody": 34,
"components/NotificationsPanel.tsx::NotificationRow": 28,
"components/OnboardingWizard.tsx::OnboardingWizard": 21,
"components/PersonalViewsModal.tsx::PersonalViewsModal": 22,
"components/PlayerModal.tsx::PlayerModal": 98,
"components/Playlists.tsx::Playlists": 66,
"components/PlexBrowse.tsx::PlexBrowse": 45,
"components/PlexCollectionView.tsx::PlexCollectionView": 41,
"components/PlexDiscover.tsx::PlexDiscover": 21,
"components/PlexInfo.tsx::PlexInfo": 65,
"components/PlexPlayer.tsx::onKey": 22,
"components/PlexPlayer.tsx::PlexPlayer": 103,
"components/PlexPlaylistView.tsx::PlexPlaylistView": 22,
"components/PlexSidebar.tsx::PlexSidebar": 32,
"components/PlexStatsOverlay.tsx::sample": 25,
"components/Scheduler.tsx::JobRow": 21,
"components/Scheduler.tsx::Scheduler": 21,
"components/SetupWizard.tsx::SetupWizard": 21,
"components/SyncStatus.tsx::SyncStatus": 22,
"components/VideoCard.tsx::VideoCard": 39,
"components/VideoEditor.tsx::VideoEditor": 49,
"components/WatchPage.tsx::WatchPage": 30,
"components/Welcome.tsx::AuthCard": 39,
"components/Welcome.tsx::onSubmit": 24,
"components/auditColumns.tsx::localDetails": 24,
"components/downloadRow.tsx::DownloadRow": 25,
"components/plexCards.tsx::PlexPosterCard": 58,
"components/plexViews.tsx::PlexPersonView": 30,
"components/plexViews.tsx::PlexSeasonView": 21,
"components/plexViews.tsx::PlexShowView": 50,
"components/ui/AnchoredPopover.tsx::PopoverSurfaceInner": 22,
"components/ui/Combobox.tsx::Combobox": 22,
"components/ui/DataTable.tsx::DataTable": 42,
"components/ui/PanelGroup.tsx::PanelGroup": 23,
"components/ui/SidePanel.tsx::SidePanel": 45,
"components/ui/SortControl.tsx::SortControl": 23,
"components/ui/anchoredPopoverHooks.ts::useAnchoredPopover": 30,
"components/usePlaylistActions.tsx::pushSteps": 23,
"components/usePlexActiveFilters.ts::buildPlexActiveFilters": 25,
"components/useVideoActions.tsx::items": 25,
"lib/api/core.ts::localizeDetail": 48,
"lib/api/core.ts::req": 22,
"lib/api/plex.ts::appendPlexFilters": 26,
"lib/diagnostics.ts::applyDiagnostics": 22,
"lib/keymap.ts::dispatch": 28,
"lib/logHooks.ts::controlAttrs": 21,
"lib/plexDetail.ts::useDetailAside": 24,
"lib/plexPlayerHooks.ts::usePlexTracks": 30,
"lib/urlState.ts::filtersToParams": 24,
"lib/urlState.ts::paramsToFilters": 26,
"lib/useActiveFilters.ts::useActiveFilters": 25,
};
describe("file size", () => {
const lines = lineCounts(shippedFiles());
it(`no file crosses ${MAX_LINES} lines, and a listed one does not grow`, () => {
const over = Object.entries(lines)
.filter(([f, n]) => n > (BIG_FILES[f] ?? MAX_LINES))
.map(([f, n]) => `${f}: ${n} lines (ceiling ${BIG_FILES[f] ?? MAX_LINES})`);
expect(over).toEqual([]);
});
it("lists no file that is back under the limit (delete it)", () => {
const back = Object.keys(BIG_FILES).filter((f) => (lines[f] ?? 0) <= MAX_LINES);
expect(back).toEqual([]);
});
});
describe("function complexity", async () => {
const actual = await complexityOffenders();
it(`no function crosses ${MAX_COMPLEXITY}, and a listed one does not rise`, () => {
const worse = Object.entries(actual)
.filter(([fn, v]) => v > (COMPLEX[fn] ?? MAX_COMPLEXITY))
.map(([fn, v]) => `${fn}: ${v} (ceiling ${COMPLEX[fn] ?? MAX_COMPLEXITY})`);
expect(worse).toEqual([]);
});
it("lists no function that is back under the limit, or gone (delete its row)", () => {
expect(Object.keys(COMPLEX).filter((fn) => !(fn in actual))).toEqual([]);
});
});
describe("function names (the ratchet's keys)", () => {
it("names an unnamed function by what it is bound to, through a wrapping call", () => {
const code = [
"const onSave = useCallback(() => 1);",
"useEffect(() => {});",
"const o = { k: function () {} };",
"const v = useMemo(() => g(() => 2));",
].join(" ");
expect(boundNamesIn(code)).toEqual(["onSave", "useEffect callback", "k", "v", "g callback"]);
});
});
-141
View File
@@ -1,141 +0,0 @@
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { ESLint, Linter, type Rule } from "eslint";
import tseslint from "typescript-eslint";
// Measures the shipped frontend source for the size/complexity ratchet (E32): lines per file, and the
// cyclomatic complexity of every function over the limit. Used by sourceSize.contract.test.ts.
export const MAX_LINES = 800;
export const MAX_COMPLEXITY = 20;
const SRC = fileURLToPath(new URL("../", import.meta.url));
/** src-relative paths of everything the build ships (no tests, no test kits). */
export function shippedFiles(): string[] {
return (readdirSync(SRC, { recursive: true }) as string[])
.map((p) => p.split("\\").join("/"))
.filter(
(p) => /\.(ts|tsx)$/.test(p) && !/\.(test|testkit)\.tsx?$/.test(p) && !p.endsWith(".d.ts"),
)
.sort();
}
export function lineCounts(files: string[]): Record<string, number> {
const out: Record<string, number> = {};
for (const f of files) out[f] = readFileSync(SRC + f, "utf8").split("\n").length;
return out;
}
/** `file::function` → its complexity, for every function over MAX_COMPLEXITY. Keyed by NAME, not by
* rank in the file: a ranked list let one function grow while another in the same file shrank by as
* much (E32 review). ESLint names declarations and methods; a function it leaves unnamed takes the
* name it is bound to (`const onSave = () => …`, `{ onSave: () => … }`), through a wrapping call
* (`const onSave = useCallback(() => …)`, round 3), else `<callee> callback` (`useEffect
* callback`), else `(anonymous)`. A name taken twice in a file gets ` 2`, ` 3` in source order —
* left only for repeated unbound callbacks (two `useEffect`s), where the order IS the identity. A
* bare rule run (no type info, no other rules), so it costs a couple of seconds, not the full lint. */
type Node = Rule.Node;
const ident = (n: { type: string; name?: string } | null | undefined): string | undefined =>
n?.type === "Identifier" ? n.name : undefined;
function boundName(p: Node): string | undefined {
switch (p.type) {
case "VariableDeclarator":
return ident(p.id);
case "Property":
case "PropertyDefinition":
return ident(p.key);
case "AssignmentExpression":
return ident(p.left.type === "MemberExpression" ? p.left.property : p.left);
case "CallExpression": {
// a wrapped function takes the wrapper's binding: `const onSave = useCallback(() => …)`
const outer = p.parent as Node | null;
const bound = outer && outer.type !== "CallExpression" ? boundName(outer) : undefined;
if (bound) return bound;
const callee = ident(p.callee.type === "MemberExpression" ? p.callee.property : p.callee);
return callee && `${callee} callback`;
}
default:
return undefined;
}
}
/** The name each function is bound to, reported where `complexity` reports it (the function's HEAD:
* the `=>` of an arrow, the key of a method, else the function's start) so the two line up. */
const functionNames: Rule.RuleModule = {
create(ctx) {
const visit = (node: Node) => {
const p = node.parent as Node | null;
const name = p && boundName(p);
if (!p || !name) return;
const head =
node.type === "ArrowFunctionExpression"
? ctx.sourceCode.getTokenBefore(node.body, (t) => t.value === "=>")!.loc.start
: p.type === "Property" ||
p.type === "MethodDefinition" ||
p.type === "PropertyDefinition"
? p.loc!.start
: node.loc!.start;
ctx.report({ loc: head, message: name });
};
return { ArrowFunctionExpression: visit, FunctionExpression: visit };
},
};
/** The name `functionNames` gives each unnamed function in `code`, in source order (its own test). */
export function boundNamesIn(code: string): string[] {
return new Linter()
.verify(
code,
[
{
files: ["**/*.tsx"],
languageOptions: {
parser: tseslint.parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { size: { rules: { "function-names": functionNames } } },
rules: { "size/function-names": "error" },
},
],
"probe.tsx",
)
.map((m) => m.message);
}
export async function complexityOffenders(): Promise<Record<string, number>> {
const eslint = new ESLint({
cwd: fileURLToPath(new URL("../../", import.meta.url)),
overrideConfigFile: true,
overrideConfig: [
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["**/*.test.*", "**/*.testkit.*"],
languageOptions: {
parser: tseslint.parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { size: { rules: { "function-names": functionNames } } },
rules: { complexity: ["error", MAX_COMPLEXITY], "size/function-names": "error" },
},
],
});
const out: Record<string, number> = {};
for (const r of await eslint.lintFiles(["src/**/*.{ts,tsx}"])) {
const file = r.filePath
.split("\\")
.join("/")
.replace(/^.*\/frontend\/src\//, "");
const at = (m: { line: number; column: number }) => `${m.line}:${m.column}`;
const bound = new Map(
r.messages.filter((m) => m.ruleId === "size/function-names").map((m) => [at(m), m.message]),
);
for (const m of r.messages) {
if (m.ruleId !== "complexity") continue;
const name = /'([^']+)'/.exec(m.message)?.[1] ?? bound.get(at(m)) ?? "(anonymous)";
let key = `${file}::${name}`;
for (let n = 2; key in out; n++) key = `${file}::${name} ${n}`;
out[key] = Number(/complexity of (\d+)/.exec(m.message)?.[1]);
}
}
return out;
}
+2 -2
View File
@@ -24,8 +24,8 @@ BE_APP = ROOT / "backend" / "app"
BE_TESTS = ROOT / "backend" / "tests"
FE_SRC = ROOT / "frontend" / "src"
BIG_FILE = 800
BE_COMPLEXITY = 10 # ruff's C901 default, the backend ratchet's limit
FE_COMPLEXITY = 20 # the frontend ratchet's limit
BE_COMPLEXITY = 10 # ruff's C901 default
FE_COMPLEXITY = 20 # the frontend's line (E32)
EPIC_CODE = re.compile(r"\b(E\d{2}|R\d{1,2})(\s?S\d+)?\b")
NARRATION = re.compile(r"\b(used to|no longer)\b", re.IGNORECASE)
+1 -1
View File
@@ -3,7 +3,7 @@
// one JSON object: {"limit": N, "over": count, "max": highest}. Shipped code only — tests and test
// kits are lists of cases, not logic anyone has to change.
//
// node scripts/fe_complexity.mjs [limit] (default 20, the frontend's ratchet limit)
// node scripts/fe_complexity.mjs [limit] (default 20, the frontend's line since E32)
import { readdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";