Merge pull request 'docs(#277): add shared i18n parity artifact' (#282) from feat/issue-277-i18n-parity-report into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
Some checks failed
CI / test-and-quality (push) Has been cancelled
This commit was merged in pull request #282.
This commit is contained in:
80
docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md
Normal file
80
docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md
Normal file
@@ -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`
|
||||
- `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-<nr>-<slug>-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
|
||||
python3 scripts/check_i18n_parity_artifact.py
|
||||
```
|
||||
214
scripts/build_i18n_parity_report.py
Normal file
214
scripts/build_i18n_parity_report.py
Normal file
@@ -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())
|
||||
36
scripts/check_i18n_parity_artifact.py
Normal file
36
scripts/check_i18n_parity_artifact.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Guard issue #277 parity artifact against non-deterministic regeneration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
ARTIFACT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md"
|
||||
REPORT_SCRIPT = REPO_ROOT / "scripts" / "report_i18n_parity.py"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
before = sha256(ARTIFACT_PATH)
|
||||
|
||||
for run in range(1, 3):
|
||||
subprocess.run([sys.executable, str(REPORT_SCRIPT)], cwd=REPO_ROOT, check=True)
|
||||
after = sha256(ARTIFACT_PATH)
|
||||
if after != before:
|
||||
raise SystemExit(
|
||||
f"issue #277 parity artifact is not deterministic after run {run}: {before} != {after}"
|
||||
)
|
||||
|
||||
print(f"issue #277 parity artifact deterministic: {before}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
196
scripts/report_i18n_parity.py
Normal file
196
scripts/report_i18n_parity.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/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-<nr>-<slug>-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("python3 scripts/check_i18n_parity_artifact.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())
|
||||
212
shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json
Normal file
212
shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user