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.
106 lines
4.1 KiB
Python
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')}")
|