217 lines · 8.1 KB · Python
RawDownload
#!/usr/bin/env python3"""Forward-looking ruff lint gate for hermes-webui. The Python twin of the ESLint runtime guard (``npm run lint:runtime``). It runs thecurated ``[tool.ruff]`` ruleset from ``pyproject.toml`` (E9 + F + B) but enforces it**only on new / changed code**, so it keeps incoming PRs clean without demanding abig-bang reformat of the existing tree (which still carries a cosmeticunused-import backlog — see issue #3273). Two modes:   --diff [BASE]   Line-scoped gate (default mode). Only report findings on lines                  that this branch ADDED or MODIFIED relative to the merge-base                  with BASE (default: origin/master). Editing a legacy file that                  has pre-existing violations elsewhere is safe — only your own                  new lines are gated. Exit 1 if any new finding, else 0.   --all           Whole-tree report (informational). Runs the curated ruleset over                  every tracked .py file and prints the backlog. Exit 0 always                  unless --strict is also passed. Useful for tracking the cleanup                  progress of #3273; NOT used as a release blocker. Usage::     python3 scripts/ruff_lint.py --diff origin/master     # the CI / pre-release gate    python3 scripts/ruff_lint.py --all                    # backlog report    python3 scripts/ruff_lint.py --all --strict           # fail on ANY tree finding ruff is resolved from PATH, or run via ``uvx ruff`` / ``python -m ruff`` as afallback. If ruff cannot be found at all the gate prints a notice and exits 0 (adev without ruff is not blocked; CI installs ruff so enforcement still holds there —same contract as the ESLint guard)."""from __future__ import annotations import argparseimport jsonimport osimport reimport shutilimport subprocessimport sysfrom collections import defaultdict REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:    return subprocess.run(        cmd, cwd=REPO_ROOT, capture_output=True, text=True, **kw    )  def _ruff_cmd() -> list[str] | None:    """Return the argv prefix that invokes ruff, or None if unavailable."""    if shutil.which("ruff"):        return ["ruff"]    # `python -m ruff` works when ruff is pip-installed in the active interpreter.    probe = _run([sys.executable, "-m", "ruff", "--version"])    if probe.returncode == 0:        return [sys.executable, "-m", "ruff"]    # uvx pulls a pinned ruff on demand (used in local dev boxes without a global ruff).    if shutil.which("uvx"):        probe = _run(["uvx", "ruff", "--version"])        if probe.returncode == 0:            return ["uvx", "ruff"]    return None  def _changed_py_files(base: str) -> tuple[str, list[str]]:    """Resolve the merge-base with `base` and return (merge_base, changed .py files)."""    mb = _run(["git", "merge-base", base, "HEAD"])    merge_base = mb.stdout.strip() if mb.returncode == 0 and mb.stdout.strip() else base    diff = _run(        ["git", "diff", "--name-only", "--diff-filter=ACMR", merge_base, "HEAD"]    )    files = [        f        for f in diff.stdout.splitlines()        if f.endswith(".py") and os.path.exists(os.path.join(REPO_ROOT, f))    ]    return merge_base, files  _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")  def _added_lines(merge_base: str, path: str) -> set[int]:    """Set of NEW-file line numbers added or modified in `path` since merge_base."""    diff = _run(["git", "diff", "--unified=0", merge_base, "HEAD", "--", path])    added: set[int] = set()    new_ln = 0    for line in diff.stdout.splitlines():        m = _HUNK_RE.match(line)        if m:            new_ln = int(m.group(1))            continue        if line.startswith("+") and not line.startswith("+++"):            added.add(new_ln)            new_ln += 1        elif line.startswith("-") and not line.startswith("---"):            # deletion: new-file pointer does not advance            continue        elif not line.startswith(("@@", "diff ", "index ", "--- ", "+++ ")):            new_ln += 1    return added  def _ruff_check_json(ruff: list[str], files: list[str]) -> list[dict]:    """Run ruff over `files` with the project config, return parsed JSON findings."""    if not files:        return []    proc = _run(ruff + ["check", "--output-format", "json", "--no-cache", *files])    out = proc.stdout.strip()    if not out:        return []    try:        return json.loads(out)    except json.JSONDecodeError:        sys.stderr.write(            "ruff_lint: could not parse ruff JSON output:\n" + proc.stdout + proc.stderr        )        # Be conservative: surface as a failure so a broken invocation never        # silently green-lights a PR.        raise  def _print_findings(findings: list[dict]) -> None:    by_file: dict[str, list[dict]] = defaultdict(list)    for f in findings:        by_file[f.get("filename", "?")].append(f)    for fname in sorted(by_file):        rel = os.path.relpath(fname, REPO_ROOT)        for f in sorted(by_file[fname], key=lambda x: (x["location"]["row"], x["location"]["column"])):            loc = f["location"]            code = f.get("code") or "?"            msg = f.get("message", "").strip()            print(f"  {rel}:{loc['row']}:{loc['column']}  {code}  {msg}")  def run_diff(base: str) -> int:    ruff = _ruff_cmd()    if ruff is None:        print("ruff_lint: ruff not found on PATH — skipping (CI installs ruff). OK.")        return 0    merge_base, files = _changed_py_files(base)    if not files:        print(f"ruff_lint: no changed .py files vs {base} ({merge_base[:12]}). OK.")        return 0     all_findings = _ruff_check_json(ruff, files)    # Build the added-line map once per file, intersect.    added_map = {f: _added_lines(merge_base, f) for f in files}    new_findings = []    for finding in all_findings:        rel = os.path.relpath(finding.get("filename", ""), REPO_ROOT)        row = finding.get("location", {}).get("row")        if rel in added_map and row in added_map[rel]:            new_findings.append(finding)     print(        f"ruff_lint (diff vs {base}): {len(files)} changed .py file(s), "        f"{len(all_findings)} total finding(s) in them, "        f"{len(new_findings)} on added/modified lines."    )    if new_findings:        print("\nNew ruff violations introduced by this change:\n")        _print_findings(new_findings)        print(            "\nThe curated ruff gate (E9+F+B) flags NEW code only. Fix the lines above, "            "or — if a finding is a genuine false positive — add a scoped "            "`# noqa: <CODE>` with a one-line reason. Config: pyproject.toml [tool.ruff]."        )        return 1    print("ruff_lint: no new violations on added/modified lines. OK.")    return 0  def run_all(strict: bool) -> int:    ruff = _ruff_cmd()    if ruff is None:        print("ruff_lint: ruff not found on PATH — skipping. OK.")        return 0    proc = _run(ruff + ["check", "--statistics", "--no-cache", "."])    sys.stdout.write(proc.stdout)    sys.stderr.write(proc.stderr)    if strict and proc.returncode != 0:        return proc.returncode    print(        "\nruff_lint --all is informational (existing-tree backlog tracked in #3273). "        "Pass --strict to fail on any finding."    )    return 0  def main(argv: list[str] | None = None) -> int:    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)    g = p.add_mutually_exclusive_group()    g.add_argument(        "--diff",        nargs="?",        const="origin/master",        metavar="BASE",        help="Line-scoped gate vs merge-base with BASE (default origin/master).",    )    g.add_argument("--all", action="store_true", help="Whole-tree backlog report.")    p.add_argument("--strict", action="store_true", help="With --all: fail on any finding.")    args = p.parse_args(argv)     if args.all:        return run_all(args.strict)    base = args.diff or "origin/master"    return run_diff(base)  if __name__ == "__main__":    raise SystemExit(main())

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t