Files
weirsoe-party-protocol/scripts/check_i18n_drift.py
DEV-bot f28a390f95
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m33s
feat(i18n): add shared key manifest and drift check script
2026-03-02 00:12:17 +00:00

133 lines
4.9 KiB
Python
Executable File

#!/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())