2959 lines
111 KiB
Python
Executable File
2959 lines
111 KiB
Python
Executable File
#!/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"(?<![A-Z0-9_-])([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)(?![A-Z0-9_-])"
|
|
)
|
|
RANGE_IN_TEXT_RE = re.compile(
|
|
r"(?<![A-Z0-9_-])"
|
|
r"([A-Z][A-Z0-9-]*?\d+)\.\."
|
|
r"((?:[A-Z][A-Z0-9-]*?)?\d+(?:-[A-Z][A-Z0-9-]*)?)"
|
|
r"(?![A-Z0-9_-])"
|
|
)
|
|
CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
|
|
BINDING_ID_RE = re.compile(r"BIND:[A-Z][A-Z0-9_]+")
|
|
BRACKET_BINDING_RE = re.compile(r"\[[A-Z][A-Z0-9_{}.-]*(?:\.\.[A-Z0-9_{}.-]+)?\]")
|
|
NAMED_PRESENTATION_ID_RE = re.compile(
|
|
r"^(?:MUS|MUSIC|AMB|AMBIENCE|SFX|UI|VO|VOC|VOICE|ART|AST|VFX|ANIM)-"
|
|
)
|
|
|
|
PENDING_PATTERNS = (
|
|
re.compile(r"\b(?:TBD|TBC|TODO|FIXME)\b"),
|
|
re.compile(r"\{\{\s*(?:binding\s*[:.]?\s*)?[^{}]+\}\}", re.IGNORECASE),
|
|
re.compile(
|
|
r"(?:\[\[|<)?\bBINDING\s*(?::|\(|\[)\s*[A-Za-z0-9_.-]+",
|
|
re.IGNORECASE,
|
|
),
|
|
)
|
|
|
|
RETIREMENT_CONTEXT_RE = re.compile(
|
|
r"\b(?:"
|
|
r"no|not|never|without|remove|removes|removed|removing|retire|retired|"
|
|
r"obsolete|forbid|forbidden|reject|rejected|exclude|excluded|"
|
|
r"do\s+not|must\s+not|no\s+longer"
|
|
r")\b",
|
|
re.IGNORECASE,
|
|
)
|
|
NEGATIVE_SEARCH_RE = re.compile(r"!\s*(?:rg|grep)\b", re.IGNORECASE)
|
|
RETIRED_CONCEPTS = (
|
|
(
|
|
"RETIRED_FIXED_CAST",
|
|
re.compile(
|
|
r"\b(?:fixed[- ]cast|fixed\s+(?:named\s+)?(?:protagonists?|characters?|heroes?)|"
|
|
r"30\s+(?:fixed|named|authored)\s+(?:protagonists?|characters?|heroes?))\b",
|
|
re.IGNORECASE,
|
|
),
|
|
),
|
|
(
|
|
"RETIRED_RELATIONSHIP_SYSTEM",
|
|
re.compile(
|
|
r"\b(?: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,
|
|
),
|
|
),
|
|
(
|
|
"RETIRED_SIX_UNIT_CAP",
|
|
re.compile(
|
|
r"\b(?:six|6)[- ](?:unit|person|member|adventurer)\b|"
|
|
r"\bfixed\s+(?:deployment|roster|party)\s+cap\s+(?:of\s+)?(?:six|6)\b",
|
|
re.IGNORECASE,
|
|
),
|
|
),
|
|
(
|
|
"RETIRED_THREE_LANE_GRID",
|
|
re.compile(
|
|
r"\b(?:three|3)[- ]lane(?:\s+grid)?\b|"
|
|
r"\b(?:three|3)[- ]band\s+(?:grid|formation|battlefield|layout|positioning)\b",
|
|
re.IGNORECASE,
|
|
),
|
|
),
|
|
("RETIRED_DIRECTIVE", re.compile(r"\bDirective(?:s)?\b")),
|
|
("RETIRED_ANCHOR", re.compile(r"\bAnchor(?:s)?\b")),
|
|
("RETIRED_CACHE", re.compile(r"\bCache(?:s)?\b")),
|
|
(
|
|
"RETIRED_MANUAL_BATTLE_COMMAND",
|
|
re.compile(
|
|
r"\b(?:manual(?:ly)?\s+(?:activate|cast|trigger|command|intervene)|"
|
|
r"in[- ]battle\s+(?:command|intervention)|skill\s+activation)\b",
|
|
re.IGNORECASE,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Issue:
|
|
code: str
|
|
path: str
|
|
line: int
|
|
column: int
|
|
message: str
|
|
|
|
def sort_key(self) -> 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<selected>{})\b"
|
|
r"(?=[^\n|.]{{0,120}}\bprovisional\b)"
|
|
r"(?=[^\n|.]{{0,120}}\brecruits?\b)"
|
|
r"[^\n|.]{{0,100}}\b(?:of|from)\s+(?:the\s+)?(?P<offered>{})\b".format(
|
|
COUNT_TOKEN, COUNT_TOKEN
|
|
),
|
|
re.IGNORECASE,
|
|
)
|
|
PROVISIONAL_SELECTED_COUNT_RES = (
|
|
re.compile(
|
|
r"\b(?:choose|select)\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
|
|
re.IGNORECASE,
|
|
),
|
|
re.compile(
|
|
r"\b(?:with\s+)?(?:exactly\s+)?(?P<count>{})\s+selected\b".format(COUNT_TOKEN),
|
|
re.IGNORECASE,
|
|
),
|
|
re.compile(
|
|
r"\bselected\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
|
|
re.IGNORECASE,
|
|
),
|
|
)
|
|
PROVISIONAL_OFFERED_COUNT_RES = (
|
|
re.compile(
|
|
r"\b(?P<count>{})\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<count>{})\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<concept>{})\b[^.;|]{{0,40}}?\b(?P<side>{})\b".format(
|
|
PARTY_ENDPOINT_EXPRESSION, PARTY_SIDE_EXPRESSION
|
|
),
|
|
re.IGNORECASE,
|
|
)
|
|
side_then_concept_re = re.compile(
|
|
r"\b(?P<side>{})\b\s*(?:is|=|:)\s*(?:the\s+)?"
|
|
r"(?P<concept>{})\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"(?<![A-Z0-9-])\d+(?![A-Z0-9-])", value)}
|
|
return next(iter(counts)) if len(counts) == 1 else None
|
|
|
|
|
|
def bounded_art_binding_count(value: str) -> 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())
|