Files
settled-reach/tooling/domains/dev/environment.py
T
jpmschweitzerandClaude Opus 5 338644b409 refactor(tooling): T-1286 — generate, pr and dev become reach domains
Twelve scripts retired, three domains registered. `reach` now covers nine.

generate: `generate-brands` and `generate-corporations` were the second and
third copies of the same 24-line build-if-missing-then-exec bash `tooling/atlas`
carried, so they collapsed into `core.process.cargo_binary` rather than being
ported. `import_economics` shelled out to the first of those, so it now calls
that helper — `generated_brands.toml` comes back byte-identical, and the stamp
registry swaps the retired wrapper for `core/process.py`.

pr: `watchlist-diff` derives its watched set from `generator_sources.py` instead
of restating it, so it cannot drift from the stamp check.

dev: the environment scripts split decision from performing, per D-263's
guarded-exec rule. `godot_plan()` and `worktree_plan()` decide what would
happen; `install_godot()`, `install_rust()` and `setup_worktree()` do it.
`tooling/test_environment.py` pins the version pin, both override precedences,
the already-current skip, the platform refusal and both worktree refusals —
none of them performed. `make setup` now installs reach first, since the
targets that install rust and godot are reach verbs.

Two live bugs found while porting:

- The clerk read its decision index from `decisions/README.md`, a path that
  stopped existing when the DQR tree moved to `governance/`. Every clerk agent
  has been grepping blind; its prompt pointed at the same dead directory.
- The conformance exec-check matched any `x.system()` regardless of receiver,
  so `platform.system()` read as `os.system()`. Narrowed and re-proved against
  a real mutant.

`process.run` gains `input=`, `timeout=` and a `ProcessTimeout` subclass so a
killed run stays distinguishable from a verdict. The pre-push hook no longer
merges the clerk's stderr into its stdout — under streaming the last merged
line is a JSONL event, which would read as an unrecognised verdict and block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:00:46 +02:00

204 lines
7.2 KiB
Python

