feat(config): T-1275 — bare reach is discovery, so it exits 0

Bare `reach` and bare `reach <domain>` printed help and exited 2, Click's
usage-error convention. Running reach with no arguments is the DISCOVERY
action — it is how the tool gets learned from nothing — and a caller that
branches on exit status would read its own onboarding as a failure. Now they
exit 0.

D-263's exit-code contract is untouched: it governs failures, and printing a
command list is not one. Verified across the whole matrix, because this change
flirts with the exit-0 trap that record opens with — bare 0, bare domain 0,
--help 0, unknown domain 2, unknown verb 2, real failure 1. All five are now
pinned as a sixth conformance invariant, since an exit code regresses silently
and nothing else would notice. Proven to fail by putting the 2 back.

The implementation also collapses a duplicated class. core/cli.py holds
ReachGroup with both shared behaviours — no-args-prints-help-and-exits-0, and
unknown-name-enumerates — and LazyDomainGroup now extends it instead of
subclassing TyperGroup directly, keeping only the laziness and the
domain-specific wording. The enumeration logic previously existed twice in
slightly different forms, which is how the root and the domains would have
drifted into disagreeing about their own conventions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:49:08 +02:00
co-authored by Claude Opus 5
parent 91e25a3e7a
commit b5beda0df7
6 changed files with 105 additions and 9 deletions
+32
View File
@@ -31,6 +31,7 @@ Run: python3 tooling/test_conformance.py
"""
import ast
import shutil
import subprocess
import sys
from pathlib import Path
@@ -195,12 +196,43 @@ def check_errors_name_a_remedy(failures: list[str]) -> None:
)
def check_exit_codes(failures: list[str]) -> None:
"""(6) Discovery succeeds; being wrong fails.
The distinction is easy to break in either direction, and both directions
are bad in ways nothing else would catch. Make discovery a usage error and
an agent reads its own onboarding as a failure. Make a wrong name succeed
and a typo in a hook passes silently — the exit-0 trap D-263 opens with.
"""
cases = [
([], 0, "bare `reach` is discovery, not a usage error"),
(["check"], 0, "bare `reach <domain>` is discovery, not a usage error"),
(["--help"], 0, "--help succeeds"),
(["definitely-not-a-domain"], 2, "an unknown domain is a usage error"),
(["check", "definitely-not-a-verb"], 2, "an unknown verb is a usage error"),
]
for args, expected, why in cases:
result = subprocess.run(
["reach", *args], capture_output=True, text=True, cwd=REPO_ROOT
)
if result.returncode != expected:
failures.append(
f"[exit] `reach {' '.join(args)}` exited {result.returncode}, "
f"expected {expected} — {why}"
)
def main() -> int:
if shutil.which("reach") is None:
print("test_conformance: `reach` is not on PATH.\n Fix: make install-reach", file=sys.stderr)
return 1
failures: list[str] = []
check_transport_isolation(failures)
check_single_output_path(failures)
check_commands_decorated(failures)
check_errors_name_a_remedy(failures)
check_exit_codes(failures)
if failures:
print("test_conformance: FAIL", file=sys.stderr)