#!/usr/bin/env python3 """Deterministically audit the integrated Aetherbound Guild design package.""" from __future__ import annotations import argparse import json import re import sys import tempfile from dataclasses import dataclass from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple REPOSITORY_ROOT = Path(__file__).resolve().parents[1] AUTHORITY_DIRECTORIES = ( Path("docs/product"), Path("docs/content"), Path("docs/presentation"), ) REQUIRED_FILES = ( Path("docs/product/GAME_PRODUCT_CONTRACT.md"), Path("docs/product/SYSTEMS_AND_BATTLE.md"), Path("docs/product/ECONOMY_AND_BALANCE.md"), Path("docs/product/SAVE_AND_FAILURE_CONTRACT.md"), Path("docs/presentation/SCREEN_AND_STATE_MAP.md"), Path("docs/presentation/UI_AND_VISUAL_SYSTEM.md"), Path("docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md"), Path("docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md"), ) FORBIDDEN_AUTHORITY_FILES = (Path("docs/content/CHARACTER_BIBLE.md"),) EXACT_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$") ID_IN_TEXT_RE = re.compile( r"(? Tuple[str, int, int, str, str]: return (self.path, self.line, self.column, self.code, self.message) def as_dict(self) -> Dict[str, object]: return { "severity": "error", "code": self.code, "path": self.path, "line": self.line, "column": self.column, "message": self.message, } @dataclass(frozen=True) class Document: path: str text: str @dataclass(frozen=True) class Definition: identifier: str path: str line: int column: int cells: Tuple[str, ...] @dataclass(frozen=True) class Reference: identifiers: Tuple[str, ...] path: str line: int column: int source: str @dataclass class AuditResult: issues: List[Issue] counts: Dict[str, int] @property def ok(self) -> bool: return not self.issues @dataclass(frozen=True) class RegistryRule: name: str path: str pattern: re.Pattern expected_ids: Tuple[str, ...] minimum_cells: int heading_pattern: Optional[re.Pattern] = None def numbered_ids(prefix: str, count: int, width: int) -> Tuple[str, ...]: return tuple("{}{:0{}d}".format(prefix, number, width) for number in range(1, count + 1)) REGISTRY_RULES = ( RegistryRule( "professions.base", "docs/content/PROFESSION_CATALOG.md", re.compile(r"^PF-B\d{2}$"), numbered_ids("PF-B", 12, 2), 7, re.compile(r"^Base Professions$"), ), RegistryRule( "professions.advanced", "docs/content/PROFESSION_CATALOG.md", re.compile(r"^PF-A\d{2}$"), numbered_ids("PF-A", 24, 2), 6, re.compile(r"^Regular Advanced Professions$"), ), RegistryRule( "professions.hidden", "docs/content/PROFESSION_CATALOG.md", re.compile(r"^PF-H\d{2}$"), numbered_ids("PF-H", 6, 2), 6, re.compile(r"^Hidden Hybrid Professions$"), ), RegistryRule( "traits", "docs/content/TRAIT_CATALOG.md", re.compile(r"^TR-\d{3}$"), numbered_ids("TR-", 36, 3), 6, ), RegistryRule( "artifacts", "docs/content/ARTIFACT_CATALOG.md", re.compile(r"^AR-\d{3}$"), numbered_ids("AR-", 60, 3), 4, ), RegistryRule( "bosses", "docs/content/ENEMY_CATALOG.md", re.compile(r"^BO-R[1-8]-\d{2}$"), tuple( "BO-R{}-{:02d}".format(region, ordinal) for region in range(1, 9) for ordinal in range(1, 3) ), 5, ), RegistryRule( "regions", "docs/content/REGION_CATALOG.md", re.compile(r"^RG-\d{2}$"), numbered_ids("RG-", 8, 2), 6, ), ) EQUIPMENT_PATH = "docs/content/EQUIPMENT_CATALOG.md" ENEMY_PATH = "docs/content/ENEMY_CATALOG.md" SCREEN_PATH = "docs/presentation/SCREEN_AND_STATE_MAP.md" AUDIO_PATH = "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md" def relative_path(path: Path, root: Path) -> str: try: return path.relative_to(root).as_posix() except ValueError: return path.as_posix() def issue( code: str, path: str, line: int, column: int, message: str, ) -> Issue: return Issue(code, path, max(1, line), max(1, column), message) def split_markdown_row(line: str) -> Optional[Tuple[str, ...]]: stripped = line.strip() if not stripped.startswith("|"): return None cells: List[str] = [] current: List[str] = [] escaped = False in_code = False for character in stripped[1:]: if escaped: current.append(character) escaped = False continue if character == "\\": current.append(character) escaped = True continue if character == "`": in_code = not in_code current.append(character) continue if character == "|" and not in_code: cells.append("".join(current).strip()) current = [] continue current.append(character) if current: cells.append("".join(current).strip()) if cells and not cells[-1]: cells.pop() if not cells: return None return tuple(cells) def first_cell_identifier(cell: str) -> Optional[Tuple[str, int]]: for match in CODE_SPAN_RE.finditer(cell): candidate = match.group(1).strip() if EXACT_ID_RE.fullmatch(candidate) and ( any(character.isdigit() for character in candidate) or NAMED_PRESENTATION_ID_RE.match(candidate) ): return candidate, match.start(1) + 1 return None def collect_documents(root: Path) -> Tuple[List[Document], List[Issue]]: documents: List[Document] = [] issues: List[Issue] = [] for required in REQUIRED_FILES: if not (root / required).is_file(): issues.append( issue("AUTHORITY_FILE_MISSING", required.as_posix(), 1, 1, "required design authority is missing") ) for forbidden in FORBIDDEN_AUTHORITY_FILES: if (root / forbidden).exists(): issues.append( issue( "RETIRED_AUTHORITY_FILE", forbidden.as_posix(), 1, 1, "fixed-character authority must be removed or replaced by generated-recruit catalogs", ) ) for directory in AUTHORITY_DIRECTORIES: absolute = root / directory if not absolute.is_dir(): issues.append( issue("AUTHORITY_DIRECTORY_MISSING", directory.as_posix(), 1, 1, "authority directory is missing") ) continue for path in sorted(absolute.rglob("*.md")): try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeError) as error: issues.append( issue( "AUTHORITY_READ_ERROR", relative_path(path, root), 1, 1, "cannot read UTF-8 Markdown: {}".format(error), ) ) continue documents.append(Document(relative_path(path, root), text)) return documents, issues def heading_text(line: str) -> Optional[str]: match = re.match(r"^#{1,6}\s+(.+?)\s*$", line) return match.group(1) if match else None def is_primary_definition(path: str, heading: str, identifier: str) -> bool: for rule in REGISTRY_RULES: if path != rule.path or not rule.pattern.fullmatch(identifier): continue if rule.heading_pattern is None or rule.heading_pattern.fullmatch(heading): return True if path == EQUIPMENT_PATH and re.fullmatch(r"EQ-\d{3}", identifier): return True if path == ENEMY_PATH and re.fullmatch(r"EN-R[1-8]-\d{2}", identifier): return True if path == SCREEN_PATH and re.fullmatch( r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}", identifier ): return True return False def collect_audio_definitions(document: Document) -> List[Definition]: if document.path != AUDIO_PATH: return [] definitions: List[Definition] = [] heading = "" in_fence = False for line_number, line in enumerate(document.text.splitlines(), 1): found_heading = heading_text(line) if found_heading is not None: heading = found_heading if line.strip().startswith("```"): in_fence = not in_fence continue expressions: List[Tuple[str, int, Tuple[str, ...]]] = [] cells = split_markdown_row(line) if heading == "5.1 Scope And Count" and cells: for match in CODE_SPAN_RE.finditer(cells[0]): expressions.append((match.group(1).strip(), line.find(match.group(1)), cells)) elif heading == "6.1 Configuration Count" and cells and len(cells) >= 2: for match in CODE_SPAN_RE.finditer(cells[1]): expressions.append((match.group(1).strip(), line.find(match.group(1)), cells)) elif heading == "7.1 Bounded Scope" and in_fence: match = re.match(r"\s*(VO-[A-Z0-9-]+)\s+(.+)$", line) if match: expressions.append((match.group(1), match.start(1), (match.group(1), match.group(2)))) elif re.fullmatch(r"8\.[1-7].*", heading) and in_fence: match = re.match(r"\s*(SFX-[A-Z0-9-]+)\s+(.+)$", line) if match: expressions.append((match.group(1), match.start(1), (match.group(1), match.group(2)))) elif heading.startswith("8.8 ") and cells: for cell in cells: for match in CODE_SPAN_RE.finditer(cell): candidate = match.group(1).strip() if candidate.startswith("SFX-"): expressions.append((candidate, line.find(candidate), cells)) for expression, column, source_cells in expressions: expanded = expand_compact_id_expression(expression) if expanded is None: continue for identifier in expanded: if not re.fullmatch(r"(?:MUS|AMB|SFX|VO)-[A-Z0-9-]+", identifier): continue definitions.append( Definition(identifier, document.path, line_number, column + 1, source_cells) ) return definitions def collect_definitions(documents: Sequence[Document]) -> Tuple[List[Definition], List[Issue]]: definitions: List[Definition] = [] issues: List[Issue] = [] for document in documents: heading = "" for line_number, line in enumerate(document.text.splitlines(), 1): found_heading = heading_text(line) if found_heading is not None: heading = found_heading cells = split_markdown_row(line) if not cells: continue found = first_cell_identifier(cells[0]) if not found: continue identifier, cell_column = found if not is_primary_definition(document.path, heading, identifier): continue definitions.append( Definition( identifier, document.path, line_number, max(1, line.find(cells[0]) + cell_column), cells, ) ) definitions.extend(collect_audio_definitions(document)) by_identifier: Dict[str, List[Definition]] = {} for definition in definitions: by_identifier.setdefault(definition.identifier, []).append(definition) for identifier, locations in sorted(by_identifier.items()): if len(locations) < 2: continue rendered = ", ".join("{}:{}".format(item.path, item.line) for item in locations) for location in locations: issues.append( issue( "ID_DUPLICATE", location.path, location.line, location.column, "{} is defined {} times ({})".format(identifier, len(locations), rendered), ) ) return definitions, issues def format_id_sample(identifiers: Iterable[str], limit: int = 12) -> str: values = sorted(set(identifiers)) if len(values) <= limit: return ", ".join(values) return "{}, ... (+{} more)".format(", ".join(values[:limit]), len(values) - limit) def has_pending_marker(value: str) -> bool: return any(pattern.search(value) for pattern in PENDING_PATTERNS) def validate_registry_rule( rule: RegistryRule, definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int], ) -> Set[str]: matches = [ definition for definition in definitions if definition.path == rule.path and rule.pattern.fullmatch(definition.identifier) ] actual_ids = {definition.identifier for definition in matches} expected_ids = set(rule.expected_ids) counts[rule.name] = len(actual_ids) if actual_ids != expected_ids: missing = expected_ids - actual_ids extra = actual_ids - expected_ids detail: List[str] = [] if missing: detail.append("missing [{}]".format(format_id_sample(missing))) if extra: detail.append("unexpected [{}]".format(format_id_sample(extra))) issues.append( issue( "REGISTRY_EXACT_SET", "docs/content", 1, 1, "{} requires exactly {} stable IDs; found {}{}".format( rule.name, len(expected_ids), len(actual_ids), "; " + "; ".join(detail) if detail else "", ), ) ) for definition in matches: if len(definition.cells) < rule.minimum_cells: issues.append( issue( "REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} row has {} cells; at least {} nonempty structural cells are required".format( definition.identifier, len(definition.cells), rule.minimum_cells ), ) ) if any(not cell.strip() for cell in definition.cells[: rule.minimum_cells]): issues.append( issue( "REGISTRY_ROW_EMPTY", definition.path, definition.line, definition.column, "{} has an empty required structural cell".format(definition.identifier), ) ) if any(has_pending_marker(cell) for cell in definition.cells): issues.append( issue( "REGISTRY_ROW_PENDING", definition.path, definition.line, definition.column, "{} contains unresolved or filler content".format(definition.identifier), ) ) return actual_ids def validate_equipment_registry( definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> Set[str]: equipment_definitions = [ definition for definition in definitions if definition.path == EQUIPMENT_PATH and re.fullmatch(r"EQ-\d{3}", definition.identifier) ] actual_ids = {definition.identifier for definition in equipment_definitions} expected_ids = set(numbered_ids("EQ-", 320, 3)) counts["equipment"] = len(actual_ids) if actual_ids != expected_ids: missing = expected_ids - actual_ids extra = actual_ids - expected_ids detail: List[str] = [] if missing: detail.append("missing [{}]".format(format_id_sample(missing))) if extra: detail.append("unexpected [{}]".format(format_id_sample(extra))) issues.append( issue( "REGISTRY_EXACT_SET", "docs/content", 1, 1, "equipment requires exactly EQ-001..EQ-320; found {}{}".format( len(actual_ids), "; " + "; ".join(detail) if detail else "", ), ) ) for definition in equipment_definitions: if len(definition.cells) < 6 or any(not cell.strip() for cell in definition.cells[:6]): issues.append( issue( "REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} requires six nonempty equipment-schema cells".format(definition.identifier), ) ) if any(has_pending_marker(cell) for cell in definition.cells): issues.append( issue( "REGISTRY_ROW_PENDING", definition.path, definition.line, definition.column, "{} contains unresolved or filler content".format(definition.identifier), ) ) return actual_ids def validate_enemy_registry( definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> Set[str]: matches = [ definition for definition in definitions if definition.path == ENEMY_PATH and re.fullmatch(r"EN-R[1-8]-\d{2}", definition.identifier) ] actual_ids = {definition.identifier for definition in matches} counts["enemies.ordinary_elite"] = len(actual_ids) if len(actual_ids) != 100: issues.append( issue( "REGISTRY_EXACT_COUNT", "docs/content", 1, 1, "ordinary/elite enemies require exactly 100 EN-Rr-nn IDs; found {}".format( len(actual_ids) ), ) ) for region in range(1, 9): prefix = "EN-R{}-".format(region) numbers = sorted( int(identifier[-2:]) for identifier in actual_ids if identifier.startswith(prefix) ) if not numbers: issues.append( issue( "ENEMY_REGION_EMPTY", "docs/content", 1, 1, "{} has no ordinary/elite enemy definitions".format(prefix.rstrip("-")), ) ) continue expected = set(range(1, max(numbers) + 1)) missing = expected - set(numbers) if missing: issues.append( issue( "ENEMY_SEQUENCE_GAP", "docs/content", 1, 1, "{} numbering must be gap-free from 01; missing [{}]".format( prefix.rstrip("-"), format_id_sample("{}{:02d}".format(prefix, number) for number in missing), ), ) ) for definition in matches: if len(definition.cells) < 5 or any(not cell.strip() for cell in definition.cells[:5]): issues.append( issue( "REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} requires five nonempty enemy-schema cells".format(definition.identifier), ) ) if len(definition.cells) >= 2 and not re.search( r"\b(?:ordinary|elite)\b", definition.cells[1], re.IGNORECASE ): issues.append( issue( "ENEMY_CLASS_MISSING", definition.path, definition.line, definition.column, "{} must declare ordinary or elite class".format(definition.identifier), ) ) if any(has_pending_marker(cell) for cell in definition.cells): issues.append( issue( "REGISTRY_ROW_PENDING", definition.path, definition.line, definition.column, "{} contains unresolved or filler content".format(definition.identifier), ) ) return actual_ids def parse_range(value: str) -> Optional[Tuple[str, ...]]: match = RANGE_IN_TEXT_RE.fullmatch(value) if not match: return None left, right = match.groups() left_match = re.fullmatch(r"(.*?)(\d+)", left) right_match = re.fullmatch(r"(.*?)(\d+)(-[A-Z][A-Z0-9-]*)?", right) if not left_match or not right_match: return None left_prefix, left_number_text = left_match.groups() right_prefix, right_number_text, suffix = right_match.groups() suffix = suffix or "" compatible_prefix = not right_prefix or right_prefix == left_prefix if right_prefix and left_prefix.rstrip("-").endswith(right_prefix.rstrip("-")): compatible_prefix = True if not compatible_prefix: right_identifier = "{}{}{}".format(right_prefix, right_number_text, suffix) if EXACT_ID_RE.fullmatch(left) and EXACT_ID_RE.fullmatch(right_identifier): return (left, right_identifier) return None start = int(left_number_text) end = int(right_number_text) if end < start or end - start > 1000: return None width = max(len(left_number_text), len(right_number_text)) return tuple( "{}{:0{}d}{}".format(left_prefix, number, width, suffix) for number in range(start, end + 1) ) def expand_compact_id_expression(value: str) -> Optional[Tuple[str, ...]]: value = value.strip() brace = re.search(r"\{(\d+)\.\.(\d+)\}", value) if brace: start_text, end_text = brace.groups() start = int(start_text) end = int(end_text) if end < start or end - start > 1000: return None expanded: List[str] = [] width = max(len(start_text), len(end_text)) for number in range(start, end + 1): nested = value[: brace.start()] + "{:0{}d}".format(number, width) + value[brace.end() :] nested_values = expand_compact_id_expression(nested) if nested_values is None: return None expanded.extend(nested_values) return tuple(expanded) if ".." in value: return parse_range(value) if EXACT_ID_RE.fullmatch(value): return (value,) return None def looks_like_id_range(value: str) -> bool: return ( ".." in value and bool(value) and value[0].isupper() and "-" in value and re.fullmatch(r"[A-Z0-9{}.-]+", value) is not None ) def collect_references(documents: Sequence[Document]) -> Tuple[List[Reference], List[Issue]]: references: List[Reference] = [] issues: List[Issue] = [] for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): masked = list(line) for match in CODE_SPAN_RE.finditer(line): expression = match.group(1).strip() if ".." not in expression: continue if BRACKET_BINDING_RE.fullmatch(expression): for index in range(match.start(), match.end()): masked[index] = " " continue if not looks_like_id_range(expression): continue expanded = expand_compact_id_expression(expression) if expanded is None: issues.append( issue( "REFERENCE_RANGE_INVALID", document.path, line_number, match.start(1) + 1, "invalid or unbounded ID range {}".format(expression), ) ) else: references.append( Reference(expanded, document.path, line_number, match.start(1) + 1, expression) ) for index in range(match.start(), match.end()): masked[index] = " " masked_after_code = "".join(masked) for match in RANGE_IN_TEXT_RE.finditer(masked_after_code): expanded = expand_compact_id_expression(match.group(0)) if expanded is None: issues.append( issue( "REFERENCE_RANGE_INVALID", document.path, line_number, match.start() + 1, "invalid or unbounded ID range {}".format(match.group(0)), ) ) else: references.append( Reference(expanded, document.path, line_number, match.start() + 1, match.group(0)) ) for index in range(match.start(), match.end()): masked[index] = " " masked_line = "".join(masked) for match in ID_IN_TEXT_RE.finditer(masked_line): references.append( Reference( (match.group(1),), document.path, line_number, match.start(1) + 1, match.group(1), ) ) return references, issues def numeric_stem(identifier: str) -> Optional[str]: match = re.fullmatch(r"(.*?)(\d+)", identifier) return match.group(1) if match else None def validate_cross_references( definitions: Sequence[Definition], references: Sequence[Reference], issues: List[Issue] ) -> None: defined_ids = {definition.identifier for definition in definitions} known_stems = { stem for stem in (numeric_stem(identifier) for identifier in defined_ids) if stem is not None } known_stems.update( ( "PF-B", "PF-A", "PF-H", "TR-", "EQ-", "AR-", "EN-R1-", "EN-R2-", "EN-R3-", "EN-R4-", "EN-R5-", "EN-R6-", "EN-R7-", "EN-R8-", "BO-R1-", "BO-R2-", "BO-R3-", "BO-R4-", "BO-R5-", "BO-R6-", "BO-R7-", "BO-R8-", "RG-", "PR-B", "PR-A", "PR-H", "IT-", "EN-O", "EN-E", "EN-B", "REG-", ) ) observed: Set[Tuple[str, int, str, Tuple[str, ...]]] = set() for reference in references: missing = tuple( identifier for identifier in reference.identifiers if identifier not in defined_ids and numeric_stem(identifier) in known_stems ) if not missing: continue key = (reference.path, reference.line, reference.source, missing) if key in observed: continue observed.add(key) issues.append( issue( "REFERENCE_BROKEN", reference.path, reference.line, reference.column, "{} references undefined ID(s): {}".format( reference.source, format_id_sample(missing) ), ) ) def validate_declared_bindings( documents: Sequence[Document], issues: List[Issue], counts: Dict[str, int] ) -> None: definitions: Dict[str, List[Tuple[str, int, int]]] = {} references: List[Tuple[str, str, int, int]] = [] for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): for match in BINDING_ID_RE.finditer(line): references.append((match.group(0), document.path, line_number, match.start() + 1)) if document.path not in (SCREEN_PATH, "docs/presentation/UI_AND_VISUAL_SYSTEM.md"): continue cells = split_markdown_row(line) if not cells: continue match = re.search(r"`(BIND:[A-Z][A-Z0-9_]+)`", cells[0]) if not match: continue binding_id = match.group(1) definitions.setdefault(binding_id, []).append( (document.path, line_number, line.find(binding_id) + 1) ) if len(cells) < 3 or any(not cell.strip() for cell in cells[:3]): issues.append( issue( "BINDING_SCHEMA_INVALID", document.path, line_number, line.find(binding_id) + 1, "{} requires binding, use, and owner/source cells".format(binding_id), ) ) counts["bindings.declared"] = len(definitions) for binding_id, locations in sorted(definitions.items()): if len(locations) < 2: continue rendered = ", ".join("{}:{}".format(path, line) for path, line, _ in locations) for path, line, column in locations: issues.append( issue( "BINDING_DUPLICATE", path, line, column, "{} is declared {} times ({})".format(binding_id, len(locations), rendered), ) ) seen_unknown: Set[Tuple[str, str, int]] = set() for binding_id, path, line, column in references: if binding_id in definitions: continue key = (binding_id, path, line) if key in seen_unknown: continue seen_unknown.add(key) issues.append( issue( "BINDING_UNKNOWN", path, line, column, "{} has no declaration in the presentation binding registries".format(binding_id), ) ) def validate_pending_bindings(documents: Sequence[Document], issues: List[Issue]) -> None: observed: Set[Tuple[str, int, int, str]] = set() observed_bracket_bindings: Set[str] = set() for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): for match in BRACKET_BINDING_RE.finditer(line): raw = match.group(0) if raw in observed_bracket_bindings: continue observed_bracket_bindings.add(raw) issues.append( issue( "BINDING_UNRESOLVED", document.path, line_number, match.start() + 1, "required bracket binding remains unresolved: {}".format(raw), ) ) for pattern in PENDING_PATTERNS: for match in pattern.finditer(line): raw = match.group(0).strip() key = (document.path, line_number, match.start() + 1, raw) if key in observed: continue observed.add(key) issues.append( issue( "BINDING_UNRESOLVED", document.path, line_number, match.start() + 1, "unresolved integration binding or placeholder: {}".format(raw), ) ) def retirement_context(line: str, start: int, end: int) -> bool: window = line[max(0, start - 100) : min(len(line), end + 100)] return RETIREMENT_CONTEXT_RE.search(window) is not None or NEGATIVE_SEARCH_RE.search(line) is not None def validate_retired_concepts(documents: Sequence[Document], issues: List[Issue]) -> None: for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): for code, pattern in RETIRED_CONCEPTS: for match in pattern.finditer(line): if retirement_context(line, match.start(), match.end()): continue issues.append( issue( code, document.path, line_number, match.start() + 1, "authoritative use of retired concept: {}".format(match.group(0)), ) ) def document_by_path(documents: Sequence[Document], path: str) -> Optional[Document]: return next((document for document in documents if document.path == path), None) def require_text_coverage( text: str, requirements: Sequence[Tuple[str, re.Pattern]], code: str, path: str, issues: List[Issue], ) -> None: for name, pattern in requirements: if pattern.search(text): continue issues.append(issue(code, path, 1, 1, "missing required coverage: {}".format(name))) def validate_screen_coverage( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> Set[str]: path = "docs/presentation/SCREEN_AND_STATE_MAP.md" document = document_by_path(documents, path) if document is None: counts["pages"] = 0 return set() page_definitions = [ definition for definition in definitions if definition.path == path and re.fullmatch(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}", definition.identifier) ] page_ids = {definition.identifier for definition in page_definitions} counts["pages"] = len(page_ids) if not page_definitions: issues.append(issue("PAGE_REGISTRY_EMPTY", path, 1, 1, "no stable page rows were found")) return page_ids for definition in page_definitions: if len(definition.cells) < 5 or any(not cell.strip() for cell in definition.cells[:5]): issues.append( issue( "PAGE_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} requires ID, page/state, composition, primary action, and state/exit cells".format( definition.identifier ), ) ) page_text = [" | ".join(definition.cells[1:]).lower() for definition in page_definitions] page_requirements = ( ("shop", re.compile(r"\b(?:shop|market|offer)\b")), ("recruit", re.compile(r"\b(?:recruit|hire)\b")), ("equipment", re.compile(r"\b(?:equipment|equip|loadout)\b")), ("party line and reorder", re.compile(r"\b(?:party\s+line|formation|reorder)\b")), ("battle", re.compile(r"\b(?:battle|combat)\b")), ("result", re.compile(r"\b(?:result|victory|reward|diagnosis)\b")), ("failure", re.compile(r"\b(?:failure|defeat|death|casualty|recovery)\b")), ("save", re.compile(r"\b(?:save|cloud|continue)\b")), ("settings", re.compile(r"\b(?:settings|options|controls)\b")), ( "accessibility", re.compile(r"\b(?:accessibility|assist|screen\s+reader|reduced\s+motion|captions|contrast)\b"), ), ("localization", re.compile(r"\b(?:localization|language|locale|text)\b")), ("endgame", re.compile(r"\b(?:endgame|postgame|ending|mastery|new\s+game)\b")), ) for name, pattern in page_requirements: if any(pattern.search(value) for value in page_text): continue issues.append(issue("PAGE_CAPABILITY_MISSING", path, 1, 1, "no page row covers {}".format(name))) require_text_coverage( document.text, ( ("landscape composition", re.compile(r"\blandscape\b", re.IGNORECASE)), ("empty state", re.compile(r"\bempty\b", re.IGNORECASE)), ("error state", re.compile(r"\b(?:error|failed)\b", re.IGNORECASE)), ("confirmation behavior", re.compile(r"\bconfirm(?:ation|ed|ing)?\b", re.IGNORECASE)), ("input coverage", re.compile(r"\b(?:input|controller|keyboard|touch)\b", re.IGNORECASE)), ("localization coverage", re.compile(r"\b(?:localization|language|locale|pseudolocale)\b", re.IGNORECASE)), ( "accessibility coverage", re.compile( r"\b(?:accessibility|screen\s+reader|reduced\s+motion|high\s+contrast|captions)\b", re.IGNORECASE, ), ), ), "PAGE_STATE_COVERAGE_MISSING", path, issues, ) return page_ids def reference_ids_for_path(references: Sequence[Reference], path: str) -> Set[str]: return { identifier for reference in references if reference.path == path for identifier in reference.identifiers } def validate_art_coverage( documents: Sequence[Document], references: Sequence[Reference], issues: List[Issue], ) -> None: path = "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md" document = document_by_path(documents, path) if document is None: return art_ids = reference_ids_for_path(references, path) profession_art_ids = ( set(numbered_ids("ART-PRO-B", 12, 2)) | set(numbered_ids("ART-PRO-A", 24, 2)) | set(numbered_ids("ART-PRO-H", 6, 2)) ) groups = ( ("all 42 profession art kits", profession_art_ids), ("all 36 trait presentation IDs", set(numbered_ids("ART-TRT-", 36, 2))), ("all 320 equipment presentation IDs", set(numbered_ids("ART-EQP-", 320, 3))), ("all 60 artifact presentation IDs", set(numbered_ids("ART-AFT-", 60, 2))), ("all 100 ordinary/elite enemy art IDs", set(numbered_ids("ART-ENM-", 100, 3))), ("all 16 boss art IDs", set(numbered_ids("ART-BOS-", 16, 2))), ("all eight region art IDs", set(numbered_ids("ART-REG-", 8, 2))), ) for name, expected in groups: missing = expected - art_ids if missing: issues.append( issue( "ART_ID_COVERAGE_MISSING", path, 1, 1, "{} lack explicit art traceability: [{}]".format(name, format_id_sample(missing)), ) ) require_text_coverage( document.text, ( ( "modular generated recruits", re.compile(r"\b(?:modular|generated)\s+recruits?\b", re.IGNORECASE), ), ( "equipment readability", re.compile(r"\bequipment\b.{0,100}\b(?:readab|silhouette|icon)", re.IGNORECASE | re.DOTALL), ), ("animation matrix", re.compile(r"\banimation\b", re.IGNORECASE)), ("VFX language", re.compile(r"\b(?:VFX|visual\s+effects?)\b", re.IGNORECASE)), ("landscape composition", re.compile(r"\blandscape\b", re.IGNORECASE)), ), "ART_CAPABILITY_MISSING", path, issues, ) def validate_audio_coverage( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int], ) -> None: path = "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md" document = document_by_path(documents, path) if document is None: for family in ("music", "ambience", "sfx", "voice"): counts["audio.{}".format(family)] = 0 return audio_definitions = [definition for definition in definitions if definition.path == path] expected_families = { "music": { "MUS-G00-TITLE", "MUS-G01-GUILD", "MUS-G02-SHOP", "MUS-G03-PREP", "MUS-G04-RUN", "MUS-G05-RESULT-WIN", "MUS-G06-RESULT-LOSS", "MUS-G07-META", } | { "MUS-R{:02d}-{}".format(region, purpose) for region in range(1, 9) for purpose in ("ROUTE", "BATTLE", "BOSS") }, "ambience": set(numbered_ids("AMB-G", 12, 2)) | { "AMB-R{:02d}-{:02d}".format(region, configuration) for region in range(1, 9) for configuration in range(1, 9) }, "sfx": set(numbered_ids("SFX-UI-", 24, 3)) | set(numbered_ids("SFX-SHP-", 24, 3)) | set(numbered_ids("SFX-EQP-", 20, 3)) | set(numbered_ids("SFX-ORD-", 20, 3)) | set(numbered_ids("SFX-BAT-", 60, 3)) | set(numbered_ids("SFX-RES-", 28, 3)) | set(numbered_ids("SFX-RUN-", 28, 3)) | set(numbered_ids("SFX-REG-", 24, 3)), "voice": set(numbered_ids("VO-SYS-", 16, 3)) | set(numbered_ids("VO-ANN-", 16, 3)), } family_prefixes = { "music": "MUS-", "ambience": "AMB-", "sfx": "SFX-", "voice": "VO-", } for family, expected_ids in expected_families.items(): family_ids = { definition.identifier for definition in audio_definitions if definition.identifier.startswith(family_prefixes[family]) } counts["audio.{}".format(family)] = len(family_ids) if family_ids != expected_ids: missing = expected_ids - family_ids extra = family_ids - expected_ids detail: List[str] = [] if missing: detail.append("missing [{}]".format(format_id_sample(missing))) if extra: detail.append("unexpected [{}]".format(format_id_sample(extra))) issues.append( issue( "AUDIO_REGISTRY_EXACT_SET", path, 1, 1, "{} registry requires exactly {} IDs; found {}{}".format( family, len(expected_ids), len(family_ids), "; " + "; ".join(detail) if detail else "", ), ) ) for definition in audio_definitions: if len(definition.cells) < 2 or any(not cell.strip() for cell in definition.cells[:2]): issues.append( issue( "AUDIO_REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} lacks a production role or configuration".format(definition.identifier), ) ) coverage_text = re.sub(r"[_-]+", " ", document.text) require_text_coverage( coverage_text, ( ("shop offers", re.compile(r"\b(?:shop|offer|market)\b", re.IGNORECASE)), ("recruitment", re.compile(r"\b(?:recruit|hire)\b", re.IGNORECASE)), ("equipment", re.compile(r"\b(?:equipment|equip|loadout)\b", re.IGNORECASE)), ("party reorder", re.compile(r"\b(?:reorder|party\s+line|formation)\b", re.IGNORECASE)), ("battle", re.compile(r"\b(?:battle|combat)\b", re.IGNORECASE)), ("pause", re.compile(r"\bpause\b", re.IGNORECASE)), ("speed", re.compile(r"\bspeed\b", re.IGNORECASE)), ("inspection", re.compile(r"\binspect(?:ion)?\b", re.IGNORECASE)), ("retreat", re.compile(r"\bretreat\b", re.IGNORECASE)), ("permanent casualty", re.compile(r"\b(?:death|casualty|permanent\s+loss)\b", re.IGNORECASE)), ("dismissal", re.compile(r"\bdismiss(?:al|ed)?\b", re.IGNORECASE)), ("reward", re.compile(r"\breward\b", re.IGNORECASE)), ("failure", re.compile(r"\b(?:failure|defeat)\b", re.IGNORECASE)), ( "audio accessibility", re.compile(r"\b(?:accessibility|caption|visual\s+cue|haptic|mono)\b", re.IGNORECASE), ), ( "generated-recruit voice scope", re.compile(r"\b(?:generated|modular)\s+recruits?\b", re.IGNORECASE), ), ), "AUDIO_CAPABILITY_MISSING", path, issues, ) def audit(root: Path) -> AuditResult: root = root.resolve() documents, issues = collect_documents(root) definitions, definition_issues = collect_definitions(documents) issues.extend(definition_issues) counts: Dict[str, int] = {} for rule in REGISTRY_RULES: validate_registry_rule(rule, definitions, issues, counts) validate_equipment_registry(definitions, issues, counts) validate_enemy_registry(definitions, issues, counts) references, reference_issues = collect_references(documents) issues.extend(reference_issues) validate_cross_references(definitions, references, issues) validate_declared_bindings(documents, issues, counts) validate_pending_bindings(documents, issues) validate_retired_concepts(documents, issues) validate_screen_coverage(documents, definitions, issues, counts) validate_art_coverage(documents, references, issues) validate_audio_coverage( documents, definitions, issues, counts, ) unique_issues = {item.sort_key(): item for item in issues} return AuditResult(sorted(unique_issues.values(), key=Issue.sort_key), dict(sorted(counts.items()))) def write_fixture(root: Path) -> None: for directory in AUTHORITY_DIRECTORIES: (root / directory).mkdir(parents=True, exist_ok=True) product_texts = { "GAME_PRODUCT_CONTRACT.md": "# Product\n\nGenerated recruits form an ordered horizontal party line.\n", "SYSTEMS_AND_BATTLE.md": ( "# Systems\n\nAutomatic battle supports pause, speed, inspect, and retreat.\n" "No relationship system, six-unit cap, three-lane grid, Directive, Anchor, or Cache remains.\n" ), "ECONOMY_AND_BALANCE.md": "# Economy\n\nRecruitment, dismissal, rewards, and replacement have explicit costs.\n", "SAVE_AND_FAILURE_CONTRACT.md": "# Save\n\nDeath, failure, rollback, and recovery are explicit.\n", } for name, text in product_texts.items(): (root / "docs/product" / name).write_text(text, encoding="utf-8") def table(ids: Iterable[str], columns_after_id: int, value: str = "decision-bearing") -> str: header = ["ID"] + ["Field {}".format(index) for index in range(1, columns_after_id + 1)] lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)] for identifier in ids: cells = ["`{}`".format(identifier)] + [value] * columns_after_id lines.append("| " + " | ".join(cells) + " |") return "\n".join(lines) + "\n" profession = ["# Profession Catalog", "", "## Base Professions", ""] profession.append(table(numbered_ids("PF-B", 12, 2), 6)) profession.extend(("", "## Regular Advanced Professions", "")) profession.append(table(numbered_ids("PF-A", 24, 2), 5)) profession.extend(("", "## Hidden Hybrid Professions", "")) profession.append(table(numbered_ids("PF-H", 6, 2), 5)) profession.extend( ( "", "## Topology And No-Filler Audit", "", "| Base | Choices | Hidden participation | Decision split |", "|---|---|---|---|", ) ) for index in range(1, 13): profession.append( "| `PF-B{:02d}` | `PF-A{:02d}`, `PF-A{:02d}` | `PF-H{:02d}` | distinct choice |".format( index, index * 2 - 1, index * 2, (index - 1) % 6 + 1 ) ) (root / "docs/content/PROFESSION_CATALOG.md").write_text( "\n".join(profession) + "\n", encoding="utf-8" ) (root / "docs/content/TRAIT_CATALOG.md").write_text( "# Recruit Trait Catalog\n\n" + table(numbered_ids("TR-", 36, 3), 5), encoding="utf-8" ) equipment_rows = table(numbered_ids("EQ-", 320, 3), 5) equipment_rows = equipment_rows.replace( "| `EQ-284` | decision-bearing", "| `EQ-284` | complete three-band cadence alternation", ) (root / "docs/content/EQUIPMENT_CATALOG.md").write_text( "# Equipment Catalog\n\n" + equipment_rows, encoding="utf-8" ) (root / "docs/content/ARTIFACT_CATALOG.md").write_text( "# Run Artifact Catalog\n\n" + table(numbered_ids("AR-", 60, 3), 3), encoding="utf-8" ) enemy_ids = tuple( "EN-R{}-{:02d}".format(region, ordinal) for region in range(1, 9) for ordinal in range(1, 13 if region <= 4 else 14) ) enemy_lines = ["# Enemy And Boss Catalog", "", table(enemy_ids, 4, "ordinary; decision-bearing")] boss_ids = tuple( "BO-R{}-{:02d}".format(region, ordinal) for region in range(1, 9) for ordinal in range(1, 3) ) enemy_lines.extend(("", "## Bosses - 16 Identities", "", table(boss_ids, 4))) enemy_lines.extend( ( "", "## Count And Assignment Check", "", "| Region | Enemy IDs | Count | Boss IDs | Count |", "|---|---|---:|---|---:|", ) ) for region in range(1, 9): maximum = 12 if region <= 4 else 13 enemy_lines.append( "| `RG-{:02d}` | `EN-R{}-01..EN-R{}-{:02d}` | {} | `BO-R{}-01..BO-R{}-02` | 2 |".format( region, region, region, maximum, maximum, region, region ) ) (root / "docs/content/ENEMY_CATALOG.md").write_text( "\n".join(enemy_lines) + "\n", encoding="utf-8" ) (root / "docs/content/REGION_CATALOG.md").write_text( "# Region Catalog\n\n## Region Matrix\n\n" + table(numbered_ids("RG-", 8, 2), 5), encoding="utf-8", ) (root / "docs/content/CONTENT_INDEX.md").write_text( "# Content Index\n\n```sh\n! rg -ni 'relationship|romance|six-unit|Directive|Anchor|Cache|three-lane' docs/content\n```\n", encoding="utf-8", ) page_rows = ( ("SHP-001", "Shop offers", "Buy recruit", "empty, error, confirmation"), ("REC-001", "Recruit hiring", "Hire", "empty, error, confirmation"), ("EQP-001", "Equipment loadout", "Equip", "empty, error, confirmation"), ("PTY-001", "Party line formation reorder", "Reorder", "empty, error, confirmation"), ("BAT-001", "Automatic battle", "Start battle", "pause, speed, inspect, retreat"), ("OUT-001", "Victory result and reward", "Take reward", "empty, error, confirmation"), ("OUT-002", "Failure death and recovery", "Continue", "empty, error, confirmation"), ("SYS-001", "Save and cloud", "Manage save", "empty, error, confirmation"), ("SET-001", "Settings and controls", "Apply", "input, empty, error, confirmation"), ("SET-002", "Accessibility assists", "Apply", "screen reader, reduced motion, captions"), ("SET-003", "Language localization", "Apply language", "locale, text expansion"), ("PRO-001", "Progression records", "Inspect", "empty, error, confirmation"), ("PST-003", "Endgame postgame", "Begin postgame", "empty, error, confirmation"), ) screen_lines = [ "# Screen and State Map", "", "Landscape composition is mandatory. Every page covers empty and error states, confirmation behavior, input, localization, and accessibility.", "", "| Binding | Use | Owner | Missing behavior |", "|---|---|---|---|", "| `BIND:PARTY_CAPACITY` | line capacity | core systems | block dependent action |", "", "| ID | Page/state | Composition | Primary action | States and exit |", "|---|---|---|---|---|", ] screen_lines.extend( "| `{}` | {} | stable landscape composition | {} | {} |".format(*row) for row in page_rows ) (root / "docs/presentation/SCREEN_AND_STATE_MAP.md").write_text( "\n".join(screen_lines) + "\n", encoding="utf-8" ) (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( "# UI\n\nLandscape input, localization, and accessibility rules are shared.\n" "Progression coverage is `PRO-001..PST-003`.\n" "| Binding | Use | Owner |\n|---|---|---|\n" "| `BIND:AUDIO_EVENT_CATALOG` | semantic events | audio catalog |\n", encoding="utf-8", ) art = """# Art Asset and Animation Catalog Modular generated recruits use a landscape composition. Equipment readability uses silhouettes and icons. The animation matrix and VFX language are production-countable. Profession coverage: `ART-PRO-B01..B12`, `ART-PRO-A01..A24`, `ART-PRO-H01..H06`. Trait coverage: `ART-TRT-01..36`. Equipment coverage: `ART-EQP-001..320`. Artifact coverage: `ART-AFT-01..60`. Enemy coverage: `ART-ENM-001..100`; boss coverage: `ART-BOS-01..16`. Region coverage: `ART-REG-01..08`. """ (root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8") music_rows = [ "| `{}` | 1 | role |".format(identifier) for identifier in ( "MUS-G00-TITLE", "MUS-G01-GUILD", "MUS-G02-SHOP", "MUS-G03-PREP", "MUS-G04-RUN", "MUS-G05-RESULT-WIN", "MUS-G06-RESULT-LOSS", "MUS-G07-META", "MUS-R01..08-ROUTE", "MUS-R01..08-BATTLE", "MUS-R01..08-BOSS", ) ] voice_ids = list(numbered_ids("VO-SYS-", 16, 3)) + list(numbered_ids("VO-ANN-", 16, 3)) sfx_ids = ( list(numbered_ids("SFX-UI-", 24, 3)) + list(numbered_ids("SFX-SHP-", 24, 3)) + list(numbered_ids("SFX-EQP-", 20, 3)) + list(numbered_ids("SFX-ORD-", 20, 3)) + list(numbered_ids("SFX-BAT-", 60, 3)) + list(numbered_ids("SFX-RES-", 28, 3)) + list(numbered_ids("SFX-RUN-", 28, 3)) ) sfx_lines = [] for identifier in sfx_ids: role = "event_role" if identifier == "SFX-UI-007": role = "inspect_open" elif identifier == "SFX-SHP-020": role = "dismiss_commit" sfx_lines.append("{} {}".format(identifier, role)) region_sfx_rows = [] for region in range(1, 9): base = (region - 1) * 3 + 1 region_sfx_rows.append( "| R{:02d} | `SFX-REG-{:03d}` | `SFX-REG-{:03d}` | `SFX-REG-{:03d}` |".format( region, base, base + 1, base + 2 ) ) audio = "\n".join( [ "# Audio Music and Voice Catalog", "", "Shop offers, recruit hire, equipment, party reorder, battle, pause, speed, retreat, permanent casualty death, dismissal, reward, failure and defeat are covered.", "Generated recruits use bounded voice. Accessibility uses captions, visual cues, haptics, and mono review.", "", "## 5. Music Plan", "", "### 5.1 Scope And Count", "", "| Suite family | Count | Role |", "|---|---:|---|", *music_rows, "", "## 6. Ambience Plan", "", "### 6.1 Configuration Count", "", "| Family | Configuration IDs | Count |", "|---|---|---:|", "| Global | `AMB-G01..G12` | 12 |", "| Per region | `AMB-R{01..08}-01..08` | 64 |", "", "## 7. Generated-Recruit Voice Contract", "", "### 7.1 Bounded Scope", "", "```text", *("{} semantic_role".format(identifier) for identifier in voice_ids), "```", "", "## 8. Shared SFX Event Ledger", "", "### 8.1 Shared Definitions", "", "```text", *sfx_lines, "```", "", "### 8.8 Eight-Region Progression: 24", "", "| Region | Entry | Hazard | Result |", "|---|---|---|---|", *region_sfx_rows, "", ] ) (root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").write_text( audio, encoding="utf-8" ) def mutate_remove_line(path: Path, needle: str) -> None: lines = path.read_text(encoding="utf-8").splitlines() for index, line in enumerate(lines): if needle in line: del lines[index] path.write_text("\n".join(lines) + "\n", encoding="utf-8") return raise AssertionError("fixture mutation target not found: {}".format(needle)) def run_self_test() -> None: with tempfile.TemporaryDirectory(prefix="abg-design-validator-") as temporary: root = Path(temporary) write_fixture(root) passing = audit(root) if not passing.ok: rendered = "\n".join( "{} {}:{} {}".format(item.code, item.path, item.line, item.message) for item in passing.issues ) raise AssertionError("passing fixture failed:\n{}".format(rendered)) expected_counts = { "professions.base": 12, "professions.advanced": 24, "professions.hidden": 6, "traits": 36, "equipment": 320, "artifacts": 60, "enemies.ordinary_elite": 100, "bosses": 16, "regions": 8, "audio.music": 32, "audio.ambience": 76, "audio.sfx": 228, "audio.voice": 32, } for name, expected in expected_counts.items(): if passing.counts.get(name) != expected: raise AssertionError( "passing fixture count {}={} (expected {})".format( name, passing.counts.get(name), expected ) ) range_regressions = { "ART-PRO-B01..B12": 12, "AMB-G01..G12": 12, "AMB-R{01..08}-01..08": 64, "MUS-R01..08-BATTLE": 8, "PRO-001..PST-003": 2, } for expression, expected in range_regressions.items(): expanded = expand_compact_id_expression(expression) if expanded is None or len(expanded) != expected: raise AssertionError("range regression failed: {}".format(expression)) cases: Sequence[Tuple[str, Callable[[Path], None], str]] = ( ( "exact-count", lambda root: mutate_remove_line(root / "docs/content/ARTIFACT_CATALOG.md", "`AR-060`"), "REGISTRY_EXACT_SET", ), ( "duplicate-id", lambda root: (root / "docs/content/TRAIT_CATALOG.md").write_text( (root / "docs/content/TRAIT_CATALOG.md").read_text(encoding="utf-8") + "| `TR-001` | duplicate | duplicate | duplicate | duplicate | duplicate |\n", encoding="utf-8", ), "ID_DUPLICATE", ), ( "enemy-gap", lambda root: mutate_remove_line(root / "docs/content/ENEMY_CATALOG.md", "`EN-R4-06`"), "ENEMY_SEQUENCE_GAP", ), ( "broken-reference", lambda root: (root / "docs/product/SYSTEMS_AND_BATTLE.md").write_text( (root / "docs/product/SYSTEMS_AND_BATTLE.md").read_text(encoding="utf-8") + "Uses `TR-999`.\n", encoding="utf-8", ), "REFERENCE_BROKEN", ), ( "pending-binding", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Capacity is {{BINDING:deployment_capacity}}.\n", encoding="utf-8", ), "BINDING_UNRESOLVED", ), ( "bracket-binding", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Required value `[UNRESOLVED_VALUE]`.\n", encoding="utf-8", ), "BINDING_UNRESOLVED", ), ( "unknown-declared-binding", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Uses `BIND:UNKNOWN_SOURCE`.\n", encoding="utf-8", ), "BINDING_UNKNOWN", ), ( "unbounded-range", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Invalid range `PF-B01..PF-B5000`.\n", encoding="utf-8", ), "REFERENCE_RANGE_INVALID", ), ( "retired-concept", lambda root: (root / "docs/product/SYSTEMS_AND_BATTLE.md").write_text( (root / "docs/product/SYSTEMS_AND_BATTLE.md").read_text(encoding="utf-8") + "Players spend a Directive during battle.\n", encoding="utf-8", ), "RETIRED_DIRECTIVE", ), ( "filler-row", lambda root: (root / "docs/content/TRAIT_CATALOG.md").write_text( (root / "docs/content/TRAIT_CATALOG.md") .read_text(encoding="utf-8") .replace("| `TR-001` | decision-bearing", "| `TR-001` | TODO", 1), encoding="utf-8", ), "REGISTRY_ROW_PENDING", ), ( "page-coverage", lambda root: mutate_remove_line( root / "docs/presentation/SCREEN_AND_STATE_MAP.md", "`PST-003`" ), "PAGE_CAPABILITY_MISSING", ), ( "art-coverage", lambda root: mutate_remove_line( root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md", "ART-PRO-H01" ), "ART_ID_COVERAGE_MISSING", ), ( "audio-coverage", lambda root: mutate_remove_line( root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md", "SFX-SHP-020" ), "AUDIO_REGISTRY_EXACT_SET", ), ( "audio-duplicate", lambda root: (root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").write_text( (root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").read_text(encoding="utf-8") .replace( "SFX-UI-001 event_role", "SFX-UI-001 event_role\nSFX-UI-001 duplicate_role", 1, ), encoding="utf-8", ), "ID_DUPLICATE", ), ) for name, mutate, expected_code in cases: with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary: root = Path(temporary) write_fixture(root) mutate(root) result = audit(root) codes = {item.code for item in result.issues} if expected_code not in codes: raise AssertionError( "{} did not produce {}; got {}".format(name, expected_code, sorted(codes)) ) print("ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases={}".format(len(cases) + 1)) def print_result(result: AuditResult, as_json: bool) -> None: if as_json: print( json.dumps( { "ok": result.ok, "errors": len(result.issues), "counts": result.counts, "diagnostics": [item.as_dict() for item in result.issues], }, indent=2, sort_keys=True, ) ) return for item in result.issues: print( "ERROR {} {}:{}:{} {}".format( item.code, item.path, item.line, item.column, item.message ) ) print("COUNTS {}".format(json.dumps(result.counts, sort_keys=True, separators=(",", ":")))) if result.ok: print("ABG_PREDEVELOPMENT_DESIGN_OK errors=0") else: print("ABG_PREDEVELOPMENT_DESIGN_FAILED errors={}".format(len(result.issues))) def parse_arguments(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "root", nargs="?", type=Path, default=REPOSITORY_ROOT, help="repository root (default: validator's repository)", ) parser.add_argument("--json", action="store_true", help="emit deterministic JSON diagnostics") parser.add_argument("--self-test", action="store_true", help="run embedded pass and negative fixtures") return parser.parse_args(argv) def main(argv: Optional[Sequence[str]] = None) -> int: arguments = parse_arguments(argv) if arguments.self_test: run_self_test() return 0 result = audit(arguments.root) print_result(result, arguments.json) return 0 if result.ok else 1 if __name__ == "__main__": sys.exit(main())