Files
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

94 lines
3.1 KiB
Python

"""Logic for the `pr` domain. Transport-agnostic (D-263)."""
from __future__ import annotations
import shutil
from pathlib import Path
from tooling.core import config, process
from tooling.core.errors import ReachError
LOGIN = "schweitz"
REPO = "jpmschweitzer/settled-reach"
# Paths whose change means the committed systems.db may be stale. The generator
# set is read from the registry rather than restated, so this list cannot drift
# from tooling/generator_sources.py (T-1067).
EXTRA_WATCHED = (
"tooling/planet-gen/import_heightmaps.py",
"tooling/planet-gen/import_province_boundaries.py",
"server/data/systems-schema.sql",
"wiki/star-systems/",
"wiki/economics/",
)
def comment(number: str, body_or_path: str) -> None:
"""Post a comment on a Gitea PR or issue.
`@path` reads the body from a file — the form that exists because a long
comment inline would need a `$(...)` subshell, and a subshell breaks the
permission gate's prefix matching (.claude/rules/tea-cli.md).
Every flag is explicit for the same reason the rule gives: omitting
`--login` or `--repo` sends `tea` interactive, and an interactive prompt
with no TTY does not wait — it crashes.
"""
body = _resolve_body(body_or_path)
tea = shutil.which("tea")
if tea is None:
raise ReachError(
"the tea CLI is not on PATH",
fix="tea is symlinked into ~/.local/bin — check that, rather than "
"calling the linuxbrew path directly, which breaks the Bash(tea *) rule",
)
process.run(
[tea, "comment", "--login", LOGIN, "--repo", REPO, number, body],
cwd=config.repo_root(),
fix=f"check that PR/issue {number} exists and the login is authorised",
)
def watchlist_diff(base: str, head: str) -> list[str]:
"""Files changed in the range that would make the committed systems.db stale."""
from tooling import generator_sources
root = config.repo_root()
registered = sorted(
{
str(path.relative_to(root))
for paths in generator_sources.GENERATOR_SOURCES.values()
for path in paths
}
)
watched = [*registered, *EXTRA_WATCHED]
if not watched:
# The original checked this too: an empty registry would diff nothing
# and report a clean watchlist, which is the answer that lets a stale DB
# through.
raise ReachError(
"the generator-source registry is empty",
fix="tooling/generator_sources.py should list every generator input",
)
result = process.run(
["git", "diff", "--name-only", f"{base}...{head}", "--", *watched],
cwd=config.repo_root(),
check=False,
)
return [line for line in (result.stdout or "").splitlines() if line]
def _resolve_body(body_or_path: str) -> str:
if not body_or_path.startswith("@"):
return body_or_path
path = Path(body_or_path[1:])
if not path.is_file():
raise ReachError(
f"comment body file not found: {path}",
fix="write the comment to a file first, then pass @that-path",
)
return path.read_text(encoding="utf-8")