From e7c61968a83e7e16e38a93cf524fee5e76478dba Mon Sep 17 00:00:00 2001 From: Kyle Welsworth Date: Fri, 21 Aug 2026 12:56:46 +0100 Subject: [PATCH] fix(verify): tests check runs the target project's own interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _run_impacted_pytest always shelled out with sys.executable, which is roam's own interpreter when installed isolated from any one project (uv tool install, pipx — the documented install path). That interpreter has neither pytest nor the target project's dependencies, so collection fails before any test runs: "pytest exited 1 without a parsed passing result", hard_block: true, on every project whose dependencies roam's own venv doesn't happen to already have. Resolve the interpreter from a .venv/venv found by walking up from the first impacted test file instead, falling back to sys.executable unchanged when no project venv is found. Walking from the test file rather than root also picks the right interpreter for a monorepo subproject nested below the indexed root. Same bug class already fixed for the pre-push hook (this release, "ran whatever python PATH resolved to, not the project's interpreter") — this closes the same gap in `roam verify`'s own tests check. --- src/roam/commands/cmd_verify.py | 34 ++++++++++- tests/test_verify_autofire_hardening.py | 79 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/roam/commands/cmd_verify.py b/src/roam/commands/cmd_verify.py index f8472fc81..ccd54b5d1 100644 --- a/src/roam/commands/cmd_verify.py +++ b/src/roam/commands/cmd_verify.py @@ -3716,14 +3716,44 @@ def _gather_and_rank_tests(conn, sym_ids, src_paths, changed_paths, root): return _existing_ranked_paths(ranked, root), None +def _resolve_test_interpreter(root: Path, first_target: str) -> str: + """Find the interpreter that owns the target project's test dependencies. + + roam is commonly installed isolated from any one project (``uv tool + install``, pipx), so ``sys.executable`` is roam's own interpreter, not + the project under test — it has neither pytest nor the project's + dependencies, so collection fails before any test runs. Walk up from the + first impacted test file (not just ``root``, so a venv nested under a + subdirectory of a monorepo is found before a repo-root one) looking for + a ``.venv``/``venv``. Falls back to ``sys.executable``, preserving + today's behaviour when no project venv is found (e.g. roam is already + running inside the project's own venv). + """ + import sys + + bin_dir = "Scripts" if os.name == "nt" else "bin" + exe_name = "python.exe" if os.name == "nt" else "python" + + start = (root / first_target).parent + for directory in (start, *start.parents): + for venv_name in (".venv", "venv"): + candidate = directory / venv_name / bin_dir / exe_name + if candidate.exists(): + return str(candidate) + if directory == root: + break + + return sys.executable + + def _run_impacted_pytest(ordered: list[str], root: Path, timeout: int) -> dict: """Run pytest over the impacted (capped) test files and report failures.""" import subprocess - import sys capped = len(ordered) > _MAX_TEST_FILES targets = ordered[:_MAX_TEST_FILES] - cmd = [sys.executable, "-B", "-m", "pytest", *targets, "--tb=line", "-q", "-p", "no:cacheprovider"] + interpreter = _resolve_test_interpreter(root, targets[0]) + cmd = [interpreter, "-B", "-m", "pytest", *targets, "--tb=line", "-q", "-p", "no:cacheprovider"] try: proc = subprocess.run(cmd, cwd=str(root), capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: diff --git a/tests/test_verify_autofire_hardening.py b/tests/test_verify_autofire_hardening.py index 3a5dd9e3e..dbc8516a8 100644 --- a/tests/test_verify_autofire_hardening.py +++ b/tests/test_verify_autofire_hardening.py @@ -139,6 +139,85 @@ class Result: assert result["violations"][0]["hard_block"] is True +def test_impacted_pytest_prefers_the_target_projects_own_venv(monkeypatch, tmp_path): + """roam commonly runs isolated from any one project (uv tool install, + pipx) — sys.executable is roam's own interpreter, which has neither + pytest nor the target project's dependencies. It must prefer a venv + that actually belongs to the project under test.""" + from roam.commands import cmd_verify + + bin_dir = "Scripts" if os.name == "nt" else "bin" + exe_name = "python.exe" if os.name == "nt" else "python" + venv_python = tmp_path / ".venv" / bin_dir / exe_name + venv_python.parent.mkdir(parents=True) + venv_python.touch() + + class Result: + returncode = 0 + stdout = "1 passed" + stderr = "" + + captured = {} + monkeypatch.setattr( + cmd_verify.subprocess, + "run", + lambda cmd, **kwargs: captured.setdefault("cmd", cmd) and Result(), + ) + cmd_verify._run_impacted_pytest(["tests/test_app.py"], tmp_path, timeout=1) + + assert captured["cmd"][0] == str(venv_python) + + +def test_impacted_pytest_prefers_a_venv_nested_under_the_target_file(monkeypatch, tmp_path): + """A monorepo subproject's own venv (e.g. a nested Python service in an + otherwise non-Python repo) must win over a repo-root venv, since that is + the interpreter that actually has the subproject's dependencies.""" + from roam.commands import cmd_verify + + bin_dir = "Scripts" if os.name == "nt" else "bin" + exe_name = "python.exe" if os.name == "nt" else "python" + root_venv = tmp_path / ".venv" / bin_dir / exe_name + root_venv.parent.mkdir(parents=True) + root_venv.touch() + nested_venv = tmp_path / "service" / ".venv" / bin_dir / exe_name + nested_venv.parent.mkdir(parents=True) + nested_venv.touch() + + class Result: + returncode = 0 + stdout = "1 passed" + stderr = "" + + captured = {} + monkeypatch.setattr( + cmd_verify.subprocess, + "run", + lambda cmd, **kwargs: captured.setdefault("cmd", cmd) and Result(), + ) + cmd_verify._run_impacted_pytest(["service/tests/test_app.py"], tmp_path, timeout=1) + + assert captured["cmd"][0] == str(nested_venv) + + +def test_impacted_pytest_falls_back_to_roams_own_interpreter_with_no_project_venv(monkeypatch, tmp_path): + from roam.commands import cmd_verify + + class Result: + returncode = 0 + stdout = "1 passed" + stderr = "" + + captured = {} + monkeypatch.setattr( + cmd_verify.subprocess, + "run", + lambda cmd, **kwargs: captured.setdefault("cmd", cmd) and Result(), + ) + cmd_verify._run_impacted_pytest(["tests/test_app.py"], tmp_path, timeout=1) + + assert captured["cmd"][0] == sys.executable + + def test_capped_impacted_tests_are_disclosed_as_partial(monkeypatch, tmp_path): from roam.commands import cmd_verify