feat(config): T-1249 — the contract is a decorator, and now a test

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>
This commit is contained in:
2026-08-31 15:03:34 +02:00
co-authored by Claude Opus 5
parent 1eb30a1460
commit 49fa6ada95
16 changed files with 965 additions and 35 deletions
+64
View File
@@ -0,0 +1,64 @@
"""The one module in `core/` that knows about Typer.
**Why this is not a violation of the layering.** The invariant D-263 states is
that typer/click appear only in `main.py` and `router.py` — and its purpose is
that a *service* must never know it was called from a CLI. A shared group class
is transport by definition; the alternative is copying the same subclass into
every `router.py`, where the copies drift and only some domains end up
enumerating their verbs. So the invariant is refined rather than broken:
no typer/click in service.py, schemas.py or helpers.py — ever.
transport lives in main.py, router.py, and this module.
Keep that bound. If something here stops being about *transport*, it belongs
somewhere else.
"""
from __future__ import annotations
import typer
from typer.core import TyperGroup
class ReachDomainGroup(TyperGroup):
"""A domain group whose unknown-verb error names the verbs that exist.
Click's default is `No such command 'x'` — which tells you that you are
wrong without telling you what would be right. That is the closed-set gap
D-263 measured in pql (an invalid status rejected without naming the six
valid ones), and the fix is nearly free: the verb list is already registered
on the group, so enumerating it costs a sort.
"""
def resolve_command(self, ctx: typer.Context, args: list[str]):
if args and self.get_command(ctx, args[0]) is None:
listed = ", ".join(sorted(self.list_commands(ctx)))
ctx.fail(f"unknown command {args[0]!r}\n\nChoose one of: {listed}")
return super().resolve_command(ctx, args)
def domain(name: str, help: str) -> typer.Typer:
"""Build a domain's Typer app with the house settings applied.
Every domain router should use this rather than calling `typer.Typer`
directly, so the settings that are easy to forget are not per-router
decisions:
- `cls=ReachDomainGroup` so unknown verbs enumerate.
- `rich_markup_mode=None` — load-bearing, not cosmetic: it keeps `rich` and
`pygments` off the import path, and stops typer drawing box-art help even
when stdout is a pipe, which would litter hook logs.
- `no_args_is_help` so a bare `reach <domain>` says what it can do.
Note the caller still needs a `@app.callback()` on the router: Typer
collapses a single-command app into a bare command, and without the callback
`reach <domain> <verb>` fails with "unexpected extra argument".
"""
return typer.Typer(
name=name,
help=help,
cls=ReachDomainGroup,
no_args_is_help=True,
add_completion=False,
rich_markup_mode=None,
)
+47
View File
@@ -0,0 +1,47 @@
"""`@command` — the whole contract in one decorator (D-263).
Cross-cutting concerns are decorators, never call-site discipline. The point of
composing them here is that a command author **cannot apply half the contract**:
there is no way to get logging without error handling, or to remember one and
forget the other on the 40th command of a long porting session. That failure
mode is the reason D-263 makes these decorators rather than conventions.
Usage, and it goes UNDER the Typer registration so it wraps the function Typer
will call:
@app.command("client-version")
@command
def client_version() -> None:
...
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, TypeVar
from tooling.core.errors import handle_errors
from tooling.core.logging import logged
F = TypeVar("F", bound=Callable[..., Any])
# Set by @command and asserted by the conformance test. A marker attribute is
# used rather than inspecting the composition after the fact, because unwrapping
# functools.wraps chains to prove "this was decorated" is brittle in exactly the
# way a conformance test must not be.
MARKER = "__reach_command__"
def command(func: F) -> F:
"""Compose the invocation contract onto one command function.
**Order is load-bearing.** `handle_errors` wraps `logged`, not the reverse:
the logger's `finally` then sees the ORIGINAL exception and records its type
as the outcome. Invert them and the error handler converts everything to
`SystemExit` first, so every failure is logged as "SystemExit" and the
record says nothing about what actually went wrong — while still looking
like it worked.
"""
wrapped = handle_errors(logged(func))
setattr(wrapped, MARKER, True)
return wrapped # type: ignore[return-value]
+8 -10
View File
@@ -13,6 +13,8 @@ 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
@@ -49,15 +51,11 @@ def path(*parts: str) -> Path:
def _validated(root: Path, source: str) -> Path:
if (root / SENTINEL).is_file():
return root
# Raised as RuntimeError only because core/errors.py does not exist yet;
# T-1249 converts this to ReachError(message, fix=...). The message already
# follows the contract — it names the command that fixes it.
raise RuntimeError(
raise ReachError(
f"cannot locate the repo root: {root} contains no {SENTINEL} "
f"(resolved from {source}).\n"
f"If reach was installed from a different checkout than the one you are "
f"working in, re-point it:\n"
f" uv tool install --editable <path-to-repo>\n"
f"To override for a single command:\n"
f" {ENV_OVERRIDE}=<path-to-repo> reach ..."
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 ..."
),
)
+10
View File
@@ -49,6 +49,16 @@ def set_level(name: str) -> None:
_threshold = LEVELS[name.lower()]
def is_verbose() -> bool:
"""True when debug-level events are being emitted.
Read by the error handler to decide whether an unexpected failure gets a
traceback or a one-liner. Verbosity is one setting, not two — a --verbose
that showed debug events but hid tracebacks would be a puzzle.
"""
return _threshold <= LEVELS["debug"]
def out(text: str = "") -> None:
"""Write to stdout — the command's actual output, never commentary."""
print(text, file=sys.stdout, flush=True)
+110
View File
@@ -0,0 +1,110 @@
"""Failures that teach, as a decorator rather than call-site discipline (D-263).
The contract: **every non-zero exit prints the command that would fix it, and
still exits non-zero.** Both halves matter, and the second is the one that gets
lost. A tool that explains itself beautifully and exits 0 has silently disabled
its own gate — and the explanation makes it look *more* correct, not less, which
is why this is a decorator with a test behind it and not a convention.
No typer or click import here. `core/` is transport substrate: 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 would leak the transport into
every layer. Exit is raised as a plain `SystemExit`, which click passes through
untouched.
"""
from __future__ import annotations
import functools
from collections.abc import Callable, Iterable
from typing import Any, TypeVar
from tooling.core import console
F = TypeVar("F", bound=Callable[..., Any])
class ReachError(Exception):
"""A failure the caller can act on.
`fix` is not optional in spirit — it is the whole point. If you cannot name
a next command, you probably do not understand the failure well enough to
report it yet, and a message that only says "no" is the thing this exists to
replace.
"""
def __init__(self, message: str, *, fix: str | None = None, exit_code: int = 1) -> None:
super().__init__(message)
self.message = message
self.fix = fix
# Non-zero by construction. A ReachError carrying exit_code=0 would be a
# contradiction — and exactly the silent-gate failure described above.
self.exit_code = exit_code if exit_code != 0 else 1
def unknown_choice(kind: str, given: str, accepted: Iterable[str]) -> ReachError:
"""Reject a value from a known finite set, naming the whole set.
Whenever the accepted values are knowable, print them. This is the specific
gap D-263 measured in pql — an invalid ticket status rejected without naming
the six valid ones — which leaves the caller grepping source to guess.
"""
options = sorted(accepted)
listed = ", ".join(options) if options else "(none available)"
return ReachError(
f"unknown {kind}: {given!r}",
fix=f"choose one of: {listed}",
exit_code=2,
)
def handle_errors(func: F) -> F:
"""Render a failure through `console`, then exit with its code.
Deliberately catches nothing it cannot improve on. `SystemExit` passes
through — a decision to exit has already been made and re-reporting it would
double the output.
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except ReachError as exc:
# The verdict prints ONCE, LAST, after whatever the command streamed.
# A remedy emitted mid-stream at line 400 of 900 is technically
# printed and practically invisible.
console.verdict(exc.message, ok=False, fix=exc.fix)
raise SystemExit(exc.exit_code) from exc
except SystemExit:
raise
except Exception as exc:
_report_unexpected(exc)
raise SystemExit(1) from exc
return wrapper # type: ignore[return-value]
def _report_unexpected(exc: Exception) -> None:
"""An exception nobody anticipated still exits non-zero and still says something.
The traceback goes behind `--verbose` rather than at a user who cannot act on
it; the one-line form names the flag that reveals it, so the next step is
always visible even when the failure was not foreseen.
"""
if console.is_verbose():
import traceback
console.event(traceback.format_exc().rstrip(), level="error")
console.verdict(
f"unexpected {type(exc).__name__}: {exc}",
ok=False,
fix="the traceback above is the whole story — this is a bug in reach, not in your input",
)
return
console.verdict(
f"unexpected {type(exc).__name__}: {exc}",
ok=False,
fix="re-run with --verbose for the traceback",
)
+66
View File
@@ -0,0 +1,66 @@
"""One structured record per invocation (D-263).
**This is not a logging subsystem.** It is a decorator and an event kind. The
record goes out through `core/console` like everything else, because D-263 names
console the single output path and two sinks would drift — in format, in
destination, in level handling — with the second always being the one nobody
remembers to configure.
Quiet by default. The record is a `debug` event, so the push hook's output looks
exactly as it does today and `--verbose` is what surfaces it. A gate that
suddenly printed a line per check would train people to stop reading gate
output, which is worse than having no record at all.
"""
from __future__ import annotations
import functools
import time
from collections.abc import Callable
from typing import Any, TypeVar
from tooling.core import console
F = TypeVar("F", bound=Callable[..., Any])
# Values that should never appear in a log line even at debug level. Repo
# tooling is not handling credentials today, but the cost of the guard is one
# frozenset and the cost of discovering it was needed is a leaked secret.
_REDACT = frozenset({"password", "token", "secret", "api_key", "apikey"})
def logged(func: F) -> F:
"""Emit command, arguments, duration and outcome for one invocation.
Records the outcome in a `finally`, so a command that raises is still
reported — with the exception type as its outcome rather than silence.
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
started = time.monotonic()
outcome = "ok"
try:
return func(*args, **kwargs)
except BaseException as exc:
outcome = type(exc).__name__
raise
finally:
console.event(
f"{func.__name__} {outcome}",
level="debug",
command=func.__name__,
args=_safe(kwargs),
duration_ms=round((time.monotonic() - started) * 1000, 1),
outcome=outcome,
)
return wrapper # type: ignore[return-value]
def _safe(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Argument values, with anything secret-shaped replaced."""
return {
key: ("***" if key.lower() in _REDACT else value)
for key, value in kwargs.items()
}
+47
View File
@@ -0,0 +1,47 @@
"""Session-level interaction state. No domain, so it lives in core (D-263).
One question, asked in one place: **may this invocation prompt?**
The answer is not just a flag, because the flag alone is not safe. A prompt with
no TTY does not wait for an answer — it *crashes*, which is the recorded `tea`
failure in this repo (interactive prompts die in Claude Code, no terminal). So
`can_prompt()` requires both an interactive stream and the absence of
`--no-input`. Hooks and agents pass `--no-input` explicitly, and would be
protected by the TTY check even if they forgot.
Nothing prompts today. This exists so that the first thing that wants to has an
obvious correct answer available, rather than inventing its own `isatty` check
that gets it half right.
"""
from __future__ import annotations
import sys
_no_input = False
def set_no_input(value: bool) -> None:
"""Record the `--no-input` flag for this invocation."""
global _no_input
_no_input = value
def no_input() -> bool:
"""True when the caller has forbidden prompting."""
return _no_input
def can_prompt() -> bool:
"""True only when prompting is both permitted AND possible.
Check this, never `isatty` alone and never the flag alone — the two guard
different failures. The flag is a caller's instruction; the TTY check is
what stops a prompt from crashing a hook that forgot to pass it.
"""
if _no_input:
return False
try:
return sys.stdin.isatty() and sys.stderr.isatty()
except (AttributeError, ValueError):
return False