Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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: 24 additions & 5 deletions engine/hooks/_runner/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
import sys
import tempfile

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from run import MIN_PYTHON, PYTHON_OVERRIDE_ENV, _pick_python, _python_dirs

HARNESS_DIRS = (".claude", ".cursor", ".codex")
SKIP_PREFIXES = ("install_", "test_")
SKIP_NAMES = ("detect.py", "state.py")
Expand Down Expand Up @@ -151,17 +155,19 @@ def _last_stderr_line(result) -> str:
return lines[-1] if lines else f"exit={result.returncode}, no stderr"


def classify(path: str, timeout: float, run=subprocess.run) -> tuple[str, str]:
def classify(path: str, timeout: float, python: str, run=subprocess.run) -> tuple[str, str]:
"""(outcome, detail) for one script: ok, unreadable, import-fail, or slow.

The child opens the file before importing anything, so `unreadable` beats
`import-fail` whenever both would apply: the more specific cause is the one
reported. A timeout is not a failure -- imports resolve before a module
reaches whatever it is still doing.
reaches whatever it is still doing. Probing under `python` rather than
`sys.executable` means this reports the interpreter the runner would
actually use for the hook, not whichever one started the doctor.
"""
try:
result = run(
[sys.executable, "-c", LOADER, path],
[python, "-c", LOADER, path],
capture_output=True,
text=True,
timeout=timeout,
Expand Down Expand Up @@ -214,12 +220,24 @@ def check_hooks(home: str, timeout: float = DEFAULT_TIMEOUT, run=subprocess.run)
"unreadable, so nothing was verified"
],
)
python = _pick_python(sys.version_info, sys.executable, _python_dirs(dict(os.environ)), dict(os.environ))
if python is None:
version = ".".join(str(part) for part in sys.version_info[:2])
return Result(
"hooks",
"fail",
[
f"the hooks need Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer; this check started "
f"on {sys.executable} ({version}) and found no python3.N (N >= {MIN_PYTHON[1]}) on "
f"PATH or in the well-known python dirs; set {PYTHON_OVERRIDE_ENV} to a newer interpreter"
],
)
unreadable: list[tuple[str, str, str]] = []
failures: list[str] = []
slow: list[str] = []
workers = min(HOOK_CHECK_WORKERS, len(scripts))
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
results = list(executor.map(lambda path: (path, classify(path, timeout, run=run)), scripts))
results = list(executor.map(lambda path: (path, classify(path, timeout, python, run=run)), scripts))
for path, (outcome, detail) in results:
shown = os.path.relpath(path, home)
if outcome == "unreadable":
Expand All @@ -229,8 +247,9 @@ def check_hooks(home: str, timeout: float = DEFAULT_TIMEOUT, run=subprocess.run)
elif outcome == "slow":
slow.append(f"{shown}: {detail} (imports resolved; still working)")
lines = [
f"probing imports with {python}",
f"checked={len(scripts)} unreadable={len(unreadable)} "
f"import-fail={len(failures)} slow={len(slow)}"
f"import-fail={len(failures)} slow={len(slow)}",
]
lines.extend(f"{shown} -> {target}: {detail}" for shown, target, detail in unreadable)
lines.extend(failures + slow)
Expand Down
52 changes: 52 additions & 0 deletions tests/test_hook_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
Expand Down Expand Up @@ -50,6 +51,13 @@ def write(self, harness, hook, name, body):
handle.write(body)
return path

def fake_python(self, name: str) -> str:
path = os.path.join(self.tmp.name, name)
with open(path, "w", encoding="utf-8") as handle:
handle.write("#!/bin/sh\n")
os.chmod(path, 0o755)
return path

def hooks_check(self, *argv):
out = io.StringIO()
code = doctor.report([doctor.check_hooks(self.home)], out)
Expand Down Expand Up @@ -239,6 +247,50 @@ def test_no_hooks_at_all_is_unchecked_not_clean(self):
self.assertEqual(code, 2, text)
self.assertIn("nothing was verified", text)

def test_the_import_probe_runs_under_the_runners_chosen_python(self):
"""When the doctor starts on a Python too old for the hooks, the import
probe must run under the same newer interpreter the runner would pick
-- not under sys.executable, which is the interpreter the runner
rejects."""
self.write(".claude", "good-hook", "claude_stop_check.py", GOOD)
newer = self.fake_python("python3.13")
calls = []

def fake_run(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

with mock.patch.object(doctor.sys, "version_info", (3, 9, 0)), mock.patch.dict(
os.environ,
{
"PATH": self.tmp.name,
"CATSTACK_HOOK_PYTHON_DIRS": self.tmp.name,
"CATSTACK_HOOK_PYTHON": "",
},
):
doctor.check_hooks(self.home, run=fake_run)

self.assertTrue(calls, "the import probe never ran")
self.assertEqual(calls[0][0], newer)
self.assertNotEqual(calls[0][0], sys.executable)

def test_hooks_check_fails_when_no_interpreter_is_new_enough(self):
self.write(".claude", "good-hook", "claude_stop_check.py", GOOD)
empty = tempfile.mkdtemp(dir=self.tmp.name)

with mock.patch.object(doctor.sys, "version_info", (3, 9, 0)), mock.patch.dict(
os.environ,
{
"PATH": empty,
"CATSTACK_HOOK_PYTHON_DIRS": empty,
"CATSTACK_HOOK_PYTHON": "",
},
):
result = doctor.check_hooks(self.home)

self.assertEqual(result.status, "fail")
self.assertIn(sys.executable, "\n".join(result.lines))


class InstalledEffectiveTest(unittest.TestCase):
"""The effective check as run from an installed copy of the doctor, which
Expand Down
Loading