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:
@@ -1,144 +0,0 @@
|
||||
"""The size / complexity RATCHET for the backend (E32, owner 2026-10-03 — "class size" from the quality
|
||||
checklist, measured rather than argued). The frontend's twin is src/lib/sourceSize.contract.test.ts.
|
||||
|
||||
Two limits, each with today's offenders as a CEILING:
|
||||
* a module over MAX_LINES lines — a listed one may not grow past its line count; any other module
|
||||
may not cross the limit;
|
||||
* a function over ruff's C901 limit (cyclomatic complexity, MAX_COMPLEXITY) — a listed one, by its
|
||||
NAME (`module::function`), may not rise; no other function may cross the limit. (Ranked per
|
||||
module until the E32 review: one function could grow by what another in the same module lost.)
|
||||
A rename reads as a new offender plus a stale entry: move its row.
|
||||
Shrinking is free; a module 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.
|
||||
|
||||
C901 is measured here rather than switched on in ruff.toml because ruff has no per-function
|
||||
baseline: on, it would need a `noqa` on each of today's offenders, and a `noqa` lets the function
|
||||
grow without limit. This keeps the ceiling on the number.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
MAX_LINES = 800
|
||||
MAX_COMPLEXITY = 10 # ruff's C901 default (mccabe max-complexity)
|
||||
|
||||
BIG_MODULES: dict[str, int] = {
|
||||
"app/downloads/edit.py": 833,
|
||||
"app/downloads/pipeline.py": 870,
|
||||
"app/logs.py": 839,
|
||||
"app/models.py": 1525,
|
||||
"app/plex/stream.py": 1128,
|
||||
"app/plex/subtitles.py": 1092,
|
||||
"app/routes/auth.py": 1273,
|
||||
"app/routes/downloads.py": 1232,
|
||||
}
|
||||
|
||||
COMPLEX: dict[str, int] = {
|
||||
"app/bodylimit.py::__call__": 11,
|
||||
"app/discover/hero.py::pick": 14,
|
||||
"app/discover/pins.py::_shelf_card": 12,
|
||||
"app/downloads/edit.py::normalize_edit_spec": 16,
|
||||
"app/downloads/edit.py::run_ffmpeg": 15,
|
||||
"app/downloads/formats.py::build_ydl_opts": 11,
|
||||
"app/downloads/gc.py::_sweep_orphan_files": 15,
|
||||
"app/downloads/gc.py::run_download_gc": 11,
|
||||
"app/downloads/og.py::_jpeg_size": 12,
|
||||
"app/downloads/og.py::_watch_tags": 11,
|
||||
"app/downloads/pipeline.py::_download": 14,
|
||||
"app/downloads/pipeline.py::_process_edit": 12,
|
||||
"app/feedquery.py::feed_summary": 14,
|
||||
"app/feedquery.py::filtered_query": 28,
|
||||
"app/plex/query.py::meta_filter_conds": 13,
|
||||
"app/plex/stream.py::start_session": 14,
|
||||
"app/plex/subtitles.py::_ffmpeg_extract": 12,
|
||||
"app/plex/sync.py::sync": 13,
|
||||
"app/plex/tmdb.py::parse_tmdb": 11,
|
||||
"app/plex/watch_sync.py::run_plex_watch_sync": 12,
|
||||
"app/routes/auth.py::callback": 19,
|
||||
"app/routes/debug_sessions.py::send_issue": 12,
|
||||
"app/routes/downloads.py::resolve_source": 11,
|
||||
"app/routes/plex/browse.py::facets": 25,
|
||||
"app/routes/plex/browse.py::per_table": 13,
|
||||
"app/routes/plex/browse.py::unified_library": 18,
|
||||
"app/routes/plex/detail.py::item_detail": 13,
|
||||
"app/routes/search.py::search_youtube": 15,
|
||||
"app/sync/playlists.py::merge_playlist": 19,
|
||||
"app/sync/runner.py::run_rss_poll": 13,
|
||||
"app/sync/tagsuggest.py::compute_tag_suggestions": 14,
|
||||
"app/worker.py::_run_supervised": 18,
|
||||
}
|
||||
|
||||
|
||||
def _line_counts() -> dict[str, int]:
|
||||
return {
|
||||
p.relative_to(BACKEND).as_posix(): len(p.read_text(encoding="utf-8").split("\n"))
|
||||
for p in sorted((BACKEND / "app").rglob("*.py"))
|
||||
}
|
||||
|
||||
|
||||
def _complexity() -> dict[str, int]:
|
||||
"""`module::function` -> its complexity; a name taken twice in a module gets ` 2`, ` 3` in
|
||||
source order."""
|
||||
out = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ruff",
|
||||
"check",
|
||||
"--config",
|
||||
str(BACKEND / "ruff.toml"),
|
||||
"--select",
|
||||
"C901",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--exit-zero",
|
||||
str(BACKEND / "app"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
found: dict[str, int] = {}
|
||||
for m in sorted(json.loads(out), key=lambda m: (m["filename"], m["location"]["row"])):
|
||||
value = re.search(r"\((\d+) > \d+\)", m["message"])
|
||||
name = re.search(r"`([^`]+)`", m["message"])
|
||||
assert value and name, m["message"]
|
||||
rel = Path(m["filename"]).resolve().relative_to(BACKEND).as_posix()
|
||||
key, n = f"{rel}::{name.group(1)}", 2
|
||||
while key in found:
|
||||
key, n = f"{rel}::{name.group(1)} {n}", n + 1
|
||||
found[key] = int(value.group(1))
|
||||
return found
|
||||
|
||||
|
||||
def test_no_module_crosses_the_line_limit_and_a_listed_one_does_not_grow():
|
||||
over = [
|
||||
f"{f}: {n} lines (ceiling {BIG_MODULES.get(f, MAX_LINES)})"
|
||||
for f, n in _line_counts().items()
|
||||
if n > BIG_MODULES.get(f, MAX_LINES)
|
||||
]
|
||||
assert over == []
|
||||
|
||||
|
||||
def test_lists_no_module_that_is_back_under_the_limit():
|
||||
lines = _line_counts()
|
||||
assert [f for f in BIG_MODULES if lines.get(f, 0) <= MAX_LINES] == []
|
||||
|
||||
|
||||
def test_no_function_crosses_the_complexity_limit_and_a_listed_one_does_not_rise():
|
||||
worse = [
|
||||
f"{fn}: {v} (ceiling {COMPLEX.get(fn, MAX_COMPLEXITY)})"
|
||||
for fn, v in _complexity().items()
|
||||
if v > COMPLEX.get(fn, MAX_COMPLEXITY)
|
||||
]
|
||||
assert worse == []
|
||||
|
||||
|
||||
def test_lists_no_function_that_is_back_under_the_limit_or_gone():
|
||||
actual = _complexity()
|
||||
assert [fn for fn in COMPLEX if fn not in actual] == []
|
||||
+1
-1
@@ -98,7 +98,7 @@ npm run typecheck # tsc x3 (app / node / e2e)
|
||||
npm run lint # eslint (typed rules included; warnings fail)
|
||||
npm run format:check # prettier
|
||||
npm run knip # unused files/exports/deps — and unused by what SHIPS (--production)
|
||||
npm run test # vitest (incl. the compiler, size/complexity and gate-register contracts)
|
||||
npm run test # vitest (incl. the compiler and gate-register contracts)
|
||||
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code)
|
||||
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
||||
```
|
||||
|
||||
+1
-3
@@ -87,7 +87,7 @@ project checks needs a row.
|
||||
| Scope | Narrowed | Why | Measured |
|
||||
| --- | --- | --- | --- |
|
||||
| backend + scripts | `B008` — narrowed: `fastapi.Depends`, `fastapi.Query` declared immutable | FastAPI's dependency injection IS a call in a default argument, on every route. Every other call in a default still fails. | 474 sites, all of them FastAPI |
|
||||
| backend/app | `C901` — not in ruff; measured by `tests/test_source_size.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. The test holds each offender, by name, at its current value. Tests and scripts are outside it: see the size / complexity rows under Lanes. | 32 functions over 10 |
|
||||
| backend/app | `C901` — not in ruff; reported by `scripts/audit_metrics.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. Complexity is a number an epic reads before and after (E35), not a gate: the ratchet that held it caught no defect in E32–E34, went red 8 times on its own ceilings and drew 5 review findings about itself. | 32 functions over 10 (E34) |
|
||||
|
||||
## basedpyright (backend)
|
||||
|
||||
@@ -121,8 +121,6 @@ project checks needs a row.
|
||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
|
||||
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
|
||||
| size / complexity | today's offenders as ceilings | `src/lib/sourceSize.contract.test.ts`, `backend/tests/test_source_size.py`: listed files and functions may not grow, nothing new may cross the limit, an entry back under it must be deleted. A function is held by its name, not its rank in the file. | files >800 lines: 13 FE / 9 BE; functions over the limit: 64 FE / 32 BE (E32 review) |
|
||||
| size / complexity | shipped code only — not tests, test kits, e2e or `scripts/` | The ratchet guards what runs in production. A test file is long because it is a list of cases, and a test kit is test code; splitting either moves lines without making anything easier to change. `scripts/` is release and seed tooling, not in the image. | outside it: tests >800 lines 2 FE / 5 BE; functions over the limit 2 FE test code (`gates.contract.test.ts` `eslintNarrowings` 23, E32 review round 3; `compilerSkips.testkit.ts` 25), 1 BE test (11), 1 script (`e2e_seed.py` `main` 13) |
|
||||
|
||||
## Accepted findings
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,8 +24,8 @@ BE_APP = ROOT / "backend" / "app"
|
||||
BE_TESTS = ROOT / "backend" / "tests"
|
||||
FE_SRC = ROOT / "frontend" / "src"
|
||||
BIG_FILE = 800
|
||||
BE_COMPLEXITY = 10 # ruff's C901 default, the backend ratchet's limit
|
||||
FE_COMPLEXITY = 20 # the frontend ratchet's limit
|
||||
BE_COMPLEXITY = 10 # ruff's C901 default
|
||||
FE_COMPLEXITY = 20 # the frontend's line (E32)
|
||||
|
||||
EPIC_CODE = re.compile(r"\b(E\d{2}|R\d{1,2})(\s?S\d+)?\b")
|
||||
NARRATION = re.compile(r"\b(used to|no longer)\b", re.IGNORECASE)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// one JSON object: {"limit": N, "over": count, "max": highest}. Shipped code only — tests and test
|
||||
// kits are lists of cases, not logic anyone has to change.
|
||||
//
|
||||
// node scripts/fe_complexity.mjs [limit] (default 20, the frontend's ratchet limit)
|
||||
// node scripts/fe_complexity.mjs [limit] (default 20, the frontend's line since E32)
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
|
||||
Reference in New Issue
Block a user