The sprint alternative matched only `(S6)` or `S3e2`, so the commonest purged form (`# S6:`, `S3's`, `"""S6 —`, `S1 justified`) and every phase code (`P1`) would come back unflagged; the phase codes had in fact survived the purge in 16 lines (Plex client sections, the upload's header shape), now reworded around them. One pattern now covers epic, roadmap, sprint and phase codes; the named exceptions are TV episode notation, a fixture's season or episode title (`title="S1"`) and ruff's `E4/E7/E9`, and a ruff rule like `S110` does not match. The case test lists the purged forms and no longer claims every form. audit_metrics copies the definition (ASCII, as JavaScript's \b is) and reports it as plan_code_lines / plan_code_files.
209 lines
8.4 KiB
Python
209 lines
8.4 KiB
Python
"""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
|
|
FE_COMPLEXITY = 20 # the frontend's line
|
|
|
|
# planCodes.contract.test.ts's definition, copied: an epic, roadmap item, sprint or phase code, or a
|
|
# review round; NOT_CODES are its named exceptions, cut first (TV episode notation, a fixture's
|
|
# season or episode title, ruff's default `E4/E7/E9`). ASCII, because JavaScript's `\b` is.
|
|
NOT_CODES = re.compile(r'\bS\d{1,2} ?·? ?E\d+|\btitle[=:] ?"[ES]\d{1,2}"|\bE4/E7/E9\b', re.ASCII)
|
|
PLAN_CODE = re.compile(
|
|
r"\b(?:E\d{1,2}(?:\.\d+)?|R\d{1,2}|S(?!3h\b)\d{1,2}(?:[a-z]\d?)?|P\d)\b|\b[Rr]ound[ -]\d|deep-review",
|
|
re.ASCII,
|
|
)
|
|
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
|
|
plan = [(p, ln) for p, ls in every for ln in ls if PLAN_CODE.search(NOT_CODES.sub("", ln))]
|
|
be_c = sum(_comment_lines(ls, True) for _, ls in be)
|
|
fe_c = sum(_comment_lines(ls, False) for _, ls in fe)
|
|
return {
|
|
"plan_code_lines": len(plan),
|
|
"plan_code_files": len({p for p, _ in plan}),
|
|
"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 = {
|
|
# the word removedFeatures.contract.test.ts bans: not "demote", not "Demokratikus"
|
|
"demo_files": re.compile(r"demo(?!t(e|ion)|kr)", re.IGNORECASE),
|
|
"chat_files": re.compile(r"\bchat\b|\bmessages?\.py|\bChat[A-Z]|message_keys"),
|
|
# the public form, not the admin's access-request queue (that stays)
|
|
"access_request_form_files": re.compile(r"request[-_]access|requestAccess|AccessRequestIn"),
|
|
}
|
|
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())
|