scripts/code_health measures the files a change touches on both sides (base revision and head) and fails when: - a function or file over target grows past its own value on the base (CC 20, 300 own lines, nesting 6, file 2,000 lines); - a new function or file is over target; - a pattern rule gains a fingerprint (rule + enclosing function + line text), so fixing one violation never buys another. Code that moves keeps its cap: functions match by name-independent body hash, pattern hits by (rule, line text) once their function has left the old place. Nested functions are their own units, so a closure edit never counts against the hook or function around it. Python CC is ruff's C901 minus the direct nested defs; TypeScript is parsed with the lockfile's typescript (no type check). Rules: swallowed exceptions (S110, BLE001, E722), correctness (F821/F823/ F811/F841, RUF006), hangs (ASYNC230/240, S113, subprocess/urlopen without timeout, sync config I/O in async def, get_event_loop, gather results checked against Exception), profile scope (hardcoded ~/.hermes, a new HERMES_* env var, UnscopedSecretError -> os.getenv, import-time capture, raw threading.Thread, P05/P06 from profile_scope_patterns.json), argv process identity, bool() of an env string, and if/elif ladders. Growth of a file already over 2,000 lines is advisory: replaying 300 merged PRs, a blocking file-size ratchet would have stopped 46 of them before the 119 over-target files are split. Everything else blocks; the same replay finds 18 PRs that would have needed a change, every finding a real hit for its rule. No baseline file: base is measured on the fly, so an improvement that lands on main is the new cap at once and parallel PRs never conflict on ratchet state. `# health: allow <RULE> -- <why>` is the reviewed escape.
125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
"""Git plumbing: which files changed between two trees, and their contents on either side.
|
|
|
|
A tree is a revision (``"abc123"``) or ``None`` for the working tree. Both sides are read
|
|
through the same functions so base and head are measured identically.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
def git(repo: Path, *args: str, check: bool = True) -> str:
|
|
proc = subprocess.run(
|
|
["git", *args],
|
|
cwd=repo,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
stdin=subprocess.DEVNULL,
|
|
timeout=120,
|
|
check=False,
|
|
)
|
|
if check and proc.returncode != 0:
|
|
raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
return proc.stdout
|
|
|
|
|
|
def repo_root(start: Path) -> Path:
|
|
return Path(git(start, "rev-parse", "--show-toplevel").strip())
|
|
|
|
|
|
def resolve_rev(repo: Path, rev: str) -> str:
|
|
return git(repo, "rev-parse", "--verify", f"{rev}^{{commit}}").strip()
|
|
|
|
|
|
def default_base(repo: Path) -> str:
|
|
"""Merge-base of HEAD with origin/main (local runs); CI passes ``--base`` explicitly."""
|
|
for ref in ("origin/main", "main"):
|
|
out = git(repo, "merge-base", "HEAD", ref, check=False).strip()
|
|
if out:
|
|
return out
|
|
raise RuntimeError("no merge-base with origin/main or main; pass --base")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Change:
|
|
status: str # A, M, D, R
|
|
old: str | None
|
|
new: str | None
|
|
|
|
|
|
def _parse_name_status(out: str) -> list[Change]:
|
|
fields = out.split("\0")
|
|
changes: list[Change] = []
|
|
i = 0
|
|
while i < len(fields) and fields[i]:
|
|
code = fields[i][0]
|
|
if code in "RC":
|
|
old, new = fields[i + 1], fields[i + 2]
|
|
changes.append(Change("R" if code == "R" else "A", old if code == "R" else None, new))
|
|
i += 3
|
|
continue
|
|
path = fields[i + 1]
|
|
if code == "D":
|
|
changes.append(Change("D", path, None))
|
|
elif code == "A":
|
|
changes.append(Change("A", None, path))
|
|
else:
|
|
changes.append(Change("M", path, path))
|
|
i += 2
|
|
return changes
|
|
|
|
|
|
def changed_files(repo: Path, base: str, head: str | None) -> list[Change]:
|
|
args = ["diff", "--name-status", "-M", "-z", "--no-color", base]
|
|
if head is not None:
|
|
args.append(head)
|
|
changes = _parse_name_status(git(repo, *args))
|
|
if head is None:
|
|
untracked = git(repo, "ls-files", "--others", "--exclude-standard", "-z")
|
|
changes.extend(Change("A", None, p) for p in untracked.split("\0") if p)
|
|
return changes
|
|
|
|
|
|
def read_file(repo: Path, tree: str | None, path: str) -> str | None:
|
|
if tree is None:
|
|
try:
|
|
return (repo / path).read_text(encoding="utf-8-sig", errors="replace")
|
|
except (FileNotFoundError, IsADirectoryError):
|
|
return None
|
|
proc = subprocess.run(
|
|
["git", "show", f"{tree}:{path}"],
|
|
cwd=repo,
|
|
capture_output=True,
|
|
stdin=subprocess.DEVNULL,
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
return None
|
|
return proc.stdout.decode("utf-8", errors="replace")
|
|
|
|
|
|
def tracked_files(repo: Path, tree: str | None) -> list[str]:
|
|
if tree is None:
|
|
return [p for p in git(repo, "ls-files", "-z").split("\0") if p]
|
|
return [p for p in git(repo, "ls-tree", "-r", "--name-only", "-z", tree).split("\0") if p]
|
|
|
|
|
|
def known_env_names(repo: Path, tree: str | None) -> set[str]:
|
|
"""Every HERMES_* literal in non-test Python of a tree (the HX002 allow set)."""
|
|
args = ["grep", "-h", "-o", "-I", "-E", r"HERMES_[A-Z0-9_]+"]
|
|
if tree is not None:
|
|
args.append(tree)
|
|
args += ["--", "*.py", ":!tests/**", ":!**/tests/**"]
|
|
out = git(repo, *args, check=False)
|
|
names = set()
|
|
for line in out.splitlines():
|
|
# `git grep <tree>` prefixes "<tree>:" even with -h on some versions.
|
|
names.add(line.rsplit(":", 1)[-1])
|
|
return names
|