89 lines of grep/sed pipeline become a service returning a FactIdCheck and a router that renders it. Parity on the live tree is exact: both implementations print "check-fact-ids: OK — 6 references validated against 61 canonical facts" and exit 0. The matching counts are the real evidence — a line-matching regex that differed from the grep chain even slightly would move 6 or 61. Kept line-matched rather than YAML-parsed on purpose. Parsing properly would change which lines count: anchors, merge keys and multi-document files would start contributing ids the old check never saw. That is a different check wearing the same name, and a port is not the place to make it. Three parity cases: ok, unknown fact_id, and the advisory mode where the catalogs hold no definitions and the gate deliberately exits 0 — failing every commit until they are populated would teach people to bypass the hook, and a gate people route around protects nothing. Proven to fail by removing the entity-attributes.yaml exclusion, and caught in a way worth noting: not by the assertion aimed at it, but by the advisory case, where including that file made the catalog non-empty so the new implementation enforced while the old stayed advisory. A real behavioural divergence, surfaced by exit code. Retirement waits for the whole domain, per the per-domain rule — three gates remain. It also resolves a tension: the parity test copies the old script into its fixture, so deleting the script early would delete the test's own subject. A parity test is scaffolding with a defined lifetime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
"""Logic for the `check` domain. Transport-agnostic (D-263).
|
|
|
|
Nothing here prints, calls `sys.exit`, or imports typer. A service must not know
|
|
it was called from a CLI — that is what lets a test call it directly, lets one
|
|
domain's service call another's, and leaves a second front end possible without
|
|
a rewrite.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from tooling.core import config
|
|
from tooling.domains.check.schemas import FactIdCheck, UnknownFact, VersionCheck
|
|
|
|
# Anchored to line start so the commentary above `version:` (which mentions
|
|
# earlier versions by number) can never be mistaken for the field itself.
|
|
_YAML_VERSION = re.compile(r"^version:\s*(\S+)\s*$", re.MULTILINE)
|
|
_GODOT_VERSION = re.compile(r'^config/version\s*=\s*"([^"]*)"\s*$', re.MULTILINE)
|
|
|
|
|
|
def client_version() -> VersionCheck:
|
|
"""Compare the version in project.yaml with the one baked into the client.
|
|
|
|
project.yaml is the version source of truth (CLAUDE.md). The client cannot
|
|
read it at runtime — an exported build has no repo root — so the value is
|
|
mirrored into `application/config/version` in client/project.godot, which
|
|
Godot bakes into the PCK (T-1241).
|
|
|
|
A mirror nobody checks is worse than the bug it replaced: the old code
|
|
failed LOUDLY in an export ("?.?.?" everywhere), whereas a stale mirror
|
|
fails SILENTLY — the Atlas disk cache keeps serving canvases under a version
|
|
that stopped matching the build. That is the T-1239 failure, which cost
|
|
eight days of a map drawn from a canvas whose generating code no longer
|
|
existed.
|
|
"""
|
|
root = config.repo_root()
|
|
yaml_version, problem = _read(root / "project.yaml", _YAML_VERSION, "`version:` line")
|
|
if problem:
|
|
return VersionCheck(ok=False, problem=problem)
|
|
|
|
godot_path = root / "client" / "project.godot"
|
|
godot_version, problem = _read(godot_path, _GODOT_VERSION, "`config/version=` line")
|
|
if problem:
|
|
return VersionCheck(ok=False, yaml_version=yaml_version, problem=problem)
|
|
|
|
return VersionCheck(
|
|
ok=yaml_version == godot_version,
|
|
yaml_version=yaml_version,
|
|
godot_version=godot_version,
|
|
)
|
|
|
|
|
|
# Ported from the bash `check-fact-ids` (D-263: the shell scripts are rewritten,
|
|
# not wrapped). Deliberately still LINE-MATCHED rather than YAML-parsed: the
|
|
# original was grep-based, and parsing YAML properly would change which lines
|
|
# count — anchors, merge keys and multi-document files would all start
|
|
# contributing ids the old check never saw. That is a different check wearing
|
|
# the same name, and this port is not the place to make it.
|
|
_FACT_ID = re.compile(r"^\s*(?:-\s*)?fact_id:\s*(.+?)\s*$")
|
|
|
|
# Its schema is attribute keys, not fact_ids — excluded by the original too.
|
|
_NOT_A_CATALOG = "entity-attributes.yaml"
|
|
|
|
|
|
def fact_ids() -> FactIdCheck:
|
|
"""Validate fact_id references in campaign content against the catalogs."""
|
|
root = config.repo_root()
|
|
canonical = _collect(root / "server" / "content" / "global" / "knowledge", catalogs=True)
|
|
referenced = _collect(root / "server" / "content" / "campaigns", catalogs=False)
|
|
|
|
if not canonical:
|
|
return FactIdCheck(
|
|
advisory=True,
|
|
canonical_count=0,
|
|
referenced=sorted(referenced),
|
|
)
|
|
|
|
unknown = [
|
|
UnknownFact(fact_id=ref, locations=_locate(root, ref))
|
|
for ref in sorted(referenced)
|
|
if ref not in canonical
|
|
]
|
|
return FactIdCheck(
|
|
advisory=False,
|
|
canonical_count=len(canonical),
|
|
referenced=sorted(referenced),
|
|
unknown=unknown,
|
|
)
|
|
|
|
|
|
def _collect(directory: Path, *, catalogs: bool) -> set[str]:
|
|
found: set[str] = set()
|
|
if not directory.is_dir():
|
|
return found
|
|
for path in directory.rglob("*.yaml"):
|
|
if catalogs and path.name == _NOT_A_CATALOG:
|
|
continue
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if line.lstrip().startswith("#"):
|
|
continue
|
|
match = _FACT_ID.match(line)
|
|
if match:
|
|
value = _clean(match.group(1))
|
|
if value:
|
|
found.add(value)
|
|
return found
|
|
|
|
|
|
def _clean(value: str) -> str:
|
|
"""Strip a trailing comment and surrounding quotes, as the sed chain did."""
|
|
value = re.sub(r"\s+#.*$", "", value).strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
value = value[1:-1]
|
|
return value.strip()
|
|
|
|
|
|
def _locate(root: Path, fact_id: str) -> list[str]:
|
|
"""Repo-relative file:line for every reference, so a failure is actionable."""
|
|
content = root / "server" / "content" / "campaigns"
|
|
hits: list[str] = []
|
|
if not content.is_dir():
|
|
return hits
|
|
for path in sorted(content.rglob("*.yaml")):
|
|
for number, line in enumerate(
|
|
path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
|
|
):
|
|
match = _FACT_ID.match(line)
|
|
if match and _clean(match.group(1)) == fact_id:
|
|
hits.append(f"{path.relative_to(root)}:{number}")
|
|
return hits
|
|
|
|
|
|
def _read(path: Path, pattern: re.Pattern[str], label: str) -> tuple[str | None, str | None]:
|
|
"""Return (value, problem). Exactly one of the two is ever set."""
|
|
if not path.exists():
|
|
return None, f"{path} not found"
|
|
match = pattern.search(path.read_text(encoding="utf-8"))
|
|
if not match:
|
|
return None, f"no {label} in {path}"
|
|
return match.group(1), None
|