Files
aetherbound-guild/tools/validate_predevelopment_design.py
T

275 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate the six consolidated Aetherbound Guild owner documents."""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
REQUIRED_DOCUMENTS = (
Path("docs/01_GAME_DESIGN.md"),
Path("docs/02_GAMEPLAY_AND_BALANCE.md"),
Path("docs/03_PAGES_AND_UX.md"),
Path("docs/04_ART_ANIMATION_AUDIO.md"),
Path("docs/05_CONTENT_CATALOG.md"),
Path("docs/06_DEVELOPMENT_AND_REVIEW.md"),
)
PAGE_DOCUMENT = Path("docs/03_PAGES_AND_UX.md")
CONTENT_DOCUMENT = Path("docs/05_CONTENT_CATALOG.md")
APPROVED_BATTLE_REFERENCE = Path(
"docs/prototype/generation/phase6_ui_direction_01/outputs/"
"05_battle_ui_japanese_scale.png"
)
ANTI_DOMINANCE_FIXTURE = Path(
"docs/product/fixtures/ANTI_DOMINANCE_FIXED_SEEDS.json"
)
HISTORICAL_SECTION_MARKERS = {
"AETHERBOUND_GUILD_COMPLETE_DEVELOPMENT_GOAL",
"ARTIFACT_CATALOG",
"ART_ASSET_AND_ANIMATION_CATALOG",
"AUDIO_MUSIC_AND_VOICE_CATALOG",
"CONTENT_INDEX",
"DEVELOPMENT_LEDGER",
"ECONOMY_AND_BALANCE",
"ENEMY_CATALOG",
"EQUIPMENT_CATALOG",
"FG4_HANDDRAWN_PRESENTATION_REPAIR_CONTRACT",
"FG4_HANDDRAWN_PRESENTATION_REPAIR_GOAL",
"FULL_GAME_IMPLEMENTATION_ROADMAP",
"GAME_PRODUCT_CONTRACT",
"IMPLEMENTATION_PLAN",
"ORIGINALITY_BOUNDARY",
"PAGE_DEVELOPMENT_PLAN",
"PROFESSION_CATALOG",
"RECRUIT_GENERATION",
"REGION_CATALOG",
"SAVE_AND_FAILURE_CONTRACT",
"SCREEN_AND_STATE_MAP",
"SYSTEMS_AND_BATTLE",
"TRAIT_CATALOG",
"UI_AND_VISUAL_SYSTEM",
"VISUAL_DIRECTION",
}
LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
PAGE_ID_RE = re.compile(r"`([A-Z]{3}-\d{3})`")
STALE_DOCUMENT_RE = re.compile(
r"(?:docs/)?(?:content|goals|presentation|product|reviews|runtime)/"
r"[^\s`)]+\.md"
)
@dataclass(frozen=True)
class Issue:
code: str
path: str
message: str
def as_dict(self) -> dict[str, str]:
return {"code": self.code, "path": self.path, "message": self.message}
def unique_matches(text: str, pattern: str) -> set[str]:
return set(re.findall(pattern, text, flags=re.MULTILINE))
def section_between(text: str, start: str, end: str) -> str | None:
start_index = text.find(start)
if start_index < 0:
return None
end_index = text.find(end, start_index + len(start))
if end_index < 0:
return None
return text[start_index:end_index]
def local_link_targets(text: str) -> Iterable[str]:
for raw_target in LINK_RE.findall(text):
target = raw_target.strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
else:
target = target.split(maxsplit=1)[0]
if target.startswith(("#", "http://", "https://", "mailto:", "data:")):
continue
target = target.split("#", 1)[0]
if target:
yield target
def expected_ids(prefix: str, count: int, width: int = 3) -> set[str]:
return {"{}-{:0{}d}".format(prefix, value, width) for value in range(1, count + 1)}
def audit(root: Path) -> tuple[list[Issue], dict[str, int]]:
issues: list[Issue] = []
counts: dict[str, int] = {}
docs_root = root / "docs"
actual_documents = set(docs_root.glob("*.md"))
supporting_documents = set(docs_root.glob("**/*.md")) - actual_documents
required_documents = {root / path for path in REQUIRED_DOCUMENTS}
counts["documents"] = len(actual_documents)
counts["supporting_documents"] = len(supporting_documents)
for missing in sorted(required_documents - actual_documents):
issues.append(Issue("MISSING_DOCUMENT", str(missing.relative_to(root)), "required owner document is missing"))
for extra in sorted(actual_documents - required_documents):
issues.append(Issue("EXTRA_DOCUMENT", str(extra.relative_to(root)), "only the six numbered owner documents may remain directly under docs/"))
loaded: dict[Path, str] = {}
for relative_path in REQUIRED_DOCUMENTS:
path = root / relative_path
if not path.is_file():
continue
text = path.read_text(encoding="utf-8")
loaded[relative_path] = text
if not text.startswith("# "):
issues.append(Issue("MISSING_TITLE", str(relative_path), "document must begin with one H1 title"))
if len(re.findall(r"^# ", text, flags=re.MULTILINE)) != 1:
issues.append(Issue("H1_COUNT", str(relative_path), "document must contain exactly one H1 title"))
stale = sorted(set(STALE_DOCUMENT_RE.findall(text)))
if stale:
issues.append(Issue("STALE_DOCUMENT_REFERENCE", str(relative_path), ", ".join(stale)))
for target in local_link_targets(text):
destination = (path.parent / target).resolve()
try:
destination.relative_to(root.resolve())
except ValueError:
issues.append(Issue("LINK_OUTSIDE_REPOSITORY", str(relative_path), target))
continue
if not destination.exists():
issues.append(Issue("BROKEN_LINK", str(relative_path), target))
marker_corpus = "\n".join(loaded.values())
marker_rows = re.findall(
r"Historical section source: ([A-Z0-9_]+);", marker_corpus
)
marker_set = set(marker_rows)
counts["historical_sections"] = len(marker_rows)
if marker_set != HISTORICAL_SECTION_MARKERS or len(marker_rows) != len(marker_set):
issues.append(
Issue(
"HISTORICAL_SECTION_SET",
"docs/",
"missing={} extra={} duplicates={}".format(
sorted(HISTORICAL_SECTION_MARKERS - marker_set),
sorted(marker_set - HISTORICAL_SECTION_MARKERS),
len(marker_rows) - len(marker_set),
),
)
)
page_text = loaded.get(PAGE_DOCUMENT, "")
plan = section_between(
page_text,
"Historical section source: PAGE_DEVELOPMENT_PLAN",
"Historical section source: SCREEN_AND_STATE_MAP",
)
state_map = section_between(
page_text,
"Historical section source: SCREEN_AND_STATE_MAP",
"Historical section source: UI_AND_VISUAL_SYSTEM",
)
if plan is None or state_map is None:
issues.append(Issue("PAGE_SECTIONS", str(PAGE_DOCUMENT), "page plan or state-map section marker is missing"))
else:
plan_ids = set(PAGE_ID_RE.findall(plan))
state_ids = set(PAGE_ID_RE.findall(state_map))
counts["page_plan_ids"] = len(plan_ids)
counts["page_state_ids"] = len(state_ids)
if len(plan_ids) != 50:
issues.append(Issue("PAGE_PLAN_COUNT", str(PAGE_DOCUMENT), "expected 50 unique page IDs, found {}".format(len(plan_ids))))
if len(state_ids) != 50:
issues.append(Issue("PAGE_STATE_COUNT", str(PAGE_DOCUMENT), "expected 50 unique page IDs, found {}".format(len(state_ids))))
if plan_ids != state_ids:
missing_state = sorted(plan_ids - state_ids)
missing_plan = sorted(state_ids - plan_ids)
issues.append(Issue("PAGE_ID_MISMATCH", str(PAGE_DOCUMENT), "missing from state={} missing from plan={}".format(missing_state, missing_plan)))
content = loaded.get(CONTENT_DOCUMENT, "")
content_contracts = (
("base_professions", r"^\| `(PF-B\d{2})`", {"PF-B{:02d}".format(i) for i in range(1, 13)}),
("advanced_professions", r"^\| `(PF-A\d{2})`", {"PF-A{:02d}".format(i) for i in range(1, 25)}),
("hidden_professions", r"^\| `(PF-H\d{2})`", {"PF-H{:02d}".format(i) for i in range(1, 7)}),
("traits", r"^\| `(TR-\d{3})`", expected_ids("TR", 36)),
("equipment", r"^\| `(EQ-\d{3})`", expected_ids("EQ", 320)),
("artifacts", r"^\| `(AR-\d{3})`", expected_ids("AR", 60)),
("enemies", r"^\| `(EN-R\d-\d{2})`", {"EN-R{}-{:02d}".format(region, row) for region in range(1, 9) for row in range(1, 13 if region <= 4 else 14)}),
("bosses", r"^\| `(BO-R\d-\d{2})`", {"BO-R{}-{:02d}".format(region, row) for region in range(1, 9) for row in range(1, 3)}),
("regions", r"^\| `(RG-\d{2})`", expected_ids("RG", 8, width=2)),
)
for name, pattern, expected in content_contracts:
actual = unique_matches(content, pattern)
counts[name] = len(actual)
if actual != expected:
missing = sorted(expected - actual)
extra = sorted(actual - expected)
issues.append(Issue("CONTENT_IDS", str(CONTENT_DOCUMENT), "{} missing={} extra={}".format(name, missing, extra)))
counts["professions"] = counts.get("base_professions", 0) + counts.get("advanced_professions", 0) + counts.get("hidden_professions", 0)
for required_path, code in (
(APPROVED_BATTLE_REFERENCE, "APPROVED_REFERENCE"),
(ANTI_DOMINANCE_FIXTURE, "ANTI_DOMINANCE_FIXTURE"),
):
if not (root / required_path).is_file():
issues.append(Issue(code, str(required_path), "required non-Markdown authority is missing"))
return issues, counts
def run_self_test(root: Path) -> None:
issues, counts = audit(root)
if issues:
raise AssertionError("repository fixture failed: {}".format([item.as_dict() for item in issues]))
checks = (
counts.get("documents") == 6,
counts.get("historical_sections") == len(HISTORICAL_SECTION_MARKERS),
counts.get("page_plan_ids") == 50,
counts.get("page_state_ids") == 50,
counts.get("professions") == 42,
counts.get("equipment") == 320,
counts.get("enemies") == 100,
)
if not all(checks):
raise AssertionError("self-test count contract failed: {}".format(counts))
print("ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases={}".format(len(checks)))
def parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("root", nargs="?", type=Path, default=REPOSITORY_ROOT)
parser.add_argument("--json", action="store_true", help="emit deterministic JSON")
parser.add_argument("--self-test", action="store_true", help="verify the repository fixture and count contract")
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
arguments = parse_arguments(argv)
root = arguments.root.resolve()
if arguments.self_test:
run_self_test(root)
return 0
issues, counts = audit(root)
if arguments.json:
print(json.dumps({"ok": not issues, "errors": len(issues), "counts": counts, "diagnostics": [item.as_dict() for item in issues]}, indent=2, sort_keys=True))
else:
for issue in issues:
print("ERROR {} {} {}".format(issue.code, issue.path, issue.message))
print("COUNTS {}".format(json.dumps(counts, sort_keys=True, separators=(",", ":"))))
if issues:
print("ABG_PREDEVELOPMENT_DESIGN_FAILED errors={}".format(len(issues)))
else:
print("ABG_PREDEVELOPMENT_DESIGN_OK errors=0 docs=6 pages=50")
return 1 if issues else 0
if __name__ == "__main__":
sys.exit(main())