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:
+30
-7
@@ -19,17 +19,35 @@ from __future__ import annotations
|
||||
import typer
|
||||
from typer.core import TyperGroup
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
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.
|
||||
class ReachGroup(TyperGroup):
|
||||
"""Shared behaviour for every group in `reach` — the root and each domain.
|
||||
|
||||
Two things, both of which exist because the primary user is an agent
|
||||
(D-263) rather than a person at a terminal.
|
||||
|
||||
**No arguments means "what is here?", not "you got it wrong".** Click's
|
||||
`no_args_is_help` prints help and exits **2**, a usage error. But running
|
||||
`reach` or `reach <domain>` bare 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. So help is printed and the exit
|
||||
is **0**. This does not weaken D-263's exit-code contract, which governs
|
||||
*failures*; printing a command list is not one.
|
||||
|
||||
**An unknown name enumerates what exists.** Click's default is
|
||||
`No such command 'x'`, which says you are wrong without saying what would
|
||||
be right — the closed-set gap D-263 measured in pql. The list is already
|
||||
registered on the group, so naming it costs a sort.
|
||||
"""
|
||||
|
||||
def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
|
||||
if not args and self.no_args_is_help and not ctx.resilient_parsing:
|
||||
console.out(ctx.get_help())
|
||||
ctx.exit(0)
|
||||
return super().parse_args(ctx, args)
|
||||
|
||||
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)))
|
||||
@@ -37,6 +55,11 @@ class ReachDomainGroup(TyperGroup):
|
||||
return super().resolve_command(ctx, args)
|
||||
|
||||
|
||||
# Kept as a name because domain routers read better with it, but there is no
|
||||
# separate behaviour: a domain group IS a reach group.
|
||||
ReachDomainGroup = ReachGroup
|
||||
|
||||
|
||||
def domain(name: str, help: str) -> typer.Typer:
|
||||
"""Build a domain's Typer app with the house settings applied.
|
||||
|
||||
|
||||
+7
-2
@@ -35,7 +35,8 @@ from __future__ import annotations
|
||||
import importlib
|
||||
|
||||
import typer
|
||||
from typer.core import TyperGroup
|
||||
|
||||
from tooling.core.cli import ReachGroup
|
||||
|
||||
# The domain registry: name -> (import target, one-line help).
|
||||
#
|
||||
@@ -53,9 +54,13 @@ DOMAINS: dict[str, tuple[str, str]] = {
|
||||
}
|
||||
|
||||
|
||||
class LazyDomainGroup(TyperGroup):
|
||||
class LazyDomainGroup(ReachGroup):
|
||||
"""Lists domains without importing them; imports exactly the one invoked.
|
||||
|
||||
Extends `ReachGroup`, so bare `reach` prints the domain list and exits 0
|
||||
like every domain group does. Only the laziness and the domain-specific
|
||||
wording live here.
|
||||
|
||||
Three overrides, and the third is the one that matters. `TyperGroup`'s own
|
||||
`format_commands` loops over `list_commands` calling `get_command` on each,
|
||||
just to read a short help string off the loaded command — which, with lazy
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user