Files
jpmschweitzerandClaude Opus 5 a3cbc478a0 feat(config): T-1281 — canvas-version, and typer's other rich path
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>
2026-08-31 18:22:25 +02:00

155 lines
4.9 KiB
Python

"""Data shapes for the `check` domain.
pydantic, as every domain's `schemas.py` should be (D-263). The earlier stdlib
dataclass here was a carve-out justified by a timing-parity budget that D-263
withdrew on 2026-08-20 — and a reference implementation carrying a footnote is a
worse reference than one that is simply normal.
Shapes are frozen. A result object is a statement about what was found, and
nothing downstream should be able to edit the finding on its way to being
reported.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, ConfigDict
class UnknownFact(BaseModel):
"""A fact_id referenced by content that no knowledge catalog defines."""
model_config = ConfigDict(frozen=True)
fact_id: str
locations: list[str] = []
class FactIdCheck(BaseModel):
"""The outcome of validating content fact_id references against the catalogs.
`advisory` is the mode where the catalogs hold no definitions at all. The
check then reports what it found and exits 0 — deliberately, because failing
every commit until the catalogs are populated would train people to bypass
the hook, and a gate people route around protects nothing.
"""
model_config = ConfigDict(frozen=True)
advisory: bool
canonical_count: int
referenced: list[str] = []
unknown: list[UnknownFact] = []
@property
def ok(self) -> bool:
return not self.unknown
class DiagramCheck(BaseModel):
"""The outcome of checking that paths named in diagrams still resolve.
`checked` matters as much as `unresolved`. A diagram whose label format
changed yields zero tokens, and a checker that finds nothing to check
reports success — so a count of zero is itself a failure, not a clean run.
"""
model_config = ConfigDict(frozen=True)
diagrams: int
checked: int
unresolved: list[str] = []
missing: list[str] = []
@property
def ok(self) -> bool:
return not self.unresolved and not self.missing and self.checked > 0
class CanvasState(str, Enum):
"""Why the canvas-version gate reached its verdict.
Two of these are SKIPS rather than passes, and keeping them distinct
matters: "no base to compare against" and "nothing relevant changed" both
exit 0, but only the second means the gate actually looked at something.
Collapsing them would hide a gate that silently stopped running.
"""
NO_BASE = "no_base" # base ref absent (fresh clone) — nothing to compare
DIFF_FAILED = "diff_failed" # git could not produce a range
CLEAN = "clean" # nothing in the registry was touched
BUMPED = "bumped" # canvas files changed AND version moved
NEEDS_BUMP = "needs_bump" # canvas files changed, version did not
class CanvasCheck(BaseModel):
"""The outcome of pairing canvas-generation changes with a version bump."""
model_config = ConfigDict(frozen=True)
state: CanvasState
commit_range: str
touched: list[str] = []
base: str = ""
@property
def ok(self) -> bool:
return self.state is not CanvasState.NEEDS_BUMP
class StampState(str, Enum):
"""Why the systems.db stamp check reached its verdict.
Distinguished rather than collapsed into pass/fail because they carry
different remedies and, in one case, a different exit code: UNSTAMPED exits
2 while every other failure exits 1, and the pre-push hook has treated that
2 as meaningful since T-857.
"""
OK = "ok"
ABSENT = "absent" # no DB at all — not a failure, nothing to verify
UNSTAMPED = "unstamped" # no meta table or empty: exit 2
BAD_VERSION = "bad_version" # schema_version is not semver
CONFLICT = "conflict" # generators disagree on schema_version
UNKNOWN = "unknown" # a generator not in the registry
BROKEN = "broken" # a registered source file is gone
STALE = "stale" # sources changed since the stamp was written
class StampCheck(BaseModel):
"""The outcome of comparing systems.db's meta stamp against its sources."""
model_config = ConfigDict(frozen=True)
state: StampState
generators: int = 0
details: list[str] = []
@property
def ok(self) -> bool:
return self.state in (StampState.OK, StampState.ABSENT)
@property
def exit_code(self) -> int:
if self.ok:
return 0
return 2 if self.state is StampState.UNSTAMPED else 1
class VersionCheck(BaseModel):
"""The outcome of comparing project.yaml against client/project.godot.
`problem` and `ok` are deliberately separate. A check can fail because the
versions disagree (`ok=False`, both versions known) or because it could not
read them at all (`problem` set) — and those want different messages, since
only the first has a remedy the caller can act on.
"""
model_config = ConfigDict(frozen=True)
ok: bool
yaml_version: str | None = None
godot_version: str | None = None
problem: str | None = None