#!/usr/bin/env python3 """Verify every frontend locale file structurally matches ``en.json``. The frontend declares ``type Messages = typeof en`` and ``const allMessages: Record``, so a locale JSON that is *missing* a key present in ``en.json`` - or that has a key of a *different shape* (an object where ``en`` has a string, or vice versa) - makes ``tsc`` / ``next build`` fail. That is exactly the break that shipped to ``main`` and only surfaced (post-merge) inside the Docker publish job - the incident that motivated the testing work. This check reproduces and prevents it with **pure stdlib** - no Node, npm, or nvm - so it runs fast inside the local pre-push hook regardless of shell setup. Exit code: 0 = all locales match; 1 = at least one locale is missing keys, has a key whose shape (leaf vs. object) differs from ``en``, or is not valid JSON. Extra keys (present in a locale but not in ``en``) are reported as warnings only (they don't break the ``typeof en`` build, but signal drift worth cleaning up). """ from __future__ import annotations import json import sys from pathlib import Path from typing import Any MESSAGES_DIR = Path(__file__).resolve().parents[1] / "apps" / "frontend" / "messages" REFERENCE = "en.json" def _node_kind(value: Any) -> str: """Classify a JSON value by the type ``typeof en`` would infer for it. ``bool`` is checked before ``int`` because ``bool`` is a subclass of ``int`` in Python (``isinstance(True, int)`` is ``True``). """ if isinstance(value, dict): return "object" if isinstance(value, list): return "array" if isinstance(value, bool): return "boolean" if isinstance(value, (int, float)): return "number" if value is None: return "null" return "string" def key_kinds(obj: Any, prefix: str = "") -> dict[str, str]: """Map every dotted key path to its JSON kind. Comparing kinds - not just key presence - is what catches a locale whose ``a.b`` is an object (or a number, or an array) where ``en`` has a string: the key path is still present, so a presence-only check passes, yet it still breaks ``next build`` because the locale is no longer assignable to ``typeof en``. Tracking the full JSON type (rather than a coarse branch/leaf) catches primitive/array mismatches too, and keeps this in lock- step with the frontend in-suite guard. Recursion descends into objects only (the only container these message files nest with). """ kinds: dict[str, str] = {} if isinstance(obj, dict): for key, value in obj.items(): path = f"{prefix}.{key}" if prefix else key kinds[path] = _node_kind(value) kinds.update(key_kinds(value, path)) return kinds def _load(path: Path) -> dict[str, Any]: """Parse a locale JSON file; raise ``ValueError`` with a clean message on bad JSON.""" try: return json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValueError(f"{path.name} is not valid JSON: {exc}") from exc def main(argv: list[str]) -> int: # Optional argv[1] overrides the messages dir (used by tests / the hook). messages_dir = Path(argv[1]).resolve() if len(argv) > 1 else MESSAGES_DIR reference_file = messages_dir / REFERENCE if not reference_file.exists(): print(f"locale-parity: reference {reference_file} not found", file=sys.stderr) return 1 try: reference_kinds = key_kinds(_load(reference_file)) except ValueError as exc: print(f"locale-parity: error: {exc}", file=sys.stderr) return 1 reference_paths = set(reference_kinds) failed = False for path in sorted(messages_dir.glob("*.json")): if path.name == REFERENCE: continue try: locale_kinds = key_kinds(_load(path)) except ValueError as exc: failed = True print(f"locale-parity: error: {exc}", file=sys.stderr) continue locale_paths = set(locale_kinds) missing = reference_paths - locale_paths extra = locale_paths - reference_paths mismatched = { p for p in reference_paths & locale_paths if reference_kinds[p] != locale_kinds[p] } if missing: failed = True print(f"locale-parity: error: {path.name} is MISSING keys from {REFERENCE}:", file=sys.stderr) for key in sorted(missing): print(f" missing: {key}", file=sys.stderr) if mismatched: failed = True print( f"locale-parity: error: {path.name} has keys whose JSON type differs " f"from {REFERENCE}; this breaks `next build`:", file=sys.stderr, ) for key in sorted(mismatched): print( f" type-mismatch: {key} " f"({REFERENCE}={reference_kinds[key]}, {path.name}={locale_kinds[key]})", file=sys.stderr, ) if extra: print( f"locale-parity: warning: {path.name} has extra keys not in {REFERENCE} (non-fatal):", file=sys.stderr, ) for key in sorted(extra): print(f" extra: {key}", file=sys.stderr) if failed: print( "locale-parity: locale files are out of sync with en.json; this would " "break `next build` (type Messages = typeof en). Fix the missing / " "mismatched keys.", file=sys.stderr, ) return 1 print("locale-parity: ok all locale files match en.json") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))