| 1 | #!/usr/bin/env python3 |
| 2 | """check-tui-locale-parity.py — CI gate against TUI locale pack drift. |
| 3 | |
| 4 | `en.json` is the reference pack. Every pack that claims completeness must |
| 5 | hold exact raw key-set parity with it in both directions, and every |
| 6 | `{named}` placeholder must survive translation (call sites substitute with |
| 7 | `.replace()`, so a dropped placeholder renders literal braces at runtime). |
| 8 | |
| 9 | Declared partial packs (PARTIAL_PACKS, mirroring `Locale::is_partial_pack()` |
| 10 | in `crates/tui/src/localization.rs`) are exempt from completeness but must |
| 11 | not define keys English lacks — an extra key in a partial pack is drift the |
| 12 | English fallback can never surface. |
| 13 | |
| 14 | This gate is the CI-visible half of the Rust parity tests |
| 15 | (`shipped_complete_packs_have_raw_key_parity_with_english`, |
| 16 | `message_id_list_english_pack_stay_in_exact_sync`). It exists so that pack |
| 17 | files on disk — including packs whose `Locale` wiring has not landed yet — |
| 18 | are held to the same contract, and so the failure is attributable to a file |
| 19 | rather than buried in a test binary. |
| 20 | |
| 21 | Exits non-zero on any parity violation. |
| 22 | """ |
| 23 | |
| 24 | import json |
| 25 | import re |
| 26 | import sys |
| 27 | from pathlib import Path |
| 28 | |
| 29 | ROOT = Path(__file__).resolve().parent.parent |
| 30 | LOCALES_DIR = ROOT / "crates" / "localization" / "locales" |
| 31 | REFERENCE = "en" |
| 32 | |
| 33 | # Packs that ship deliberately incomplete, with English fallback for the |
| 34 | # missing keys. Mirrors `Locale::is_partial_pack()`. Every entry needs an |
| 35 | # issue reference; a partial pack without a tracking issue is silent drift. |
| 36 | # Empty since #5143 brought `zh-Hant` to full `en.json` parity and |
| 37 | # `Locale::is_partial_pack()` began returning false for every shipped locale. |
| 38 | PARTIAL_PACKS: dict[str, str] = {} |
| 39 | |
| 40 | PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}") |
| 41 | |
| 42 | |
| 43 | def unique_keys(pairs: list[tuple[str, str]]) -> dict: |
| 44 | data = {} |
| 45 | for key, value in pairs: |
| 46 | if key in data: |
| 47 | raise ValueError(f"duplicate key: {key}") |
| 48 | data[key] = value |
| 49 | return data |
| 50 | |
| 51 | |
| 52 | def load_pack(path: Path) -> dict: |
| 53 | try: |
| 54 | data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_keys) |
| 55 | except (OSError, ValueError) as exc: |
| 56 | print(f"[tui-locale-parity] FAIL — {path.name}: unreadable JSON: {exc}") |
| 57 | sys.exit(1) |
| 58 | if not isinstance(data, dict) or not all( |
| 59 | isinstance(k, str) and isinstance(v, str) for k, v in data.items() |
| 60 | ): |
| 61 | print(f"[tui-locale-parity] FAIL — {path.name}: must be a flat string map") |
| 62 | sys.exit(1) |
| 63 | return data |
| 64 | |
| 65 | |
| 66 | def placeholders(value: str) -> set: |
| 67 | return set(PLACEHOLDER_RE.findall(value)) |
| 68 | |
| 69 | |
| 70 | def main() -> int: |
| 71 | ref_path = LOCALES_DIR / f"{REFERENCE}.json" |
| 72 | if not ref_path.is_file(): |
| 73 | print(f"[tui-locale-parity] FAIL — reference pack {ref_path} missing") |
| 74 | return 1 |
| 75 | reference = load_pack(ref_path) |
| 76 | ref_keys = set(reference) |
| 77 | print(f"[tui-locale-parity] reference {REFERENCE}.json: {len(ref_keys)} keys") |
| 78 | |
| 79 | failures = [] |
| 80 | pack_files = sorted( |
| 81 | p for p in LOCALES_DIR.glob("*.json") if p.stem != REFERENCE |
| 82 | ) |
| 83 | for path in pack_files: |
| 84 | tag = path.stem |
| 85 | pack = load_pack(path) |
| 86 | keys = set(pack) |
| 87 | partial_issue = PARTIAL_PACKS.get(tag) |
| 88 | |
| 89 | missing = sorted(ref_keys - keys) |
| 90 | extra = sorted(keys - ref_keys) |
| 91 | |
| 92 | if extra: |
| 93 | failures.append( |
| 94 | f"{tag}: defines {len(extra)} key(s) {REFERENCE}.json lacks: {extra[:10]}" |
| 95 | ) |
| 96 | if partial_issue: |
| 97 | print( |
| 98 | f"[tui-locale-parity] {tag}: {len(keys)}/{len(ref_keys)} keys " |
| 99 | f"(declared partial, {partial_issue})" |
| 100 | ) |
| 101 | else: |
| 102 | if missing: |
| 103 | failures.append( |
| 104 | f"{tag}: claims completeness but lacks {len(missing)} key(s); " |
| 105 | f"the English fallback hides these at runtime: {missing[:10]}" |
| 106 | ) |
| 107 | # Placeholder parity only makes sense on complete packs: a |
| 108 | # partial pack legitimately omits keys wholesale. |
| 109 | for key in sorted(ref_keys & keys): |
| 110 | ref_ph = placeholders(reference[key]) |
| 111 | if placeholders(pack[key]) != ref_ph: |
| 112 | failures.append( |
| 113 | f"{tag}: {key} changed placeholders " |
| 114 | f"(expected {sorted(ref_ph)}, got {sorted(placeholders(pack[key]))})" |
| 115 | ) |
| 116 | if not missing: |
| 117 | print(f"[tui-locale-parity] {tag}: {len(keys)}/{len(ref_keys)} keys — complete") |
| 118 | |
| 119 | if failures: |
| 120 | print("[tui-locale-parity] FAIL") |
| 121 | for failure in failures: |
| 122 | print(f" - {failure}") |
| 123 | return 1 |
| 124 | print("[tui-locale-parity] PASS") |
| 125 | return 0 |
| 126 | |
| 127 | |
| 128 | if __name__ == "__main__": |
| 129 | sys.exit(main()) |
| 130 |