Second-round review on #132646 (F1-F10), each reproduced on 2531fb2 first: - Moves are matched one-to-one: unchanged units are reserved first, then same-file body, then cross-file body only when the origin's name is gone, then same name. A copy of unchanged debt is new debt; a function moved over a same-name stub keeps its own cap (F1, F3). - Every base hit is owned by exactly one head scope, so a file rename no longer counts each old hit twice (F2). - Ruff runs when either side has Python, so a delete-only .py diff with a TS edit no longer crashes on a missing ruff result (F4). - Deadlines must be finite: timeout=None, **{} and asyncio.timeout(None) are no deadline; awaited calls inside `async with asyncio.timeout(x)` are bounded (F5, F8). - HX005 walks only what runs at import: the import-time branch of a `__name__` guard (either operator), loops, match, try*, decorators, class bases; a lambda value is deferred (F9). - `health: allow` counts only in real comment tokens (Python tokenize, TS comment trivia), never in string literals (F7). - TS function lines are owned by the innermost function, so siblings on one line give it up once (F10). - The pre-commit hook runs `scripts/check --staged`: health compares the index tree, and when the working tree differs from the index the other checkers run on a checkout-index copy with a copied index, so commits are judged on the staged bytes and unstaged work is never touched (F6).
95 lines
3.8 KiB
Python
95 lines
3.8 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 commit or tree (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
|
|
# Base files are measured too: a deleted .py still needs ruff on the base side.
|
|
needs_ruff = any(p.endswith(".py") for p in (*head_paths, *base_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_tree(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
|