#!/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())