reach godot parse-sweep / cold-parse, reach visual diff / blank-check / thumbnail. Five scripts retired, and the callers rewired — tests/run-visual invoked three of them by path at four sites, which is a wider blast radius than the make targets were. The godot pair were grep pipelines encoding five hard-won lessons as comments nobody could test. They are Python filters now, with the reasons attached, and the engine invocation is a guarded exec. Verified on the real client: 229 scripts, clean. Their three not-ok states stay distinct, because only one is a verdict about the code. An engine that crashed or is missing is not a parse failure — reporting it as one blames the tree for a broken toolchain. A sweep that emitted no completion marker checked nothing, and zero errors from a check that never ran reads as clean, which is the false-green the sweep exists to close. The deliberate asymmetry between the two checks is preserved and documented: cold-parse filters "Cannot infer the type", the sweep does not, because that suppression is why cold-parse stayed silent about a helper that genuinely does not parse. All three visual scripts carried the same root bug as validate-checklist: Path(__file__).parent.parent, correct at tooling/ and two levels too deep at tooling/domains/visual. Fixed during the move rather than after, having learned that it fails silently — paths resolve to nothing, the work appears to have nothing to do, and the tool reports success. Three domains now where that would have shipped a false pass. Two bugs my own transformation introduced, both found by running rather than reading. Multi-line print(..., file=sys.stderr) became console.event(..., file=sys.stderr), and console puts unknown kwargs into the payload — a file object would have reached json.dumps at the exact moment something was already being reported as an error. And the replacement script wrote escaped quotes into three files. Mechanical transformations need mechanical verification. sys.exit removed from four sites: a service must not end the process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
226 lines
9.2 KiB
Python
226 lines
9.2 KiB
Python
"""`reach` — one command for every repo tool (D-263).
|
|
|
|
**This file is a router and nothing else.** No logic, no I/O, no pydantic, no
|
|
domain imports at module level. It is the file most likely to accumulate "just
|
|
one small thing", and the only defence is that it stays short enough that an
|
|
addition is obvious in review.
|
|
|
|
The root is a `typer.Typer`, and the reason is worth recording because the
|
|
original plan was different. T-1259 specified a `click.Group` root, on the
|
|
theory that it would keep typer off the `reach --help` path. **That is no longer
|
|
possible: typer vendors click as of 0.26.0** — there is no top-level `click`
|
|
package to import, and the docs are explicit that "extracting the internal Click
|
|
app" is unsupported. Mixing a real `click.Group` root with typer sub-apps would
|
|
mean two different Click implementations in one process.
|
|
|
|
So the customisation surface is `typer.Typer(cls=...)` with a `TyperGroup`
|
|
subclass, which is the supported path and is what `LazyDomainGroup` below uses
|
|
to register domains lazily.
|
|
|
|
`rich_markup_mode=None` is not a style preference — it is worth 94 ms of the
|
|
168 ms an empty `--help` otherwise costs, and it keeps `rich` and `pygments`
|
|
off the import path entirely (verified absent from `sys.modules`). It also
|
|
stops typer drawing box-art help, which it does **even when stdout is a pipe**,
|
|
so hook logs and agent output stay readable. One line to revert if the boxes
|
|
are ever wanted more than the milliseconds.
|
|
|
|
The callback below is not decoration. A `typer.Typer` with **no commands and no
|
|
callback** raises `RuntimeError: Could not get a command for this Typer
|
|
instance` at build time; with a callback it builds fine. Since every domain is
|
|
registered lazily and none is eager, the callback is what makes the root legal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import typer
|
|
|
|
from tooling.core.cli import ReachGroup
|
|
|
|
# The domain registry: name -> (import target, one-line help).
|
|
#
|
|
# This table is the ONLY thing `reach --help` reads. The short help lives here
|
|
# as a literal string rather than being pulled off the loaded command, because
|
|
# reading it off the command is precisely what would import the world — see
|
|
# LazyDomainGroup.format_commands.
|
|
#
|
|
# Adding a domain is adding a line here plus a `router.py` that exposes `app`.
|
|
DOMAINS: dict[str, tuple[str, str]] = {
|
|
"check": (
|
|
"tooling.domains.check.router:app",
|
|
"Consistency gates — the checks the push hook runs",
|
|
),
|
|
"validate": (
|
|
"tooling.domains.validate.router:app",
|
|
"Content, checklists and RON against their schemas",
|
|
),
|
|
"godot": (
|
|
"tooling.domains.godot.router:app",
|
|
"Does the client parse, and does it parse cold",
|
|
),
|
|
"visual": (
|
|
"tooling.domains.visual.router:app",
|
|
"Compare captures against goldens, and catch blank ones",
|
|
),
|
|
"jobs": (
|
|
"tooling.domains.jobs.router:app",
|
|
"Detached runs — status, logs and outcomes",
|
|
),
|
|
"dev": (
|
|
"tooling.domains.dev.router:app",
|
|
"Developer environment and self-diagnosis",
|
|
),
|
|
}
|
|
|
|
|
|
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
|
|
loading underneath, imports every domain in the registry to render `--help`.
|
|
That would defeat the whole mechanism silently, while looking correct.
|
|
|
|
So `format_commands` is overridden to read help from `DOMAINS` and never
|
|
touch `get_command`. The cost of `reach --help` is then flat no matter how
|
|
many domains exist, which is the property that has to hold as this grows
|
|
from one domain to a dozen.
|
|
"""
|
|
|
|
def list_commands(self, ctx: typer.Context) -> list[str]:
|
|
return sorted({*super().list_commands(ctx), *DOMAINS})
|
|
|
|
def get_command(self, ctx: typer.Context, cmd_name: str):
|
|
if cmd_name in DOMAINS:
|
|
return _load_domain(cmd_name)
|
|
return super().get_command(ctx, cmd_name)
|
|
|
|
def resolve_command(self, ctx: typer.Context, args: list[str]):
|
|
# Closed-set enumeration (D-263). Click's default is "No such command
|
|
# 'x'" — which tells you that you are wrong without telling you what
|
|
# would be right, the exact gap measured in pql. The accepted set is
|
|
# sitting in DOMAINS, already loaded for --help, so naming it is free.
|
|
if args and args[0] not in DOMAINS and super().get_command(ctx, args[0]) is None:
|
|
listed = ", ".join(sorted({*DOMAINS, *super().list_commands(ctx)}))
|
|
ctx.fail(f"unknown domain {args[0]!r}\n\nChoose one of: {listed}")
|
|
return super().resolve_command(ctx, args)
|
|
|
|
def format_commands(self, ctx: typer.Context, formatter) -> None:
|
|
# Deliberately does NOT call get_command. See the class docstring.
|
|
rows = [(name, short) for name, (_target, short) in sorted(DOMAINS.items())]
|
|
for name in sorted(super().list_commands(ctx)):
|
|
command = super().get_command(ctx, name)
|
|
if command is not None and not command.hidden:
|
|
rows.append((name, command.get_short_help_str(80)))
|
|
if rows:
|
|
with formatter.section("Domains"):
|
|
formatter.write_dl(sorted(rows))
|
|
|
|
|
|
def _load_domain(name: str):
|
|
"""Import one domain's router and convert its Typer app to a command."""
|
|
target, _short = DOMAINS[name]
|
|
module_name, _, attr = target.partition(":")
|
|
module = importlib.import_module(module_name)
|
|
return typer.main.get_command(getattr(module, attr))
|
|
|
|
|
|
cli = typer.Typer(
|
|
name="reach",
|
|
cls=LazyDomainGroup,
|
|
help="Repo tooling for The Settled Reach.\n\nRun `reach <domain> --help` to see what a domain can do.",
|
|
no_args_is_help=True,
|
|
add_completion=False,
|
|
rich_markup_mode=None,
|
|
# See core/cli.py — a second rich path that renders unhandled exceptions as
|
|
# box-art, arriving through a different door than rich_markup_mode.
|
|
pretty_exceptions_enable=False,
|
|
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Console entry point — `reach`.
|
|
|
|
Exists so a detached child records its completion at the PROCESS's exit
|
|
rather than at a command's. Recording it inside `@command` looked right and
|
|
was subtly wrong: a child that fails before any command runs — bad
|
|
arguments, an unknown verb, an import error — never reaches that decorator,
|
|
so its metadata said `running` forever. A failed job that looks busy is the
|
|
exit-0 trap wearing a new disguise, and worse than the original because
|
|
nothing is watching a background job.
|
|
|
|
Here, every exit path passes through one `finally`.
|
|
"""
|
|
exit_code = 0
|
|
try:
|
|
cli()
|
|
except SystemExit as exc:
|
|
exit_code = exc.code if isinstance(exc.code, int) else 1
|
|
raise
|
|
except BaseException:
|
|
exit_code = 1
|
|
raise
|
|
finally:
|
|
# Imported lazily and only when detached, so `reach --help` never pays
|
|
# for it — the laziness T-1260 protects applies here too.
|
|
import os
|
|
|
|
from tooling.core import jobs
|
|
|
|
if os.environ.get(jobs.ENV_JOB_ID):
|
|
from tooling.core import process
|
|
|
|
process.finish_if_detached(exit_code)
|
|
|
|
|
|
@cli.callback()
|
|
def root(
|
|
verbose: bool = typer.Option(
|
|
False, "--verbose", "-v", help="Show progress events and full tracebacks."
|
|
),
|
|
no_input: bool = typer.Option(
|
|
False, "--no-input", help="Never prompt. Hooks and agents should always pass this."
|
|
),
|
|
detach: bool = typer.Option(
|
|
False, "--detach", help="Run in the background; print a job id and return at once."
|
|
),
|
|
) -> None:
|
|
"""Global options, declared once here so every domain inherits them.
|
|
|
|
A per-domain copy of these is how the two would drift apart — one router
|
|
growing a `--verbose` that sets a different level, or forgetting `--no-input`
|
|
entirely.
|
|
|
|
Note `--no-input` is about PROMPTING, not output format. Console already
|
|
chooses JSONL versus rendered text from `isatty` with an `SR_OUTPUT_FORMAT`
|
|
override; making this flag a second, conflicting way to say the same thing
|
|
would leave nobody sure which one wins.
|
|
"""
|
|
from tooling.core import console, runtime
|
|
|
|
if verbose:
|
|
console.set_level("debug")
|
|
runtime.set_no_input(no_input)
|
|
|
|
if detach:
|
|
# Handled here, before any domain loads, because detaching is a property
|
|
# of the INVOCATION rather than of the verb — every command gets it and
|
|
# no command implements it. The child re-runs this same argv with
|
|
# --detach stripped, so it does the work instead of forking again.
|
|
from tooling.core import process
|
|
|
|
job_id = process.spawn_detached(process.current_argv())
|
|
console.out(job_id)
|
|
console.verdict(
|
|
f"started job {job_id} — this exit status means STARTED, not succeeded",
|
|
fix=None,
|
|
)
|
|
raise typer.Exit(0)
|