#!/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/content/RECRUIT_GENERATION.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" PRODUCT_PATH = "docs/product/GAME_PRODUCT_CONTRACT.md" RECRUIT_PATH = "docs/content/RECRUIT_GENERATION.md" UI_PATH = "docs/presentation/UI_AND_VISUAL_SYSTEM.md" ART_PATH = "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md" REFERENCE_SCOPE_PATH = "docs/reference/TRANSFERABLE_STRUCTURE_LESSONS.md" PAGE_ID_RE = re.compile(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}") COUNT_WORDS = { "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "twelve": 12, "sixteen": 16, "twenty": 20, "twenty-four": 24, "thirty-six": 36, "forty-two": 42, } COUNT_TOKEN = r"(?:\d+|" + "|".join(sorted(COUNT_WORDS, key=len, reverse=True)) + r")" PROVISIONAL_SELECTION_RE = re.compile( r"\b(?:choose|select(?:ed|s|ing)?)\s+(?:exactly\s+)?(?P{})\b" r"(?=[^\n|.]{{0,120}}\bprovisional\b)" r"(?=[^\n|.]{{0,120}}\brecruits?\b)" r"[^\n|.]{{0,100}}\b(?:of|from)\s+(?:the\s+)?(?P{})\b".format( COUNT_TOKEN, COUNT_TOKEN ), re.IGNORECASE, ) PROVISIONAL_SELECTED_COUNT_RES = ( re.compile( r"\b(?:choose|select)\s+(?:exactly\s+)?(?P{})\b".format(COUNT_TOKEN), re.IGNORECASE, ), re.compile( r"\b(?:with\s+)?(?:exactly\s+)?(?P{})\s+selected\b".format(COUNT_TOKEN), re.IGNORECASE, ), re.compile( r"\bselected\s+(?:exactly\s+)?(?P{})\b".format(COUNT_TOKEN), re.IGNORECASE, ), ) PROVISIONAL_OFFERED_COUNT_RES = ( re.compile( r"\b(?P{})\b(?:\s+[a-z][a-z-]*){{0,6}}\s+offers?\b".format( COUNT_TOKEN ), re.IGNORECASE, ), re.compile( r"\b(?:of|from)\s+(?:the\s+)?(?P{})\b" r"[^\n|.]{{0,100}}\bprovisional\b[^\n|.]{{0,60}}\brecruits?\b".format( COUNT_TOKEN ), re.IGNORECASE, ), ) REVISABLE_SELECTION_RE = re.compile( r"\b(?:revis(?:e|ed|able)|change(?:d|able)?|edit(?:ed|able)?|reselect|undo|uncommitted)\b", re.IGNORECASE, ) BEFORE_RUN_CREATION_RE = re.compile( r"(?:\bbefore\b.{0,60}\brun\s+creat(?:ion|ed?)\b|" r"\buntil\b.{0,60}\brun\s+(?:creation|commit)|" r"\bbefore\s+(?:the\s+)?commit\b|\buntil\s+(?:the\s+)?commit\b|" r"\bno\s+run\s+(?:is\s+)?created\b)", re.IGNORECASE, ) RECRUIT_PART_FIELDS = ( ( "body_frame", re.compile(r"^body\s+frame$", re.IGNORECASE), re.compile(r"\bbody\s+(?:foundations?|frames?)\b", re.IGNORECASE), ), ( "face_shape", re.compile(r"^face\s+shape$", re.IGNORECASE), re.compile(r"\bface\s+(?:structures?|shapes?)\b", re.IGNORECASE), ), ( "skin_palette", re.compile(r"^skin\s+palette$", re.IGNORECASE), re.compile(r"\bskin\s+(?:ramps?|palettes?)\b", re.IGNORECASE), ), ( "hair_shape", re.compile(r"^hair(?:/head)?\s+shape$", re.IGNORECASE), re.compile(r"\bhair\s+(?:famil(?:y|ies)|shapes?)\b", re.IGNORECASE), ), ( "hair_palette", re.compile(r"^hair\s+palette$", re.IGNORECASE), re.compile(r"\bhair\s+(?:ramps?|palettes?)\b", re.IGNORECASE), ), ( "garment_cut", re.compile(r"^garment\s+cut$", re.IGNORECASE), re.compile(r"\bgarment\s+cuts?\b|\bunderlayers?\b(?!\s+ramps?)", re.IGNORECASE), ), ( "garment_palette", re.compile(r"^garment\s+palette$", re.IGNORECASE), re.compile(r"\b(?:garment\s+palettes?|underlayer\s+ramps?)\b", re.IGNORECASE), ), ( "profession_overlay", re.compile(r"^profession\s+overlay$", re.IGNORECASE), re.compile(r"\bprofession\s+(?:kits?|overlays?)\b", re.IGNORECASE), ), ( "detail_mark", re.compile(r"^detail\s+mark$", re.IGNORECASE), re.compile(r"\b(?:face\s+detail\s+sets?|detail\s+marks?)\b", re.IGNORECASE), ), ( "voice_effort_set", re.compile(r"^voice\s+effort\s+set$", re.IGNORECASE), re.compile(r"\b(?:voice\s+effort\s+sets?|audio)\b", re.IGNORECASE), ), ) RECRUIT_ART_BINDING_PATTERNS = { "body_frame": re.compile( r"\bART-REC-BODY-|\bbody\s+(?:foundations?|frames?)\b", re.IGNORECASE ), "face_shape": re.compile( r"\bART-REC-FACE-|\bface\s+(?:structures?|shapes?)\b", re.IGNORECASE ), "skin_palette": re.compile( r"\bART-REC-SKINPAL-|\bskin\s+(?:ramps?|palettes?)\b", re.IGNORECASE ), "hair_shape": re.compile( r"\bART-REC-HAIR-|\bhair\s+(?:famil(?:y|ies)|shapes?)\b", re.IGNORECASE ), "hair_palette": re.compile( r"\bART-REC-HAIRPAL-|\bhair\s+(?:ramps?|palettes?)\b", re.IGNORECASE ), "garment_cut": re.compile( r"\bART-REC-GARMENT-|\b(?:garment\s+cuts?|underlayers?)\b", re.IGNORECASE ), "garment_palette": re.compile( r"\bART-REC-GARMENTPAL-|\b(?:garment\s+palettes?|underlayer\s+ramps?)\b", re.IGNORECASE, ), "profession_overlay": re.compile( r"\bART-PRO-|\bprofession\s+(?:kits?|overlays?)\b", re.IGNORECASE ), "detail_mark": re.compile( r"\bART-REC-DETAIL-|\b(?:face\s+detail\s+sets?|detail\s+marks?)\b", re.IGNORECASE, ), "voice_effort_set": re.compile( r"\b(?:audio|no\s+art|nonvisual)\b", re.IGNORECASE ), } 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 read_scoped_document(root: Path, path: str, issues: List[Issue]) -> Optional[Document]: absolute = root / path if not absolute.is_file(): issues.append(issue("AUTHORITY_FILE_MISSING", path, 1, 1, "required scoped authority is missing")) return None try: return Document(path, absolute.read_text(encoding="utf-8")) except (OSError, UnicodeError) as error: issues.append( issue( "AUTHORITY_READ_ERROR", path, 1, 1, "cannot read UTF-8 Markdown: {}".format(error), ) ) return None def heading_level_and_text(line: str) -> Optional[Tuple[int, str]]: match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line) return (len(match.group(1)), match.group(2)) if match else None def section_lines( document: Document, heading_pattern: re.Pattern ) -> List[Tuple[int, str]]: lines = document.text.splitlines() for index, line in enumerate(lines): heading = heading_level_and_text(line) if heading is None or not heading_pattern.search(heading[1]): continue level = heading[0] selected: List[Tuple[int, str]] = [] for following_index in range(index + 1, len(lines)): following = lines[following_index] next_heading = heading_level_and_text(following) if next_heading is not None and next_heading[0] <= level: break selected.append((following_index + 1, following)) return selected return [] def count_value(token: str) -> Optional[int]: normalized = token.strip().lower() if normalized.isdigit(): return int(normalized) return COUNT_WORDS.get(normalized) def provisional_selection_pairs(text: str) -> List[Tuple[int, int, int, int]]: pairs: List[Tuple[int, int, int, int]] = [] for line_number, line in enumerate(text.splitlines(), 1): direct_matches = list(PROVISIONAL_SELECTION_RE.finditer(line)) for match in direct_matches: selected = count_value(match.group("selected")) offered = count_value(match.group("offered")) if selected is not None and offered is not None: pairs.append((selected, offered, line_number, match.start() + 1)) if direct_matches or not re.search(r"\bprovisional\b", line, re.IGNORECASE): continue if not re.search(r"\b(?:recruits?|offers?|company)\b", line, re.IGNORECASE): continue selected_matches: List[re.Match] = [] for pattern in PROVISIONAL_SELECTED_COUNT_RES: selected_matches = list(pattern.finditer(line)) if selected_matches: break offered_matches = [] for pattern in PROVISIONAL_OFFERED_COUNT_RES: offered_matches = [ match for match in pattern.finditer(line) if not re.search( r"\b(?:unchosen|selected|remaining|other)\b", match.group(0), re.IGNORECASE, ) ] if offered_matches: break selected_values = { count_value(match.group("count")) for match in selected_matches } - {None} offered_values = { count_value(match.group("count")) for match in offered_matches } - {None} if len(selected_values) == 1 and len(offered_values) == 1: column = selected_matches[0].start() + 1 if selected_matches else 1 pairs.append( (next(iter(selected_values)), next(iter(offered_values)), line_number, column) ) return pairs def selection_is_revisable_before_creation(text: str) -> bool: return REVISABLE_SELECTION_RE.search(text) is not None and BEFORE_RUN_CREATION_RE.search(text) is not None def validate_provisional_selection_presentation( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue] ) -> None: product = document_by_path(documents, PRODUCT_PATH) screen = document_by_path(documents, SCREEN_PATH) if product is None or screen is None: return product_pairs = provisional_selection_pairs(product.text) product_values = {(selected, offered) for selected, offered, _, _ in product_pairs} if len(product_values) != 1: issues.append( issue( "PROVISIONAL_SELECTION_CONTRACT_INVALID", PRODUCT_PATH, product_pairs[0][2] if product_pairs else 1, product_pairs[0][3] if product_pairs else 1, "product authority must declare one consistent provisional selected/offered pair", ) ) return expected = next(iter(product_values)) primary_candidates: List[Tuple[Definition, str]] = [] for definition in definitions: if definition.path != SCREEN_PATH or not PAGE_ID_RE.fullmatch(definition.identifier): continue row_text = " | ".join(definition.cells) if re.search(r"\bprovisional\b", row_text, re.IGNORECASE) and re.search( r"\brecruits?\b", row_text, re.IGNORECASE ): primary_candidates.append((definition, row_text)) primary_valid_ids = { definition.identifier for definition, row_text in primary_candidates if expected in { (selected, offered) for selected, offered, _, _ in provisional_selection_pairs(row_text) } and selection_is_revisable_before_creation(row_text) } primary_valid = bool(primary_valid_ids) onboarding = section_lines(screen, re.compile(r"\bonboarding\b", re.IGNORECASE)) onboarding_by_screen: Dict[str, List[str]] = { identifier: [] for identifier in primary_valid_ids } for _, line in onboarding: for identifier in primary_valid_ids: if re.search(r"`{}`".format(re.escape(identifier)), line): onboarding_by_screen[identifier].append(line) onboarding_valid = False for lines in onboarding_by_screen.values(): grouped_text = "\n".join(lines) if ( expected in { (selected, offered) for selected, offered, _, _ in provisional_selection_pairs(grouped_text) } and selection_is_revisable_before_creation(grouped_text) ): onboarding_valid = True break if primary_valid and onboarding_valid: return missing: List[str] = [] if not primary_valid: missing.append("a stable screen row") if not onboarding_valid: missing.append("the onboarding map") issues.append( issue( "PROVISIONAL_SELECTION_PRESENTATION_MISMATCH", SCREEN_PATH, primary_candidates[0][0].line if primary_candidates else 1, primary_candidates[0][0].column if primary_candidates else 1, "{} must present choosing {} of {} provisional recruits as revisable before run creation".format( " and ".join(missing), expected[0], expected[1] ), ) ) def endpoint_kind(value: str) -> Optional[str]: exposed = re.search(r"\b(?:exposed|front)\b", value, re.IGNORECASE) is not None protected = re.search(r"\b(?:protected|rear|last)\b", value, re.IGNORECASE) is not None if exposed == protected: return None return "exposed" if exposed else "protected" PARTY_ENDPOINT_EXPRESSION = ( r"(?:first\s+exposed|exposed(?:\s+(?:position\s+0\s+)?front)?|" r"protected(?:\s+rear)?|rear|last|front)" ) PARTY_SIDE_EXPRESSION = r"(?:screen[- ]?)?(?:left|right)" def extract_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]: direction_re = re.compile( r"\bleft\s*-?to\s*-?right\b\s+from\s+(.{1,50}?)\s+to\s+(.{1,50}?)(?:[.;]|$)", re.IGNORECASE, ) concept_then_side_re = re.compile( r"\b(?P{})\b[^.;|]{{0,40}}?\b(?P{})\b".format( PARTY_ENDPOINT_EXPRESSION, PARTY_SIDE_EXPRESSION ), re.IGNORECASE, ) side_then_concept_re = re.compile( r"\b(?P{})\b\s*(?:is|=|:)\s*(?:the\s+)?" r"(?P{})\b".format( PARTY_SIDE_EXPRESSION, PARTY_ENDPOINT_EXPRESSION ), re.IGNORECASE, ) lines = document.text.splitlines() for index in range(len(lines)): window_lines: List[str] = [] for candidate in lines[index : index + 3]: if not candidate.strip() and window_lines: break window_lines.append(candidate.strip()) window = re.sub(r"[`*]", "", " ".join(window_lines)) match = direction_re.search(window) if match: left = endpoint_kind(match.group(1)) right = endpoint_kind(match.group(2)) if left is not None and right is not None and left != right: return left, right, index + 1, match.start() + 1 assignments: Dict[str, Set[str]] = {"left": set(), "right": set()} for pattern in (concept_then_side_re, side_then_concept_re): for endpoint_match in pattern.finditer(window): concept = endpoint_kind(endpoint_match.group("concept")) side_match = re.search( r"\b(left|right)\b", endpoint_match.group("side"), re.IGNORECASE ) if concept is not None and side_match is not None: assignments[side_match.group(1).lower()].add(concept) if ( len(assignments["left"]) == 1 and len(assignments["right"]) == 1 and assignments["left"] != assignments["right"] ): return ( next(iter(assignments["left"])), next(iter(assignments["right"])), index + 1, 1, ) return None def extract_ui_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]: return extract_party_direction(document) def extract_art_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]: return extract_party_direction(document) def validate_party_direction(documents: Sequence[Document], issues: List[Issue]) -> None: ui = document_by_path(documents, UI_PATH) art = document_by_path(documents, ART_PATH) if ui is None or art is None: return ui_direction = extract_ui_party_direction(ui) art_direction = extract_art_party_direction(art) if ui_direction is not None and art_direction is not None and ui_direction[:2] == art_direction[:2]: return line = art_direction[2] if art_direction else 1 column = art_direction[3] if art_direction else 1 rendered_ui = "{} left / {} right".format(*ui_direction[:2]) if ui_direction else "not explicit" rendered_art = "{} left / {} right".format(*art_direction[:2]) if art_direction else "not explicit" issues.append( issue( "PARTY_DIRECTION_MISMATCH", ART_PATH, line, column, "party endpoints must agree with UI authority (UI: {}; art: {})".format(rendered_ui, rendered_art), ) ) def normalize_cell(value: str) -> str: return re.sub(r"\s+", " ", re.sub(r"[`*]", "", value)).strip().lower() def first_fenced_block(lines: Sequence[Tuple[int, str]]) -> str: collected: List[str] = [] in_fence = False for _, line in lines: if line.strip().startswith("```"): if in_fence: return "\n".join(collected) in_fence = True continue if in_fence: collected.append(line) return "" def recruit_content_part_counts(document: Document) -> Tuple[Dict[str, int], List[str]]: errors: List[str] = [] record_lines = section_lines(document, re.compile(r"\brecruit\s+record\b", re.IGNORECASE)) record_block = first_fenced_block(record_lines) record_fields = set(re.findall(r"\b[a-z][a-z0-9_]*(?:\[\])?\b", record_block)) grammar_lines = section_lines(document, re.compile(r"\bmodular\s+visual\s+grammar\b", re.IGNORECASE)) counts: Dict[str, int] = {} for _, line in grammar_lines: cells = split_markdown_row(line) if cells is None or len(cells) < 2: continue label = normalize_cell(cells[0]) for field, content_pattern, _ in RECRUIT_PART_FIELDS: if not content_pattern.fullmatch(label): continue value = count_value(normalize_cell(cells[1])) if value is None: errors.append("{} lacks an exact content option count".format(field)) elif field in counts: errors.append("{} is declared more than once in the content grammar".format(field)) else: counts[field] = value for field, _, _ in RECRUIT_PART_FIELDS: if field not in record_fields: errors.append("{} is absent from the Recruit Record".format(field)) if field not in counts: errors.append("{} is absent from Modular Visual Grammar".format(field)) return counts, errors def art_declared_source_counts(document: Document) -> List[Tuple[str, int]]: declarations: List[Tuple[str, int]] = [] for line in document.text.splitlines(): cells = split_markdown_row(line) if cells is None or len(cells) < 3: continue label = normalize_cell(cells[0]) if not label or label in ("library", "family") or set(label) <= {"-", ":"}: continue candidate_cells = (cells[1], cells[2]) for candidate in candidate_cells: cleaned = normalize_cell(candidate) match = re.fullmatch(r"(\d+)(?:\s+(?:masters?|identities?))?", cleaned) if match: declarations.append((label, int(match.group(1)))) break return declarations def find_crosswalk_rows( document: Document, ) -> Tuple[Dict[str, Tuple[Tuple[str, ...], int, Dict[str, int]]], List[str]]: lines = section_lines( document, re.compile(r"(?:\brecruit\b.*\bcrosswalk\b|\bcrosswalk\b.*\brecruit\b)", re.IGNORECASE), ) if not lines: return {}, ["explicit recruit content/art crosswalk section is missing"] header: Optional[Tuple[str, ...]] = None columns: Dict[str, int] = {} rows: Dict[str, Tuple[Tuple[str, ...], int, Dict[str, int]]] = {} for line_number, line in lines: cells = split_markdown_row(line) if cells is None: continue normalized = tuple(normalize_cell(cell) for cell in cells) if header is None: candidate_columns: Dict[str, int] = {} for index, cell in enumerate(normalized): if "field" in cell and ( "content" in cell or "recruit" in cell or "record" in cell or cell == "field" ): candidate_columns["field"] = index elif "stored" in cell and "authority" in cell: candidate_columns["content_count"] = index elif "content" in cell and re.search( r"\b(?:count|options?|values?)\b", cell ): candidate_columns["content_count"] = index elif "art" in cell and re.search(r"\b(?:source|target|library|binding)\b", cell): candidate_columns["art_source"] = index elif "art" in cell and re.search(r"\b(?:count|options?|masters?|values?)\b", cell): candidate_columns["art_count"] = index elif re.search(r"\b(?:mapping|resolution|rule|derived\s+assembly)\b", cell): candidate_columns["mapping"] = index if {"field", "content_count", "art_source"} <= set(candidate_columns): header = cells columns = candidate_columns continue if all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in normalized): continue maximum = max(columns.values()) if len(cells) <= maximum: continue field = normalize_cell(cells[columns["field"]]).replace("[]", "") if not re.fullmatch(r"[a-z][a-z0-9_]*", field): continue if field in rows: return rows, ["{} appears more than once in the art crosswalk".format(field)] rows[field] = (cells, line_number, dict(columns)) if header is None: return {}, [ "crosswalk table requires recruit field, content authority/count, and art binding columns" ] return rows, [] def mapping_is_deterministic(value: str) -> bool: return re.search( r"\b(?:deterministic|one-to-one|same\s+(?:stable\s+)?(?:index|ordinal)|" r"matching\s+(?:index|ordinal)|direct(?:ly)?\s+(?:maps?|indexes?)|" r"nonvisual|audio[- ]owned)\b|1\s*:\s*1", value, re.IGNORECASE, ) is not None def embedded_count(value: str) -> Optional[int]: counts = {int(match.group(0)) for match in re.finditer(r"(? Optional[int]: identifiers: Set[str] = set() for match in CODE_SPAN_RE.finditer(value): expression = match.group(1).strip() expanded = expand_compact_id_expression(expression) if expanded is None or not all(identifier.startswith("ART-") for identifier in expanded): continue identifiers.update(expanded) return len(identifiers) if identifiers else None def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[Issue]) -> None: content = document_by_path(documents, RECRUIT_PATH) art = document_by_path(documents, ART_PATH) if content is None or art is None: return content_counts, errors = recruit_content_part_counts(content) rows, row_errors = find_crosswalk_rows(art) errors.extend(row_errors) source_counts = art_declared_source_counts(art) first_crosswalk_line = min((line for _, line, _ in rows.values()), default=1) for field, _, source_pattern in RECRUIT_PART_FIELDS: expected = content_counts.get(field) row = rows.get(field) if expected is None or row is None: if row is None: errors.append("{} lacks an art crosswalk row".format(field)) continue cells, _, columns = row content_authority = normalize_cell(cells[columns["content_count"]]) content_count = embedded_count(content_authority) source = normalize_cell(cells[columns["art_source"]]) explicit_art_count = ( embedded_count(normalize_cell(cells[columns["art_count"]])) if "art_count" in columns else None ) binding_count = bounded_art_binding_count(cells[columns["art_source"]]) art_count = explicit_art_count if "art_count" in columns else binding_count mapping = ( normalize_cell(cells[columns["mapping"]]) if "mapping" in columns else " | ".join(normalize_cell(cell) for cell in cells) ) binding_pattern = RECRUIT_ART_BINDING_PATTERNS[field] matching_sources = { count for label, count in source_counts if source_pattern.search(label) } if content_count != expected: errors.append( "{} crosswalk content count is {}; expected {}".format(field, content_count, expected) ) if field == "voice_effort_set": if not binding_pattern.search("{} {}".format(content_authority, source)): errors.append("voice_effort_set must be explicitly audio-owned/nonvisual") if art_count not in (None, 0): errors.append("voice_effort_set must not declare visual masters") else: if not binding_pattern.search(source): errors.append("{} maps to an unrecognized art source".format(field)) if art_count is not None and art_count != expected: errors.append("{} art count is {}; expected {}".format(field, art_count, expected)) if matching_sources != {expected}: errors.append( "{} art source declares {}; expected exactly {}".format( field, sorted(matching_sources) if matching_sources else "no count", expected, ) ) if art_count is None and matching_sources != {expected}: errors.append("{} lacks a bounded art count".format(field)) deterministic_mapping = ( field == "voice_effort_set" or mapping_is_deterministic(mapping) or binding_count == expected or ( re.search(r"\bone\b", source, re.IGNORECASE) is not None and matching_sources == {expected} ) ) if not deterministic_mapping: errors.append("{} lacks a deterministic mapping rule".format(field)) if not errors: return issues.append( issue( "RECRUIT_ART_CROSSWALK_MISMATCH", ART_PATH, first_crosswalk_line, 1, "recruit field crosswalk is incomplete or incompatible: {}".format("; ".join(sorted(set(errors)))), ) ) def validate_audio_screen_inventory( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> None: audio = document_by_path(documents, AUDIO_PATH) if audio is None: counts["pages.audio_bound"] = 0 return page_ids = { definition.identifier for definition in definitions if definition.path == SCREEN_PATH and PAGE_ID_RE.fullmatch(definition.identifier) } binding_row: Optional[Tuple[int, Tuple[str, ...]]] = None for line_number, line in enumerate(audio.text.splitlines(), 1): cells = split_markdown_row(line) if cells is None or not cells: continue label = normalize_cell(cells[0]) if "screen" in label and "action" in label and "id" in label: binding_row = (line_number, cells) break audio_ids: Set[str] = set() declared_total: Optional[int] = None if binding_row is not None: line_number, cells = binding_row for cell in cells[1:]: for match in CODE_SPAN_RE.finditer(cell): expression = match.group(1).strip() expanded = expand_compact_id_expression(expression) if expanded is None: continue audio_ids.update(identifier for identifier in expanded if PAGE_ID_RE.fullmatch(identifier)) row_text = " | ".join(cells) total_match = re.search(r"\ball\s+(\d+)\s+(?:final\s+)?screens?\b", row_text, re.IGNORECASE) if total_match is None: total_match = re.search(r"\b(\d+)\s+(?:final\s+)?screens?\b", row_text, re.IGNORECASE) if total_match: declared_total = int(total_match.group(1)) else: line_number = 1 counts["pages.audio_bound"] = len(audio_ids) if ( "OUT-006" in page_ids and audio_ids == page_ids and declared_total == len(page_ids) ): return details: List[str] = [] if "OUT-006" not in page_ids: details.append("screen authority lacks OUT-006") missing = page_ids - audio_ids extra = audio_ids - page_ids if missing: details.append("audio missing [{}]".format(format_id_sample(missing))) if extra: details.append("audio has unexpected [{}]".format(format_id_sample(extra))) if declared_total != len(page_ids): details.append("audio declares {} screens; screen registry has {}".format(declared_total, len(page_ids))) if binding_row is None: details.append("Screen and action IDs binding row is missing") issues.append( issue( "AUDIO_SCREEN_INVENTORY_MISMATCH", AUDIO_PATH, line_number, 1, "audio screen binding must equal the complete screen registry: {}".format("; ".join(details)), ) ) REFERENCE_RELATIONSHIP_SCOPE_RE = re.compile( r"\b(?:profession\s+affinit(?:y|ies)|relationships?\s+hooks?|" r"relationships?\s+(?:system|scene|meter|level|arc|reward|prompt|link)s?|" r"affinity\s+(?:system|meter|level|rank|scene)s?|romance|personal[- ]arc)\b", re.IGNORECASE, ) REFERENCE_FIXED_CAST_RE = re.compile( r"\b(?:fixed[- ]cast|fixed\s+(?:named\s+)?(?:protagonists?|characters?|heroes?))\b", re.IGNORECASE, ) REFERENCE_NAMED_THIRTY_RE = re.compile( r"(?:\b30\b.{0,80}\bnamed\s+recruitable\s+characters?\b|" r"\bnamed\s+recruitable\s+characters?\b.{0,80}\b30\b)", re.IGNORECASE, ) REFERENCE_HISTORY_RE = re.compile( r"\b(?:historical|history|earlier|prior|superseded|observed\s+reference|reference\s+census)\b", re.IGNORECASE, ) REFERENCE_EXPLICIT_REJECTION_RE = re.compile( r"(?:\b(?:no|without|reject(?:s|ed|ing)?|retire(?:s|d|ing)?|" r"exclude(?:s|d|ing)?|forbid(?:s|den|ding)?|do\s+not|must\s+not|" r"not\s+(?:approved|required))\b.{0,100}$|" r"^.{0,100}\b(?:is|are|remain)?\s*(?:rejected|retired|excluded|forbidden|" r"not\s+(?:approved|required))\b)", re.IGNORECASE, ) REFERENCE_POSITIVE_AUTHORITY_RE = re.compile( r"\b(?:approved|require(?:s|d)?|must|shall|direct\s+specification|" r"exact\s+(?:scope|commitment)|commitment|authoritative)\b", re.IGNORECASE, ) def reference_retirement_context(value: str, start: int, end: int) -> bool: window = value[max(0, start - 120) : min(len(value), end + 120)] relative_start = min(start, 120) relative_end = relative_start + (end - start) before = window[:relative_start] after = window[relative_end:] if REFERENCE_EXPLICIT_REJECTION_RE.search(before) or REFERENCE_EXPLICIT_REJECTION_RE.search(after): return True return ( REFERENCE_HISTORY_RE.search(window) is not None and REFERENCE_POSITIVE_AUTHORITY_RE.search(window) is None ) def validate_reference_retired_scope(document: Optional[Document], issues: List[Issue]) -> None: if document is None: return observed: Set[Tuple[int, int, str]] = set() for line_number, line in enumerate(document.text.splitlines(), 1): cells = split_markdown_row(line) fragments = cells if cells is not None else (line,) for fragment in fragments: for pattern in (REFERENCE_RELATIONSHIP_SCOPE_RE, REFERENCE_FIXED_CAST_RE): for match in pattern.finditer(fragment): if reference_retirement_context(fragment, match.start(), match.end()): continue column = max(1, line.find(match.group(0)) + 1) observed.add((line_number, column, match.group(0))) for match in REFERENCE_NAMED_THIRTY_RE.finditer(line): if reference_retirement_context(line, match.start(), match.end()): continue observed.add((line_number, match.start() + 1, match.group(0))) for line_number, column, matched in sorted(observed): issues.append( issue( "REFERENCE_RETIRED_SCOPE", REFERENCE_SCOPE_PATH, line_number, column, "reference authority positively prescribes retired fixed-cast/relationship scope: {}".format( matched ), ) ) 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) reference_scope = read_scoped_document(root, REFERENCE_SCOPE_PATH, issues) 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_provisional_selection_presentation(documents, definitions, issues) validate_party_direction(documents, issues) validate_recruit_art_crosswalk(documents, issues) validate_screen_coverage(documents, definitions, issues, counts) validate_art_coverage(documents, references, issues) validate_audio_coverage( documents, definitions, issues, counts, ) validate_audio_screen_inventory(documents, definitions, issues, counts) validate_reference_retired_scope(reference_scope, issues) 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" "Choose two of four provisional generated Recruits at no cost.\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", ) recruit = """# Generated Recruit Authority ## 1. Recruit Record ```text recruit_instance_id, generation_seed, offer_index display_name_tokens, pronoun_set, voice_effort_set body_frame, face_shape, skin_palette, hair_shape, hair_palette garment_cut, garment_palette, profession_overlay, detail_mark ``` ## 4. Modular Visual Grammar | Part | Options | Rule | |---|---:|---| | Body frame | 8 | deterministic option | | Face shape | 12 | deterministic option | | Skin palette | 12 | deterministic option | | Hair/head shape | 24 | deterministic option | | Hair palette | 10 | deterministic option | | Garment cut | 12 | deterministic option | | Garment palette | 16 | deterministic option | | Profession overlay | 42 | deterministic option | | Detail mark | 20 | deterministic option | | Voice effort set | 8 | audio-owned option | """ (root / RECRUIT_PATH).write_text(recruit, 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"), ("OUT-006", "Run abandonment review", "Abandon run", "empty, error, confirmation"), ("SYS-001", "Save and cloud", "Manage save", "empty, error, confirmation"), ( "SYS-005", "New run provisional choice", "Choose two provisional Recruits from four generated offers", "Selection is revisable before Run creation; cancel creates no run", ), ("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 ) screen_lines.extend( ( "", "## 7. Onboarding Map", "", "| Moment | Base screen | Required action | Safe boundary |", "|---|---|---|---|", "| Opening choice | `SYS-005` | Choose two provisional Recruits from four generated offers | Choice is revisable before Run creation |", ) ) (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" "The party cord runs from First exposed at left to Last at right.\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. The player party reads left-to-right from exposed front to protected rear. 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`. ## 4. Generated Recruit Visual System ### 4.2 Modular Source Inventory | Library | Exact masters | Runtime states | |---|---:|---:| | Body foundations | 8 | 1 | | Face structures | 12 | 1 | | Skin ramps | 12 | 1 | | Hair families | 24 | 1 | | Hair ramps | 10 | 1 | | Underlayers | 12 | 1 | | Underlayer ramps | 16 | 1 | | Profession kits | 42 | 1 | | Face detail sets | 20 | 1 | ### 4.3 Recruit Content/Art Crosswalk | Content field | Content options | Art source | Art masters | Mapping rule | |---|---:|---|---:|---| | `body_frame` | 8 | Body foundations | 8 | same stable index | | `face_shape` | 12 | Face structures | 12 | same stable index | | `skin_palette` | 12 | Skin ramps | 12 | same stable index | | `hair_shape` | 24 | Hair families | 24 | same stable index | | `hair_palette` | 10 | Hair ramps | 10 | same stable index | | `garment_cut` | 12 | Underlayers | 12 | same stable index | | `garment_palette` | 16 | Underlayer ramps | 16 | same stable index | | `profession_overlay` | 42 | Profession kits | 42 | same stable index | | `detail_mark` | 20 | Face detail sets | 20 | same stable index | | `voice_effort_set` | 8 | Audio-owned nonvisual field | N/A | audio-owned deterministic field value | """ (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.", "", "| Dependency | Resolved source and value | Audio use |", "|---|---|---|", "| Screen and action IDs | `SYS-001`, `SYS-005`, `SHP-001`, `REC-001`, `EQP-001`, `PTY-001`, `BAT-001`, `OUT-001..002`, `OUT-006`, `SET-001..003`, `PRO-001`, `PST-003` | bind shared events to all 15 final screens |", "", "## 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" ) (root / "docs/reference").mkdir(parents=True, exist_ok=True) (root / REFERENCE_SCOPE_PATH).write_text( "# Transferable Structure Lessons\n\n" "Historical evidence mentioned profession affinities, relationship hooks, and 30 named recruitable characters; this authority rejects those retired concepts.\n", encoding="utf-8", ) def replace_fixture_text(path: Path, old: str, new: str) -> None: text = path.read_text(encoding="utf-8") if old not in text: raise AssertionError("fixture rewrite target not found in {}: {}".format(path, old[:80])) path.write_text(text.replace(old, new, 1), encoding="utf-8") def rewrite_fixture_with_integrated_shapes(root: Path) -> None: screen_path = root / SCREEN_PATH replace_fixture_text( screen_path, "Choose two provisional Recruits from four generated offers", "Compare four stable provisional generated Recruit offers and choose exactly two before Run creation", ) replace_fixture_text( screen_path, "| Opening choice | `SYS-005` | Choose two provisional Recruits from four generated offers | Choice is revisable before Run creation |", "| Provisional Company | `SYS-005` stages `A-C` | inspect four stable offers and choose exactly two, then review both | selection alone creates no run |\n" "| Run creation | `SYS-005` stages `D-E` | confirm selected two and the unchosen consequence | `Revise Recruits` remains legal before commit |", ) replace_fixture_text( root / UI_PATH, "The party cord runs from First exposed at left to Last at right.", "The party cord runs from `Protected rear` at left to `Exposed front` at right.", ) art_path = root / ART_PATH replace_fixture_text( art_path, "The player party reads left-to-right from exposed front to protected rear.", "Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.", ) old_crosswalk = """### 4.3 Recruit Content/Art Crosswalk | Content field | Content options | Art source | Art masters | Mapping rule | |---|---:|---|---:|---| | `body_frame` | 8 | Body foundations | 8 | same stable index | | `face_shape` | 12 | Face structures | 12 | same stable index | | `skin_palette` | 12 | Skin ramps | 12 | same stable index | | `hair_shape` | 24 | Hair families | 24 | same stable index | | `hair_palette` | 10 | Hair ramps | 10 | same stable index | | `garment_cut` | 12 | Underlayers | 12 | same stable index | | `garment_palette` | 16 | Underlayer ramps | 16 | same stable index | | `profession_overlay` | 42 | Profession kits | 42 | same stable index | | `detail_mark` | 20 | Face detail sets | 20 | same stable index | | `voice_effort_set` | 8 | Audio-owned nonvisual field | N/A | audio-owned deterministic field value | """ integrated_crosswalk = """### 4.3 Recruit Record To Art Crosswalk | Recruit record field | Stored authority | Art binding | Derived assembly and runtime output | |---|---|---|---| | `voice_effort_set` | Audio field with 8 content options | No art master | Audio binds independently to the recruit ID. | | `body_frame` | Visual grammar: 8 options | `ART-REC-BODY-01..08` | Shared body layers use the stored field. | | `face_shape` | Visual grammar: 12 options | `ART-REC-FACE-01..12` | Portrait assembly uses the stored field. | | `skin_palette` | Visual grammar: 12 options | `ART-REC-SKINPAL-01..12` | Palette data uses the stored field. | | `hair_shape` | Visual grammar: 24 options | `ART-REC-HAIR-01..24` | Hair assemblies use the stored field. | | `hair_palette` | Visual grammar: 10 options | `ART-REC-HAIRPAL-01..10` | Palette data uses the stored field. | | `garment_cut` | Visual grammar: 12 options | `ART-REC-GARMENT-01..12` | Garment layers use the stored field. | | `garment_palette` | Visual grammar: 16 options | `ART-REC-GARMENTPAL-01..16` | Palette data uses the stored field. | | `profession_overlay` | Visual grammar: 42 options | One `ART-PRO-B*`, `ART-PRO-A*`, or `ART-PRO-H*` package | The selected profession owns one binding. | | `detail_mark` | Visual grammar: 20 options | `ART-REC-DETAIL-01..20` | Detail assembly uses the stored field. | """ replace_fixture_text(art_path, old_crosswalk, integrated_crosswalk) def mutate_integrated_provisional_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text( root / SCREEN_PATH, "choose exactly two before Run creation", "choose exactly one before Run creation", ) def mutate_integrated_direction_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text( root / ART_PATH, "Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.", "Screen-left is `First exposed` and the party front; screen-right is the protected rear and `Last`.", ) def mutate_integrated_crosswalk_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text(root / ART_PATH, "`ART-REC-BODY-01..08`", "`ART-REC-BODY-01..07`") def mutate_integrated_audio_screen_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text(root / AUDIO_PATH, "`OUT-006`, ", "") def mutate_integrated_reference_scope_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) path = root / REFERENCE_SCOPE_PATH path.write_text( path.read_text(encoding="utf-8") + "Approved scope: 30 named recruitable characters require profession affinities and relationship hooks.\n", 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, "pages": 15, "pages.audio_bound": 15, } 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)) rewrite_fixture_with_integrated_shapes(root) integrated_shape = audit(root) if not integrated_shape.ok: rendered = "\n".join( "{} {}:{} {}".format(item.code, item.path, item.line, item.message) for item in integrated_shape.issues ) raise AssertionError("integrated-shape passing fixture failed:\n{}".format(rendered)) 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", ), ( "provisional-selection-presentation", mutate_integrated_provisional_fixture, "PROVISIONAL_SELECTION_PRESENTATION_MISMATCH", ), ( "party-direction", mutate_integrated_direction_fixture, "PARTY_DIRECTION_MISMATCH", ), ( "recruit-art-crosswalk", mutate_integrated_crosswalk_fixture, "RECRUIT_ART_CROSSWALK_MISMATCH", ), ( "audio-screen-inventory", mutate_integrated_audio_screen_fixture, "AUDIO_SCREEN_INVENTORY_MISMATCH", ), ( "reference-retired-scope", mutate_integrated_reference_scope_fixture, "REFERENCE_RETIRED_SCOPE", ), ) isolated_semantic_cases = { "provisional-selection-presentation", "party-direction", "recruit-art-crosswalk", "audio-screen-inventory", "reference-retired-scope", } 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)) ) if name in isolated_semantic_cases and codes != {expected_code}: raise AssertionError( "{} was not isolated to {}; 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())