From c2bf8fb7f6621b6128aba716fd5bb0e40fc15026 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 20:09:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(tooling):=20audit=5Fmetrics.py=20=E2=80=94?= =?UTF-8?q?=20size,=20complexity,=20gate=20weight=20and=20a=20speed=20base?= =?UTF-8?q?line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/development.md | 16 ++- frontend/e2e/speed.measure.ts | 128 ++++++++++++++++++++++ frontend/knip.jsonc | 2 +- frontend/playwright.config.ts | 12 ++ scripts/audit_metrics.py | 199 ++++++++++++++++++++++++++++++++++ scripts/fe_complexity.mjs | 45 ++++++++ 7 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 frontend/e2e/speed.measure.ts create mode 100644 scripts/audit_metrics.py create mode 100644 scripts/fe_complexity.mjs diff --git a/.gitignore b/.gitignore index d034f201..04718ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ Thumbs.db # TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane # reads or regenerates them, so a committed one is stale from the commit that adds it. *.tsbuildinfo +frontend/e2e/.speed/ diff --git a/docs/development.md b/docs/development.md index 03b8cfae..e23464b1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,10 +16,10 @@ next. A gate that is green on a major you do not ship is green about nothing. ## Run it locally -The whole stack (Postgres + API + worker) comes up from the localdev compose file: +The whole stack (Postgres + API + worker) comes up from the base compose file plus the localdev override: ```bash -docker compose -f docker-compose.localdev.yml up --build +docker compose -f docker-compose.yml -f docker-compose.localdev.yml up --build ``` Browse at **http://localhost:8080** (Google login works — the OAuth redirect is @@ -43,9 +43,19 @@ A change is done when this is green, from the repo root: ```bash bash scripts/check.sh # every lane bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop +bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one +bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only ``` -It runs every lane even after one fails and names each red one at the end. What it runs, and why +It runs every lane even after one fails and names each red one at the end. `lint-changed.sh` is a +convenience, not the gate; with `--hook` it doubles as a Claude Code Stop hook (silent when clean, +the findings handed back otherwise). + +The codebase's health numbers — size, complexity, test ratio, gate weight, comment noise — come from +`python scripts/audit_metrics.py` (seconds; it reports, it never fails). `--speed` adds the speed +baseline (`frontend/e2e/speed.measure.ts`: the feed's first card and a shared `/watch` link's +time-to-play against the :8080 stack, with `E2E_PASSWORD` set). An epic that changes structure +compares its before and after. What it runs, and why each lane runs where it does, is in the script's comments; `frontend/src/lib/gates.contract.test.ts` holds its lines, so a lane dropped or narrowed there fails a test. What each gate deliberately does NOT check — and why — is the [gate register](gates.md). There is no CI: the gate runs here, and a diff --git a/frontend/e2e/speed.measure.ts b/frontend/e2e/speed.measure.ts new file mode 100644 index 00000000..f723f0cd --- /dev/null +++ b/frontend/e2e/speed.measure.ts @@ -0,0 +1,128 @@ +import { test, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; + +/** + * The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which + * runs this as the `speed` project (never part of the e2e suite: its file name is not a `.spec`). + * Each reading is the median of RUNS cold page loads (a fresh browser context, so no HTTP cache), + * against the localdev stack and the seeded E2E account. It is a baseline for before/after within + * one machine, not an absolute figure: the same measurement on a phone over the internet is slower. + * + * Not measured here: the in-app players (the E2E account owns no download, Plex item or watch + * history to open), and any iOS reading (Playwright's WebKit decodes media through the host OS — + * see playwright.config.ts) — the owner's phone gives that one by hand. + */ + +const RUNS = 5; +const OUT = "e2e/.speed/latest.json"; +const CONTAINER = process.env.E2E_DB_CONTAINER || "siftlode-db-1"; + +const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? NaN; + +/** JavaScript the page pulled in, uncompressed: the same on every stack (localdev serves it raw, + * prod gzips it at the proxy), and what the browser has to parse. */ +const scriptBytes = (page: Page) => + page.evaluate(() => + (performance.getEntriesByType("resource") as PerformanceResourceTiming[]) + .filter((r) => r.initiatorType === "script" || r.name.endsWith(".js")) + .reduce((sum, r) => sum + r.decodedBodySize, 0), + ); + +/** Public share links on the local stack that could play: no password, not expired, asset ready. + * The page itself is the judge of the last step (its file must be on THIS disk — a localdev DB + * seeded from prod lists assets whose files never came along), so the caller probes them. */ +function shareTokens(): string[] { + const sql = + "select l.token from download_links l join download_jobs j on j.id = l.job_id" + + " join media_assets a on a.id = j.asset_id" + + " where l.password_hash is null and (l.expires_at is null or l.expires_at > now())" + + " and a.status = 'ready' order by l.id"; + try { + const out = execFileSync( + "docker", + ["exec", CONTAINER, "psql", "-U", "siftlode", "-d", "siftlode", "-tAc", sql], + { encoding: "utf8" }, + ); + return out + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +test("speed baseline", async ({ browser, page: page0 }) => { + test.setTimeout(180_000); + const result: Record = { + measured_at: new Date().toISOString(), + runs: RUNS, + }; + + // Feed: navigation start → the first video card on screen. + const feedMs: number[] = []; + let feedJs = 0; + for (let i = 0; i < RUNS; i++) { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("/"); + // Timed inside the page, every frame: an `expect` would read the clock on its own retry + // schedule (100 ms … 1 s steps), not when the card appeared. + const at = await page.waitForFunction( + () => document.querySelector('[data-testid="video-card"]') && performance.now(), + null, + { polling: "raf", timeout: 30_000 }, + ); + feedMs.push((await at.jsonValue()) as number); + feedJs = await scriptBytes(page); + await ctx.close(); + } + result.feed_first_card_ms = Math.round(median(feedMs)); + result.feed_js_raw_bytes = feedJs; + + // /watch: navigation start → the shared video playing (started muted by script, standing in for + // the visitor's tap — the page has no autoplay). + let token: string | null = null; + for (const t of shareTokens()) { + if ((await page0.request.get(`/api/public/watch/${t}`)).ok()) { + token = t; + break; + } + } + if (token) { + const watchMs: number[] = []; + let watchJs = 0; + for (let i = 0; i < RUNS; i++) { + const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const page = await ctx.newPage(); + await page.goto(`/watch/${token}`); + await page.waitForFunction(() => document.querySelector("video, audio"), null, { + polling: "raf", + timeout: 30_000, + }); + const video = page.locator("video, audio").first(); + watchMs.push( + await video.evaluate( + (el: HTMLMediaElement) => + new Promise((resolve, reject) => { + el.muted = true; + el.addEventListener("playing", () => resolve(performance.now()), { once: true }); + el.play().catch(reject); + }), + ), + ); + watchJs = await scriptBytes(page); + await ctx.close(); + } + result.watch_time_to_play_ms = Math.round(median(watchMs)); + result.watch_js_raw_bytes = watchJs; + } else { + result.watch_time_to_play_ms = null; + result.watch_js_raw_bytes = null; + result.watch_note = "no playable share link on the local stack"; + } + + mkdirSync("e2e/.speed", { recursive: true }); + writeFileSync(OUT, JSON.stringify(result, null, 2) + "\n"); +}); diff --git a/frontend/knip.jsonc b/frontend/knip.jsonc index 7fa7cc10..e3ac4ab9 100644 --- a/frontend/knip.jsonc +++ b/frontend/knip.jsonc @@ -15,6 +15,6 @@ ], "playwright": { "config": ["playwright.config.ts"], - "entry": ["e2e/**/*.@(spec|setup).ts"], + "entry": ["e2e/**/*.@(spec|setup|measure).ts"], }, } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 45b26084..922b061f 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -61,5 +61,17 @@ export default defineConfig({ use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE }, dependencies: ["setup"], }, + // The speed baseline (e2e/speed.measure.ts): readings, not tests. Only when PW_SPEED is set — + // `scripts/audit_metrics.py --speed` sets it — so the e2e suite never runs it. + ...(process.env.PW_SPEED + ? [ + { + name: "speed", + testMatch: /\.measure\.ts/, + use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE }, + dependencies: ["setup"], + }, + ] + : []), ], }); diff --git a/scripts/audit_metrics.py b/scripts/audit_metrics.py new file mode 100644 index 00000000..f34129ca --- /dev/null +++ b/scripts/audit_metrics.py @@ -0,0 +1,199 @@ +"""The codebase's health numbers in one deterministic run: size, complexity, tests, governance +weight, comment noise and the footprint of features marked for removal. It REPORTS, it never fails +— what used to be a gate (the size/complexity ratchet) is read here instead, and an epic compares +its before and after. + + python scripts/audit_metrics.py the static numbers (seconds), as JSON on stdout + python scripts/audit_metrics.py --speed plus the speed baseline: frontend/e2e/speed.measure.ts + against the localdev stack (E2E_PASSWORD must be set; + the ops wrapper exports it) + +The readings are counts of files and lines, so a number moves only when the code does. +""" + +import ast +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +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 + +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) + + +def _is_fe_test(p: Path) -> bool: + return bool(re.search(r"\.(test|testkit)\.tsx?$", p.name)) + + +def _files(root: Path, pattern: str) -> list[Path]: + return sorted(p for p in root.rglob(pattern) if "__pycache__" not in p.parts) + + +def _lines(paths: list[Path]) -> list[tuple[Path, list[str]]]: + return [(p, p.read_text(encoding="utf-8").splitlines()) for p in paths] + + +def _comment_lines(lines: list[str], python: bool) -> int: + """Comment lines; for Python the docstrings count too — this codebase writes its "why" there.""" + if not python: + return sum(1 for line in lines if line.lstrip().startswith(("//", "/*", "*"))) + hashes = sum(1 for line in lines if line.lstrip().startswith("#")) + docs = 0 + for node in ast.walk(ast.parse("\n".join(lines))): + body = getattr(node, "body", None) + if not isinstance(body, list) or not body: + continue + first = body[0] + if ( + isinstance(first, ast.Expr) + and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str) + ): + docs += (first.end_lineno or first.lineno) - first.lineno + 1 + return hashes + docs + + +def _size(be: list[tuple[Path, list[str]]], fe: list[tuple[Path, list[str]]]) -> dict[str, object]: + be_tests = _lines(_files(BE_TESTS, "*.py")) + fe_tests = _lines([p for p in _files(FE_SRC, "*.ts*") if _is_fe_test(p)]) + be_n = sum(len(ls) for _, ls in be) + fe_n = sum(len(ls) for _, ls in fe) + return { + "backend_lines": be_n, + "frontend_lines": fe_n, + "backend_test_ratio": round(sum(len(ls) for _, ls in be_tests) / be_n, 2), + "frontend_test_ratio": round(sum(len(ls) for _, ls in fe_tests) / fe_n, 2), + "files_over_800": sum(1 for _, ls in be + fe if len(ls) > BIG_FILE), + "largest_file": max(((len(ls), p) for p, ls in be + fe), key=lambda t: t[0])[1] + .relative_to(ROOT) + .as_posix(), + "tables": len(re.findall(r'__tablename__ = "', (BE_APP / "models.py").read_text("utf-8"))), + } + + +def _complexity() -> dict[str, object]: + ruff = subprocess.run( + [sys.executable, "-m", "ruff", "check", "--config", "backend/ruff.toml", "--select", "C901"] + + ["--output-format", "json", "--exit-zero", "backend/app"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + found = [ + int(m) for d in json.loads(ruff.stdout) for m in re.findall(r"\((\d+) > ", d["message"]) + ] + fe = subprocess.run( + ["node", "scripts/fe_complexity.mjs", str(FE_COMPLEXITY)], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + fe_json = json.loads(fe.stdout) + return { + f"backend_functions_over_{BE_COMPLEXITY}": len(found), + "backend_max_complexity": max(found, default=0), + f"frontend_functions_over_{FE_COMPLEXITY}": fe_json["over"], + "frontend_max_complexity": fe_json["max"], + } + + +def _noise(be: list[tuple[Path, list[str]]], fe: list[tuple[Path, list[str]]]) -> dict[str, object]: + every = be + fe + epic = [(p, ln) for p, ls in every for ln in ls if EPIC_CODE.search(ln)] + be_c = sum(_comment_lines(ls, True) for _, ls in be) + fe_c = sum(_comment_lines(ls, False) for _, ls in fe) + return { + "epic_code_lines": len(epic), + "epic_code_files": len({p for p, _ in epic}), + "history_narration_lines": sum(1 for _, ls in every for ln in ls if NARRATION.search(ln)), + "backend_comment_share": round(be_c / sum(len(ls) for _, ls in be), 3), + "frontend_comment_share": round(fe_c / sum(len(ls) for _, ls in fe), 3), + } + + +def _governance() -> dict[str, object]: + eslint = (ROOT / "frontend" / "eslint.config.js").read_text("utf-8") + check = (ROOT / "scripts" / "check.sh").read_text("utf-8") + contract = [p for p in _files(FE_SRC, "*.contract.test.ts*")] + return { + "gate_lanes": len(re.findall(r'^\s*lane "', check, re.MULTILINE)), + "local_eslint_rules": len(set(re.findall(r'"local/([a-z0-9-]+)"', eslint))), + "frontend_contract_tests": len(contract), + "gates_snapshot_lines": len( + (FE_SRC / "lib" / "gates.snapshot.json").read_text("utf-8").splitlines() + ), + "claude_md_lines": len((ROOT / "CLAUDE.md").read_text("utf-8").splitlines()), + "gates_md_lines": len((ROOT / "docs" / "gates.md").read_text("utf-8").splitlines()), + } + + +def _footprint( + be: list[tuple[Path, list[str]]], fe: list[tuple[Path, list[str]]] +) -> dict[str, int]: + """Files that still mention a feature marked for removal: 0 once it is gone.""" + patterns = { + "demo_files": re.compile(r"\bdemo|isDemo|is_demo", re.IGNORECASE), + "chat_files": re.compile(r"\bchat\b|\bmessages?\.py|\bChat[A-Z]|message_keys"), + "access_request_files": re.compile(r"access[ _-]?request|AccessRequest", re.IGNORECASE), + } + return { + k: sum(1 for _, ls in be + fe if any(rx.search(ln) for ln in ls)) + for k, rx in patterns.items() + } + + +def _speed() -> dict[str, object]: + env = {**os.environ, "PW_SPEED": "1"} + out = ROOT / "frontend" / "e2e" / ".speed" / "latest.json" + out.unlink(missing_ok=True) # never report an earlier run's readings + run = subprocess.run( + ["npx", "playwright", "test", "--project=speed", "--reporter=line"], + cwd=ROOT / "frontend", + env=env, + capture_output=True, + text=True, + encoding="utf-8", + shell=os.name == "nt", # npx is a .cmd on Windows + check=False, # a failed run is reported in the result, not raised + ) + if run.returncode != 0 or not out.exists(): + lines = [ln.strip() for ln in (run.stdout + run.stderr).splitlines() if ln.strip()] + first_error = next( + (i for i, ln in enumerate(lines) if "Error" in ln), max(0, len(lines) - 15) + ) + return {"error": "the speed run failed", "output": lines[first_error : first_error + 15]} + return json.loads(out.read_text("utf-8")) + + +def main() -> int: + be = _lines(_files(BE_APP, "*.py")) + fe = _lines( + [p for p in _files(FE_SRC, "*.ts*") if not _is_fe_test(p) and not p.name.endswith(".d.ts")] + ) + report: dict[str, object] = { + "size": _size(be, fe), + "complexity": _complexity(), + "noise": _noise(be, fe), + "governance": _governance(), + "footprint": _footprint(be, fe), + } + if "--speed" in sys.argv[1:]: + report["speed"] = _speed() + sys.stdout.write(json.dumps(report, indent=2) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fe_complexity.mjs b/scripts/fe_complexity.mjs new file mode 100644 index 00000000..64f8a806 --- /dev/null +++ b/scripts/fe_complexity.mjs @@ -0,0 +1,45 @@ +// Frontend functions over a cyclomatic-complexity limit, counted for scripts/audit_metrics.py: a bare +// `complexity` rule run (no type info, no other rules), so it takes seconds, not a full lint. Prints +// 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) +import { readdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SRC = fileURLToPath(new URL("../frontend/src/", import.meta.url)); +const require = createRequire(new URL("../frontend/package.json", import.meta.url)); +const { Linter } = require("eslint"); +const tseslint = require("typescript-eslint"); + +const limit = Number(process.argv[2] ?? 20); +const files = readdirSync(SRC, { recursive: true }) + .map((p) => p.split("\\").join("/")) + .filter( + (p) => /\.(ts|tsx)$/.test(p) && !/\.(test|testkit)\.tsx?$/.test(p) && !p.endsWith(".d.ts"), + ); + +const linter = new Linter(); +const config = [ + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tseslint.parser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + rules: { complexity: ["error", limit] }, + }, +]; +let over = 0; +let max = 0; +for (const f of files) { + for (const m of linter.verify(readFileSync(join(SRC, f), "utf8"), config, f)) { + const n = Number(/complexity of (\d+)/.exec(m.message)?.[1]); + if (!n) continue; + over += 1; + max = Math.max(max, n); + } +} +console.log(JSON.stringify({ limit, over, max }));