All five gates now live in the check domain. canvas-version produces byte-identical output to the original on the live tree. It is the first real consumer of core/process.run. The git calls pass check=False deliberately: a git failure here is not an error to report but a signal that there is nothing to compare, since a fresh clone with no remote is a legitimate state rather than a broken one. The argv-list and missing-binary guards still apply. Its two skips are kept distinct from its pass. NO_BASE and DIFF_FAILED exit 0, as does CLEAN — but only CLEAN means the gate actually looked at something. Collapsing them would hide a gate that had silently stopped running, which for this check in particular is the exact failure it exists to prevent. Found a second rich path while a NameError was rendering as a full-width box-drawn traceback: typer's pretty-exception handler is a different mechanism from rich_markup_mode, and setting one does nothing about the other. Same log pollution T-1259 thought it had closed, arriving through another door and landing in the worst place — a hook log at the moment something has already gone wrong. pretty_exceptions_enable=False now on the root and on every domain built by cli.domain(). test_canvas_version_check.py moves with the code it guards. It had been loading the extensionless script through a SourceFileLoader and reaching canvas_sources by sys.path insert, both only because tooling/ was not importable. Second instance of that debt evaporating on contact. What it asserts is unchanged, which is the point: diff_has_version_bump was kept pure in the port so its six properties still hold without constructing git history. Also restores an import the check router dropped in T-1267 when it moved to cli.domain() — caught by running the command rather than by reading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
9.3 KiB
Python
237 lines
9.3 KiB
Python
"""Transport for the `check` domain — args in, delegate, format out.
|
|
|
|
**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 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 cli, console
|
|
from tooling.core.command import command
|
|
from tooling.core.errors import ReachError
|
|
from tooling.domains.check import service
|
|
from tooling.domains.check.schemas import CanvasState, StampState
|
|
|
|
app = cli.domain("check", "Consistency gates — the checks the push hook runs.")
|
|
|
|
|
|
@app.callback()
|
|
def _domain() -> None:
|
|
"""Keeps `check` a group.
|
|
|
|
Typer collapses a single-command app into a bare command, so without this
|
|
`reach check client-version` fails with "unexpected extra argument". Every
|
|
domain router needs this until it has two or more verbs — and keeping it
|
|
afterwards costs nothing and stops the shape changing under you.
|
|
"""
|
|
|
|
|
|
@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:
|
|
raise ReachError(
|
|
f"check-client-version: {result.problem}",
|
|
fix="check that project.yaml and client/project.godot exist and are readable",
|
|
)
|
|
|
|
if not result.ok:
|
|
raise ReachError(
|
|
"check-client-version: version drift\n"
|
|
f" project.yaml {result.yaml_version}\n"
|
|
f" client/project.godot {result.godot_version}\n"
|
|
"\n"
|
|
"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).",
|
|
fix=(
|
|
"set config/version in client/project.godot's [application] "
|
|
f"section to {result.yaml_version} — project.yaml is the source of truth"
|
|
),
|
|
)
|
|
|
|
console.verdict(f"check-client-version: OK — {result.yaml_version}")
|
|
|
|
|
|
@app.command("canvas-version")
|
|
@command
|
|
def canvas_version(
|
|
base: str = typer.Option(
|
|
service.DEFAULT_BASE, "--base", help="Base ref to compare against."
|
|
),
|
|
head: str = typer.Option("HEAD", "--head", help="Head ref to compare."),
|
|
) -> None:
|
|
"""Fail if canvas generation changed without project.yaml's version moving."""
|
|
result = service.canvas_version(base, head)
|
|
|
|
if result.state is CanvasState.NO_BASE:
|
|
console.verdict(
|
|
f"check-canvas-version: {result.base} not found — skipping (nothing to compare)"
|
|
)
|
|
return
|
|
if result.state is CanvasState.DIFF_FAILED:
|
|
console.verdict(
|
|
f"check-canvas-version: could not diff {result.commit_range} — skipping"
|
|
)
|
|
return
|
|
if result.state is CanvasState.CLEAN:
|
|
console.verdict("check-canvas-version: no canvas-generation changes in range — OK")
|
|
return
|
|
if result.state is CanvasState.BUMPED:
|
|
console.verdict(
|
|
f"check-canvas-version: OK — {len(result.touched)} canvas-generation "
|
|
"file(s) changed and project.yaml's version moved with them"
|
|
)
|
|
return
|
|
|
|
shown = result.touched[:10]
|
|
remainder = len(result.touched) - len(shown)
|
|
listing = "".join(f" {path}\n" for path in shown)
|
|
if remainder:
|
|
listing += f" ... and {remainder} more\n"
|
|
|
|
raise ReachError(
|
|
"check-canvas-version: canvas generation changed without a version bump\n"
|
|
"\n"
|
|
f" Range: {result.commit_range}\n"
|
|
" Changed canvas-generation files:\n" + listing + "\n"
|
|
"project.yaml's `version:` is the Atlas disk cache's ONLY invalidation\n"
|
|
"signal. Without a bump, every warm cache keeps serving canvases built by\n"
|
|
"the code you just changed — silently, and only on machines that have a\n"
|
|
"warm cache, so you will not see it on a cold checkout.",
|
|
fix=(
|
|
"bump `version:` in project.yaml (scheme 0.{phase}.{n}), note it in the "
|
|
"comment block above, and mirror the value into client/project.godot's "
|
|
"config/version. If you are certain the change cannot alter canvas bytes, "
|
|
"bump it anyway — the cost is one round of cache misses, and this has "
|
|
"shipped broken five times, most recently T-1239, which took eight days "
|
|
"to find."
|
|
),
|
|
)
|
|
|
|
|
|
_STAMP_REMEDY = {
|
|
StampState.UNSTAMPED: "make regen-db — systems.db carries no stamp to verify",
|
|
StampState.BAD_VERSION: "make regen-db — the recorded schema_version predates semver",
|
|
StampState.CONFLICT: "make regen-db — the DB was partially regenerated, so nothing "
|
|
"in it is internally consistent",
|
|
StampState.UNKNOWN: "register the generator in tooling/generator_sources.py, then "
|
|
"make regen-db",
|
|
StampState.BROKEN: "a registered generator source has moved or been deleted — fix "
|
|
"the path in tooling/generator_sources.py",
|
|
StampState.STALE: "make regen-db, then stage server/data/systems.db",
|
|
}
|
|
|
|
|
|
@app.command("systems-db-stamp")
|
|
@command
|
|
def systems_db_stamp() -> None:
|
|
"""Fail if systems.db is older than the generator sources that produced it."""
|
|
result = service.systems_db_stamp()
|
|
|
|
if result.state is StampState.ABSENT:
|
|
console.verdict("check-systems-db-stamp: no systems.db — nothing to verify")
|
|
return
|
|
|
|
if result.state is StampState.OK:
|
|
console.verdict(
|
|
f"check-systems-db-stamp: OK — {result.generators} generator(s) up to date"
|
|
)
|
|
return
|
|
|
|
detail = "".join(f"\n {line}" for line in result.details)
|
|
raise ReachError(
|
|
f"check-systems-db-stamp: {result.state.value.upper()}{detail}",
|
|
fix=_STAMP_REMEDY[result.state],
|
|
# UNSTAMPED is 2, everything else 1 — a distinction the pre-push hook
|
|
# has relied on since T-857 and which parity must preserve.
|
|
exit_code=result.exit_code,
|
|
)
|
|
|
|
|
|
@app.command("dataflow-graph")
|
|
@command
|
|
def dataflow_graph() -> None:
|
|
"""Fail if a path named in a hand-authored diagram no longer resolves."""
|
|
result = service.dataflow_graph()
|
|
|
|
if result.missing or result.unresolved:
|
|
detail = "\n".join(f" {line}" for line in result.missing + result.unresolved)
|
|
raise ReachError(
|
|
f"check-dataflow-graph: FAILED\n{detail}",
|
|
fix=(
|
|
"either the path moved, in which case update the diagram, or the "
|
|
"diagram was always wrong — check the source before editing either"
|
|
),
|
|
)
|
|
|
|
if result.checked == 0:
|
|
# A checker that found nothing to check reports success, which is the
|
|
# quietest way for this gate to stop working: the label format changes
|
|
# and every run stays green while asserting nothing.
|
|
raise ReachError(
|
|
"check-dataflow-graph: no path-like tokens found — the checker is "
|
|
"not actually checking anything",
|
|
fix="the d2 label format likely changed; update the token pattern in "
|
|
"domains/check/service.py",
|
|
)
|
|
|
|
console.verdict(f"check-dataflow-graph: OK — {result.diagrams} diagram(s)")
|
|
|
|
|
|
@app.command("fact-ids")
|
|
@command
|
|
def fact_ids() -> None:
|
|
"""Fail if content references a fact_id no knowledge catalog defines."""
|
|
result = service.fact_ids()
|
|
|
|
if result.advisory:
|
|
# Advisory rather than failing: until the catalogs hold definitions,
|
|
# failing every commit would teach people to bypass the hook, and a gate
|
|
# people route around protects nothing.
|
|
console.event(
|
|
"check-fact-ids: WARNING — no canonical fact_ids in knowledge catalogs",
|
|
level="warn",
|
|
)
|
|
for fact in result.referenced:
|
|
console.event(f" {fact}", level="warn")
|
|
console.verdict(
|
|
f"check-fact-ids: advisory — catalogs unpopulated, "
|
|
f"{len(result.referenced)} reference(s) unchecked"
|
|
)
|
|
return
|
|
|
|
if result.unknown:
|
|
detail = "\n".join(
|
|
f" {fact.fact_id}\n" + "".join(f" {where}\n" for where in fact.locations)
|
|
for fact in result.unknown
|
|
)
|
|
raise ReachError(
|
|
f"check-fact-ids: {len(result.unknown)} unknown fact_id(s)\n{detail}",
|
|
fix=(
|
|
"define them in server/content/global/knowledge/*.yaml, or correct "
|
|
"the references above"
|
|
),
|
|
)
|
|
|
|
console.verdict(
|
|
f"check-fact-ids: OK — {len(result.referenced)} references validated "
|
|
f"against {result.canonical_count} canonical facts"
|
|
)
|