From 9ce9d4fd3af26fcdec7e6a1bb9a270e381ac34d3 Mon Sep 17 00:00:00 2001 From: Hermes Evolution Date: Sat, 1 Aug 2026 03:08:07 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20auto-format=20changed=20files=20before?= =?UTF-8?q?=20CI=20=E2=80=94=20prevents=20format-fail=20merge=20blocks=20(?= =?UTF-8?q?#1540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/evolution_autoformat.py: runs ruff check --fix + ruff format on ONLY the files changed vs origin/main. Called by the implementation skill before git commit and by the integration skill as format-then-retry. Closes #1540 Co-Authored-By: Hermes Evolution --- scripts/evolution_autoformat.py | 117 +++++++++++++++++++++ tests/scripts/test_evolution_autoformat.py | 78 ++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 scripts/evolution_autoformat.py create mode 100644 tests/scripts/test_evolution_autoformat.py diff --git a/scripts/evolution_autoformat.py b/scripts/evolution_autoformat.py new file mode 100644 index 000000000..588991756 --- /dev/null +++ b/scripts/evolution_autoformat.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Auto-format changed Python files before CI — prevents format-fail merge blocks (#1540). + +CI's ``ruff check .`` (blocking in ``lint.yml``) rejects unformatted code. When +the implementation agent forgets to run ``ruff check --fix`` + ``ruff format``, +the PR sits red until a human formats it. This script runs both on ONLY the +changed files (vs ``base``, default ``origin/main``), called by the +implementation skill before commit and the integration skill as format-then-retry. + +Usage:: + + python scripts/evolution_autoformat.py # format changed files + python scripts/evolution_autoformat.py --base origin/main # diff vs custom ref + python scripts/evolution_autoformat.py --dry-run # report only + python scripts/evolution_autoformat.py --files a.py,b.py # explicit file list + +Exit 0 = success (or dry-run), 1 = formatting error. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + + +def _run(cmd: List[str], cwd: Optional[str] = None) -> Tuple[int, str, str]: + p = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) + return p.returncode, p.stdout, p.stderr + + +def get_changed_files( + base: str = "origin/main", cwd: Optional[str] = None +) -> List[str]: + """Return Python files changed vs ``base`` that still exist on disk.""" + code, out, _ = _run(["git", "diff", "--name-only", base], cwd=cwd) + if code != 0: + return [] + root = Path(cwd) if cwd else Path.cwd() + return [ + line.strip() + for line in out.strip().splitlines() + if line.strip().endswith(".py") and (root / line.strip()).exists() + ] + + +def format_files( + files: Sequence[str], *, dry_run: bool = False, runner=subprocess.run +) -> Tuple[bool, List[str]]: + """Run ``ruff check --fix`` + ``ruff format`` on ``files``. + + Returns ``(success, messages)``. Dry-run uses ``--check`` variants. + """ + if not files: + return True, ["no Python files to format"] + + messages: List[str] = [] + # Step 1: ruff check --fix (lint auto-fix; --exit-zero in dry-run) + check_cmd = ["ruff", "check", "--exit-zero" if dry_run else "--fix", *files] + p = runner(check_cmd, capture_output=True, text=True) + if not dry_run and p.returncode != 0: + messages.append(f"ruff check: {p.stdout.strip() or p.stderr.strip()}") + elif dry_run and p.stdout.strip(): + messages.append(f"ruff check (dry-run): {p.stdout.strip()}") + + # Step 2: ruff format (--check in dry-run) + fmt_cmd = ["ruff", "format", *(["--check"] if dry_run else []), *files] + p2 = runner(fmt_cmd, capture_output=True, text=True) + if not dry_run and p2.returncode != 0: + messages.append(f"ruff format FAILED: {p2.stderr.strip()}") + return False, messages + if dry_run and p2.returncode != 0: + messages.append(f"files need formatting:\n{p2.stdout.strip()}") + elif dry_run: + messages.append("all files already formatted") + + return True, messages + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Auto-format changed Python files before CI" + ) + parser.add_argument("--base", default="origin/main", help="Git ref to diff against") + parser.add_argument("--files", help="Comma-separated file list (skips git diff)") + parser.add_argument( + "--dry-run", action="store_true", help="Report without modifying" + ) + args = parser.parse_args(argv) + + if args.files: + files = [f.strip() for f in args.files.split(",") if f.strip()] + else: + files = get_changed_files(args.base) + if not files: + print("[autoformat] no changed Python files — nothing to format") + return 0 + + print( + f"[autoformat] {'checking' if args.dry_run else 'formatting'} {len(files)} file(s)" + ) + success, messages = format_files(files, dry_run=args.dry_run) + for msg in messages: + print(f" {msg}") + if not success: + print("[autoformat] FORMATTING FAILED") + return 1 + + action = "would be formatted" if args.dry_run else "formatted successfully" + print(f"[autoformat] {len(files)} file(s) {action}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/scripts/test_evolution_autoformat.py b/tests/scripts/test_evolution_autoformat.py new file mode 100644 index 000000000..97ec84f9f --- /dev/null +++ b/tests/scripts/test_evolution_autoformat.py @@ -0,0 +1,78 @@ +"""Tests for evolution_autoformat.py (#1540). + +Covers: get_changed_files, format_files (success/failure/dry-run), CLI main(). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) + +from evolution_autoformat import format_files, get_changed_files, main # noqa: E402 + + +class _FakeProc: + """Mimic subprocess.run result.""" + + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class TestGetChangedFiles: + def test_filters_to_python_only(self, tmp_path): + git_out = "scripts/foo.py\nREADME.md\nbar.py\n" + with patch("evolution_autoformat._run", return_value=(0, git_out, "")): + with patch("pathlib.Path.exists", return_value=True): + with patch("pathlib.Path.cwd", return_value=tmp_path): + files = get_changed_files("origin/main", cwd=str(tmp_path)) + assert "scripts/foo.py" in files and "bar.py" in files + assert "README.md" not in files + + def test_returns_empty_on_git_error(self): + with patch("evolution_autoformat._run", return_value=(1, "", "fatal")): + assert get_changed_files("bad-ref") == [] + + +class TestFormatFiles: + def test_empty_file_list(self): + success, msgs = format_files([]) + assert success is True and "no Python files" in msgs[0] + + def test_success_path(self): + runner = MagicMock(side_effect=[_FakeProc(0, "Fixed 1"), _FakeProc(0, "ok")]) + success, _ = format_files(["a.py", "b.py"], runner=runner) + assert success is True and runner.call_count == 2 + + def test_format_failure(self): + runner = MagicMock( + side_effect=[_FakeProc(0, ""), _FakeProc(1, "", "fmt error")] + ) + success, msgs = format_files(["a.py"], runner=runner) + assert success is False and any("FAILED" in m for m in msgs) + + def test_dry_run_already_clean(self): + runner = MagicMock(side_effect=[_FakeProc(0, ""), _FakeProc(0, "")]) + success, msgs = format_files(["a.py"], dry_run=True, runner=runner) + assert success is True and any("already formatted" in m for m in msgs) + + +class TestMain: + def test_no_changed_files(self, capsys): + with patch("evolution_autoformat.get_changed_files", return_value=[]): + assert main([]) == 0 + assert "nothing to format" in capsys.readouterr().out + + def test_explicit_files(self): + with patch("evolution_autoformat.format_files", return_value=(True, ["ok"])): + assert main(["--files", "a.py,b.py"]) == 0 + + def test_format_failure_exit_code(self): + with patch("evolution_autoformat.format_files", return_value=(False, ["FAILED"])): + assert main(["--files", "a.py"]) == 1