docs(#277): add shared i18n parity artifact #282

Merged
integrator-bot merged 5 commits from feat/issue-277-i18n-parity-report into main 2026-03-13 11:59:51 +01:00
2 changed files with 426 additions and 0 deletions
Showing only changes of commit e0aba3fdf6 - Show all commits

View 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())

View 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"
}
]
}
}