feat(config): T-1259 — reach is a real command, and Typer vendors Click

`reach --help` runs from the console entrypoint in 80 ms. typer 0.27.1 and
pydantic 2.13.4 join the dependencies, both CVE-checked against NVD, OSV and
the GitHub Advisory Database.

The design in the ticket did not survive contact. It specified a click.Group
root, on the reasoning that it would keep typer off the --help path — but
typer vendors Click as of 0.26.0, so there is no top-level click package to
import and no supported way to extract typer's internal one. A click.Group
root hosting Typer sub-apps would put two Click implementations in one
process. The root is therefore a typer.Typer, and lazy registration will go
through the supported typer.Typer(cls=...) surface with a TyperGroup
subclass. T-1260 is corrected to match.

The callback is not decoration: a Typer root with no commands AND no callback
raises at build time, and lazy registration means no command is ever eager.
The ticket claimed a zero-command root always raises — half right, and the
half that matters is that a callback makes it legal.

rich_markup_mode=None is load-bearing rather than cosmetic. It takes an empty
--help from 168 ms to 74 ms, and keeps rich and pygments off the import path
entirely rather than merely skipping the render. It also stops typer drawing
box-art help, which it does even when stdout is a pipe — that would have put
box-drawing characters into every hook log and agent capture. typer-slim was
considered and rejected: deprecated since 0.22.0, now a shallow wrapper that
installs all of typer.

D-263 amended: the feels-instant ceiling goes from 250 ms to 500 ms. A ceiling
is not a typical and most invocations sit far below it; the tighter number was
buying discipline that the import-graph assertion enforces better. Stay smart
about what loads, stop worrying about tightness.

