Files
hermes-agent/scripts/code_health/cli.py
teknium1 4156715960 feat: code-health ratchet with per-unit caps for Python and TypeScript
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.
2026-10-04 21:29:39 -07:00

94 lines
3.7 KiB
Python

"""``python -m scripts.code_health`` / ``scripts/check``: the code-health ratchet."""
from __future__ import annotations
import argparse
import json
import sys
import time
from dataclasses import asdict
from pathlib import Path
from scripts.code_health import gitio
from scripts.code_health.compare import compare
from scripts.code_health.config import in_scope
from scripts.code_health.measure import Measurer
from scripts.code_health.report import (
apply_allows,
format_findings,
summarize_tree,
verdict,
)
from scripts.code_health.ruff_runner import pinned_version, resolve_ruff
_FOOTER = """
Each function and file has its own cap: its value on main if it is already over target,
otherwise the target. New code meets the target; existing code may only go down. Fix the code
(the `fix:` line is the repo's remedy); a genuine exception takes a reviewed comment on the line,
`# health: allow <RULE> -- <why>`. Rules and targets: scripts/code_health/config.py."""
def _parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="code_health", description=__doc__)
p.add_argument("--base", help="base revision (default: merge-base of HEAD with origin/main)")
p.add_argument("--head", help="head revision (default: the working tree, untracked included)")
p.add_argument("--report", action="store_true", help="burn-down summary of the whole head tree")
p.add_argument("--json", action="store_true", help="machine-readable findings")
p.add_argument("--print-pins", action="store_true",
help="print the pinned ruff requirement (CI installs exactly this)")
return p
def _report(repo: Path, head: str | None) -> int:
paths = [p for p in gitio.tracked_files(repo, head) if in_scope(p)]
measurer = Measurer(repo, resolve_ruff(repo), known_env=gitio.known_env_names(repo, head))
print(summarize_tree(measurer.measure(head, paths)))
return 0
def run(repo: Path, base: str, head: str | None, as_json: bool = False) -> int:
started = time.monotonic()
changes = gitio.changed_files(repo, base, head)
head_paths = sorted({c.new for c in changes if c.new and in_scope(c.new)})
base_paths = sorted({c.old for c in changes if c.old and in_scope(c.old)})
if not head_paths:
print("code health: no measured files changed")
return 0
needs_ruff = any(p.endswith(".py") for p in head_paths)
ruff = resolve_ruff(repo) if needs_ruff else []
measurer = Measurer(repo, ruff, known_env=gitio.known_env_names(repo, base))
base_m = measurer.measure(base, base_paths)
head_m = measurer.measure(head, head_paths)
findings = compare(base_m, head_m, changes)
apply_allows(findings, head_m)
blocking, advisory = verdict(findings)
if as_json:
print(json.dumps([asdict(f) for f in findings], indent=2))
return 1 if blocking else 0
body = format_findings(findings)
if body:
print(body)
elapsed = time.monotonic() - started
print(f"\ncode health: {len(head_paths)} files vs {base[:12]}: {blocking} blocking, "
f"{advisory} advisory ({elapsed:.1f}s)")
if blocking:
print(_FOOTER)
return 1 if blocking else 0
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
repo = gitio.repo_root(Path.cwd())
if args.print_pins:
print(f"ruff=={pinned_version(repo)}")
return 0
head = gitio.resolve_rev(repo, args.head) if args.head else None
try:
if args.report:
return _report(repo, head)
base = gitio.resolve_rev(repo, args.base) if args.base else gitio.default_base(repo)
return run(repo, base, head, as_json=args.json)
except RuntimeError as exc:
print(f"code health: {exc}", file=sys.stderr)
return 2