feat(tooling): audit_metrics.py — size, complexity, gate weight and a speed baseline
This commit is contained in:
@@ -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/
|
||||
|
||||
+13
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -15,6 +15,6 @@
|
||||
],
|
||||
"playwright": {
|
||||
"config": ["playwright.config.ts"],
|
||||
"entry": ["e2e/**/*.@(spec|setup).ts"],
|
||||
"entry": ["e2e/**/*.@(spec|setup|measure).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"],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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())
|
||||
@@ -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 }));
|
||||
Reference in New Issue
Block a user