Security, checked 2026-08-23. typer has no advisories on record. pydantic
2.13.4 clears PYSEC-2026-1812 (email-regex ReDoS, fixed in 2.4.0) — and the
2026 SSRF advisories CVE-2026-25580 and CVE-2026-54249 are against
pydantic-ai, a different package that is not a dependency here, recorded in
pyproject so the next sweep does not re-panic. Transitively, pygments 2.21.0
clears CVE-2026-4539.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:01:32 +02:00
co-authored by Claude Opus 5
parent 559f3d82dc
commit 8d64800fe9
5 changed files with 218 additions and 2 deletions
+3 -2
View File
@@ -2546,7 +2546,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
| language | **Python.** The friction is packaging, not language — see below. |
| command name | **`reach`.** Free on PATH; `Bash(reach *)` becomes the single allowlist entry. |
| package home | **`tooling/` itself is the package** (`tooling/__init__.py`, imported as `tooling.*`), with every domain under `tooling/domains/`. Fewest path rewrites across the 160 markdown files that name `tooling/…`. |
| dependencies added | **`typer`** (transport) and **`pydantic`** (data shapes) join `pyproject.toml`. Neither may be imported at module level in `main.py`. |
| dependencies added | **`typer`** (transport) and **`pydantic`** (data shapes) join `pyproject.toml`. pydantic may not be imported at module level in `main.py`. **typer necessarily is** — see the vendoring note below. |
| numerics | **They move too, and stay Python.** `planet-gen`, `garment-fit`, `economy-db` and `blender` all come inside. No numerical-equivalence problem is created because no numerical code is rewritten. |
**Why not Rust — the friction Q-124 names is packaging, and Rust is not the cheapest fix for any of it.**
@@ -2596,6 +2596,7 @@ tooling/
- `@logged` (`core/logging.py`) emits one structured line per invocation — command, arguments, duration, outcome — through the shared logger. **To stderr, never stdout**, so machine-readable output stays parseable, and quiet by default so hooks are not spammed.
- They compose into a single `@command` decorator so no command can carry one without the other, and **the conformance test asserts every registered command carries it.** A cross-cutting concern applied by hand is a cross-cutting concern applied to 90% of cases.
- **Pydantic is the data-shape vocabulary, and it lives in `domains/*/schemas.py`.** Available to every domain, including the gate domain. `main.py` and `core/` stay pydantic-free — not for the milliseconds, but because `main.py` is a router and `core/` is a substrate, and neither has data shapes of its own.
- **Typer vendors Click, so the root group is a Typer** *(found 2026-08-23 while building T-1259)*. As of **typer 0.26.0** Click is vendored: there is no top-level `click` package installed, and the docs state that "extracting the internal Click app" is no longer supported. The plan of a `click.Group` root hosting Typer sub-apps — which would have kept typer off the `--help` path — is therefore impossible; it would mean two Click implementations in one process. Lazy registration goes through the supported `typer.Typer(cls=...)` surface with a `TyperGroup` subclass instead. Two consequences worth recording: a Typer root with **no commands and no callback raises at build time** (`Could not get a command for this Typer instance`), so the empty root needs a callback; and **`rich_markup_mode=None` is load-bearing, not cosmetic** — it removes `rich` and `pygments` from the import path and takes an empty `--help` from 168 ms to 74 ms. Also: **`typer-slim` is not the answer** — deprecated as of 0.22.0 and now a shallow wrapper that installs all of typer.
- **The import rule that actually matters: nothing heavy at module level in `main.py` or any `router.py`.** Measured 2026-08-20 in the repo venv: `scipy.ndimage` **275 ms**, `pydantic` 87 ms, `numpy` 72 ms, `PIL.Image` 29 ms. An entrypoint that eagerly imported the tree would pay **~460 ms before executing a line of its own** — and *that*, not pydantic, is what lazy registration exists to prevent. Heavy imports belong inside a service, or inside the function that needs them.
- **Enforced, not asked for:** no `typer`/`click` import outside `main.py` and `router.py`; no bare `print` outside `core/console.py`; no heavy import (numpy, scipy, PIL, pydantic) reachable from `main.py`; every command carries `@command`. All four are grep-shaped or import-graph-shaped, and belong in the conformance test alongside the help/failure checks. The **import-graph** assertion is the one worth writing carefully — a wall-clock assertion is flaky on a loaded machine and tells you *that* something got slow rather than *what*, whereas asserting `sys.modules` after `reach --help` names the offender directly.
- **Not every domain needs every file.** `schemas.py`/`dependencies.py` appear when a domain has data shapes or collaborators worth naming; `check/` may be a router and a service and nothing else. The layering is a vocabulary, not a quota — a folder of five empty modules is worse than a folder of two full ones.
@@ -2625,7 +2626,7 @@ tooling/
**Constraints on execution (these are why the work is sequenced, not why it is hard):**
- **The acceptance criterion is output parity, not timing parity** *(amended 2026-08-20, same day: the original text set the ported gates a hard ceiling of ~104 ms — the summed single-sample cost of the three unconditional checks — and that was wrong in kind. It imported "do not regress" as a requirement without asking who pays.)* **Who pays is the pre-push hook, and almost nobody else.** On a push touching `server/` or `client/` the hook runs `cargo test` or the gdUnit4 suite — minutes — so a few hundred milliseconds is invisible. On a governance-only push the whole hook is about a second. No human and no loop consumes these often enough for 100 ms versus 400 ms to register. **So a ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast.**
**The budget that replaces it is a ceiling with headroom, not a ratchet:** a `reach` invocation should feel instant to a human — **under ~250 ms** — and the unconditional gate set should stay **comfortably under a second**. That is loose enough that pydantic, a subprocess, or a DB open are all affordable, and tight enough that nobody imports scipy at module level. **Lazy registration stays mandatory**, justified by the real threat rather than by parity: an eager entrypoint would pay ~460 ms of numpy + scipy + PIL + pydantic before executing a line of its own, and would grow every time a domain was added.
**The budget that replaces it is a ceiling with headroom, not a ratchet:** a `reach` invocation should stay **under ~500 ms** *(raised from 250 ms, 2026-08-23 — a ceiling is not a typical, and most invocations sit far below it; the tighter number was buying discipline that the import-graph assertion enforces better anyway)*. Stay smart about what gets loaded; stop worrying about tightness. That is loose enough that pydantic, a subprocess, or a DB open are all affordable, and tight enough that nobody imports scipy at module level. **Lazy registration stays mandatory**, justified by the real threat rather than by parity: an eager entrypoint would pay ~460 ms of numpy + scipy + PIL + pydantic before executing a line of its own, and would grow every time a domain was added.
- **The `systems.db` stamp survives the move or the move does not land.** `tooling/generator_sources.py` SHAs the concatenated bytes of the generator's sources **sorted by path**, so renaming a file changes the stamp even when its content is byte-identical. The generator relocation must therefore land as **one commit** — registry paths updated, `make regen-db` run, stamp verified — never split across pushes, or the pre-push gate rejects an intermediate state that is in fact correct.
- **Old paths are retired through a deprecation window, not deleted under the callers.** 160 markdown files under `.claude/` and `docs/`, 84 make targets, the pre-push hook and the skills all name `tooling/…` paths. Each retired path leaves a shim that prints the new command and exits non-zero — the failure contract applied to the migration itself — before the shims are removed.
- **What this decision does not claim.** It does not make make-target invocation cheaper (already free), and it does not make the tooling faster to *run* — only to start, find, and be allowed to call. The wins are: ad-hoc and direct invocation stop prompting, `--help` answers "what tooling exists" without an `ls`, arguments become expressible where make could not express them, the venv split disappears, and failures carry their own remedy.