Commit Graph
4 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 5 49fa6ada95 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>
2026-08-31 15:03:34 +02:00
jpmschweitzerandClaude Opus 5 5cdb3e9327 feat(config): T-1262 — parity is facts and exit codes, not bytes
schemas.py becomes pydantic, so the reference domain is the normal pattern
rather than an exception carrying a footnote. Frozen: a result is a statement
about what was found, and nothing downstream should edit the finding on its way
to being reported. pydantic stays off the --help path — test_lazy_domains still
passes, which is precisely the assertion that it loads with the domain and not
with the CLI.

The acceptance criterion could not be met as written, and that is the finding
worth keeping. It asked for byte-for-byte parity with the old script; D-263 was
amended after this ticket to give reach a streaming model that puts the verdict
on stderr, while the old script writes its success line to stdout. Measured:
the text is byte-identical in text mode, only the stream differs. Matching both
would mean abandoning streaming or special-casing every ported gate.

So parity is redefined, and it is stronger than bytes where it counts: exit
codes match exactly, no fact the old message carried is lost, and failures name
a remedy as a structured field. That governs every port in T-1251, not just
this one, so it is in D-263 rather than only here.

test_check_parity.py runs three paths — ok, drift, missing file — through both
implementations and compares. It builds a throwaway fixture repo and copies the
OLD script into it, because that script resolves its root from __file__ and has
no override; the new command just takes SR_REPO_ROOT. That asymmetry is part of
why the port earns its keep. It also asserts the failing paths actually exit
non-zero, without which "the exit codes matched" would be vacuous for two
checks that both silently pass.

Proven to fail twice before being trusted. Once by accident: the first version
asserted the yaml version appears on every failing path, which the old script
does not report when the client file is missing — the test was wrong, not the
code, and it now derives expected facts from what the old output actually
contains. Once on purpose: mutating the router to drop a version made it fail
and name the missing fact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:45:39 +02:00
jpmschweitzerandClaude Opus 5 b9d81ac694 feat(config): T-1260 — reach lists its domains without importing them
`reach --help` renders from a declaration table and imports nothing. The cost
of help is now flat as the registry grows, which is the property that has to
hold going from one domain to a dozen.

The trap is real and was confirmed in typer's vendored source rather than
assumed from upstream Click: TyperGroup.format_commands loops over
list_commands calling get_command on each, purely to read a short help string
off the loaded command. With lazy loading underneath, that imports every
domain in the registry to render --help — while the output looks entirely
correct. Nothing observable changes; only the import graph does.

So the test asserts on sys.modules, and it was proven to fail before being
trusted. Disabling the format_commands override made it fail and name the
cause, listing all five leaked check modules. It also carries a positive
control — invoking a domain must import its service — because without one,
"nothing was imported" would pass equally for a loader that is simply broken,
and it fails on an empty registry, which would otherwise satisfy everything
vacuously.

The check domain is created here because the test needs a subject: a stub
raising NotImplementedError would have been committed dead code. That takes
the port out of T-1262, which is rescoped to what it still owns — pydantic
schemas, byte-for-byte output parity on the drift path, and the failure
tests. The old tooling/check-client-version script stays in place and stays
wired to the pre-push hook; the deprecation window is deliberate.

One Typer behaviour worth knowing before every future domain: a single-command
app collapses into a bare command, so `reach check client-version` failed with
"unexpected extra argument" until the router got a callback. Same mechanism as
the root callback, different symptom.

Help now works at every level, closing item 5 of T-1248.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 07:31:15 +02:00
jpmschweitzerandClaude Opus 5 559f3d82dc chore(config): T-1258 — tooling/ becomes an importable package
The skeleton the reach CLI hangs off. Nothing moves yet: this adds the
package, the bounded core/, and explicit setuptools discovery.

core/console.py is the single output path, and the split it enforces is the
whole design — stdout carries the command's actual output so `reach ... | jq`
keeps working, stderr carries the event stream as JSONL. Rendering happens at
the sink: a terminal gets human text, anything else gets raw JSONL, so a live
view and a job log are one artefact in two presentations. Emitting is
optional — the gates emit nothing — and verdict() prints once, last, carrying
its remedy as a structured field.

core/config.py resolves the repo root from __file__ against a project.yaml
sentinel, with an SR_REPO_ROOT override. No subprocess and no git call: this
is on the gate path, and cwd is not a reliable signal anyway since a hook runs
from the root and an agent call may not. Both paths are validated, because a
silent fallback is how you end up editing one checkout and checking another.

Discovery is configured explicitly rather than left to flat-layout
auto-discovery, which would have had to choose between erroring on the
ambiguity and quietly shipping client/ or docs/. Verified: top_level.txt
contains exactly "tooling".

Verified beyond the happy path — the sentinel rejects SR_REPO_ROOT=/tmp and
names both remedies; debug events are suppressed at the default threshold
while the verdict is not; stdout stays clean with stderr redirected away; and
the three unconditional push-gate checks still pass now that tooling/ is a
package, which was the real regression risk.

Two findings recorded on the tickets. make setup-venv is stale — it calls
.venv/bin/pip, but the venv was created by uv and has no pip, so the recorded
procedure and the actual state have already diverged (T-1261 owns the fix).
And settled-reach-tooling had never actually been installed: site-packages
held the dependencies but no dist-info, which follows from there being no
__init__.py to expose. This is the first commit where `import tooling` means
anything.

.venv/ was only ignored via .git/info/exclude, which is machine-local, so a
fresh clone or a new worktree did not ignore it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:53:48 +02:00