fix(hooks): auto_fix ruff legs were silently dead — invoke via venv interpreter

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 <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-06-09 22:34:38 -07:00
co-authored by Claude Fable 5
parent c3c6c2dde7
commit 00edd8b3a0
2 changed files with 21 additions and 12 deletions
@@ -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:
+5
View File
@@ -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):