test(gates): the complexity ratchet names a function by what it is bound to

ESLint leaves an arrow bound to a const or a property unnamed, so three ratchet keys were
(anonymous) and ranked by position again. A tiny rule reports each unnamed function's
binding (const, property, assignment, else <callee> callback) at the head complexity
reports, and the keys line up by location.
This commit is contained in:
2026-10-04 04:16:52 +02:00
parent 3f6a9efd57
commit d1df76e8e5
2 changed files with 57 additions and 8 deletions
+3 -3
View File
@@ -61,11 +61,11 @@ const COMPLEX: Record<string, number> = {
"components/PlexCollectionView.tsx::PlexCollectionView": 41, "components/PlexCollectionView.tsx::PlexCollectionView": 41,
"components/PlexDiscover.tsx::PlexDiscover": 21, "components/PlexDiscover.tsx::PlexDiscover": 21,
"components/PlexInfo.tsx::PlexInfo": 65, "components/PlexInfo.tsx::PlexInfo": 65,
"components/PlexPlayer.tsx::(anonymous)": 22, "components/PlexPlayer.tsx::onKey": 22,
"components/PlexPlayer.tsx::PlexPlayer": 103, "components/PlexPlayer.tsx::PlexPlayer": 103,
"components/PlexPlaylistView.tsx::PlexPlaylistView": 22, "components/PlexPlaylistView.tsx::PlexPlaylistView": 22,
"components/PlexSidebar.tsx::PlexSidebar": 32, "components/PlexSidebar.tsx::PlexSidebar": 32,
"components/PlexStatsOverlay.tsx::(anonymous)": 25, "components/PlexStatsOverlay.tsx::sample": 25,
"components/Scheduler.tsx::JobRow": 21, "components/Scheduler.tsx::JobRow": 21,
"components/Scheduler.tsx::Scheduler": 21, "components/Scheduler.tsx::Scheduler": 21,
"components/SetupWizard.tsx::SetupWizard": 21, "components/SetupWizard.tsx::SetupWizard": 21,
@@ -90,7 +90,7 @@ const COMPLEX: Record<string, number> = {
"components/ui/anchoredPopoverHooks.ts::useAnchoredPopover": 30, "components/ui/anchoredPopoverHooks.ts::useAnchoredPopover": 30,
"components/usePlaylistActions.tsx::pushSteps": 23, "components/usePlaylistActions.tsx::pushSteps": 23,
"components/usePlexActiveFilters.ts::buildPlexActiveFilters": 25, "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::localizeDetail": 48,
"lib/api/core.ts::req": 22, "lib/api/core.ts::req": 22,
"lib/api/plex.ts::appendPlexFilters": 26, "lib/api/plex.ts::appendPlexFilters": 26,
+54 -5
View File
@@ -1,6 +1,6 @@
import { readdirSync, readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { ESLint } from "eslint"; import { ESLint, type Rule } from "eslint";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
// Measures the shipped frontend source for the size/complexity ratchet (E32): lines per file, and the // 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<string, number> {
/** `file::function` → its complexity, for every function over MAX_COMPLEXITY. Keyed by NAME, not by /** `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 * 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 * much (E32 review). ESLint names declarations and methods; a function it leaves unnamed takes the
* ` 2`, ` 3` in source order. A bare rule run (no type info, no other rules), so it costs a couple of * name it is bound to (`const onSave = () => …`, `{ onSave: () => … }`), else `<callee> 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. */ * 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<Record<string, number>> { export async function complexityOffenders(): Promise<Record<string, number>> {
const eslint = new ESLint({ const eslint = new ESLint({
cwd: fileURLToPath(new URL("../../", import.meta.url)), cwd: fileURLToPath(new URL("../../", import.meta.url)),
@@ -44,7 +88,8 @@ export async function complexityOffenders(): Promise<Record<string, number>> {
parser: tseslint.parser, parser: tseslint.parser,
parserOptions: { ecmaFeatures: { jsx: true } }, 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<Record<string, number>> {
.split("\\") .split("\\")
.join("/") .join("/")
.replace(/^.*\/frontend\/src\//, ""); .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) { for (const m of r.messages) {
if (m.ruleId !== "complexity") continue; 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}`; let key = `${file}::${name}`;
for (let n = 2; key in out; n++) key = `${file}::${name} ${n}`; for (let n = 2; key in out; n++) key = `${file}::${name} ${n}`;
out[key] = Number(/complexity of (\d+)/.exec(m.message)?.[1]); out[key] = Number(/complexity of (\d+)/.exec(m.message)?.[1]);