From 751e1ae5edf393383dc7ac4c4203fbbbf84621ee Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 17:18:53 +0200 Subject: [PATCH] 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. --- .dockerignore | 1 + .gitignore | 1 + backend/docker-compose.test.yml | 7 +- backend/requirements-dev.txt | 3 + backend/tests/test_source_size.py | 139 +++++++++++++++++ frontend/package-lock.json | 148 +++++++++++++++++++ frontend/package.json | 1 + frontend/src/lib/sourceSize.contract.test.ts | 134 +++++++++++++++++ frontend/src/lib/sourceSize.testkit.ts | 63 ++++++++ frontend/vitest.config.ts | 8 + 10 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_source_size.py create mode 100644 frontend/src/lib/sourceSize.contract.test.ts create mode 100644 frontend/src/lib/sourceSize.testkit.ts diff --git a/.dockerignore b/.dockerignore index f9bc435f..5ae987fb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ *.env.local **/__pycache__/ **/*.pyc +**/.coverage .venv/ venv/ # Docker patterns are anchored at the context root with NO implicit `**/` (unlike .gitignore, where diff --git a/.gitignore b/.gitignore index d5349a1d..d034f201 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ venv/ .mypy_cache/ .pytest_cache/ +.coverage .ruff_cache/ # Node / frontend diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index 95d6ee95..4ce8ae39 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -35,4 +35,9 @@ services: volumes: - ./:/app working_dir: /app - command: python -m pytest + # The suite, then the coverage total (E32 D4: reported, no threshold). pytest's exit code is the + # lane's — the report is printed either way and never decides it. + command: + - sh + - -c + - 'python -m pytest --cov=app --cov-report= ; rc=$$?; echo ">> backend coverage (app/) $$(python -m coverage report --format=total)% of lines"; exit $$rc' diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 311a35bd..b82f6104 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -7,7 +7,10 @@ # basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type # checker whose version floats silently changes what "clean" means, and the ratchet baseline it # writes is keyed to the diagnostics THIS version produces. +# pytest-cov: the coverage REPORT the gate prints (E32 D4) — a number to read, deliberately with no +# threshold; "every regression test seen red" is the bar, not a percentage. basedpyright==1.40.1 pytest==8.4.2 +pytest-cov==7.1.0 ruff==0.15.21 httpx2==2.13.0 diff --git a/backend/tests/test_source_size.py b/backend/tests/test_source_size.py new file mode 100644 index 00000000..467f3e1e --- /dev/null +++ b/backend/tests/test_source_size.py @@ -0,0 +1,139 @@ +"""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) — per module, the + offenders' values highest-first may not rise, and no new offender may appear. +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": 1607, + "app/plex/stream.py": 1128, + "app/plex/subtitles.py": 1092, + "app/plex/sync.py": 841, + "app/routes/admin.py": 864, + "app/routes/auth.py": 1353, + "app/routes/downloads.py": 1237, + "app/sync/playlists.py": 843, +} + +COMPLEX: dict[str, list[int]] = { + "app/bodylimit.py": [11], + "app/discover/hero.py": [14], + "app/discover/pins.py": [12], + "app/downloads/edit.py": [16, 15], + "app/downloads/formats.py": [11], + "app/downloads/gc.py": [15, 11], + "app/downloads/og.py": [12, 11], + "app/downloads/pipeline.py": [14, 12], + "app/feedquery.py": [28, 14], + "app/plex/query.py": [13], + "app/plex/stream.py": [14], + "app/plex/subtitles.py": [12], + "app/plex/sync.py": [13], + "app/plex/tmdb.py": [11], + "app/plex/watch_sync.py": [12], + "app/routes/auth.py": [19], + "app/routes/debug_sessions.py": [12], + "app/routes/downloads.py": [11], + "app/routes/plex/browse.py": [25, 18, 13], + "app/routes/plex/detail.py": [13], + "app/routes/search.py": [15], + "app/sync/playlists.py": [19], + "app/sync/runner.py": [13], + "app/sync/tagsuggest.py": [14], + "app/worker.py": [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, list[int]]: + 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, list[int]] = {} + for m in json.loads(out): + value = re.search(r"\((\d+) > \d+\)", m["message"]) + assert value, m["message"] + rel = Path(m["filename"]).resolve().relative_to(BACKEND).as_posix() + found.setdefault(rel, []).append(int(value.group(1))) + return {f: sorted(v, reverse=True) for f, v in found.items()} + + +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 = [] + for f, values in _complexity().items(): + ceiling = COMPLEX.get(f, []) + for i, v in enumerate(values): + cap = ceiling[i] if i < len(ceiling) else MAX_COMPLEXITY + if v > cap: + worse.append(f"{f}: #{i + 1} is {v} (ceiling {cap})") + assert worse == [] + + +def test_lists_no_offender_that_is_back_under_the_limit(): + actual = _complexity() + stale = [ + f"{f}: listed {len(v)}, now {len(actual.get(f, []))}" + for f, v in COMPLEX.items() + if len(actual.get(f, [])) < len(v) + ] + assert stale == [] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4dbaadcb..38982ff5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "4.1.11", "@vitest/eslint-plugin": "1.6.27", "babel-plugin-react-compiler": "1.0.0", "eslint": "9.39.1", @@ -380,6 +381,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -3313,6 +3324,37 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/eslint-plugin": { "version": "1.6.27", "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz", @@ -3562,6 +3604,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4679,6 +4740,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4826,6 +4894,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -5353,6 +5460,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 97286735..aba641ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "4.1.11", "@vitest/eslint-plugin": "1.6.27", "babel-plugin-react-compiler": "1.0.0", "eslint": "9.39.1", diff --git a/frontend/src/lib/sourceSize.contract.test.ts b/frontend/src/lib/sourceSize.contract.test.ts new file mode 100644 index 00000000..b6cd8146 --- /dev/null +++ b/frontend/src/lib/sourceSize.contract.test.ts @@ -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 = { + "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 = { + "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([]); + }); +}); diff --git a/frontend/src/lib/sourceSize.testkit.ts b/frontend/src/lib/sourceSize.testkit.ts new file mode 100644 index 00000000..a9f70bde --- /dev/null +++ b/frontend/src/lib/sourceSize.testkit.ts @@ -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 { + const out: Record = {}; + 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> { + 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 = {}; + 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; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index c8ce1502..f7fc3212 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -19,5 +19,13 @@ export default defineConfig({ // collected, and the gate still prints green. include: ["src/**/*.test.{ts,tsx}"], environment: "node", + // E32 D4: a coverage REPORT, read and not enforced — the gate's `vitest --coverage` prints the + // summary; no threshold, because "every regression test seen red" is the bar, not a percentage. + coverage: { + provider: "v8", + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.test.{ts,tsx}", "src/**/*.testkit.ts", "src/**/*.d.ts"], + reporter: ["text-summary"], + }, }, });