feat(i18n): add shared key manifest and drift check script
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m33s

This commit is contained in:
2026-03-02 00:12:17 +00:00
parent a1bb1ccbed
commit f28a390f95
3 changed files with 226 additions and 0 deletions

34
docs/i18n-drift-check.md Normal file
View File

@@ -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.

132
scripts/check_i18n_drift.py Executable file
View File

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

View File

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