magicciv/tools/check-no-rust-hardcoded-content.py
Natalie 6332d47011 fix(infra): make the DO fleet actually work on real hardware + render host
Real-DO testing surfaced bugs the mocked tests couldn't:
- ssh key: reference shared 'mc-fleet' key via data source, not a duplicate (DO 422s on dup pubkeys).
- cmd_dist_up: fail loudly on failed apply; dist:up waits for cloud-init readiness.
- snapshot cloud-init skips runcmd -> bake authorized_keys (FLEET_PUBKEY) + 'cloud-init clean' before snapshot.
- build user passwordless sudo; apt dpkg-lock race fixed (cloud-init --wait + Lock::Timeout).
- size s-8vcpu-16gb-amd (tier max); creds via PKR_VAR env not argv.
- render host: weston+Mesa baked; ./run dist:render proven (Godot->PNG on DO, no GPU). forge:dns shortcut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:45:29 -04:00

175 lines
7.4 KiB
Python
Executable file

#!/usr/bin/env python3
"""Rail-2 guard: Rust loads canonical game content from JSON, never hardcodes it.
Rail-2 — "JSON game packs are the canonical content store; neither Rust nor
GDScript hardcodes game content." The failure mode this gate exists to stop is
the *two-path divergence*: content reaches the sim two ways — in-game the
GDScript `DataLoader` reads the JSON at runtime, headless (tests, CI, AI
self-play, the WASM guide) Rust falls back to a compile-time copy. If a balance
table is also hardcoded in a Rust crate, the two copies drift apart silently,
and the headless path is where the AI trains. (See instruction module
`rust-source-of-truth.md` → "the two-path divergence", and p3-28 for the
structural endgame: one host-fed `ContentRegistry` both paths read.)
This is a REGISTRY-DRIVEN gate, deliberately low-false-positive — it does NOT
blind-grep for "balance-looking constants" (that flags legit sim-tuning consts
like `MIGRATION_RATE`). It enforces two things per registered content file:
Check A — the JSON file exists and its owning module(s) actually `include_str!`
it (the headless Rust path reads the canonical JSON, not a private
copy). Catches a module that stops loading its content.
Check B — "tombstones": const/static names that previously held now-externalized
content must NOT reappear in the owning module. Exact-name match →
zero false positives. Catches a re-introduced hardcode (the exact
promotions regression: XP_THRESHOLDS / HEAL_ON_PROMOTE_FRACTION).
Coverage is opt-in: a file is guarded once it's added to REGISTRY below. This is
intentional — the gate makes a precise promise (registered content stays loaded;
known-deleted hardcodes stay dead), not the unkeepable one of catching every
possible future hardcode. That blanket guarantee is the ContentRegistry's job
(p3-28). Grow REGISTRY as content modules are identified.
Escape hatch: none needed — Check B uses exact tombstone names, so it only ever
fires on a deliberate resurrection of a deleted hardcode.
Usage:
tools/check-no-rust-hardcoded-content.py # report; exit 1 if violations
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
@dataclass(frozen=True)
class ContentEntry:
"""One canonical JSON content file and the Rust module(s) that load it."""
json: str # repo-relative path to the canonical JSON content file
modules: tuple[str, ...] # repo-relative Rust module(s) that must load it
# const/static names that PREVIOUSLY hardcoded this content and were removed;
# the gate fails if any reappears in an owning module (a divergence regression).
tombstones: tuple[str, ...] = field(default=())
# The registry. Each entry was verified (file:line) at authoring time against the
# `include_str!` content sites in src/simulator/crates/*/src/.
REGISTRY: tuple[ContentEntry, ...] = (
ContentEntry(
json="public/resources/promotions/promotions.json",
modules=("src/simulator/crates/mc-combat/src/promotions.rs",),
# Removed 2026-06-27 when promotion tuning moved to promotions.json
# (the divergence that motivated this gate). Must not come back.
tombstones=("XP_THRESHOLDS", "HEAL_ON_PROMOTE_FRACTION"),
),
ContentEntry(
json="public/resources/diplomacy/treaty_rules.json",
modules=(
"src/simulator/crates/mc-trade/src/rules.rs",
"src/simulator/crates/mc-trade/src/tribute.rs",
"src/simulator/crates/mc-trade/src/renewal.rs",
),
),
ContentEntry(
json="public/resources/ai/freepeople/freepeople.json",
modules=("src/simulator/crates/mc-trade/src/tribute.rs",),
),
ContentEntry(
json="public/games/age-of-dwarves/data/score.json",
modules=("src/simulator/crates/mc-score/src/lib.rs",),
),
ContentEntry(
json="public/resources/ecology/traits/biome_trait_weights.json",
modules=("src/simulator/crates/mc-ecology/src/generation.rs",),
),
ContentEntry(
json="public/resources/ecology/traits/flavor.json",
modules=("src/simulator/crates/mc-ecology/src/generation.rs",),
),
)
def _json_suffix(json_path: str) -> str:
"""Last two path segments, e.g. 'promotions/promotions.json' — robust to the
differing `../` depths used across include_str! sites."""
parts = json_path.split("/")
return "/".join(parts[-2:])
def _const_decl(name: str) -> re.Pattern[str]:
"""Match a `const NAME:` / `static NAME:` (optionally `pub`) declaration."""
return re.compile(rf"^\s*(?:pub\s+)?(?:const|static)\s+{re.escape(name)}\s*:")
def main() -> int:
violations: list[str] = []
for entry in REGISTRY:
json_path = REPO_ROOT / entry.json
suffix = _json_suffix(entry.json)
# Check A.0 — the canonical content file must exist.
if not json_path.is_file():
violations.append(
f"[A] missing canonical content file: {entry.json}\n"
f" registered as game content but not present on disk"
)
continue
for mod in entry.modules:
mod_path = REPO_ROOT / mod
if not mod_path.is_file():
violations.append(
f"[A] owning module not found: {mod}\n"
f" registered as loader of {entry.json}"
)
continue
text = mod_path.read_text(encoding="utf-8", errors="replace")
# Check A — the module must include_str! its registered content.
if "include_str!" not in text or suffix not in text:
violations.append(
f"[A] {mod} no longer loads {entry.json}\n"
f" expected an include_str!(... {suffix}) — content must be\n"
f" LOADED from the canonical JSON, not hardcoded (Rail-2)."
)
# Check B — no tombstoned hardcode may reappear.
for name in entry.tombstones:
rx = _const_decl(name)
for lineno, line in enumerate(text.splitlines(), start=1):
if rx.search(line):
violations.append(
f"[B] {mod}:{lineno} resurrects deleted hardcode `{name}`\n"
f" {line.strip()}\n"
f" This content lives in {entry.json}; load it, don't hardcode (Rail-2)."
)
if violations:
print(f"Rail-2 violation: {len(violations)} content-divergence issue(s):\n")
for v in violations:
print(f" {v}")
print(
"\nRail-2: JSON game packs are the canonical content store. A Rust crate\n"
"must LOAD balance content from public/resources/** (OnceLock+include_str!,\n"
"WASM/gdext-safe), never hold a second hardcoded copy that drifts from the\n"
"JSON. See .claude/instructions/rust-source-of-truth.md and p3-28."
)
return 1
n_files = len(REGISTRY)
n_tombstones = sum(len(e.tombstones) for e in REGISTRY)
print(
f"OK: {n_files} registered content file(s) loaded from JSON; "
f"{n_tombstones} tombstoned hardcode(s) stay dead (Rail-2)"
)
return 0
if __name__ == "__main__":
sys.exit(main())