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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ __pycache__/
|
||||
venv/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.ruff_cache/
|
||||
|
||||
# Node / frontend
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
Generated
+148
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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": [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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
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<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 } },
|
||||
},
|
||||
rules: { complexity: ["error", MAX_COMPLEXITY] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out: Record<string, number[]> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user