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:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user