magicciv/tools/check-no-rust-hardcoded-content.py
Natalie 69f80189c6 refactor(mc-combat): read promotions.json via ContentRegistry; fold Rail-2 gate (p3-28)
Both promotion_config() and build_registry() now pull raw bytes from
content::get(Promotions) instead of a crate-local include_str! const. The
embedded fallback moved to the central ContentRegistry, so the Rail-2 gate gains
a registry_owned flag: Check A verifies mc-core/src/content.rs embeds the JSON,
while the XP_THRESHOLDS/HEAL_ON_PROMOTE_FRACTION tombstones still guard
mc-combat. Gate passes; mc-combat/mc-turn/mc-player-api tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:56:16 -04:00

219 lines
9.9 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
# The central host-fed content registry (p3-28). When a content pack's embedded
# `include_str!` fallback is centralized here, Check A is satisfied by THIS file
# carrying the include_str! rather than each consumer module — the consumers now
# read the bytes via `mc_core::content::get(...)`. Tombstones still apply to the
# original consumer modules (a hardcode resurrection is caught wherever it lands).
CONTENT_REGISTRY_MODULE = "src/simulator/crates/mc-core/src/content.rs"
@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=())
# True once the embedded include_str! fallback for this pack has moved into the
# central ContentRegistry (CONTENT_REGISTRY_MODULE). Check A then verifies the
# registry loads the JSON; `modules` are kept only for the tombstone scan.
registry_owned: bool = field(default=False)
# 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"),
# p3-28: embedded fallback now lives in the central ContentRegistry;
# mc-combat reads it via mc_core::content::get(Promotions).
registry_owned=True,
),
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
# Check A — the canonical JSON must be include_str!-loaded somewhere the
# headless/WASM path reaches. For registry-owned packs that is the central
# ContentRegistry; otherwise it is each owning consumer module.
if entry.registry_owned:
reg_path = REPO_ROOT / CONTENT_REGISTRY_MODULE
reg_text = (
reg_path.read_text(encoding="utf-8", errors="replace")
if reg_path.is_file()
else ""
)
if "include_str!" not in reg_text or suffix not in reg_text:
violations.append(
f"[A] {CONTENT_REGISTRY_MODULE} no longer embeds {entry.json}\n"
f" this pack is registry_owned — the central ContentRegistry must\n"
f" carry an include_str!(... {suffix}) embedded fallback (Rail-2)."
)
else:
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")
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 in any consumer module.
for mod in entry.modules:
mod_path = REPO_ROOT / mod
if not mod_path.is_file():
# Only matters for tombstone scanning; a missing non-registry
# loader was already reported above.
if entry.registry_owned and entry.tombstones:
violations.append(
f"[A] tombstone-owner module not found: {mod}\n"
f" registered for tombstone scan of {entry.json}"
)
continue
text = mod_path.read_text(encoding="utf-8", errors="replace")
# 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())