1270 lines
46 KiB
Python
Executable File
1270 lines
46 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/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]+)`")
|
|
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,
|
|
)
|
|
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|band)(?:\s+grid)?\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
|
|
pattern: re.Pattern
|
|
expected_ids: Tuple[str, ...]
|
|
minimum_cells: int
|
|
|
|
|
|
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", re.compile(r"^PR-B\d{2}$"), numbered_ids("PR-B", 12, 2), 3),
|
|
RegistryRule(
|
|
"professions.advanced",
|
|
re.compile(r"^PR-A\d{2}$"),
|
|
numbered_ids("PR-A", 24, 2),
|
|
3,
|
|
),
|
|
RegistryRule("professions.hidden", re.compile(r"^PR-H\d{2}$"), numbered_ids("PR-H", 6, 2), 3),
|
|
RegistryRule("traits", re.compile(r"^TR-\d{3}$"), numbered_ids("TR-", 36, 3), 3),
|
|
RegistryRule("artifacts", re.compile(r"^AR-\d{3}$"), numbered_ids("AR-", 60, 3), 3),
|
|
RegistryRule("bosses", re.compile(r"^EN-B\d{3}$"), numbered_ids("EN-B", 16, 3), 3),
|
|
RegistryRule("regions", re.compile(r"^REG-\d{2}$"), numbered_ids("REG-", 8, 2), 3),
|
|
)
|
|
|
|
|
|
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 collect_definitions(documents: Sequence[Document]) -> Tuple[List[Definition], List[Issue]]:
|
|
definitions: List[Definition] = []
|
|
issues: List[Issue] = []
|
|
for document in documents:
|
|
for line_number, line in enumerate(document.text.splitlines(), 1):
|
|
cells = split_markdown_row(line)
|
|
if not cells:
|
|
continue
|
|
found = first_cell_identifier(cells[0])
|
|
if not found:
|
|
continue
|
|
identifier, cell_column = found
|
|
definitions.append(
|
|
Definition(
|
|
identifier,
|
|
document.path,
|
|
line_number,
|
|
max(1, line.find(cells[0]) + cell_column),
|
|
cells,
|
|
)
|
|
)
|
|
|
|
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 definition_is_content(definition: Definition) -> bool:
|
|
return definition.path.startswith("docs/content/")
|
|
|
|
|
|
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_is_content(definition) 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]:
|
|
item_definitions = [
|
|
definition
|
|
for definition in definitions
|
|
if definition_is_content(definition) and re.fullmatch(r"IT-\d{3}", definition.identifier)
|
|
]
|
|
equipment_definitions = [
|
|
definition
|
|
for definition in definitions
|
|
if definition_is_content(definition) and re.fullmatch(r"EQ-\d{3}", definition.identifier)
|
|
]
|
|
if item_definitions and equipment_definitions:
|
|
issues.append(
|
|
issue(
|
|
"EQUIPMENT_NAMESPACE_AMBIGUOUS",
|
|
"docs/content",
|
|
1,
|
|
1,
|
|
"equipment definitions mix IT-### and EQ-###; use exactly one namespace",
|
|
)
|
|
)
|
|
chosen = equipment_definitions if equipment_definitions else item_definitions
|
|
prefix = "EQ-" if equipment_definitions else "IT-"
|
|
actual_ids = {definition.identifier for definition in chosen}
|
|
expected_ids = set(numbered_ids(prefix, 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 {}001..{}320; found {}{}".format(
|
|
prefix,
|
|
prefix,
|
|
len(actual_ids),
|
|
"; " + "; ".join(detail) if detail else "",
|
|
),
|
|
)
|
|
)
|
|
for definition in chosen:
|
|
if len(definition.cells) < 3 or any(not cell.strip() for cell in definition.cells[:3]):
|
|
issues.append(
|
|
issue(
|
|
"REGISTRY_ROW_SCHEMA",
|
|
definition.path,
|
|
definition.line,
|
|
definition.column,
|
|
"{} requires at least three nonempty structural 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_is_content(definition) and re.fullmatch(r"EN-[OE]\d{3}", 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-O### or EN-E### IDs; found {}".format(
|
|
len(actual_ids)
|
|
),
|
|
)
|
|
)
|
|
for kind in ("O", "E"):
|
|
numbers = sorted(
|
|
int(identifier[-3:]) for identifier in actual_ids if identifier.startswith("EN-{}".format(kind))
|
|
)
|
|
if not numbers:
|
|
issues.append(
|
|
issue(
|
|
"ENEMY_CATEGORY_EMPTY",
|
|
"docs/content",
|
|
1,
|
|
1,
|
|
"EN-{} ordinary/elite category has no definitions".format(kind),
|
|
)
|
|
)
|
|
continue
|
|
expected = set(range(1, max(numbers) + 1))
|
|
missing = expected - set(numbers)
|
|
if missing:
|
|
issues.append(
|
|
issue(
|
|
"ENEMY_SEQUENCE_GAP",
|
|
"docs/content",
|
|
1,
|
|
1,
|
|
"EN-{} numbering must be gap-free from 001; missing [{}]".format(
|
|
kind,
|
|
format_id_sample("EN-{}{:03d}".format(kind, number) for number in missing),
|
|
),
|
|
)
|
|
)
|
|
for definition in matches:
|
|
if len(definition.cells) < 3 or any(not cell.strip() for cell in definition.cells[:3]):
|
|
issues.append(
|
|
issue(
|
|
"REGISTRY_ROW_SCHEMA",
|
|
definition.path,
|
|
definition.line,
|
|
definition.column,
|
|
"{} requires at least three nonempty structural 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 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()
|
|
if right_prefix and right_prefix != left_prefix:
|
|
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))
|
|
suffix = suffix or ""
|
|
return tuple(
|
|
"{}{:0{}d}{}".format(left_prefix, number, width, suffix)
|
|
for number in range(start, end + 1)
|
|
)
|
|
|
|
|
|
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 RANGE_IN_TEXT_RE.finditer(line):
|
|
expanded = parse_range(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(("PR-B", "PR-A", "PR-H", "TR-", "IT-", "EQ-", "AR-", "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_pending_bindings(documents: Sequence[Document], issues: List[Issue]) -> None:
|
|
observed: Set[Tuple[str, int, int, str]] = set()
|
|
for document in documents:
|
|
for line_number, line in enumerate(document.text.splitlines(), 1):
|
|
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
|
|
|
|
|
|
def validate_retired_concepts(documents: Sequence[Document], issues: List[Issue]) -> None:
|
|
for document in documents:
|
|
for line_number, line in enumerate(document.text.splitlines(), 1):
|
|
for code, pattern in RETIRED_CONCEPTS:
|
|
for match in pattern.finditer(line):
|
|
if retirement_context(line, match.start(), match.end()):
|
|
continue
|
|
issues.append(
|
|
issue(
|
|
code,
|
|
document.path,
|
|
line_number,
|
|
match.start() + 1,
|
|
"authoritative use of retired concept: {}".format(match.group(0)),
|
|
)
|
|
)
|
|
|
|
|
|
def document_by_path(documents: Sequence[Document], path: str) -> Optional[Document]:
|
|
return next((document for document in documents if document.path == path), None)
|
|
|
|
|
|
def require_text_coverage(
|
|
text: str,
|
|
requirements: Sequence[Tuple[str, re.Pattern]],
|
|
code: str,
|
|
path: str,
|
|
issues: List[Issue],
|
|
) -> None:
|
|
for name, pattern in requirements:
|
|
if pattern.search(text):
|
|
continue
|
|
issues.append(issue(code, path, 1, 1, "missing required coverage: {}".format(name)))
|
|
|
|
|
|
def validate_screen_coverage(
|
|
documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int]
|
|
) -> Set[str]:
|
|
path = "docs/presentation/SCREEN_AND_STATE_MAP.md"
|
|
document = document_by_path(documents, path)
|
|
if document is None:
|
|
counts["pages"] = 0
|
|
return set()
|
|
page_definitions = [
|
|
definition
|
|
for definition in definitions
|
|
if definition.path == path and re.fullmatch(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}", definition.identifier)
|
|
]
|
|
page_ids = {definition.identifier for definition in page_definitions}
|
|
counts["pages"] = len(page_ids)
|
|
if not page_definitions:
|
|
issues.append(issue("PAGE_REGISTRY_EMPTY", path, 1, 1, "no stable page rows were found"))
|
|
return page_ids
|
|
for definition in page_definitions:
|
|
if len(definition.cells) < 5 or any(not cell.strip() for cell in definition.cells[:5]):
|
|
issues.append(
|
|
issue(
|
|
"PAGE_ROW_SCHEMA",
|
|
definition.path,
|
|
definition.line,
|
|
definition.column,
|
|
"{} requires ID, page/state, composition, primary action, and state/exit cells".format(
|
|
definition.identifier
|
|
),
|
|
)
|
|
)
|
|
|
|
page_text = [" | ".join(definition.cells[1:]).lower() for definition in page_definitions]
|
|
page_requirements = (
|
|
("shop", re.compile(r"\b(?:shop|market|offer)\b")),
|
|
("recruit", re.compile(r"\b(?:recruit|hire)\b")),
|
|
("equipment", re.compile(r"\b(?:equipment|equip|loadout)\b")),
|
|
("party line and reorder", re.compile(r"\b(?:party\s+line|formation|reorder)\b")),
|
|
("battle", re.compile(r"\b(?:battle|combat)\b")),
|
|
("result", re.compile(r"\b(?:result|victory|reward|diagnosis)\b")),
|
|
("failure", re.compile(r"\b(?:failure|defeat|death|casualty|recovery)\b")),
|
|
("save", re.compile(r"\b(?:save|cloud|continue)\b")),
|
|
("settings", re.compile(r"\b(?:settings|options|controls)\b")),
|
|
(
|
|
"accessibility",
|
|
re.compile(r"\b(?:accessibility|assist|screen\s+reader|reduced\s+motion|captions|contrast)\b"),
|
|
),
|
|
("localization", re.compile(r"\b(?:localization|language|locale|text)\b")),
|
|
("endgame", re.compile(r"\b(?:endgame|postgame|ending|mastery|new\s+game)\b")),
|
|
)
|
|
for name, pattern in page_requirements:
|
|
if any(pattern.search(value) for value in page_text):
|
|
continue
|
|
issues.append(issue("PAGE_CAPABILITY_MISSING", path, 1, 1, "no page row covers {}".format(name)))
|
|
|
|
require_text_coverage(
|
|
document.text,
|
|
(
|
|
("landscape composition", re.compile(r"\blandscape\b", re.IGNORECASE)),
|
|
("empty state", re.compile(r"\bempty\b", re.IGNORECASE)),
|
|
("error state", re.compile(r"\b(?:error|failed)\b", re.IGNORECASE)),
|
|
("confirmation behavior", re.compile(r"\bconfirm(?:ation|ed|ing)?\b", re.IGNORECASE)),
|
|
("input coverage", re.compile(r"\b(?:input|controller|keyboard|touch)\b", re.IGNORECASE)),
|
|
("localization coverage", re.compile(r"\b(?:localization|language|locale|pseudolocale)\b", re.IGNORECASE)),
|
|
(
|
|
"accessibility coverage",
|
|
re.compile(
|
|
r"\b(?:accessibility|screen\s+reader|reduced\s+motion|high\s+contrast|captions)\b",
|
|
re.IGNORECASE,
|
|
),
|
|
),
|
|
),
|
|
"PAGE_STATE_COVERAGE_MISSING",
|
|
path,
|
|
issues,
|
|
)
|
|
return page_ids
|
|
|
|
|
|
def reference_ids_for_path(references: Sequence[Reference], path: str) -> Set[str]:
|
|
return {
|
|
identifier
|
|
for reference in references
|
|
if reference.path == path
|
|
for identifier in reference.identifiers
|
|
}
|
|
|
|
|
|
def validate_art_coverage(
|
|
documents: Sequence[Document],
|
|
references: Sequence[Reference],
|
|
required_ids: Dict[str, Set[str]],
|
|
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)
|
|
groups = (
|
|
("all 42 professions", required_ids["professions"]),
|
|
("all eight regions", required_ids["regions"]),
|
|
("all 100 ordinary/elite enemies", required_ids["enemies"]),
|
|
("all 16 bosses", required_ids["bosses"]),
|
|
)
|
|
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],
|
|
references: Sequence[Reference],
|
|
required_region_ids: Set[str],
|
|
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]
|
|
families = {
|
|
"music": re.compile(r"^(?:MUS|MUSIC)-"),
|
|
"ambience": re.compile(r"^(?:AMB|AMBIENCE)-"),
|
|
"sfx": re.compile(r"^(?:SFX|UI)-"),
|
|
"voice": re.compile(r"^(?:VO|VOC|VOICE)-"),
|
|
}
|
|
for family, pattern in families.items():
|
|
family_ids = {definition.identifier for definition in audio_definitions if pattern.match(definition.identifier)}
|
|
counts["audio.{}".format(family)] = len(family_ids)
|
|
if not family_ids:
|
|
issues.append(
|
|
issue(
|
|
"AUDIO_REGISTRY_FAMILY_MISSING",
|
|
path,
|
|
1,
|
|
1,
|
|
"no stable {} production IDs were found".format(family),
|
|
)
|
|
)
|
|
|
|
audio_region_ids = reference_ids_for_path(references, path)
|
|
missing_regions = required_region_ids - audio_region_ids
|
|
if missing_regions:
|
|
issues.append(
|
|
issue(
|
|
"AUDIO_REGION_COVERAGE_MISSING",
|
|
path,
|
|
1,
|
|
1,
|
|
"regional audio lacks explicit traceability: [{}]".format(
|
|
format_id_sample(missing_regions)
|
|
),
|
|
)
|
|
)
|
|
|
|
require_text_coverage(
|
|
document.text,
|
|
(
|
|
("shop offers", re.compile(r"\b(?:shop|offer|market)\b", re.IGNORECASE)),
|
|
("recruitment", re.compile(r"\b(?:recruit|hire)\b", re.IGNORECASE)),
|
|
("equipment", re.compile(r"\b(?:equipment|equip|loadout)\b", re.IGNORECASE)),
|
|
("party reorder", re.compile(r"\b(?:reorder|party\s+line|formation)\b", re.IGNORECASE)),
|
|
("battle", re.compile(r"\b(?:battle|combat)\b", re.IGNORECASE)),
|
|
("pause", re.compile(r"\bpause\b", re.IGNORECASE)),
|
|
("speed", re.compile(r"\bspeed\b", re.IGNORECASE)),
|
|
("inspection", re.compile(r"\binspect(?:ion)?\b", re.IGNORECASE)),
|
|
("retreat", re.compile(r"\bretreat\b", re.IGNORECASE)),
|
|
("permanent casualty", re.compile(r"\b(?:death|casualty|permanent\s+loss)\b", re.IGNORECASE)),
|
|
("dismissal", re.compile(r"\bdismiss(?:al|ed)?\b", re.IGNORECASE)),
|
|
("reward", re.compile(r"\breward\b", re.IGNORECASE)),
|
|
("failure", re.compile(r"\b(?:failure|defeat)\b", re.IGNORECASE)),
|
|
(
|
|
"audio accessibility",
|
|
re.compile(r"\b(?:accessibility|caption|visual\s+cue|haptic|mono)\b", re.IGNORECASE),
|
|
),
|
|
(
|
|
"generated-recruit voice scope",
|
|
re.compile(r"\b(?:generated|modular)\s+recruits?\b", re.IGNORECASE),
|
|
),
|
|
),
|
|
"AUDIO_CAPABILITY_MISSING",
|
|
path,
|
|
issues,
|
|
)
|
|
|
|
|
|
def audit(root: Path) -> AuditResult:
|
|
root = root.resolve()
|
|
documents, issues = collect_documents(root)
|
|
definitions, definition_issues = collect_definitions(documents)
|
|
issues.extend(definition_issues)
|
|
counts: Dict[str, int] = {}
|
|
|
|
required_ids: Dict[str, Set[str]] = {}
|
|
profession_ids: Set[str] = set()
|
|
for rule in REGISTRY_RULES:
|
|
actual = validate_registry_rule(rule, definitions, issues, counts)
|
|
if rule.name.startswith("professions."):
|
|
profession_ids.update(actual)
|
|
elif rule.name == "bosses":
|
|
required_ids["bosses"] = actual
|
|
elif rule.name == "regions":
|
|
required_ids["regions"] = actual
|
|
required_ids["professions"] = profession_ids
|
|
required_ids["equipment"] = validate_equipment_registry(definitions, issues, counts)
|
|
required_ids["enemies"] = validate_enemy_registry(definitions, issues, counts)
|
|
required_ids.setdefault("bosses", set())
|
|
required_ids.setdefault("regions", set())
|
|
|
|
references, reference_issues = collect_references(documents)
|
|
issues.extend(reference_issues)
|
|
validate_cross_references(definitions, references, issues)
|
|
validate_pending_bindings(documents, issues)
|
|
validate_retired_concepts(documents, issues)
|
|
validate_screen_coverage(documents, definitions, issues, counts)
|
|
validate_art_coverage(documents, references, required_ids, issues)
|
|
validate_audio_coverage(
|
|
documents,
|
|
definitions,
|
|
references,
|
|
required_ids["regions"],
|
|
issues,
|
|
counts,
|
|
)
|
|
|
|
unique_issues = {item.sort_key(): item for item in issues}
|
|
return AuditResult(sorted(unique_issues.values(), key=Issue.sort_key), dict(sorted(counts.items())))
|
|
|
|
|
|
def markdown_table(rows: Iterable[Tuple[str, str, str]]) -> str:
|
|
output = ["| ID | Decision | Production |", "|---|---|---|"]
|
|
output.extend("| `{}` | {} | {} |".format(*row) for row in rows)
|
|
return "\n".join(output) + "\n"
|
|
|
|
|
|
def write_fixture(root: Path) -> None:
|
|
for directory in AUTHORITY_DIRECTORIES:
|
|
(root / directory).mkdir(parents=True, exist_ok=True)
|
|
|
|
product_texts = {
|
|
"GAME_PRODUCT_CONTRACT.md": "# Product\n\nGenerated recruits form an ordered horizontal party line.\n",
|
|
"SYSTEMS_AND_BATTLE.md": "# Systems\n\nAutomatic battle supports pause, speed, inspect, and retreat.\n",
|
|
"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")
|
|
|
|
catalog_rows: List[Tuple[str, str, str]] = []
|
|
families = (
|
|
numbered_ids("PR-B", 12, 2),
|
|
numbered_ids("PR-A", 24, 2),
|
|
numbered_ids("PR-H", 6, 2),
|
|
numbered_ids("TR-", 36, 3),
|
|
numbered_ids("IT-", 320, 3),
|
|
numbered_ids("AR-", 60, 3),
|
|
numbered_ids("EN-O", 60, 3),
|
|
numbered_ids("EN-E", 40, 3),
|
|
numbered_ids("EN-B", 16, 3),
|
|
numbered_ids("REG-", 8, 2),
|
|
)
|
|
for identifiers in families:
|
|
catalog_rows.extend((identifier, "changes a decision", "countable scope") for identifier in identifiers)
|
|
(root / "docs/content/CATALOGS.md").write_text(
|
|
"# Content Registries\n\n" + markdown_table(catalog_rows), encoding="utf-8"
|
|
)
|
|
|
|
page_rows = (
|
|
("PG-001", "Shop offers", "Buy recruit", "empty, error, confirmation"),
|
|
("PG-002", "Recruit hiring", "Hire", "empty, error, confirmation"),
|
|
("PG-003", "Equipment loadout", "Equip", "empty, error, confirmation"),
|
|
("PG-004", "Party line formation reorder", "Reorder", "empty, error, confirmation"),
|
|
("PG-005", "Automatic battle", "Start battle", "pause, speed, inspect, retreat"),
|
|
("PG-006", "Victory result and reward", "Take reward", "empty, error, confirmation"),
|
|
("PG-007", "Failure death and recovery", "Continue", "empty, error, confirmation"),
|
|
("PG-008", "Save and cloud", "Manage save", "empty, error, confirmation"),
|
|
("PG-009", "Settings and controls", "Apply", "input, empty, error, confirmation"),
|
|
("PG-010", "Accessibility assists", "Apply", "screen reader, reduced motion, captions"),
|
|
("PG-011", "Language localization", "Apply language", "locale, text expansion"),
|
|
("PG-012", "Endgame mastery", "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.",
|
|
"",
|
|
"| ID | Page/state | Composition | Primary action | States and exit |",
|
|
"|---|---|---|---|---|",
|
|
]
|
|
screen_lines.extend(
|
|
"| `{}` | {} | stable landscape composition | {} | {} |".format(*row) for row in page_rows
|
|
)
|
|
(root / "docs/presentation/SCREEN_AND_STATE_MAP.md").write_text(
|
|
"\n".join(screen_lines) + "\n", encoding="utf-8"
|
|
)
|
|
(root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text(
|
|
"# UI\n\nLandscape input, localization, and accessibility rules are shared.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
art = """# Art Asset and Animation Catalog
|
|
|
|
Modular generated recruits use a landscape composition.
|
|
Equipment readability uses silhouettes and icons. The animation matrix and VFX language are production-countable.
|
|
|
|
Profession coverage: `PR-B01..PR-B12`, `PR-A01..PR-A24`, `PR-H01..PR-H06`.
|
|
Region coverage: `REG-01..REG-08`.
|
|
Enemy coverage: `EN-O001..EN-O060`, `EN-E001..EN-E040`, `EN-B001..EN-B016`.
|
|
"""
|
|
(root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8")
|
|
|
|
audio_rows = (
|
|
("MUS-REGION", "music for `REG-01..REG-08`", "eight regional mixes"),
|
|
("AMB-REGION", "ambience for `REG-01..REG-08`", "loop lifecycle"),
|
|
("SFX-SHOP", "shop offers", "event cue"),
|
|
("SFX-RECRUIT", "recruit hiring", "event cue"),
|
|
("SFX-EQUIP", "equipment loadout", "event cue"),
|
|
("SFX-REORDER", "party line reorder", "event cue"),
|
|
("SFX-BATTLE", "battle start", "event cue"),
|
|
("SFX-PAUSE", "pause", "event cue"),
|
|
("SFX-SPEED", "speed change", "event cue"),
|
|
("SFX-INSPECT", "inspect", "event cue"),
|
|
("SFX-RETREAT", "retreat", "event cue"),
|
|
("SFX-DEATH", "permanent casualty death", "event cue"),
|
|
("SFX-DISMISSAL", "dismissal", "event cue"),
|
|
("SFX-REWARD", "reward", "event cue"),
|
|
("SFX-FAILURE", "failure and defeat", "event cue"),
|
|
("SFX-ACCESS", "accessibility visual cue, caption, haptic, and mono", "fallback"),
|
|
("VO-GENERATED", "voice scope for generated recruits; no fixed cast", "bounded pool"),
|
|
)
|
|
(root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").write_text(
|
|
"# Audio Music and Voice Catalog\n\n" + markdown_table(audio_rows), 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))
|
|
|
|
cases: Sequence[Tuple[str, Callable[[Path], None], str]] = (
|
|
(
|
|
"exact-count",
|
|
lambda root: mutate_remove_line(root / "docs/content/CATALOGS.md", "`AR-060`"),
|
|
"REGISTRY_EXACT_SET",
|
|
),
|
|
(
|
|
"duplicate-id",
|
|
lambda root: (root / "docs/content/CATALOGS.md").write_text(
|
|
(root / "docs/content/CATALOGS.md").read_text(encoding="utf-8")
|
|
+ "| `TR-001` | duplicate | duplicate |\n",
|
|
encoding="utf-8",
|
|
),
|
|
"ID_DUPLICATE",
|
|
),
|
|
(
|
|
"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",
|
|
),
|
|
(
|
|
"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",
|
|
),
|
|
(
|
|
"page-coverage",
|
|
lambda root: mutate_remove_line(
|
|
root / "docs/presentation/SCREEN_AND_STATE_MAP.md", "`PG-012`"
|
|
),
|
|
"PAGE_CAPABILITY_MISSING",
|
|
),
|
|
(
|
|
"art-coverage",
|
|
lambda root: mutate_remove_line(
|
|
root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md", "Profession coverage"
|
|
),
|
|
"ART_ID_COVERAGE_MISSING",
|
|
),
|
|
(
|
|
"audio-coverage",
|
|
lambda root: mutate_remove_line(
|
|
root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md", "`SFX-DISMISSAL`"
|
|
),
|
|
"AUDIO_CAPABILITY_MISSING",
|
|
),
|
|
)
|
|
for name, mutate, expected_code in cases:
|
|
with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary:
|
|
root = Path(temporary)
|
|
write_fixture(root)
|
|
mutate(root)
|
|
result = audit(root)
|
|
codes = {item.code for item in result.issues}
|
|
if expected_code not in codes:
|
|
raise AssertionError(
|
|
"{} did not produce {}; got {}".format(name, expected_code, sorted(codes))
|
|
)
|
|
print("ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases={}".format(len(cases) + 1))
|
|
|
|
|
|
def print_result(result: AuditResult, as_json: bool) -> None:
|
|
if as_json:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"ok": result.ok,
|
|
"errors": len(result.issues),
|
|
"counts": result.counts,
|
|
"diagnostics": [item.as_dict() for item in result.issues],
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
return
|
|
for item in result.issues:
|
|
print(
|
|
"ERROR {} {}:{}:{} {}".format(
|
|
item.code, item.path, item.line, item.column, item.message
|
|
)
|
|
)
|
|
print("COUNTS {}".format(json.dumps(result.counts, sort_keys=True, separators=(",", ":"))))
|
|
if result.ok:
|
|
print("ABG_PREDEVELOPMENT_DESIGN_OK errors=0")
|
|
else:
|
|
print("ABG_PREDEVELOPMENT_DESIGN_FAILED errors={}".format(len(result.issues)))
|
|
|
|
|
|
def parse_arguments(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"root",
|
|
nargs="?",
|
|
type=Path,
|
|
default=REPOSITORY_ROOT,
|
|
help="repository root (default: validator's repository)",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="emit deterministic JSON diagnostics")
|
|
parser.add_argument("--self-test", action="store_true", help="run embedded pass and negative fixtures")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
arguments = parse_arguments(argv)
|
|
if arguments.self_test:
|
|
run_self_test()
|
|
return 0
|
|
result = audit(arguments.root)
|
|
print_result(result, arguments.json)
|
|
return 0 if result.ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|