chore: import upstream snapshot with attribution
Publish Docker Image / publish (push) Has been cancelled
Publish Docker Image / publish (push) Has been cancelled
This commit is contained in:
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify every frontend locale file structurally matches ``en.json``.
|
||||
|
||||
The frontend declares ``type Messages = typeof en`` and
|
||||
``const allMessages: Record<Locale, Messages>``, 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))
|
||||
Reference in New Issue
Block a user