feat(config): core/process.run — one guarded exec, and the logic in Python

Clarifies the rewrite decision to what it actually meant: rewriting the bash in
Python does not mean reimplementing the operating system. A guarded exec is the
right answer for rustup, curl, unzip, git, godot, blender. What must become
Python is the LOGIC — which version is wanted, whether it is already present,
what the output means, what to do when it fails. The test of a correct port is
not whether it calls anything external, but whether the decisions can be
exercised without performing them.

Delivered ahead of the remaining ports because every one of them needs it.
core/process.run is the single sanctioned exec, and each of its guards exists
because a per-domain subprocess call is precisely where that guard goes
missing:

- An argv list, never a shell string. A string is rejected outright rather than
  helpfully split, since the helpful split is the vulnerability.
- shell=False always.
- A non-zero exit becomes a ReachError naming the command, carrying its output,
  and preserving its exit code — not a CalledProcessError traceback at someone
  who wanted to know the next step.
- A missing binary reports what to install. FileNotFoundError names the path
  that was not found, which is the less useful half of the answer.

All four verified against real commands, including a genuine git failure
relaying exit 128.

A conformance invariant keeps the door single: nothing outside core/process.py
may import subprocess or call os.system/popen/exec*. Proven to fail by
importing subprocess into a domain service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 17:48:22 +02:00
co-authored by Claude Opus 5
parent b88791705c
commit afe2328182
5 changed files with 171 additions and 0 deletions
+2
View File
@@ -2624,6 +2624,8 @@ tooling/
- **Why not wrap them.** Wrapping achieves one door while leaving half the surface outside the contract: no `@command`, no remedy on failure, no streaming, no testable service. `reach --help` would then list verbs that behave differently from the ones beside them, which is worse than two doors because the inconsistency is invisible until a failure.
- **The cost is real and lands unevenly.** The grep-pipeline scripts (`check-fact-ids`, `validate-ron`, `godot-cold-parse`) compute verdicts and gain the most from becoming services — testable, with failures that teach. The environment scripts (`install-godot`, `install-rust`, `worktree-setup`) gain least and carry the most regression risk, because what they do — download and unzip a specific build, drive `rustup`, manipulate git worktrees — is awkward to exercise in a gate.
- **So the risk is named rather than absorbed:** for those three, port the *decision* logic (which version, is it already installed, what does the tree look like) into a testable service and keep the irreducible external calls behind `core/process`. A rewrite that cannot be tested is a rewrite that has to be trusted, and trusting an installer is how a working environment becomes an unreproducible one.
- **"Rewrite in Python" does not mean reimplementing the operating system** *(clarified 2026-08-31)*. A guarded `exec` is the right answer for genuine OS-level work — `rustup`, `curl`, `unzip`, `git`, `godot`, `blender`. **What must become Python is the LOGIC**: which version is wanted, whether it is already present, what the output means, what to do when it fails. The test of a correct port is not "does it call anything external" but **"can the decisions be exercised without performing them"**.
**Guarded means, specifically:** an argv *list* and never a shell string, so nothing can be injected by a filename; `shell=False` always; a non-zero exit turned into a `ReachError` naming the command and carrying a remedy; and a missing binary reported as *what to install* rather than as `FileNotFoundError`. All of that belongs in one place — `core/process.run` — because a per-domain `subprocess` call is where each of those guards quietly goes missing.
**`make` and `reach` split by kind, not by preference** *(settled 2026-08-31)*. The Makefile has 84 targets and is today's front door, so "one CLI for all repo tooling" is not true until that relationship is stated. The boundary is **what the target actually does**, not who calls it: