diff --git a/docs/i18n-drift-check.md b/docs/i18n-drift-check.md new file mode 100644 index 0000000..d88ec0f --- /dev/null +++ b/docs/i18n-drift-check.md @@ -0,0 +1,34 @@ +# i18n key manifest + drift check + +Issue: #240 + +This repo keeps shared lobby keyspaces in two files: + +- Contract source: `shared/i18n/lobby.json` +- Key manifest: `shared/i18n/key-manifest.json` + +The manifest is intentionally small and explicit. It lists: + +- Supported locales (`locales`) +- Frontend error key set (`frontend_error_keys`) +- Backend error code set (`backend_error_codes`) +- Backend translation key set (`backend_error_keys`) +- Optional contract-only aliases (`allowed_contract_only_backend_codes`) + +## Local check + +Run the read-only drift checker from repo root: + +```bash +python3 scripts/check_i18n_drift.py +``` + +The script returns non-zero when it detects drift, including: + +- key set mismatch between manifest and shared catalog +- missing backend→frontend mapping coverage +- mapping to unknown frontend keys +- mappings for unknown backend codes +- missing/empty locale translations (`en`/`da`) + +No CI gating changes are included in this task; this is a local guardrail. diff --git a/scripts/check_i18n_drift.py b/scripts/check_i18n_drift.py new file mode 100755 index 0000000..a8bd92d --- /dev/null +++ b/scripts/check_i18n_drift.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Read-only drift check for shared i18n key coverage. + +Compares `shared/i18n/key-manifest.json` against `shared/i18n/lobby.json` and +fails fast when keyspaces drift between frontend/backend contract sections. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" +MANIFEST_PATH = REPO_ROOT / "shared" / "i18n" / "key-manifest.json" + + +def _load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _as_set(value: Any) -> set[str]: + if not isinstance(value, list): + return set() + return {str(item) for item in value} + + +def _require_translations( + errors: dict[str, Any], + locales: set[str], + label: str, + failures: list[str], +) -> None: + for key, translations in errors.items(): + if not isinstance(translations, dict): + failures.append(f"{label}.{key} must be an object of locale->message") + continue + + for locale in sorted(locales): + value = translations.get(locale) + if not isinstance(value, str) or not value.strip(): + failures.append(f"{label}.{key} missing non-empty '{locale}' translation") + + +def main() -> int: + catalog = _load_json(CATALOG_PATH) + manifest = _load_json(MANIFEST_PATH) + + failures: list[str] = [] + + manifest_locales = _as_set(manifest.get("locales")) + catalog_locales = _as_set(catalog.get("locales", {}).get("supported")) + if manifest_locales != catalog_locales: + failures.append( + "locale set mismatch: " + f"manifest={sorted(manifest_locales)} catalog={sorted(catalog_locales)}" + ) + + frontend_manifest = _as_set(manifest.get("frontend_error_keys")) + frontend_catalog = set(catalog.get("frontend", {}).get("errors", {}).keys()) + if frontend_manifest != frontend_catalog: + failures.append( + "frontend error key mismatch: " + f"manifest-only={sorted(frontend_manifest - frontend_catalog)} " + f"catalog-only={sorted(frontend_catalog - frontend_manifest)}" + ) + + backend_code_manifest = _as_set(manifest.get("backend_error_codes")) + backend_code_catalog = set(catalog.get("backend", {}).get("error_codes", {}).keys()) + if backend_code_manifest != backend_code_catalog: + failures.append( + "backend error code mismatch: " + f"manifest-only={sorted(backend_code_manifest - backend_code_catalog)} " + f"catalog-only={sorted(backend_code_catalog - backend_code_manifest)}" + ) + + backend_key_manifest = _as_set(manifest.get("backend_error_keys")) + backend_key_catalog = set(catalog.get("backend", {}).get("errors", {}).keys()) + if backend_key_manifest != backend_key_catalog: + failures.append( + "backend error key mismatch: " + f"manifest-only={sorted(backend_key_manifest - backend_key_catalog)} " + f"catalog-only={sorted(backend_key_catalog - backend_key_manifest)}" + ) + + backend_to_frontend = catalog.get("contract", {}).get("backend_to_frontend_error_keys", {}) + if not isinstance(backend_to_frontend, dict): + failures.append("contract.backend_to_frontend_error_keys must be an object") + backend_to_frontend = {} + + for code in sorted(backend_code_catalog): + frontend_key = backend_to_frontend.get(code) + if frontend_key is None: + failures.append(f"missing contract mapping for backend code '{code}'") + continue + if frontend_key not in frontend_catalog: + failures.append( + f"mapping for backend code '{code}' points to unknown frontend key '{frontend_key}'" + ) + + allowed_contract_only_codes = _as_set(manifest.get("allowed_contract_only_backend_codes")) + unknown_mapping_codes = set(backend_to_frontend.keys()) - backend_code_catalog + disallowed_unknown_mapping_codes = unknown_mapping_codes - allowed_contract_only_codes + if disallowed_unknown_mapping_codes: + failures.append( + "contract contains mappings for unknown backend codes: " + f"{sorted(disallowed_unknown_mapping_codes)}" + ) + + _require_translations(catalog.get("frontend", {}).get("errors", {}), manifest_locales, "frontend.errors", failures) + _require_translations(catalog.get("backend", {}).get("errors", {}), manifest_locales, "backend.errors", failures) + + if failures: + print("i18n drift check FAILED") + for failure in failures: + print(f" - {failure}") + return 1 + + print("i18n drift check OK") + print(f" - locales: {sorted(manifest_locales)}") + print(f" - frontend error keys: {len(frontend_catalog)}") + print(f" - backend error codes: {len(backend_code_catalog)}") + print(f" - backend error keys: {len(backend_key_catalog)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shared/i18n/key-manifest.json b/shared/i18n/key-manifest.json new file mode 100644 index 0000000..2240fb8 --- /dev/null +++ b/shared/i18n/key-manifest.json @@ -0,0 +1,60 @@ +{ + "locales": ["en", "da"], + "frontend_error_keys": [ + "join_failed", + "nickname_invalid", + "nickname_taken", + "session_code_required", + "session_fetch_failed", + "session_not_found", + "start_round_failed", + "unknown" + ], + "backend_error_codes": [ + "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" + ], + "allowed_contract_only_backend_codes": [ + "host_only_action" + ], + "backend_error_keys": [ + "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" + ] +}