diff --git a/scripts/build_i18n_parity_report.py b/scripts/build_i18n_parity_report.py new file mode 100644 index 0000000..93e238e --- /dev/null +++ b/scripts/build_i18n_parity_report.py @@ -0,0 +1,214 @@ +#!/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()) diff --git a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json new file mode 100644 index 0000000..503bb44 --- /dev/null +++ b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json @@ -0,0 +1,212 @@ +{ + "artifact_name": "shared.i18n.lobby.mvp_keyspace_parity_report", + "artifact_version": "v1", + "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": "shared/i18n/lobby.json", + "catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb", + "source_paths": [ + "lobby/views.py", + "frontend/src/spa/vertical-slice.ts", + "frontend/angular/src/app/lobby-i18n.ts", + "frontend/angular/src/app/features/host/host-shell.component.ts", + "frontend/angular/src/app/features/player/player-shell.component.ts" + ] + }, + "scope": { + "issue": 277, + "related_epic": 175, + "mvp_locales": [ + "en", + "da" + ], + "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": "pass", + "django_backend_error_codes_used_by_mvp": [ + "category_has_no_questions", + "category_not_found", + "category_slug_required", + "host_only_mix_answers", + "host_only_show_question", + "host_only_start_round", + "mix_answers_invalid_phase", + "nickname_invalid", + "nickname_taken", + "no_available_questions", + "not_enough_answers_to_mix", + "question_already_shown", + "round_already_configured", + "round_config_missing", + "round_question_not_found", + "round_start_invalid_phase", + "session_code_required", + "session_not_found", + "session_not_joinable", + "show_question_invalid_phase" + ], + "angular_frontend_error_fallback_keys_used_by_mvp": [ + "join_failed", + "session_code_required", + "session_fetch_failed", + "start_round_failed" + ], + "angular_ui_keys_used_by_mvp": [ + "common.back_to_join", + "common.points_short", + "common.prompt", + "common.refresh", + "common.retry", + "common.round", + "common.round_question_id", + "common.session_code", + "common.status", + "common.unknown_error", + "host.audio_locale_hint", + "host.calculate_scores", + "host.category", + "host.final_leaderboard", + "host.finish_game", + "host.finish_game_failed", + "host.load_scoreboard", + "host.mix_answers", + "host.next_round_failed", + "host.retry_finish", + "host.retry_next_round", + "host.retry_scoreboard", + "host.scoreboard_failed", + "host.session_code_required", + "host.session_refresh_failed", + "host.show_question", + "host.start_next_round", + "host.start_round", + "host.title", + "host.winner", + "player.audio_policy_notice", + "player.final_leaderboard", + "player.guess_submit_failed", + "player.join", + "player.join_failed", + "player.lie_label", + "player.lie_submit_failed", + "player.loading_join", + "player.loading_refresh", + "player.loading_submit_guess", + "player.loading_submit_lie", + "player.nickname", + "player.offline_text", + "player.reconnecting_text", + "player.retry_guess_submit", + "player.retry_lie_submit", + "player.retry_now", + "player.session_refresh_failed", + "player.submit_guess", + "player.submit_lie", + "player.title" + ], + "angular_ui_catalog_paths": [ + "common.back_to_join", + "common.points_short", + "common.prompt", + "common.refresh", + "common.retry", + "common.round", + "common.round_question_id", + "common.session_code", + "common.status", + "common.unknown_error", + "host.audio_locale_hint", + "host.calculate_scores", + "host.category", + "host.final_leaderboard", + "host.finish_game", + "host.finish_game_failed", + "host.load_scoreboard", + "host.mix_answers", + "host.next_round_failed", + "host.retry_finish", + "host.retry_next_round", + "host.retry_scoreboard", + "host.scoreboard_failed", + "host.session_code_required", + "host.session_refresh_failed", + "host.show_question", + "host.start_next_round", + "host.start_round", + "host.title", + "host.winner", + "player.audio_policy_notice", + "player.final_leaderboard", + "player.guess_submit_failed", + "player.join", + "player.join_failed", + "player.lie_label", + "player.lie_submit_failed", + "player.loading_join", + "player.loading_refresh", + "player.loading_submit_guess", + "player.loading_submit_lie", + "player.nickname", + "player.offline_text", + "player.reconnecting_text", + "player.retry_guess_submit", + "player.retry_lie_submit", + "player.retry_now", + "player.session_refresh_failed", + "player.submit_guess", + "player.submit_lie", + "player.title" + ], + "backend_codes_mapped_to_frontend_error_keys": { + "category_has_no_questions": "start_round_failed", + "category_not_found": "start_round_failed", + "category_slug_required": "start_round_failed", + "host_only_mix_answers": "start_round_failed", + "host_only_show_question": "start_round_failed", + "host_only_start_round": "start_round_failed", + "mix_answers_invalid_phase": "start_round_failed", + "nickname_invalid": "nickname_invalid", + "nickname_taken": "nickname_taken", + "no_available_questions": "start_round_failed", + "not_enough_answers_to_mix": "start_round_failed", + "question_already_shown": "start_round_failed", + "round_already_configured": "start_round_failed", + "round_config_missing": "start_round_failed", + "round_question_not_found": "start_round_failed", + "round_start_invalid_phase": "start_round_failed", + "session_code_required": "session_code_required", + "session_not_found": "session_not_found", + "session_not_joinable": "join_failed", + "show_question_invalid_phase": "start_round_failed" + }, + "unique_frontend_error_keys_reached_from_django": [ + "join_failed", + "nickname_invalid", + "nickname_taken", + "session_code_required", + "session_not_found", + "start_round_failed" + ], + "blocking_issues": { + "missing_backend_codes": [], + "missing_backend_translations": [], + "missing_contract_mappings": [], + "missing_frontend_error_keys": [], + "missing_frontend_runtime_fallbacks": [], + "missing_angular_catalog_paths": [] + }, + "follow_ups": [ + { + "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": "host_only_action" + }, + { + "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": "start_round_failed <= category_has_no_questions, category_not_found, category_slug_required, host_only_mix_answers, host_only_show_question, host_only_start_round, mix_answers_invalid_phase, no_available_questions, not_enough_answers_to_mix, question_already_shown, round_already_configured, round_config_missing, round_question_not_found, round_start_invalid_phase, show_question_invalid_phase" + } + ] + } +}