The fixture was four lines. monkeypatch.setattr(registry_lib, "REGISTRY_PATH", test_path), point at a temp directory, run the test, assert the output. Clean, idiomatic, the kind of thing you write without thinking because you’ve written it a hundred times. The test passed. The CI was green. The real ~/.claude/skills/roadmap/registry.json had three entries in it that shouldn’t exist.
That was the moment I understood the fixture was wrong in a way pytest wasn’t going to tell me about.
What monkeypatch actually does
monkeypatch.setattr rebinds an attribute on a live Python object in the current process. The target here was registry_lib.REGISTRY_PATH — a module-level string that the roadmap CLI reads to find its registry file. The patch sets it to a throwaway path inside tmp_path. At teardown, monkeypatch restores the original. Standard pytest isolation.
The problem is the word “process.”
The roadmap CLI doesn’t run as an in-process function call. The test suite invokes it as a subprocess — subprocess.run(["python", "-m", "roadmap", ...]) or similar. That subprocess is a fresh Python interpreter, importing registry_lib from scratch and reading whatever the source code says REGISTRY_PATH is. The monkeypatch in the parent process never touched this interpreter; it never could. The attribute rebinding lives in a completely different memory space.
So the subprocess opens the real ~/.claude/skills/roadmap/registry.json and writes to it. Every CLI test pollutes production state. The fixture sells you isolation; the real registry accumulates noise behind it.
The test passes because the assertion in the parent process is checking the patched path, which is indeed clean. There’s no error. There’s no warning. The green tick is a lie told by two processes that aren’t talking to each other.
Why the roadmap registry was the casualty
The registry in this project is ~/.claude/skills/roadmap/registry.json. It tracks every repo that has been enrolled in the roadmap system — at the point this bug surfaced, that was three repos: BBBrain, captainrandom, and ~/.claude/ itself, registered during the Wave 4b backfill that produced the 303-line skeleton in .claude.
Each test run that touched roadmap init or any write path was creating scratch entries in that file. They were structurally valid, because the CLI writes well-formed JSON, so nothing downstream complained. The registry just grew. Entries for paths that didn’t exist outside the test run, repos that had never been initialised, phase data fabricated by the fixture setup. By the time I caught it, the real registry had entries it shouldn’t have had.
The devlog entry for E4 names this directly: “kill scratch-repo registry pollution.” That’s the right framing. It wasn’t subtle corruption. It was write pollution accumulating invisibly because the isolation boundary was in the wrong place.
The fix
The subprocess needs to receive the override. The only mechanism that crosses a process boundary cleanly is the environment.
Replace the monkeypatch.setattr with monkeypatch.setenv:
def test_registry_write(monkeypatch, tmp_path):
monkeypatch.setenv("ROADMAP_REGISTRY_PATH", str(tmp_path / "registry.json"))
result = subprocess.run(
["python", "-m", "roadmap", "init", "--repo", str(tmp_path / "repo")],
capture_output=True,
text=True,
)
assert result.returncode == 0
The CLI reads ROADMAP_REGISTRY_PATH from the environment on startup, before any module-level constant is used. The subprocess inherits the env from the parent, monkeypatch restores the parent env at teardown, and the real registry is never opened.
The other half of the fix is in the CLI itself. REGISTRY_PATH can’t be a bare module-level constant if you want environment-driven overrides to work. It needs to be resolved at call time:
import os
from pathlib import Path
def get_registry_path() -> Path:
override = os.environ.get("ROADMAP_REGISTRY_PATH")
if override:
return Path(override)
return Path.home() / ".claude" / "skills" / "roadmap" / "registry.json"
Every call site that imported REGISTRY_PATH directly now goes through get_registry_path() — the constant is gone and resolution is deferred to call time. That’s the structural change that makes the env-var override meaningful. Without it, the module-level constant is cached at import time in the subprocess and the env var is never consulted.
Trade-off: every registry operation now pays the cost of an os.environ.get call. Acceptable — this is file I/O code, the lookup is noise.
What the existing fixture was actually testing
This is worth naming because it isn’t nothing.
The original monkeypatch.setattr fixture was testing something real: that the in-process module-level attribute was correctly read and used by any Python code running in the same interpreter. If the CLI had been implemented as a library function called directly from the test, the fixture would have been exactly correct.
The architectural assumption was the gap. The fixture assumed in-process execution; the test exercised a subprocess. In small codebases where one author knows every call path, those assumptions converge. In a CLI project with multiple entry points, they don’t, and pytest has no way to surface the mismatch.
The heuristic I now use: if the test uses subprocess.run, subprocess.Popen, or any shell invocation, monkeypatch is the wrong isolation tool. Env vars or temp config files are the candidates. Pick whichever the target process already reads.
Scope note
Out of scope: the MCP GitHub plugin issue that also surfaced this week. That’s a separate failure mode — plugin:github:github needs interactive Copilot OAuth that can’t run in CI, resolved by accepting gh CLI as the GitHub answer instead. Different problem, different fix, logged in the roadmap decision record at §5.
Out of scope: the broader Wave 4b backfill work, the cross-repo log aggregation (D1, 19 entries across 5 repos), and the BBBrain dashboard panel (D2). Those shipped and are working. The registry pollution was introduced during that work and didn’t surface until the E4 test run.
What I’d tell past-me
Check the call path before you pick the isolation tool. Subprocess tests and monkeypatch are incompatible. The test will pass, the fixture will feel correct, and the side effects will land somewhere you’re not looking.
Env vars cross process boundaries; attribute patches don’t. This is not a pytest limitation. It’s process isolation working exactly as designed.
Design the CLI to read env vars for any path it writes to. If a path is hardcoded at module level, it can’t be overridden without a source change. os.environ.get with a sensible default costs nothing and makes every subprocess test trivially isolatable.
A passing test is not evidence of correct isolation. It’s evidence that the assertion fired without raising. Those are different things when the test and the system under test run in different processes.
The scratch-repo entries are gone from ~/.claude/skills/roadmap/registry.json. The fixture now points the subprocess at a temp path it will never find again after teardown. The roadmap registry has three entries, the right three, and stays that way between test runs.
The devlog entry is E4, dated 2026-05-25. The brainstorm that prompted the fix is B-004, promoted and shipped the same day. The full chain is in the append-only log if you want the sequence.