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).
167 lines
7.1 KiB
Python
Executable File
167 lines
7.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Run every blocking lint check CI runs, locally, with the same pinned tools.
|
|
|
|
python scripts/check # everything, against the merge-base with origin/main
|
|
python scripts/check --only health # just the code-health ratchet
|
|
python scripts/check --staged # judge the index (the pre-commit hook runs this)
|
|
python scripts/check --install-hook # run it as the git pre-commit hook
|
|
|
|
CI runs the same command (``.github/workflows/lint.yml``), so a green local run means a green
|
|
lint lane. The pre-commit hook is skippable (``git commit --no-verify``); CI is not.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Iterator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import ExitStack, contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO))
|
|
|
|
from scripts.code_health import gitio # noqa: E402
|
|
from scripts.code_health.ruff_runner import resolve_ruff # noqa: E402
|
|
|
|
_HOOK_MARK = "# hermes-agent scripts/check pre-commit hook"
|
|
_HOOK = f"""#!/usr/bin/env bash
|
|
{_HOOK_MARK} (installed by `scripts/check --install-hook`; skip once with --no-verify)
|
|
repo="$(git rev-parse --show-toplevel)"
|
|
# Hooks are shared by every worktree; a branch that predates scripts/check commits unchecked.
|
|
[ -f "$repo/scripts/check" ] || exit 0
|
|
py=python3
|
|
command -v python3 >/dev/null 2>&1 || py=python
|
|
exec "$py" "$repo/scripts/check" --staged
|
|
"""
|
|
|
|
# name -> argv (relative to the repo root); mirrors the blocking steps in lint.yml.
|
|
_GUARDS = {
|
|
"footguns": ["scripts/check-windows-footguns.py", "--all"],
|
|
"shebangs": ["scripts/check_bash_shebangs.py"],
|
|
"no-tmp": ["scripts/check_no_tmp_literals.py"],
|
|
"config-writers": ["scripts/check_config_yaml_writers.py"],
|
|
"os-marker-fakes": ["scripts/ci/check_os_marker_fakes.py"],
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class _Job:
|
|
argv: list[str]
|
|
cwd: Path = REPO
|
|
env: dict[str, str] | None = None
|
|
|
|
|
|
def _run(job: _Job) -> tuple[int, str]:
|
|
proc = subprocess.run(
|
|
job.argv, cwd=job.cwd, env=job.env, capture_output=True, text=True, encoding="utf-8",
|
|
errors="replace", stdin=subprocess.DEVNULL, timeout=900, check=False,
|
|
)
|
|
return proc.returncode, (proc.stdout + proc.stderr).strip()
|
|
|
|
|
|
def _worktree_matches_index() -> bool:
|
|
unstaged = gitio.git(REPO, "diff", "--name-only", "-z").strip("\0")
|
|
untracked = gitio.git(REPO, "ls-files", "--others", "--exclude-standard", "-z").strip("\0")
|
|
return not unstaged and not untracked
|
|
|
|
|
|
@contextmanager
|
|
def _index_snapshot() -> Iterator[tuple[Path, dict[str, str]]]:
|
|
"""The staged tree checked out on its own, plus git env that sees exactly that tree.
|
|
|
|
Unstaged edits stay where they are (no stash, no checkout); the index is read from a copy,
|
|
so nothing a checker runs can rewrite the user's real index.
|
|
"""
|
|
git_dir = Path(gitio.git(REPO, "rev-parse", "--absolute-git-dir").strip())
|
|
index = Path(gitio.git(REPO, "rev-parse", "--path-format=absolute", "--git-path", "index").strip())
|
|
with tempfile.TemporaryDirectory(prefix="hermes-check-index-") as tmp:
|
|
root = Path(tmp) / "tree"
|
|
gitio.git(REPO, "checkout-index", "-a", f"--prefix={root}{os.sep}")
|
|
index_copy = Path(tmp) / "index"
|
|
shutil.copyfile(index, index_copy)
|
|
env = {**os.environ, "GIT_DIR": str(git_dir), "GIT_WORK_TREE": str(root),
|
|
"GIT_INDEX_FILE": str(index_copy)}
|
|
yield root, env
|
|
|
|
|
|
def _jobs(only: str | None, base: str | None, head: str | None,
|
|
root: Path = REPO, env: dict[str, str] | None = None) -> dict[str, _Job]:
|
|
jobs = {name: _Job([sys.executable, *argv], root, env) for name, argv in _GUARDS.items()}
|
|
jobs["ruff"] = _Job([*resolve_ruff(REPO), "check", "."], root, env)
|
|
# The ratchet reads both sides from git, so it runs in the real repo against the head tree.
|
|
health = [sys.executable, "-m", "scripts.code_health"]
|
|
health += ["--base", base] if base else []
|
|
health += ["--head", head] if head else []
|
|
jobs["health"] = _Job(health)
|
|
if only:
|
|
return {name: job for name, job in jobs.items() if name in only.split(",")}
|
|
return jobs
|
|
|
|
|
|
def _run_all(args: argparse.Namespace) -> dict[str, tuple[int, str]]:
|
|
if not args.staged:
|
|
jobs = _jobs(args.only, args.base, None)
|
|
with ThreadPoolExecutor(max_workers=len(jobs) or 1) as pool:
|
|
return dict(zip(jobs, pool.map(_run, jobs.values()), strict=True))
|
|
# Pre-commit: judge the bytes being committed, not the working tree.
|
|
tree = gitio.git(REPO, "write-tree").strip()
|
|
with ExitStack() as stack:
|
|
root, env = REPO, None
|
|
if not _worktree_matches_index():
|
|
root, env = stack.enter_context(_index_snapshot())
|
|
jobs = _jobs(args.only, args.base, tree, root, env)
|
|
with ThreadPoolExecutor(max_workers=len(jobs) or 1) as pool:
|
|
return dict(zip(jobs, pool.map(_run, jobs.values()), strict=True))
|
|
|
|
|
|
def install_hook() -> int:
|
|
hooks = Path(gitio.git(REPO, "rev-parse", "--path-format=absolute", "--git-path", "hooks").strip())
|
|
target = hooks / "pre-commit"
|
|
if target.exists() and _HOOK_MARK not in target.read_text(encoding="utf-8-sig", errors="replace"):
|
|
print(f"scripts/check: {target} exists and is not ours; add `python scripts/check` to it")
|
|
return 1
|
|
hooks.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(_HOOK, encoding="utf-8", newline="\n")
|
|
os.chmod(target, 0o755) # windows-footgun: ok — chmod +x is a no-op on Windows, harmless
|
|
print(f"scripts/check: pre-commit hook installed at {target}")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(prog="scripts/check", description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--only", help=f"comma list of: {', '.join([*_GUARDS, 'ruff', 'health'])}")
|
|
parser.add_argument("--base", help="base revision for the code-health ratchet")
|
|
parser.add_argument("--staged", action="store_true",
|
|
help="check the index (what `git commit` records), not the working tree")
|
|
parser.add_argument("--install-hook", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
if args.install_hook:
|
|
return install_hook()
|
|
try:
|
|
results = _run_all(args)
|
|
except RuntimeError as exc:
|
|
print(f"scripts/check: {exc}", file=sys.stderr)
|
|
return 2
|
|
failed = [name for name, (code, _) in results.items() if code != 0]
|
|
# A passing health run still carries advisories and comment-allowed findings that the
|
|
# reviewer must see, so its report is always shown.
|
|
for name, (code, output) in results.items():
|
|
if code != 0 or name == "health":
|
|
label = "failed" if code != 0 else "ok"
|
|
print(f"\n=== {name} ({label}) ===\n{output.rstrip()}")
|
|
status = "FAILED: " + ", ".join(failed) if failed else "ok"
|
|
print(f"\nscripts/check: {len(results)} checks, {status}")
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|