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
-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;
}