Files
hermes-agent/scripts/code_health/ruff_runner.py
teknium1 cf0755f947 fix(code-health): occurrence provenance, alias-aware rules, strict file cap (round 4)
Fourth review round (F11-F18, N1-N4, kvnloo, Adolanium):

- Hits are old only where the base occurrence survives: a line the comment- and
  whitespace-blind line diff keeps (blame semantics), or anything inside a unit whose
  body is unchanged. An identical violation moved past unrelated code is new; removing
  an allow comment or re-indenting is not. Hit fingerprints exclude comments (N4).
- Inline allows cover their own line; only a comment-only line covers the next (N3).
- Rules read a copy of the AST with import aliases resolved, so `sp.run`,
  `from subprocess import run as execute`, aliased threading/asyncio/load_config are
  seen (F12); deadline helpers must resolve to the real asyncio API, and
  communicate()/wait() are judged only on receivers holding a spawned process (F13);
  infinite deadlines are unbounded (F14).
- HERMES_* writes introduce a name too (N1); bool(os.environ[...]) is HX010 (N2);
  HX009 needs the isinstance subject to come from gather(return_exceptions=True).
- Recursive self-references are normalised out of body hashes, so a recursive rename
  keeps its cap, py and ts (F15). Decorators belong to their function's scope and
  classes are matchable units, so their hits follow proven moves (F16).
- An over-cap file may not grow (F17, Teknium: strict improvement-only).
- A file the checker cannot parse or measure (ast/ruff/TS parse error) fails.
- scripts/check: --staged always lints an index snapshot (ignored files are invisible
  to git status, not to guards) (F18); create the --prefix dir for Git for Windows
  (F11); --only builds just the selected jobs, so it needs no ruff for non-ruff checks.
- CODEOWNERS covers scripts/check + scripts/code_health; frontend-only PRs skip the
  Python-only lint jobs again and run only the ratchet.
2026-10-04 21:29:39 -07:00

106 lines
4.1 KiB
Python

"""Run the pinned ruff and split its output into per-function CC and pattern hits.
The pin lives in pyproject's ``dev`` dependency group (the version CI installs); a different
local ruff is never used silently, because rule behaviour and CC counts drift between releases.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from scripts.code_health.config import RUFF_CODES
_CC_MESSAGE = re.compile(r"is too complex \((\d+) >")
_CHUNK = 400
@dataclass
class RuffFile:
cc_by_line: dict[int, int] = field(default_factory=dict)
hits: list[tuple[str, int]] = field(default_factory=list)
# Ruff could not parse the file (code-less diagnostics): its CC and hits are missing.
errors: list[str] = field(default_factory=list)
def pinned_version(repo: Path) -> str:
data = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8-sig"))
for spec in data.get("dependency-groups", {}).get("dev", []):
if isinstance(spec, str) and spec.startswith("ruff=="):
return spec.split("==", 1)[1].strip()
raise RuntimeError("pyproject.toml [dependency-groups].dev has no ruff==<version> pin")
def _version_of(cmd: list[str]) -> str | None:
try:
out = subprocess.run(
[*cmd, "--version"], capture_output=True, text=True, timeout=60,
stdin=subprocess.DEVNULL, check=False,
).stdout
except (OSError, subprocess.TimeoutExpired):
return None
parts = out.split()
return parts[1] if len(parts) >= 2 and parts[0] == "ruff" else None
def resolve_ruff(repo: Path) -> list[str]:
"""Command prefix for the pinned ruff: a matching install, else ``uvx ruff@<pin>``."""
pin = pinned_version(repo)
candidates = [Path(sys.prefix) / "bin" / "ruff", Path(sys.prefix) / "Scripts" / "ruff.exe"]
on_path = shutil.which("ruff")
if on_path:
candidates.insert(0, Path(on_path))
for cand in candidates:
if cand.exists() and _version_of([str(cand)]) == pin:
return [str(cand)]
uvx = shutil.which("uvx")
if uvx and _version_of([uvx, f"ruff@{pin}"]) == pin:
return [uvx, f"ruff@{pin}"]
found = _version_of(["ruff"]) if on_path else None
raise RuntimeError(
f"code health needs ruff {pin} (pyproject dev group); found {found or 'none'}. "
f"Install it (`uv tool install ruff=={pin}`) or put uv on PATH so `uvx ruff@{pin}` works."
)
def run_ruff(ruff: list[str], root: Path, paths: list[str]) -> dict[str, RuffFile]:
"""Ruff over ``paths`` (relative to ``root``), keyed by the same relative path."""
results: dict[str, RuffFile] = {p: RuffFile() for p in paths}
select = ",".join(("C901", *RUFF_CODES))
for start in range(0, len(paths), _CHUNK):
chunk = paths[start : start + _CHUNK]
proc = subprocess.run(
[*ruff, "check", "--isolated", "--ignore-noqa", "--no-cache", "--preview",
"--target-version", "py311", "--select", select,
"--config", "lint.mccabe.max-complexity=0",
"--output-format", "json", "--exit-zero", *chunk],
cwd=root, capture_output=True, text=True, encoding="utf-8", timeout=600,
stdin=subprocess.DEVNULL, check=False,
)
if proc.returncode != 0:
raise RuntimeError(f"ruff failed: {proc.stderr.strip()[:2000]}")
for diag in json.loads(proc.stdout or "[]"):
_record(results, root, diag)
return results
def _record(results: dict[str, RuffFile], root: Path, diag: dict) -> None:
rel = Path(diag["filename"]).resolve().relative_to(root.resolve()).as_posix()
entry = results.setdefault(rel, RuffFile())
row = diag["location"]["row"]
code = diag.get("code")
if code == "C901":
match = _CC_MESSAGE.search(diag.get("message", ""))
if match:
entry.cc_by_line[row] = int(match.group(1))
elif code:
entry.hits.append((code, row))
else:
entry.errors.append(f"line {row}: {diag.get('message', 'syntax error')}")