diff --git a/.gitignore b/.gitignore index 880da2ce0..3b1b91500 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,11 @@ vendor/ __pycache__/ *.pyc *.pyo +# Editable-install metadata, regenerated by every `uv pip install -e .` (T-1258) +*.egg-info/ +# Was only in .git/info/exclude, which is machine-local — so a fresh clone or a +# new worktree did not ignore it at all (T-1258) +.venv/ # OS .DS_Store diff --git a/pyproject.toml b/pyproject.toml index 70ea82f9d..36079e5f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,18 @@ dependencies = [ "Pillow==12.2.0", ] +[tool.setuptools.packages.find] +# Explicit, not flat-layout auto-discovery. The repo root holds client/, server/, +# docs/, wiki/, db/ and tests/ alongside tooling/, and auto-discovery either +# errors on the ambiguity or quietly ships something unintended (T-1258). +# +# Nothing needs excluding yet: the hyphenated directories (planet-gen, +# economy-db, garment-fit, pql-migrate) are invisible to package discovery +# because a hyphen is not a valid Python identifier, and tooling/econ-sim is a +# Rust crate with no __init__.py. That changes in T-1250, which renames them — +# at which point they become real packages and this include starts matching them. +include = ["tooling*"] + [project.optional-dependencies] dev = [ # ruff 0.15.9 — checked clean against NVD + OSV, no CVEs on record (2026-04-05) diff --git a/tooling/__init__.py b/tooling/__init__.py new file mode 100644 index 000000000..1046a01cc --- /dev/null +++ b/tooling/__init__.py @@ -0,0 +1,7 @@ +"""Repo tooling, as one installable package behind the `reach` command (D-263). + +Deliberately empty. Every import here is paid by every invocation of `reach`, +including the four that run in the pre-push hook, so this file holds no imports +and no logic. Put things in `tooling.core` (shared substrate) or +`tooling.domains.` (everything else). +""" diff --git a/tooling/core/__init__.py b/tooling/core/__init__.py new file mode 100644 index 000000000..694d943f8 --- /dev/null +++ b/tooling/core/__init__.py @@ -0,0 +1,10 @@ +"""Shared substrate — only what has no domain (D-263). + +config, paths, errors, console output, process launching. Nothing else. + +The bound is deliberate and it is the rule most likely to erode invisibly: the +moment a module in here grows a service — its own logic, its own data store, its +own verbs — it is a domain and it moves to `tooling.domains`. A `core/` that +accumulates services becomes a package everything imports and nobody can change, +which is the worst possible shape for the one directory meant to be stable. +""" diff --git a/tooling/core/config.py b/tooling/core/config.py new file mode 100644 index 000000000..53fff6b4b --- /dev/null +++ b/tooling/core/config.py @@ -0,0 +1,63 @@ +"""Repo-root and path resolution. No domain logic lives here (D-263). + +This module is on the push-gate path, so it does no subprocess work: no +`git rev-parse`, no shelling out, nothing beyond the stdlib. Resolution is +`__file__`-relative and validated against a sentinel file. That is not only +faster than asking git — it is *more correct*, because the current working +directory is not a reliable signal. A git hook runs from the repo root, an agent +`Bash` call may not, and `reach` is meant to work from anywhere. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# 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 +# here, by name, than to hand out a plausible wrong path. +SENTINEL = "project.yaml" + +ENV_OVERRIDE = "SR_REPO_ROOT" + + +def repo_root() -> Path: + """Absolute path to the repo root. + + `SR_REPO_ROOT` wins if set; otherwise the package's own location is used. + Both are validated, deliberately: an override pointing somewhere useless + should fail loudly rather than fall back to a default that happens to work, + because a silent fallback is how you end up editing one checkout and + checking another. + + Not cached. The two syscalls are microseconds, and a cache would make the + override untestable for the sake of nothing measurable. + """ + override = os.environ.get(ENV_OVERRIDE) + if override: + return _validated(Path(override).expanduser().resolve(), f"{ENV_OVERRIDE}={override}") + # tooling/core/config.py -> tooling/core -> tooling -> repo root + return _validated(Path(__file__).resolve().parents[2], "the installed package location") + + +def path(*parts: str) -> Path: + """Join `parts` onto the repo root, so callers do not each re-resolve it.""" + return repo_root().joinpath(*parts) + + +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( + 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 \n" + f"To override for a single command:\n" + f" {ENV_OVERRIDE}= reach ..." + ) diff --git a/tooling/core/console.py b/tooling/core/console.py new file mode 100644 index 000000000..3d0fcdfb4 --- /dev/null +++ b/tooling/core/console.py @@ -0,0 +1,147 @@ +"""The single output path (D-263). Nothing else in `reach` prints. + +Two channels, and keeping them apart is the whole design: + + stdout the command's ACTUAL OUTPUT — the data the caller asked for. + Nothing else ever goes here, so `reach ... | jq` keeps working. + stderr the EVENT STREAM — progress, and the final verdict — as JSONL, + one object per line. + +**Rendering happens at the sink, not at the emit site.** When stderr is a +terminal the events are rendered for a human; otherwise they are written as raw +JSONL. A live terminal and a job log are then the same artefact in two +presentations, which is what lets `reach jobs log` render for a person while a +conformance test asserts on the same bytes. + +**Emitting is optional.** A command that never calls `event()` works normally, +and the push-gate checks deliberately emit nothing. This is a channel, not an +obligation — the point is that a long command *has somewhere to speak*, not that +every command must. + +**The stream never replaces the verdict.** A remedy emitted at line 400 of 900 +is technically printed and practically invisible, so `verdict()` prints once, +last, and is what a caller reads when it reads only one thing. + +Stdlib only, and cheap to import: this module is on the gate path. +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone +from typing import Any, TextIO + +LEVELS: dict[str, int] = {"debug": 10, "info": 20, "warn": 30, "error": 40} + +ENV_LEVEL = "SR_LOG_LEVEL" +ENV_FORMAT = "SR_OUTPUT_FORMAT" # "json" | "text" — overrides TTY detection + +# Quiet by default so hooks are not spammed. T-1249's global --verbose lowers it. +_threshold = LEVELS.get(os.environ.get(ENV_LEVEL, "info").lower(), LEVELS["info"]) + + +def set_level(name: str) -> None: + """Set the minimum level that reaches the stream. Unknown names are a no-op.""" + global _threshold + if name.lower() in LEVELS: + _threshold = LEVELS[name.lower()] + + +def out(text: str = "") -> None: + """Write to stdout — the command's actual output, never commentary.""" + print(text, file=sys.stdout, flush=True) + + +def event( + message: str, + *, + level: str = "info", + phase: str | None = None, + progress: float | None = None, + **extra: Any, +) -> None: + """Emit one progress event onto the stream. + + Suppressed entirely if below the current level, so a debug-chatty service + costs nothing in a hook. + """ + if LEVELS.get(level, LEVELS["info"]) < _threshold: + return + _write( + { + "ts": _now(), + "level": level, + "phase": phase, + "message": message, + "progress": progress, + **extra, + } + ) + + +def verdict(message: str, *, ok: bool = True, fix: str | None = None) -> None: + """Print the final summary: once, last, and never suppressed by the level. + + `fix` is the command that would resolve a failure — the contract at the top + of D-263. It is carried as a structured field so a caller can extract it + without parsing prose. + """ + _write( + { + "ts": _now(), + "level": "info" if ok else "error", + "kind": "verdict", + "ok": ok, + "message": message, + "fix": fix, + } + ) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds") + + +def _write(payload: dict[str, Any]) -> None: + stream: TextIO = sys.stderr + if _render_as_text(stream): + stream.write(_render(payload)) + else: + # Drop None-valued optionals so a log line carries what happened, not a + # census of the fields that did not apply. + compact = {k: v for k, v in payload.items() if v is not None} + stream.write(json.dumps(compact, ensure_ascii=False) + "\n") + stream.flush() + + +def _render_as_text(stream: TextIO) -> str | bool: + override = os.environ.get(ENV_FORMAT, "").lower() + if override == "text": + return True + if override == "json": + return False + try: + return stream.isatty() + except (AttributeError, ValueError): + # A closed or substituted stream: prefer the machine format, which is + # the one that stays parseable when nobody is watching. + return False + + +def _render(payload: dict[str, Any]) -> str: + message = payload.get("message", "") + if payload.get("kind") == "verdict": + if payload.get("ok"): + return f"{message}\n" + fix = payload.get("fix") + tail = f"\n\nFix: {fix}\n" if fix else "\n" + return f"{message}{tail}" + + prefix = {"warn": "warning: ", "error": "error: "}.get(payload.get("level", ""), "") + phase = payload.get("phase") + scope = f"[{phase}] " if phase else "" + progress = payload.get("progress") + pct = f" ({progress * 100:.0f}%)" if isinstance(progress, (int, float)) else "" + return f"{scope}{prefix}{message}{pct}\n" diff --git a/tooling/domains/__init__.py b/tooling/domains/__init__.py new file mode 100644 index 000000000..344c380e9 --- /dev/null +++ b/tooling/domains/__init__.py @@ -0,0 +1,23 @@ +"""One directory per domain (D-263). + +Each domain is split by role, and the split is what makes this a codebase that +shares rather than 123 scripts in twelve folders: + + router.py CONTROLLER — args in, delegate, format out. No logic. + service.py LOGIC — transport-agnostic, importable by anything. + schemas.py this domain's data shapes (pydantic). + helpers.py domain-local pure helpers. + dependencies.py resolved collaborators (DB handle, paths, launchers). + +The invariant that makes it work: `router.py` holds no logic and `service.py` +holds no Typer. A service must not know it was called from a CLI — that is what +lets one domain's service call another's, lets tests call services directly +without a CLI round-trip, and leaves a second front end possible without a +rewrite. It is also what keeps lazy loading achievable: routers are cheap, +services are not, and only the invoked domain's service is ever imported. + +Not every domain needs every file. `schemas.py` and `dependencies.py` appear +when a domain has data shapes or collaborators worth naming. The layering is a +vocabulary, not a quota — a folder of five empty modules is worse than a folder +of two full ones. +"""