diff --git a/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md b/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md new file mode 100644 index 0000000..2817095 --- /dev/null +++ b/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md @@ -0,0 +1,80 @@ +# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP) + +Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`) + +## Artifact metadata + +- `artifact_id`: `issue-277-shared-i18n-parity-report` +- `artifact_version`: `1.0` +- `generated_at`: `2026-03-13T09:10:14Z` +- `catalog_source`: `shared/i18n/lobby.json` +- `generator`: `scripts/report_i18n_parity.py` + +## Naming/version rules (email-manager-inspired strategy) + +- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE---ARTIFACT.md`. +- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops. +- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer. +- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter. +- **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. + +## MVP-critical parity summary + +- Frontend UI gameplay keys checked: **16** → `OK` +- Frontend error keys checked: **7** → `OK` +- Backend gameplay/error codes checked: **9** → `OK` +- Distinct frontend error keys reached from backend MVP flow: **6** (`join_failed, nickname_invalid, nickname_taken, session_code_required, session_not_found, start_round_failed`) + +Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.** + +## Django ↔ Angular parity matrix (MVP-critical error contract) + +| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note | +|---|---|---|---|---| +| `session_code_required` | `session_code_required` | `session_code_required` | `OK` | 1:1 | +| `nickname_invalid` | `nickname_invalid` | `nickname_invalid` | `OK` | 1:1 | +| `session_not_found` | `session_not_found` | `session_not_found` | `OK` | 1:1 | +| `session_not_joinable` | `session_not_joinable` | `join_failed` | `OK` | mapped alias | +| `nickname_taken` | `nickname_taken` | `nickname_taken` | `OK` | 1:1 | +| `category_slug_required` | `category_slug_required` | `start_round_failed` | `OK` | many:1 collapse | +| `category_not_found` | `category_not_found` | `start_round_failed` | `OK` | many:1 collapse | +| `round_start_invalid_phase` | `round_start_invalid_phase` | `start_round_failed` | `OK` | many:1 collapse | +| `round_already_configured` | `round_already_configured` | `start_round_failed` | `OK` | many:1 collapse | + +## Scope notes + +- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`. +- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`. +- **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. + +## Verified MVP gameplay UI keyspace present in the shared registry + +- `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` + +## Concrete deviations / follow-up items + +1. **Error granularity collapse remains intentional**: backend codes `category_slug_required, category_not_found, round_start_invalid_phase, round_already_configured` 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. +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. +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. + +## Re-run + +```bash +python3 scripts/check_i18n_drift.py +python3 scripts/report_i18n_parity.py +``` diff --git a/scripts/report_i18n_parity.py b/scripts/report_i18n_parity.py new file mode 100644 index 0000000..49c90c2 --- /dev/null +++ b/scripts/report_i18n_parity.py @@ -0,0 +1,198 @@ +#!/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 datetime import datetime, timezone +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: + generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + 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"- `generated_at`: `{generated_at}`") + 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())