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:
2026-10-03 17:18:53 +02:00
parent da554df7c7
commit 751e1ae5ed
10 changed files with 504 additions and 1 deletions
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
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) — per file, the
// offenders' values highest-first may not rise, and no new offender may appear.
// 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": [28],
"components/AccountPanel.tsx": [29],
"components/AdminLogs.tsx": [35],
"components/auditColumns.tsx": [24],
"components/ChannelPage.tsx": [91],
"components/Channels.tsx": [53],
"components/ConfigPanel.tsx": [28, 24],
"components/DownloadCenter.tsx": [36],
"components/downloadRow.tsx": [25],
"components/Feed.tsx": [64],
"components/Home.tsx": [34, 32, 21],
"components/IssueComposer.tsx": [34],
"components/KeyGate.tsx": [22],
"components/NotificationsPanel.tsx": [28],
"components/OnboardingWizard.tsx": [23],
"components/PersonalViewsModal.tsx": [24],
"components/PlayerModal.tsx": [99],
"components/Playlists.tsx": [66],
"components/PlexBrowse.tsx": [45],
"components/plexCards.tsx": [58],
"components/PlexCollectionView.tsx": [41],
"components/PlexDiscover.tsx": [21],
"components/PlexInfo.tsx": [65],
"components/PlexPlayer.tsx": [103, 22],
"components/PlexPlaylistView.tsx": [22],
"components/PlexSidebar.tsx": [32],
"components/PlexStatsOverlay.tsx": [29],
"components/plexViews.tsx": [50, 30, 21],
"components/Scheduler.tsx": [21, 21],
"components/SetupWizard.tsx": [21],
"components/SyncStatus.tsx": [22],
"components/ui/AnchoredPopover.tsx": [22],
"components/ui/anchoredPopoverHooks.ts": [30],
"components/ui/Combobox.tsx": [22],
"components/ui/DataTable.tsx": [42],
"components/ui/PanelGroup.tsx": [23],
"components/ui/SidePanel.tsx": [45],
"components/ui/SortControl.tsx": [23],
"components/usePlaylistActions.tsx": [25],
"components/usePlexActiveFilters.ts": [29],
"components/useVideoActions.tsx": [25],
"components/VideoCard.tsx": [39],
"components/VideoEditor.tsx": [50],
"components/WatchPage.tsx": [30],
"components/Welcome.tsx": [39, 24],
"lib/api/core.ts": [48, 22],
"lib/api/plex.ts": [26],
"lib/diagnostics.ts": [22],
"lib/keymap.ts": [28],
"lib/logHooks.ts": [21],
"lib/plexDetail.ts": [24],
"lib/plexPlayerHooks.ts": [30],
"lib/urlState.ts": [26, 24],
"lib/useActiveFilters.ts": [28],
};
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: string[] = [];
for (const [file, values] of Object.entries(actual)) {
const ceiling = COMPLEX[file] ?? [];
values.forEach((v, i) => {
if (v > (ceiling[i] ?? MAX_COMPLEXITY))
worse.push(`${file}: #${i + 1} is ${v} (ceiling ${ceiling[i] ?? MAX_COMPLEXITY})`);
});
}
expect(worse).toEqual([]);
});
it("lists no offender that is back under the limit (shorten the entry)", () => {
const stale = Object.entries(COMPLEX)
.filter(([file, values]) => (actual[file]?.length ?? 0) < values.length)
.map(
([file, values]) => `${file}: listed ${values.length}, now ${actual[file]?.length ?? 0}`,
);
expect(stale).toEqual([]);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { ESLint } 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 → the complexities over MAX_COMPLEXITY, highest first. A bare rule run (no type info, no
* other rules), so it costs a couple of seconds, not the full lint. */
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 } },
},
rules: { complexity: ["error", MAX_COMPLEXITY] },
},
],
});
const out: Record<string, number[]> = {};
for (const r of await eslint.lintFiles(["src/**/*.{ts,tsx}"])) {
const values = r.messages
.filter((m) => m.ruleId === "complexity")
.map((m) => Number(/complexity of (\d+)/.exec(m.message)?.[1]))
.sort((a, b) => b - a);
if (values.length)
out[
r.filePath
.split("\\")
.join("/")
.replace(/^.*\/frontend\/src\//, "")
] = values;
}
return out;
}