"""Environment setup — the decisions, separated from the performing (D-263).
These three ported worst and mattered most to get right. `install-godot`
downloads and unzips a pinned build, `install-rust` drives `rustup`,
`worktree-setup` manipulates git worktrees — none of which can be exercised in
a gate without actually doing it.
So the shape here is deliberate: **every function that decides is pure and
importable, and every function that acts is a thin call through
`core.process.run`.** `godot_plan()` answers "what would be downloaded, and is
it needed" without touching the network; `install_godot()` performs it. The
test of a correct port is whether the decisions can be exercised WITHOUT
performing them — a rewrite that cannot be tested has to be trusted instead,
and trusting an installer is how a working environment becomes an
unreproducible one.
"""
from __future__ import annotations
import os
import platform
import shutil
from dataclasses import dataclass
from pathlib import Path
from tooling.core import config, console, process
from tooling.core.errors import ReachError
DEFAULT_GODOT_VERSION = "4.6"
INSTALL_DIR = Path.home() / "bin"
GODOT_BINARY = INSTALL_DIR / "godot4"
# Godot publishes one archive per platform triple; an unsupported pair is a
# clear failure rather than a download that 404s.
PLATFORMS = {
("Linux", "x86_64"): "linux.x86_64",
("Linux", "aarch64"): "linux.arm64",
("Darwin", "x86_64"): "macos.universal",
("Darwin", "arm64"): "macos.universal",
}
@dataclass(frozen=True)
class GodotPlan:
"""What installing Godot would do — decided without doing any of it."""
wanted: str
installed: str | None
platform_tag: str
url: str
filename: str
@property
def already_current(self) -> bool:
return self.installed == self.wanted
def godot_plan(wanted: str | None = None) -> GodotPlan:
"""Decide what an install would fetch. Pure apart from reading the binary."""
wanted = wanted or os.environ.get("GODOT_VERSION", DEFAULT_GODOT_VERSION)
system, machine = platform.system(), platform.machine()
tag = PLATFORMS.get((system, machine))
if tag is None:
raise ReachError(
f"unsupported platform: {system} {machine}",
fix="install Godot manually from https://godotengine.org/download",
)
filename = f"Godot_v{wanted}-stable_{tag}.zip"
return GodotPlan(
wanted=wanted,
installed=installed_godot_version(),
platform_tag=tag,
filename=filename,
url=(
"https://github.com/godotengine/godot/releases/download/"
f"{wanted}-stable/{filename}"
),
)
def installed_godot_version() -> str | None:
"""The major.minor already on disk, or None. Never raises."""
if not GODOT_BINARY.is_file() or not os.access(GODOT_BINARY, os.X_OK):
return None
result = process.run([str(GODOT_BINARY), "--version"], check=False)
first = (result.stdout or "").splitlines()
if not first:
return None
return ".".join(first[0].split(".")[:2])
def install_godot(wanted: str | None = None) -> GodotPlan:
"""Perform the install. The decision is `godot_plan`; this is the doing."""
plan = godot_plan(wanted)
if plan.already_current:
console.verdict(f"Godot {plan.wanted} already installed at {GODOT_BINARY}")
return plan
if plan.installed:
console.event(
f"Godot found but version is {plan.installed}, want {plan.wanted}",
level="warn",
)
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
import tempfile
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / plan.filename
console.event(f"downloading Godot {plan.wanted} for {plan.platform_tag}", phase="godot")
process.run(
["curl", "-fSL", "--progress-bar", "-o", str(archive), plan.url],
capture=False,
fix=f"check that GODOT_VERSION={plan.wanted} is a real release",
missing_fix="install curl",
)
console.event("extracting", phase="godot")
process.run(
["unzip", "-q", "-o", str(archive), "-d", tmp],
fix="the archive may be truncated — re-run to download it again",
missing_fix="install unzip",
)
extracted = next(
(p for p in Path(tmp).iterdir() if p.is_file() and p.name.startswith("Godot")),
None,
)
if extracted is None:
raise ReachError(
"the Godot archive contained no binary",
fix="the download may be corrupt — delete it and re-run",
)
shutil.move(str(extracted), GODOT_BINARY)
GODOT_BINARY.chmod(0o755)
console.verdict(f"Godot {plan.wanted} installed at {GODOT_BINARY}")
return plan
def install_rust() -> None:
"""Ensure rustup, clippy and rustfmt are present."""
if shutil.which("cargo") is None:
raise ReachError(
"cargo is not installed",
fix="install Rust from https://rustup.rs, then re-run — this command "
"adds the components but does not bootstrap the toolchain",
)
for component in ("clippy", "rustfmt"):
console.event(f"ensuring {component}", phase="rust")
process.run(
["rustup", "component", "add", component],
check=False,
missing_fix="install rustup from https://rustup.rs",
)
console.verdict("Rust toolchain ready — clippy and rustfmt present")
def worktree_plan(branch: str) -> Path:
"""Where a worktree for `branch` would go. Raises if it cannot be created.
Both refusals are the original's and both are worth keeping: a worktree
created from inside a worktree nests confusingly, and silently reusing an
existing directory is how two branches end up sharing one tree.
"""
root = config.repo_root()
if (root / ".git").is_file():
raise ReachError(
"this is a worktree, not the main checkout",
fix="run this from the main checkout — nesting worktrees confuses "
"both git and the tools that resolve the repo root",
)
target = root / ".worktrees" / branch
if target.exists():
raise ReachError(
f"{target} already exists",
fix=f"reuse it, or remove it first with: git worktree remove {target}",
)
return target
def setup_worktree(branch: str, start: str = "HEAD") -> Path:
"""Create a worktree and link the venv into it."""
target = worktree_plan(branch)
root = config.repo_root()
process.run(
["git", "worktree", "add", str(target), "-b", branch, start],
cwd=root,
fix=f"check that {start} is a valid ref and {branch} is not already a branch",
)
venv = root / ".venv"
if venv.is_dir() and not (target / ".venv").exists():
(target / ".venv").symlink_to(venv)
console.event(f"linked .venv -> {venv}")
console.verdict(
f"worktree ready: {target}\n"
f" pql in this worktree needs --vault {target} (FR-4)\n"
" tea runs from the main checkout — its go-git cannot read a linked worktree\n"
" reach follows whichever checkout it was installed from; `make reach-repoint` "
"if you want it to follow this one"
)
return target