From 00edd8b3a0f33ccf2726c30d35f208e05bd19e14 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 9 Jun 2026 22:34:38 -0700 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20auto=5Ffix=20ruff=20legs=20were?= =?UTF-8?q?=20silently=20dead=20=E2=80=94=20invoke=20via=20venv=20interpre?= =?UTF-8?q?ter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ruff call sites called bare `ruff`, absent from the hook subprocess PATH (ruff lives only in .venv) — logged 'ruff not found' 177x over ~a month, silently skipping lint+format on every edit. Also `--output-format=text` was removed from modern ruff. Fixed all three sites to `sys.executable -m ruff` (the pattern the working pyright leg already uses) + concise format + honest rc>=2 error logging. Tests now pin the invocation (argv == sys.executable -m ruff) so a regression fails loud — the subprocess-mock is how this hid. 438/438 hooks tests green; live-verified through the real hook pipeline. Co-Authored-By: Claude Fable 5 --- .../hooks/apps/handlers/lifecycle/auto_fix.py | 28 +++++++++++-------- src/aipass/hooks/tests/test_auto_fix.py | 5 ++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py index cd9f9b6e..279ddc73 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py @@ -5,7 +5,7 @@ # Branch: hooks # Layer: apps/handlers/lifecycle # Created: 2026-05-22 -# Modified: 2026-05-22 +# Modified: 2026-06-09 # ============================================= """Runs diagnostics on edited files and surfaces errors for the agent to fix.""" @@ -71,15 +71,18 @@ def _check_syntax(file_path: str) -> list[str]: def _check_ruff_lint(file_path: str) -> list[str]: try: result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=text", file_path], + [sys.executable, "-m", "ruff", "check", "--select=E,F,W", "--output-format=concise", file_path], capture_output=True, text=True, timeout=10, ) + if result.returncode not in (0, 1): + logger.info("[HOOKS] auto_fix: ruff lint error: %s", result.stderr.strip()) + return [] if result.stdout.strip(): - return [f"LINT: {line}" for line in result.stdout.strip().split("\n")[:5]] - except FileNotFoundError: - logger.info("[HOOKS] auto_fix: ruff not found") + lines = result.stdout.strip().split("\n") + violations = [line for line in lines if ".py:" in line] + return [f"LINT: {line}" for line in violations[:5]] except Exception as exc: logger.info("[HOOKS] auto_fix: ruff lint failed: %s", exc) return [] @@ -88,16 +91,16 @@ def _check_ruff_lint(file_path: str) -> list[str]: def _check_ruff_format(file_path: str) -> list[str]: try: result = subprocess.run( - ["ruff", "format", "--check", file_path], + [sys.executable, "-m", "ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10, ) - if result.returncode != 0: + if result.returncode == 1: name = Path(file_path).name return [f"FORMAT: {name} needs ruff format (run: ruff format {name})"] - except FileNotFoundError: - logger.info("[HOOKS] auto_fix: ruff not found") + if result.returncode not in (0, 1): + logger.info("[HOOKS] auto_fix: ruff format error: %s", result.stderr.strip()) except Exception as exc: logger.info("[HOOKS] auto_fix: ruff format check failed: %s", exc) return [] @@ -151,11 +154,14 @@ def _run_ruff_lint_structured(file_path: str) -> list[dict]: return [] try: result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], + [sys.executable, "-m", "ruff", "check", "--select=E,F,W", "--output-format=json", file_path], capture_output=True, text=True, timeout=10, ) + if result.returncode not in (0, 1): + logger.info("[HOOKS] auto_fix: ruff structured lint error: %s", result.stderr.strip()) + return [] if not result.stdout.strip(): return [] violations = json.loads(result.stdout) @@ -168,8 +174,6 @@ def _run_ruff_lint_structured(file_path: str) -> list[dict]: message = v.get("message", "unknown")[:100] errors.append({"line": line, "message": f"{code}: {message}"}) return errors - except FileNotFoundError: - logger.info("[HOOKS] auto_fix: ruff not found for structured lint") except json.JSONDecodeError as exc: logger.info("[HOOKS] auto_fix: ruff JSON parse failed: %s", exc) except subprocess.TimeoutExpired: diff --git a/src/aipass/hooks/tests/test_auto_fix.py b/src/aipass/hooks/tests/test_auto_fix.py index 14c3ace2..d246c493 100644 --- a/src/aipass/hooks/tests/test_auto_fix.py +++ b/src/aipass/hooks/tests/test_auto_fix.py @@ -15,6 +15,7 @@ paths stay silent (no "sound" key). """ import json +import sys import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -259,6 +260,8 @@ class TestAutoFixSubprocessChecks: errors = _check_ruff_lint("/tmp/bad.py") assert len(errors) == 1 assert "LINT" in errors[0] + # bare "ruff" relies on PATH the hook env doesn't have — must go through the venv interpreter + assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"] @patch("subprocess.run") def test_check_ruff_format_drift(self, mock_run): @@ -268,6 +271,7 @@ class TestAutoFixSubprocessChecks: errors = _check_ruff_format("/tmp/unformatted.py") assert len(errors) == 1 assert "FORMAT" in errors[0] + assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"] @patch("subprocess.run") def test_run_ruff_lint_structured_returns_dicts(self, mock_run): @@ -285,6 +289,7 @@ class TestAutoFixSubprocessChecks: assert len(errors) == 1 assert errors[0]["line"] == 5 assert "F401" in errors[0]["message"] + assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"] @patch("subprocess.run") def test_run_ruff_lint_structured_skips_claude_hooks(self, mock_run):