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:
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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 ..."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,29 +1,28 @@
|
||||
"""Transport for the `check` domain — args in, delegate, format out.
|
||||
|
||||
**Zero logic lives here.** Every command in this file should read as: parse,
|
||||
call a service, turn the result into output and an exit code. If a command
|
||||
grows a branch that is about the *problem* rather than about *presentation*,
|
||||
that branch belongs in `service.py`.
|
||||
**Zero logic lives here.** Every command should read as: parse, call a service,
|
||||
turn the result into output. If a command grows a branch that is about the
|
||||
*problem* rather than about *presentation*, that branch belongs in `service.py`.
|
||||
|
||||
Note what the commands below no longer do: no `console.verdict(..., ok=False)`
|
||||
followed by `raise typer.Exit(1)` at each failing branch. They raise
|
||||
`ReachError` with a remedy and `@command` does the rest — renders the verdict
|
||||
once, last, and exits non-zero. That is the difference between a contract and a
|
||||
habit, and it is why every command here wears `@command`.
|
||||
|
||||
The service import is deliberately at module level: by the time this module is
|
||||
imported at all, `reach` has already decided to run a `check` command, so there
|
||||
is nothing left to defer. Laziness lives one level up, in `main.py`.
|
||||
imported at all, `reach` has decided to run a `check` command, so there is
|
||||
nothing left to defer. Laziness lives one level up, in `main.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from tooling.core import console
|
||||
from tooling.core import cli, console
|
||||
from tooling.core.command import command
|
||||
from tooling.core.errors import ReachError
|
||||
from tooling.domains.check import service
|
||||
|
||||
app = typer.Typer(
|
||||
name="check",
|
||||
help="Consistency gates — the checks the push hook runs.",
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
rich_markup_mode=None,
|
||||
)
|
||||
app = cli.domain("check", "Consistency gates — the checks the push hook runs.")
|
||||
|
||||
|
||||
@app.callback()
|
||||
@@ -38,20 +37,19 @@ def _domain() -> None:
|
||||
|
||||
|
||||
@app.command("client-version")
|
||||
@command
|
||||
def client_version() -> None:
|
||||
"""Fail if the client's baked version has drifted from project.yaml."""
|
||||
result = service.client_version()
|
||||
|
||||
if result.problem:
|
||||
console.verdict(
|
||||
raise ReachError(
|
||||
f"check-client-version: {result.problem}",
|
||||
ok=False,
|
||||
fix="check that project.yaml and client/project.godot exist and are readable",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not result.ok:
|
||||
console.verdict(
|
||||
raise ReachError(
|
||||
"check-client-version: version drift\n"
|
||||
f" project.yaml {result.yaml_version}\n"
|
||||
f" client/project.godot {result.godot_version}\n"
|
||||
@@ -59,12 +57,10 @@ def client_version() -> None:
|
||||
"This matters beyond cosmetics: the Atlas disk cache keys its\n"
|
||||
"invalidation on this version, so a stale mirror makes a shipped\n"
|
||||
"build serve canvases generated by code it no longer runs (T-1239).",
|
||||
ok=False,
|
||||
fix=(
|
||||
"set config/version in client/project.godot's [application] "
|
||||
f"section to {result.yaml_version} — project.yaml is the source of truth"
|
||||
),
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.verdict(f"check-client-version: OK — {result.yaml_version}")
|
||||
|
||||
+34
-2
@@ -76,6 +76,16 @@ class LazyDomainGroup(TyperGroup):
|
||||
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())]
|
||||
@@ -108,5 +118,27 @@ cli = typer.Typer(
|
||||
|
||||
|
||||
@cli.callback()
|
||||
def root() -> None:
|
||||
"""Present so an empty root is legal — see the module docstring."""
|
||||
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."
|
||||
),
|
||||
) -> 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)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The D-263 invariants, as a test rather than a style guide (T-1270).
|
||||
|
||||
D-263 lists the rules that make the layering real and then says plainly that a
|
||||
contract nothing checks is a style guide. This is the check.
|
||||
|
||||
It matters most *later*. With one domain, every rule here is obvious and nobody
|
||||
would break one. Once T-1250 lands ~120 commands, nobody re-reads a decision
|
||||
record before adding a verb — this is what tells them, at the moment it is cheap
|
||||
to fix rather than after the pattern has been copied forty times.
|
||||
|
||||
Invariants:
|
||||
|
||||
1. Transport stays out of the logic layers. No typer/click import in any
|
||||
service.py, schemas.py or helpers.py — ever. Transport lives in main.py,
|
||||
router.py, and core/cli.py (the single designated transport module).
|
||||
2. Nothing prints but console. No bare print()/sys.stdout.write outside
|
||||
core/console.py — two output paths drift, and the second is always the one
|
||||
that ends up unformatted on stdout inside a hook.
|
||||
3. Every registered command carries @command, so no command can have logging
|
||||
without error handling or vice versa.
|
||||
4. Every command has help at its own level.
|
||||
5. Every ReachError names a remedy — a `fix=` on every raise site. The hardest
|
||||
to enforce and the most valuable: an error that only says "no" is the thing
|
||||
D-263 exists to replace.
|
||||
|
||||
The import-graph invariant (nothing heavy reachable from main.py) lives in
|
||||
test_lazy_domains.py rather than being duplicated here.
|
||||
|
||||
Run: python3 tooling/test_conformance.py
|
||||
"""
|
||||
|
||||
import ast
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PACKAGE = REPO_ROOT / "tooling"
|
||||
|
||||
# Files permitted to import the CLI framework. See core/cli.py for why it is on
|
||||
# the list: a shared group class is transport by definition, and the alternative
|
||||
# is a copy of it in every router that drifts.
|
||||
TRANSPORT_FILES = {"main.py", "router.py", "cli.py"}
|
||||
LOGIC_FILES = {"service.py", "schemas.py", "helpers.py", "dependencies.py"}
|
||||
FORBIDDEN_IMPORTS = {"typer", "click"}
|
||||
|
||||
|
||||
# The invariants govern the PACKAGE, not the legacy tree. The ~123 loose scripts
|
||||
# under tooling/ predate all of this and use bare print() throughout; holding
|
||||
# them to a contract they were never written against would mean 500 failures on
|
||||
# day one and a suite nobody runs.
|
||||
#
|
||||
# This is not a permanent carve-out. As T-1250 moves each script into
|
||||
# domains/<name>/, it lands inside this scope and the invariants start applying
|
||||
# automatically — so the test's reach grows with the migration rather than
|
||||
# needing to be widened by hand.
|
||||
PACKAGE_ROOTS = ("main.py", "__init__.py", "core", "domains")
|
||||
|
||||
|
||||
def _package_files() -> list[Path]:
|
||||
"""Every .py that is part of the reach package — not the legacy scripts."""
|
||||
files: list[Path] = []
|
||||
for entry in PACKAGE_ROOTS:
|
||||
target = PACKAGE / entry
|
||||
if target.is_dir():
|
||||
files.extend(target.rglob("*.py"))
|
||||
elif target.is_file():
|
||||
files.append(target)
|
||||
return sorted(path for path in files if not path.name.startswith("test_"))
|
||||
|
||||
|
||||
def _imports(tree: ast.AST) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
found.update(alias.name.split(".")[0] for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||
found.add(node.module.split(".")[0])
|
||||
return found
|
||||
|
||||
|
||||
def check_transport_isolation(failures: list[str]) -> None:
|
||||
"""(1) No typer/click outside the designated transport files."""
|
||||
for path in _package_files():
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
offending = _imports(tree) & FORBIDDEN_IMPORTS
|
||||
if not offending:
|
||||
continue
|
||||
if path.name in TRANSPORT_FILES:
|
||||
continue
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
failures.append(
|
||||
f"[transport] {rel} imports {', '.join(sorted(offending))} — "
|
||||
f"only {', '.join(sorted(TRANSPORT_FILES))} may. "
|
||||
"A service must not know it was called from a CLI."
|
||||
)
|
||||
if path.name in LOGIC_FILES:
|
||||
failures.append(
|
||||
f"[transport] {rel} is a LOGIC file — this is the invariant that "
|
||||
"makes services callable from tests and from each other"
|
||||
)
|
||||
|
||||
|
||||
def check_single_output_path(failures: list[str]) -> None:
|
||||
"""(2) Nothing prints but console."""
|
||||
for path in _package_files():
|
||||
if path.name == "console.py":
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "print":
|
||||
failures.append(
|
||||
f"[output] {path.relative_to(REPO_ROOT)}:{node.lineno} calls print() — "
|
||||
"core/console.py is the single output path (D-263)"
|
||||
)
|
||||
elif (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "write"
|
||||
and isinstance(func.value, ast.Attribute)
|
||||
and func.value.attr in {"stdout", "stderr"}
|
||||
):
|
||||
failures.append(
|
||||
f"[output] {path.relative_to(REPO_ROOT)}:{node.lineno} writes to "
|
||||
"sys.stdout/stderr directly — go through core/console.py"
|
||||
)
|
||||
|
||||
|
||||
def check_commands_decorated(failures: list[str]) -> None:
|
||||
"""(3) and (4): every registered command carries @command and has help."""
|
||||
probe = """
|
||||
import json, sys
|
||||
from tooling.core.command import MARKER
|
||||
from tooling.main import DOMAINS, _load_domain
|
||||
|
||||
report = []
|
||||
for name in sorted(DOMAINS):
|
||||
group = _load_domain(name)
|
||||
ctx = None
|
||||
for verb in group.list_commands(ctx):
|
||||
cmd = group.get_command(ctx, verb)
|
||||
callback = getattr(cmd, "callback", None)
|
||||
report.append({
|
||||
"domain": name,
|
||||
"verb": verb,
|
||||
"decorated": bool(getattr(callback, MARKER, False)),
|
||||
"help": (cmd.help or cmd.short_help or "").strip(),
|
||||
})
|
||||
print(json.dumps(report))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", probe], capture_output=True, text=True, cwd=REPO_ROOT
|
||||
)
|
||||
if result.returncode != 0:
|
||||
failures.append(f"[commands] could not introspect the CLI:\n{result.stderr}")
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
report = json.loads(result.stdout)
|
||||
if not report:
|
||||
failures.append(
|
||||
"[commands] no commands found — every assertion here would pass vacuously"
|
||||
)
|
||||
for entry in report:
|
||||
where = f"{entry['domain']} {entry['verb']}"
|
||||
if not entry["decorated"]:
|
||||
failures.append(
|
||||
f"[commands] `reach {where}` is missing @command — it would run "
|
||||
"without the error contract or the invocation record"
|
||||
)
|
||||
if not entry["help"]:
|
||||
failures.append(f"[commands] `reach {where}` has no help text")
|
||||
|
||||
|
||||
def check_errors_name_a_remedy(failures: list[str]) -> None:
|
||||
"""(5) Every ReachError raise site passes fix=."""
|
||||
for path in _package_files():
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Raise) or not isinstance(node.exc, ast.Call):
|
||||
continue
|
||||
func = node.exc.func
|
||||
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
||||
if name != "ReachError":
|
||||
continue
|
||||
if not any(kw.arg == "fix" for kw in node.exc.keywords):
|
||||
failures.append(
|
||||
f"[remedy] {path.relative_to(REPO_ROOT)}:{node.lineno} raises "
|
||||
"ReachError without fix= — an error that only says 'no' is "
|
||||
"what D-263 exists to replace"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures: list[str] = []
|
||||
check_transport_isolation(failures)
|
||||
check_single_output_path(failures)
|
||||
check_commands_decorated(failures)
|
||||
check_errors_name_a_remedy(failures)
|
||||
|
||||
if failures:
|
||||
print("test_conformance: FAIL", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("test_conformance: OK — transport isolated, one output path, "
|
||||
"every command decorated and helped, every error names a remedy")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user