- planCodes: the quote and slash lookarounds let the commonest form through (a docstring or test title opening with a code, a slash pair); the two real exceptions (an episode titled "E1", ruff's E4/E7/E9) are now named and cut first, with cases that fail on the old pattern. audit_metrics counts by the same definition. - button-primitive: both file lists live in the rule body, out of sight of the snapshot and the register; each is now pinned to its exact set of ui/ files (red when either is widened). - check.sh --brief shows a short log whole and keeps the line before each hit, so ruff's rule and eslint's file path survive the excerpt. - lint-changed runs prettier over every changed frontend file, as the gate does, not only code. - CLAUDE.md and gates.md no longer claim the basedpyright baseline was found harmless: it was never triaged, and the reportOptional* entries are the likeliest real bugs.
212 lines
8.3 KiB
Python
212 lines
8.3 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
|
|
|
|
# what planCodes.contract.test.ts holds the code to: an epic (`E28`, `E9.5`) or a roadmap item (`R14`),
|
|
# not `E2E`, not a TV episode (`S2 · E5`); NOT_CODES are its named exceptions, cut first (an episode
|
|
# titled "E1", ruff's default `E4/E7/E9`)
|
|
EPIC_CODE = re.compile(r"\b(E\d{1,2}(\.\d+)?|R\d{1,2})\b(?!E)")
|
|
EPISODE = re.compile(r"S\d{1,2}\s?·?\s?E\d+")
|
|
NOT_CODES = re.compile(r'"E\d{1,2}"|\bE4/E7/E9\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(NOT_CODES.sub("", EPISODE.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 {
|
|
"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 = {
|
|
# 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())
|