diff --git a/codeframe/core/gates.py b/codeframe/core/gates.py index 88f5a2f2..26347e4c 100644 --- a/codeframe/core/gates.py +++ b/codeframe/core/gates.py @@ -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 @@ -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) + ) + ): + return GateCheck( + name=cfg.name, + status=GateStatus.SKIPPED, + output=f"{cfg.name} not found in project dependencies", + duration_ms=duration_ms, + ) + passed = result.returncode == 0 check = GateCheck( name=cfg.name, diff --git a/tests/core/test_gates_observability.py b/tests/core/test_gates_observability.py index 807bf431..bb19634c 100644 --- a/tests/core/test_gates_observability.py +++ b/tests/core/test_gates_observability.py @@ -1,5 +1,6 @@ """Tests for gate observability enhancements.""" +import subprocess from pathlib import Path from unittest.mock import patch @@ -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()