#!/usr/bin/env python3 """Build the shared i18n parity artifact for MVP-critical Django/Angular keys.""" from __future__ import annotations import argparse import hashlib import json import re from collections import defaultdict from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" ARTIFACT_PATH = REPO_ROOT / "shared" / "i18n" / "artifacts" / "lobby-mvp-keyspace-parity-report.v1.json" DJANGO_VIEWS_PATH = REPO_ROOT / "lobby" / "views.py" FRONTEND_VERTICAL_SLICE_PATH = REPO_ROOT / "frontend" / "src" / "spa" / "vertical-slice.ts" ANGULAR_I18N_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "lobby-i18n.ts" ANGULAR_HOST_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "host" / "host-shell.component.ts" ANGULAR_PLAYER_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "player" / "player-shell.component.ts" ARTIFACT_NAME = "shared.i18n.lobby.mvp_keyspace_parity_report" ARTIFACT_VERSION = "v1" class ParityError(RuntimeError): pass def _read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def _load_catalog() -> dict[str, Any]: return json.loads(_read_text(CATALOG_PATH)) def _catalog_hash() -> str: return hashlib.sha256(CATALOG_PATH.read_bytes()).hexdigest() def _sorted_unique(values: list[str]) -> list[str]: return sorted({value for value in values}) def _extract_matches(path: Path, pattern: str) -> list[str]: return re.findall(pattern, _read_text(path), re.MULTILINE) def _resolve_frontend_ui_key(key: str) -> str: if key.startswith("lobby.shell."): return key.replace("lobby.shell.", "app.", 1) if key.startswith("game.host."): return key.replace("game.host.", "host.", 1) if key.startswith("game.player."): return key.replace("game.player.", "player.", 1) return key def _has_nested_key(root: dict[str, Any], dotted_key: str) -> bool: current: Any = root for part in dotted_key.split("."): if not isinstance(current, dict) or part not in current: return False current = current[part] return isinstance(current, dict) def build_report() -> dict[str, Any]: catalog = _load_catalog() locales = list(catalog["locales"]["supported"]) frontend_errors: dict[str, Any] = catalog["frontend"]["errors"] frontend_ui: dict[str, Any] = catalog["frontend"]["ui"] backend_error_codes: dict[str, str] = catalog["backend"]["error_codes"] backend_errors: dict[str, Any] = catalog["backend"]["errors"] backend_to_frontend: dict[str, str] = catalog["contract"]["backend_to_frontend_error_keys"] django_error_codes = _sorted_unique( _extract_matches(DJANGO_VIEWS_PATH, r'ERROR_CODES\.get\("([a-z0-9_]+)"') ) frontend_runtime_error_fallbacks = _sorted_unique( _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessageFromApiPayload\([^\n]+?'([a-z0-9_]+)'\)") + _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessage\('([a-z0-9_]+)'\)") ) angular_copy_keys = _sorted_unique( _extract_matches(ANGULAR_HOST_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)") + _extract_matches(ANGULAR_PLAYER_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)") ) angular_catalog_paths = [_resolve_frontend_ui_key(key) for key in angular_copy_keys] missing_backend_codes = [code for code in django_error_codes if code not in backend_error_codes] missing_backend_translations = [code for code in django_error_codes if code not in backend_errors] missing_contract_mappings = [code for code in django_error_codes if code not in backend_to_frontend] mapped_frontend_error_keys = _sorted_unique( [backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend] ) missing_frontend_error_keys = [key for key in mapped_frontend_error_keys if key not in frontend_errors] missing_frontend_runtime_fallbacks = [key for key in frontend_runtime_error_fallbacks if key not in frontend_errors] missing_angular_catalog_paths = [path for path in angular_catalog_paths if not _has_nested_key(frontend_ui, path)] dead_contract_aliases = _sorted_unique([code for code in backend_to_frontend if code not in backend_error_codes]) many_to_one_mappings: dict[str, list[str]] = defaultdict(list) for code in django_error_codes: frontend_key = backend_to_frontend.get(code) if frontend_key: many_to_one_mappings[frontend_key].append(code) many_to_one_mappings = { frontend_key: sorted(codes) for frontend_key, codes in sorted(many_to_one_mappings.items()) if len(codes) > 1 } blocking_issues = { "missing_backend_codes": missing_backend_codes, "missing_backend_translations": missing_backend_translations, "missing_contract_mappings": missing_contract_mappings, "missing_frontend_error_keys": missing_frontend_error_keys, "missing_frontend_runtime_fallbacks": missing_frontend_runtime_fallbacks, "missing_angular_catalog_paths": missing_angular_catalog_paths, } status = "pass" if not any(blocking_issues.values()) else "fail" follow_ups: list[dict[str, str]] = [] if dead_contract_aliases: follow_ups.append( { "priority": "need-to-have", "item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.", "evidence": ", ".join(dead_contract_aliases), } ) if many_to_one_mappings: follow_ups.append( { "priority": "nice-to-have", "item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.", "evidence": "; ".join( f"{frontend_key} <= {', '.join(codes)}" for frontend_key, codes in many_to_one_mappings.items() ), } ) return { "artifact_name": ARTIFACT_NAME, "artifact_version": ARTIFACT_VERSION, "naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.", "source_of_truth": { "catalog": str(CATALOG_PATH.relative_to(REPO_ROOT)), "catalog_sha256": _catalog_hash(), "source_paths": [ str(path.relative_to(REPO_ROOT)) for path in [ DJANGO_VIEWS_PATH, FRONTEND_VERTICAL_SLICE_PATH, ANGULAR_I18N_PATH, ANGULAR_HOST_PATH, ANGULAR_PLAYER_PATH, ] ], }, "scope": { "issue": 277, "related_epic": 175, "mvp_locales": locales, "definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells.", }, "parity": { "status": status, "django_backend_error_codes_used_by_mvp": django_error_codes, "angular_frontend_error_fallback_keys_used_by_mvp": frontend_runtime_error_fallbacks, "angular_ui_keys_used_by_mvp": angular_copy_keys, "angular_ui_catalog_paths": angular_catalog_paths, "backend_codes_mapped_to_frontend_error_keys": {code: backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend}, "unique_frontend_error_keys_reached_from_django": mapped_frontend_error_keys, "blocking_issues": blocking_issues, "follow_ups": follow_ups, }, } def write_report() -> None: report = build_report() ARTIFACT_PATH.parent.mkdir(parents=True, exist_ok=True) ARTIFACT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def check_report() -> None: report = build_report() if not ARTIFACT_PATH.exists(): raise ParityError(f"artifact missing: {ARTIFACT_PATH}") existing = json.loads(_read_text(ARTIFACT_PATH)) if existing != report: raise ParityError("artifact out of date; run scripts/build_i18n_parity_report.py --write") if report["parity"]["status"] != "pass": raise ParityError(json.dumps(report["parity"]["blocking_issues"], indent=2)) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--write", action="store_true") parser.add_argument("--check", action="store_true") args = parser.parse_args() if args.write: write_report() if args.check: check_report() if not args.write and not args.check: print(json.dumps(build_report(), indent=2, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())