feat(tooling): audit_metrics.py — size, complexity, gate weight and a speed baseline

This commit is contained in:
2026-10-04 20:09:58 +02:00
parent 11d35910fd
commit c2bf8fb7f6
7 changed files with 399 additions and 4 deletions
+1
View File
@@ -42,3 +42,4 @@ Thumbs.db
# TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane # 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. # reads or regenerates them, so a committed one is stale from the commit that adds it.
*.tsbuildinfo *.tsbuildinfo
frontend/e2e/.speed/
+13 -3
View File
@@ -16,10 +16,10 @@ next. A gate that is green on a major you do not ship is green about nothing.
## Run it locally ## 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 ```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 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
bash scripts/check.sh # every lane 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 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` 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 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 NOT check — and why — is the [gate register](gates.md). There is no CI: the gate runs here, and a
+128
View File
@@ -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<string, number | string | null> = {
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<number>((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");
});
+1 -1
View File
@@ -15,6 +15,6 @@
], ],
"playwright": { "playwright": {
"config": ["playwright.config.ts"], "config": ["playwright.config.ts"],
"entry": ["e2e/**/*.@(spec|setup).ts"], "entry": ["e2e/**/*.@(spec|setup|measure).ts"],
}, },
} }
+12
View File
@@ -61,5 +61,17 @@ export default defineConfig({
use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE }, use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE },
dependencies: ["setup"], 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"],
},
]
: []),
], ],
}); });
+199
View File
@@ -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())
+45
View File
@@ -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 }));