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>
62 lines
2.4 KiB
Python
62 lines
2.4 KiB
Python
"""Repo-root and path resolution. No domain logic lives here (D-263).
|
|
|
|
This module is on the push-gate path, so it does no subprocess work: no
|
|
`git rev-parse`, no shelling out, nothing beyond the stdlib. Resolution is
|
|
`__file__`-relative and validated against a sentinel file. That is not only
|
|
faster than asking git — it is *more correct*, because the current working
|
|
directory is not a reliable signal. A git hook runs from the repo root, an agent
|
|
`Bash` call may not, and `reach` is meant to work from anywhere.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from tooling.core.errors import ReachError
|
|
|
|
# The file whose presence proves a directory is the repo root. project.yaml is
|
|
# the version source of truth (CLAUDE.md), so it is the honest sentinel: if it
|
|
# is absent, everything downstream was going to fail anyway — better to say so
|
|
# here, by name, than to hand out a plausible wrong path.
|
|
SENTINEL = "project.yaml"
|
|
|
|
ENV_OVERRIDE = "SR_REPO_ROOT"
|
|
|
|
|
|
def repo_root() -> Path:
|
|
"""Absolute path to the repo root.
|
|
|
|
`SR_REPO_ROOT` wins if set; otherwise the package's own location is used.
|
|
Both are validated, deliberately: an override pointing somewhere useless
|
|
should fail loudly rather than fall back to a default that happens to work,
|
|
because a silent fallback is how you end up editing one checkout and
|
|
checking another.
|
|
|
|
Not cached. The two syscalls are microseconds, and a cache would make the
|
|
override untestable for the sake of nothing measurable.
|
|
"""
|
|
override = os.environ.get(ENV_OVERRIDE)
|
|
if override:
|
|
return _validated(Path(override).expanduser().resolve(), f"{ENV_OVERRIDE}={override}")
|
|
# tooling/core/config.py -> tooling/core -> tooling -> repo root
|
|
return _validated(Path(__file__).resolve().parents[2], "the installed package location")
|
|
|
|
|
|
def path(*parts: str) -> Path:
|
|
"""Join `parts` onto the repo root, so callers do not each re-resolve it."""
|
|
return repo_root().joinpath(*parts)
|
|
|
|
|
|
def _validated(root: Path, source: str) -> Path:
|
|
if (root / SENTINEL).is_file():
|
|
return root
|
|
raise ReachError(
|
|
f"cannot locate the repo root: {root} contains no {SENTINEL} "
|
|
f"(resolved from {source})",
|
|
fix=(
|
|
"make reach-repoint — from the checkout you want reach to follow. "
|
|
f"For a single command instead: {ENV_OVERRIDE}=<path-to-repo> reach ..."
|
|
),
|
|
)
|