Every non-zero exit names the command that would fix it, and still exits non-zero. Both halves matter; the second is the one that gets lost, because a tool that explains itself beautifully and exits 0 looks MORE correct while having silently disabled its own gate. core/errors.py holds ReachError(message, fix=) and @handle_errors. core/logging.py holds @logged, emitting through console rather than a second sink — one output path, so there is nothing to drift. core/command.py composes them, and the order is load-bearing: handle_errors wraps logged, so the logger sees the original exception. Inverted, every failure would be recorded as "SystemExit" and the log would say nothing about what went wrong while looking like it worked. core/ raises SystemExit, not typer.Exit. A service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI leaks the transport into every layer. The check router is retrofitted off its hand-rolled verdict-and-exit pattern — exactly the boilerplate this removes — and test_check_parity.py passes unchanged across the retrofit. That test predates the decorators and pins exit codes against the old script, so it is independent evidence, not a test tuned to match new behaviour. Unknown domains and unknown verbs now enumerate what exists instead of only saying no. That needed a shared group class, which collided with "no typer outside main.py and router.py" — resolved by sharpening the invariant rather than breaking it, since its purpose is that a SERVICE never knows it was called from a CLI. Transport now lives in main.py, router.py and core/cli.py; never in service.py, schemas.py or helpers.py. The upside is that cli.domain() carries the settings that were previously per-router decisions, including the load-bearing rich_markup_mode=None that one forgetful domain could have undone. test_conformance.py makes five invariants executable, AST-based rather than grep. Scoped to the package, not the 123 legacy scripts — and deliberately so: as T-1250 moves each script into domains/, it lands inside the scope and the rules start applying automatically, so the test's reach grows with the migration. Proven to fail before being trusted: removing @command and removing a fix= each produced a failure naming the file, the line and the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
67 lines
2.7 KiB
Python
67 lines
2.7 KiB
Python
"""Transport for the `check` domain — args in, delegate, format out.
|
|
|
|
**Zero logic lives here.** Every command should read as: parse, call a service,
|
|
turn the result into output. If a command grows a branch that is about the
|
|
*problem* rather than about *presentation*, that branch belongs in `service.py`.
|
|
|
|
Note what the commands below no longer do: no `console.verdict(..., ok=False)`
|
|
followed by `raise typer.Exit(1)` at each failing branch. They raise
|
|
`ReachError` with a remedy and `@command` does the rest — renders the verdict
|
|
once, last, and exits non-zero. That is the difference between a contract and a
|
|
habit, and it is why every command here wears `@command`.
|
|
|
|
The service import is deliberately at module level: by the time this module is
|
|
imported at all, `reach` has decided to run a `check` command, so there is
|
|
nothing left to defer. Laziness lives one level up, in `main.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from tooling.core import cli, console
|
|
from tooling.core.command import command
|
|
from tooling.core.errors import ReachError
|
|
from tooling.domains.check import service
|
|
|
|
app = cli.domain("check", "Consistency gates — the checks the push hook runs.")
|
|
|
|
|
|
@app.callback()
|
|
def _domain() -> None:
|
|
"""Keeps `check` a group.
|
|
|
|
Typer collapses a single-command app into a bare command, so without this
|
|
`reach check client-version` fails with "unexpected extra argument". Every
|
|
domain router needs this until it has two or more verbs — and keeping it
|
|
afterwards costs nothing and stops the shape changing under you.
|
|
"""
|
|
|
|
|
|
@app.command("client-version")
|
|
@command
|
|
def client_version() -> None:
|
|
"""Fail if the client's baked version has drifted from project.yaml."""
|
|
result = service.client_version()
|
|
|
|
if result.problem:
|
|
raise ReachError(
|
|
f"check-client-version: {result.problem}",
|
|
fix="check that project.yaml and client/project.godot exist and are readable",
|
|
)
|
|
|
|
if not result.ok:
|
|
raise ReachError(
|
|
"check-client-version: version drift\n"
|
|
f" project.yaml {result.yaml_version}\n"
|
|
f" client/project.godot {result.godot_version}\n"
|
|
"\n"
|
|
"This matters beyond cosmetics: the Atlas disk cache keys its\n"
|
|
"invalidation on this version, so a stale mirror makes a shipped\n"
|
|
"build serve canvases generated by code it no longer runs (T-1239).",
|
|
fix=(
|
|
"set config/version in client/project.godot's [application] "
|
|
f"section to {result.yaml_version} — project.yaml is the source of truth"
|
|
),
|
|
)
|
|
|
|
console.verdict(f"check-client-version: OK — {result.yaml_version}")
|