Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions codeframe/core/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,8 +637,9 @@ def run_lint_on_file(
"""Run the appropriate linter on a single file.

Returns a ``GateCheck`` with status PASSED / FAILED / SKIPPED / ERROR.
SKIPPED is returned when no linter is registered for the file extension
or the required binary is not installed.
SKIPPED is returned when no linter is registered for the file extension,
the required binary is not installed, or the tool is not found in the
project's dependencies (e.g. ``uv run ruff`` fails to spawn).
"""
import time

Expand Down Expand Up @@ -674,6 +675,30 @@ def run_lint_on_file(
output += "\n" + result.stderr
output = output.strip()

# Detect tool-not-found: uv/shell report "Failed to spawn",
# "command not found", or "No such file or directory" when the
# linter binary isn't installed in the target project.
# Note: "no such file or directory" is only matched when the tool
# name also appears in stderr, to avoid false positives from
# missing *target* files.
if result.returncode != 0 and result.stderr:
stderr_lower = result.stderr.lower()
tool_names = {cfg.cmd[0].lower(), cfg.name.lower()}
if (
"failed to spawn" in stderr_lower
or "command not found" in stderr_lower
or (
"no such file or directory" in stderr_lower
and any(name in stderr_lower for name in tool_names)
)
):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return GateCheck(
name=cfg.name,
status=GateStatus.SKIPPED,
output=f"{cfg.name} not found in project dependencies",
duration_ms=duration_ms,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

passed = result.returncode == 0
check = GateCheck(
name=cfg.name,
Expand Down
23 changes: 23 additions & 0 deletions tests/core/test_gates_observability.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for gate observability enhancements."""

import subprocess
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -254,3 +255,25 @@ def test_missing_linter_binary_skips(self, mock_which, tmp_path):

check = run_lint_on_file(py_file, tmp_path)
assert check.status == GateStatus.SKIPPED

@patch("codeframe.core.gates.subprocess.run")
@patch("codeframe.core.gates.shutil.which")
def test_uv_run_missing_tool_returns_skipped(self, mock_which, mock_run, tmp_path):
"""When uv run fails because the linter isn't a project dependency, return SKIPPED."""
py_file = tmp_path / "test.py"
py_file.write_text("x = 1\n")

# ruff not on PATH but uv is → gates proceeds with 'uv run ruff'
mock_which.side_effect = lambda cmd: "/usr/bin/uv" if cmd == "uv" else None

# Simulate uv failing to spawn ruff (exit code 2)
mock_run.return_value = subprocess.CompletedProcess(
args=["uv", "run", "ruff", "check", str(py_file)],
returncode=2,
stdout="",
stderr="error: Failed to spawn: `ruff`\nCaused by: No such file or directory (os error 2)",
)

check = run_lint_on_file(py_file, tmp_path)
assert check.status == GateStatus.SKIPPED
assert "not found" in check.output.lower()
Loading