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
31 changes: 31 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,37 @@ contract = [
# imports the mineru package directly.
# See docs/install.md for the install procedure.

[dependency-groups]
# PEP 735 dependency group — deliberately MIRRORS the ``dev`` extra above.
#
# The duplication is load-bearing, not an oversight. The two tables are
# consumed by different installers and neither can reference the other:
#
# * ``[project.optional-dependencies].dev`` is what ``pip install -e
# ".[dev]"`` resolves — the Makefile ``bootstrap`` target and
# docs/install.md both use that form.
# * ``[dependency-groups].dev`` is what **uv** installs BY DEFAULT on
# every ``uv sync`` and every ``uv run``. Extras are NOT installed by
# default; groups named in ``default-groups`` are, and ``dev`` is
# uv's implicit default.
#
# Without this block, ANY ``uv run`` fired from inside the repo syncs the
# project ``.venv`` down to the default dependency set and PRUNES pytest
# and ruff out of it — including a ``uv run`` fired from inside the test
# suite, which strips the toolchain of the very interpreter running the
# tests. Observed live on 2026-08-01 (tests/test_daily_metrics_report.py
# ``TestRegenFixture``): the in-flight run survived on already-imported
# modules, so the test passed, and the NEXT ``make test`` died with
# "No module named pytest". Roughly twenty ``uv run python -m tools.…``
# commands are documented across tools/ docstrings and docs/ops/ runbooks;
# this block is what makes every one of them safe to run.
#
# ``tests/test_dev_toolchain_guard.py`` fails if the two lists drift apart.
dev = [
"ruff>=0.5",
"pytest>=8.0",
]

[tool.ruff]
line-length = 100
target-version = "py311"
Expand Down
223 changes: 223 additions & 0 deletions tests/test_dev_toolchain_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""Guards that the test suite cannot destroy its own toolchain.

Two defects were observed live on 2026-08-01 in
``tests/test_daily_metrics_report.py::TestRegenFixture``, which shelled
out to ``uv run python -c "…"`` to render a fixture in a fresh process.
Neither had anything to do with product code:

1. **``uv run`` prunes the test runner's own tooling.** ``uv run`` syncs
the project environment to the project's DEFAULT dependency set before
executing. ``pytest`` and ``ruff`` were declared ONLY in the ``dev``
*extra*, and uv does not install extras by default — so the subprocess
uninstalled pytest, ruff and pip from the very ``.venv`` the running
pytest process had been launched from. The in-flight run survived on
already-imported modules, so **the test passed** while the NEXT
``make test`` died with "No module named pytest".

2. **On Windows the same call hard-fails when an MCP session is live.**
While syncing, uv replaces the project's own installed entry points,
including ``.venv/Scripts/arxmcp-shim.exe``. Any Claude session with
the arXMCP MCP server connected holds that exe open, Windows refuses
the delete, and uv aborts with ``Access is denied. (os error 5)``.
Pruning happens FIRST, so this path leaves a half-stripped venv AND a
red test.

Both are closed here, from two directions:

* ``TestUvRunNotInvokedFromTests`` bans naming the ``uv`` binary from
anywhere under ``tests/``. The fixed call site uses ``sys.executable``,
which is already the correct project interpreter under pytest and needs
no environment sync at all. This also retires the hardcoded
``/Users/chris.dare/Library/Python/3.9/bin/uv`` fallback the old call
site carried — dead on every non-macOS box, and user-specific even
there (the ``docs/ops/`` peer of this rule lives in
``tests/test_runbook_index.py::TestNoHardcodedUserPaths``).

* ``TestDevGroupMirrorsDevExtra`` pins the ``[dependency-groups].dev``
block in ``pyproject.toml`` that makes ``uv run`` non-destructive
everywhere else in the repo — roughly twenty ``uv run python -m
tools.…`` commands are documented across tools/ docstrings and
docs/ops/ runbooks, and every one of them used to strip the toolchain.
"""

from __future__ import annotations

import ast
import tomllib
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[1]
PYPROJECT = REPO_ROOT / "pyproject.toml"
TESTS_DIR = REPO_ROOT / "tests"


def _load_pyproject() -> dict:
with PYPROJECT.open("rb") as fh:
return tomllib.load(fh)


class TestDevGroupMirrorsDevExtra:
"""``uv`` installs dependency GROUPS by default and extras never.

``pip install -e ".[dev]"`` (Makefile ``bootstrap``, docs/install.md)
reads the extra; ``uv sync`` / ``uv run`` read the group. Neither
table can reference the other, so pyproject.toml carries both and
this test is what keeps them from drifting.
"""

def test_dev_group_exists(self) -> None:
groups = _load_pyproject().get("dependency-groups", {})
assert "dev" in groups, (
"pyproject.toml has no `[dependency-groups].dev`. Without it "
"every `uv run` in this repo syncs the project .venv down to "
"the default dependency set and PRUNES pytest + ruff out of "
"it — including a `uv run` fired from inside the suite, which "
"strips the interpreter running the tests. Re-add the block."
)

def test_dev_group_matches_dev_extra(self) -> None:
cfg = _load_pyproject()
extra = cfg["project"]["optional-dependencies"]["dev"]
group = cfg["dependency-groups"]["dev"]
# Only plain requirement strings are compared. PEP 735 also allows
# `{include-group = ...}` table entries; if one is ever added here
# this guard must be revisited rather than silently loosened.
assert all(isinstance(x, str) for x in group), (
"`[dependency-groups].dev` gained a non-string entry "
f"({group!r}). This guard compares requirement strings only — "
"update it deliberately."
)
assert sorted(group) == sorted(extra), (
"`[dependency-groups].dev` and "
"`[project.optional-dependencies].dev` have drifted:\n"
f" group (uv sync / uv run): {sorted(group)}\n"
f" extra (pip install -e '.[dev]'): {sorted(extra)}\n"
"Both must list the full dev toolchain. A package present in "
"only the extra is invisible to `uv run` and gets pruned from "
"the project .venv on the next sync."
)


class TestUvRunNotInvokedFromTests:
"""No test may shell out to the ``uv`` binary.

A test that needs a fresh interpreter wants ``sys.executable``, not
an environment sync. Detection is AST-based (string CONSTANTS only),
so the prose in this file — and in the fixed call site's explanatory
comment — does not trip it; only real code naming the binary does.
"""

@staticmethod
def _names_uv_binary(value: str) -> bool:
# Bare executable name, Windows form, or any absolute/relative
# path ending in the binary (catches the old hardcoded
# `/Users/chris.dare/Library/Python/3.9/bin/uv` fallback).
stem = value.replace("\\", "/").rsplit("/", 1)[-1]
return stem in {"uv", "uv.exe"}

@staticmethod
def _presence_check_constants(tree: ast.AST) -> set[int]:
"""Constants that are an argument to ``shutil.which`` / ``which``.

A PATH lookup is not an invocation: it starts no process, syncs no
environment and cannot prune a package. Banning it would push callers
into running the binary blind to decide whether it exists, which is
strictly worse. What stays banned is naming the binary anywhere it
could be EXECUTED -- an argv[0], a command list, a hardcoded path.
"""
allowed: set[int] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = (
func.attr if isinstance(func, ast.Attribute)
else func.id if isinstance(func, ast.Name)
else None
)
if name == "which":
for arg in node.args:
if isinstance(arg, ast.Constant):
allowed.add(id(arg))
return allowed

def test_no_uv_binary_reference_in_tests(self) -> None:
offenders: list[str] = []
# This file is the detector; its own literals are the pattern
# being banned, not an instance of it.
self_path = Path(__file__).resolve()
for py in sorted(TESTS_DIR.rglob("*.py")):
if py.resolve() == self_path:
continue
try:
tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
except SyntaxError: # pragma: no cover - would fail collection anyway
continue
allowed = self._presence_check_constants(tree)
for node in ast.walk(tree):
if (
isinstance(node, ast.Constant)
and isinstance(node.value, str)
and self._names_uv_binary(node.value)
and id(node) not in allowed
):
offenders.append(
f" {py.relative_to(REPO_ROOT).as_posix()}:"
f"{node.lineno}: {node.value!r}"
)
assert not offenders, (
"Test code names the `uv` binary:\n"
+ "\n".join(offenders)
+ "\n\n`uv run` syncs the project environment before executing "
"and uninstalls anything outside the default dependency set — "
"run from inside the suite it prunes pytest/ruff out of the "
"venv the tests are running in, and on Windows it additionally "
"fails outright on the locked arxmcp-shim.exe when an MCP "
"session is connected.\nUse `sys.executable` for a fresh "
"interpreter (set PYTHONPATH=REPO_ROOT so the repo imports), "
"or drive the real thing through tools/wheel_install_check.py, "
"which builds into a throwaway venv and never touches .venv."
)


class TestDetectorItself:
"""The detector was narrowed to permit ``shutil.which("uv")``. That
narrowing is only safe if it did NOT also stop catching invocation, so
the detector is exercised against synthetic source rather than trusted.

Without this, a future "just allow one more form" edit could hollow the
guard out while every other test in this file still passes.
"""

@staticmethod
def _offenders(source: str) -> list[str]:
tree = ast.parse(source)
allowed = TestUvRunNotInvokedFromTests._presence_check_constants(tree)
return [
node.value
for node in ast.walk(tree)
if isinstance(node, ast.Constant)
and isinstance(node.value, str)
and TestUvRunNotInvokedFromTests._names_uv_binary(node.value)
and id(node) not in allowed
]

def test_presence_check_is_permitted(self) -> None:
assert self._offenders('import shutil; shutil.which("uv")') == []
assert self._offenders('from shutil import which; which("uv")') == []

def test_invocation_is_still_caught(self) -> None:
"""The forms that actually sync the environment."""
assert self._offenders('subprocess.run(["uv", "run", "python"])') == ["uv"]
assert self._offenders('subprocess.run(["uv.exe", "sync"])') == ["uv.exe"]
assert self._offenders('CMD = "/opt/homebrew/bin/uv"') == ["/opt/homebrew/bin/uv"]

def test_a_which_result_that_is_then_run_is_not_laundered(self) -> None:
"""``shutil.which`` returns a PATH; passing that VARIABLE to ``run()``
is invisible to a string-constant detector with or without the
exemption. The exemption covers the literal argument only -- it does
not make such a call site safe. This test records the limit rather
than letting the guard imply cover it does not have.
"""
src = 'uv = shutil.which("uv"); subprocess.run([uv, "run"])'
assert self._offenders(src) == []
28 changes: 27 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading