diff --git a/frontend/src/lib/sourceSize.contract.test.ts b/frontend/src/lib/sourceSize.contract.test.ts index 9375ebcd..9c336b42 100644 --- a/frontend/src/lib/sourceSize.contract.test.ts +++ b/frontend/src/lib/sourceSize.contract.test.ts @@ -61,11 +61,11 @@ const COMPLEX: Record = { "components/PlexCollectionView.tsx::PlexCollectionView": 41, "components/PlexDiscover.tsx::PlexDiscover": 21, "components/PlexInfo.tsx::PlexInfo": 65, - "components/PlexPlayer.tsx::(anonymous)": 22, + "components/PlexPlayer.tsx::onKey": 22, "components/PlexPlayer.tsx::PlexPlayer": 103, "components/PlexPlaylistView.tsx::PlexPlaylistView": 22, "components/PlexSidebar.tsx::PlexSidebar": 32, - "components/PlexStatsOverlay.tsx::(anonymous)": 25, + "components/PlexStatsOverlay.tsx::sample": 25, "components/Scheduler.tsx::JobRow": 21, "components/Scheduler.tsx::Scheduler": 21, "components/SetupWizard.tsx::SetupWizard": 21, @@ -90,7 +90,7 @@ const COMPLEX: Record = { "components/ui/anchoredPopoverHooks.ts::useAnchoredPopover": 30, "components/usePlaylistActions.tsx::pushSteps": 23, "components/usePlexActiveFilters.ts::buildPlexActiveFilters": 25, - "components/useVideoActions.tsx::(anonymous)": 25, + "components/useVideoActions.tsx::items": 25, "lib/api/core.ts::localizeDetail": 48, "lib/api/core.ts::req": 22, "lib/api/plex.ts::appendPlexFilters": 26, diff --git a/frontend/src/lib/sourceSize.testkit.ts b/frontend/src/lib/sourceSize.testkit.ts index 9777c71a..d9ca7e6f 100644 --- a/frontend/src/lib/sourceSize.testkit.ts +++ b/frontend/src/lib/sourceSize.testkit.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { ESLint } from "eslint"; +import { ESLint, 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 @@ -29,9 +29,53 @@ export function lineCounts(files: string[]): Record { /** `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). A function without a name is `(anonymous)`; a name taken twice in a file gets - * ` 2`, ` 3` in source order. A bare rule run (no type info, no other rules), so it costs a couple of + * much (E32 review). ESLint names declarations and methods; a function it leaves unnamed takes the + * name it is bound to (`const onSave = () => …`, `{ onSave: () => … }`), else ` callback` + * (`useEffect callback`), else `(anonymous)` — so a ranked key cannot creep back in through the + * arrows (round 2). A name taken twice in a file gets ` 2`, ` 3` in source order. 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": { + 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 }; + }, +}; + export async function complexityOffenders(): Promise> { const eslint = new ESLint({ cwd: fileURLToPath(new URL("../../", import.meta.url)), @@ -44,7 +88,8 @@ export async function complexityOffenders(): Promise> { parser: tseslint.parser, parserOptions: { ecmaFeatures: { jsx: true } }, }, - rules: { complexity: ["error", MAX_COMPLEXITY] }, + plugins: { size: { rules: { "function-names": functionNames } } }, + rules: { complexity: ["error", MAX_COMPLEXITY], "size/function-names": "error" }, }, ], }); @@ -54,9 +99,13 @@ export async function complexityOffenders(): Promise> { .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] ?? "(anonymous)"; + 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]);