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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user