#!/usr/bin/env python3 """Generate issue #277 shared i18n parity artifact. Read-only report over the shared lobby i18n catalog, with focus on MVP-critical backend/frontend parity used by Django and Angular. """ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" OUTPUT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md" ARTIFACT_ID = "issue-277-shared-i18n-parity-report" ARTIFACT_VERSION = "1.0" MVP_FRONTEND_UI_KEYS = [ "frontend.ui.host.title", "frontend.ui.player.title", "frontend.ui.common.session_code", "frontend.ui.player.nickname", "frontend.ui.player.join", "frontend.ui.host.start_round", "frontend.ui.host.show_question", "frontend.ui.player.lie_label", "frontend.ui.player.submit_lie", "frontend.ui.player.submit_guess", "frontend.ui.host.mix_answers", "frontend.ui.host.calculate_scores", "frontend.ui.host.load_scoreboard", "frontend.ui.host.final_leaderboard", "frontend.ui.player.final_leaderboard", "frontend.ui.common.points_short", ] MVP_FRONTEND_ERROR_KEYS = [ "frontend.errors.session_code_required", "frontend.errors.session_not_found", "frontend.errors.nickname_invalid", "frontend.errors.nickname_taken", "frontend.errors.join_failed", "frontend.errors.start_round_failed", "frontend.errors.unknown", ] MVP_BACKEND_CODES = [ "backend.error_codes.session_code_required", "backend.error_codes.nickname_invalid", "backend.error_codes.session_not_found", "backend.error_codes.session_not_joinable", "backend.error_codes.nickname_taken", "backend.error_codes.category_slug_required", "backend.error_codes.category_not_found", "backend.error_codes.round_start_invalid_phase", "backend.error_codes.round_already_configured", ] @dataclass(frozen=True) class MappingRow: backend_code: str backend_key: str frontend_key: str locales_ok: bool parity_status: str note: str def load_catalog() -> dict: return json.loads(CATALOG_PATH.read_text(encoding="utf-8")) def get_path(data: dict, dotted: str): node = data for part in dotted.split("."): node = node[part] return node def translation_state(data: dict, dotted: str) -> tuple[bool, list[str]]: translations = get_path(data, dotted) missing = [locale for locale in ("en", "da") if not isinstance(translations.get(locale), str) or not translations[locale].strip()] return (not missing, missing) def build_mapping_rows(catalog: dict) -> list[MappingRow]: rows: list[MappingRow] = [] mapping = catalog["contract"]["backend_to_frontend_error_keys"] backend_errors = catalog["backend"]["errors"] for dotted_code in MVP_BACKEND_CODES: code = dotted_code.removeprefix("backend.error_codes.") backend_key = catalog["backend"]["error_codes"][code] frontend_key = mapping[code] backend_locales_ok, _ = translation_state(catalog, f"backend.errors.{backend_key}") frontend_locales_ok, _ = translation_state(catalog, f"frontend.errors.{frontend_key}") note = "1:1" if code == frontend_key else "many:1 collapse" if frontend_key == "start_round_failed" else "mapped alias" rows.append( MappingRow( backend_code=code, backend_key=backend_key, frontend_key=frontend_key, locales_ok=backend_locales_ok and frontend_locales_ok, parity_status="aligned" if frontend_key in backend_errors or code == frontend_key else "mapped", note=note, ) ) return rows def render_report(catalog: dict) -> str: mapping_rows = build_mapping_rows(catalog) frontend_ui_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_UI_KEYS) frontend_error_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_ERROR_KEYS) backend_code_ok = all(translation_state(catalog, f"backend.errors.{catalog['backend']['error_codes'][key.removeprefix('backend.error_codes.')]}" )[0] for key in MVP_BACKEND_CODES) mapped_frontend_keys = sorted({row.frontend_key for row in mapping_rows}) collapsed_codes = [row.backend_code for row in mapping_rows if row.frontend_key == "start_round_failed"] lines: list[str] = [] lines.append("# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)") lines.append("") lines.append("Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)") lines.append("") lines.append("## Artifact metadata") lines.append("") lines.append(f"- `artifact_id`: `{ARTIFACT_ID}`") lines.append(f"- `artifact_version`: `{ARTIFACT_VERSION}`") lines.append(f"- `catalog_source`: `{CATALOG_PATH.relative_to(REPO_ROOT)}`") lines.append(f"- `generator`: `scripts/{Path(__file__).name}`") lines.append("") lines.append("## Naming/version rules (email-manager-inspired strategy)") lines.append("") lines.append("- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE---ARTIFACT.md`.") lines.append("- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.") lines.append("- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.") lines.append("- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.") lines.append("- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter.") lines.append("") lines.append("## MVP-critical parity summary") lines.append("") lines.append(f"- Frontend UI gameplay keys checked: **{len(MVP_FRONTEND_UI_KEYS)}** → `{'OK' if frontend_ui_ok else 'DRIFT'}`") lines.append(f"- Frontend error keys checked: **{len(MVP_FRONTEND_ERROR_KEYS)}** → `{'OK' if frontend_error_ok else 'DRIFT'}`") lines.append(f"- Backend gameplay/error codes checked: **{len(MVP_BACKEND_CODES)}** → `{'OK' if backend_code_ok else 'DRIFT'}`") lines.append(f"- Distinct frontend error keys reached from backend MVP flow: **{len(mapped_frontend_keys)}** (`{', '.join(mapped_frontend_keys)}`)") lines.append("") lines.append("Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**") lines.append("") lines.append("## Django ↔ Angular parity matrix (MVP-critical error contract)") lines.append("") lines.append("| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |") lines.append("|---|---|---|---|---|") for row in mapping_rows: lines.append( f"| `{row.backend_code}` | `{row.backend_key}` | `{row.frontend_key}` | `{'OK' if row.locales_ok else 'DRIFT'}` | {row.note} |" ) lines.append("") lines.append("## Scope notes") lines.append("") lines.append("- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.") lines.append("- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.") lines.append("- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry.") lines.append("") lines.append("## Verified MVP gameplay UI keyspace present in the shared registry") lines.append("") for key in MVP_FRONTEND_UI_KEYS: lines.append(f"- `{key}`") lines.append("") lines.append("## Concrete deviations / follow-up items") lines.append("") lines.append(f"1. **Error granularity collapse remains intentional**: backend codes `{', '.join(collapsed_codes)}` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message.") lines.append("2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states.") lines.append("3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular.") lines.append("") lines.append("## Re-run") lines.append("") lines.append("```bash") lines.append("python3 scripts/check_i18n_drift.py") lines.append("python3 scripts/report_i18n_parity.py") lines.append("```") lines.append("") return "\n".join(lines) def main() -> int: catalog = load_catalog() OUTPUT_PATH.write_text(render_report(catalog), encoding="utf-8") print(OUTPUT_PATH.relative_to(REPO_ROOT)) return 0 if __name__ == "__main__": raise SystemExit(main())