| 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" / "tui" / "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 | PARTIAL_PACKS = { |
| 37 | "zh-Hant": "#4057", # Setup core only; English fallback for the rest. |
| 38 | } |
| 39 | |
| 40 | PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}") |
| 41 | |
| 42 | |
| 43 | def load_pack(path: Path) -> dict: |
| 44 | try: |
| 45 | data = json.loads(path.read_text(encoding="utf-8")) |
| 46 | except (OSError, json.JSONDecodeError) as exc: |
| 47 | print(f"[tui-locale-parity] FAIL — {path.name}: unreadable JSON: {exc}") |
| 48 | sys.exit(1) |
| 49 | if not isinstance(data, dict) or not all( |
| 50 | isinstance(k, str) and isinstance(v, str) for k, v in data.items() |
| 51 | ): |
| 52 | print(f"[tui-locale-parity] FAIL — {path.name}: must be a flat string map") |
| 53 | sys.exit(1) |
| 54 | return data |
| 55 | |
| 56 | |
| 57 | def placeholders(value: str) -> set: |
| 58 | return set(PLACEHOLDER_RE.findall(value)) |
| 59 | |
| 60 | |
| 61 | def main() -> int: |
| 62 | ref_path = LOCALES_DIR / f"{REFERENCE}.json" |
| 63 | if not ref_path.is_file(): |
| 64 | print(f"[tui-locale-parity] FAIL — reference pack {ref_path} missing") |
| 65 | return 1 |
| 66 | reference = load_pack(ref_path) |
| 67 | ref_keys = set(reference) |
| 68 | print(f"[tui-locale-parity] reference {REFERENCE}.json: {len(ref_keys)} keys") |
| 69 | |
| 70 | failures = [] |
| 71 | pack_files = sorted( |
| 72 | p for p in LOCALES_DIR.glob("*.json") if p.stem != REFERENCE |
| 73 | ) |
| 74 | for path in pack_files: |
| 75 | tag = path.stem |
| 76 | pack = load_pack(path) |
| 77 | keys = set(pack) |
| 78 | partial_issue = PARTIAL_PACKS.get(tag) |
| 79 | |
| 80 | missing = sorted(ref_keys - keys) |
| 81 | extra = sorted(keys - ref_keys) |
| 82 | |
| 83 | if extra: |
| 84 | failures.append( |
| 85 | f"{tag}: defines {len(extra)} key(s) {REFERENCE}.json lacks: {extra[:10]}" |
| 86 | ) |
| 87 | if partial_issue: |
| 88 | print( |
| 89 | f"[tui-locale-parity] {tag}: {len(keys)}/{len(ref_keys)} keys " |
| 90 | f"(declared partial, {partial_issue})" |
| 91 | ) |
| 92 | else: |
| 93 | if missing: |
| 94 | failures.append( |
| 95 | f"{tag}: claims completeness but lacks {len(missing)} key(s); " |
| 96 | f"the English fallback hides these at runtime: {missing[:10]}" |
| 97 | ) |
| 98 | # Placeholder parity only makes sense on complete packs: a |
| 99 | # partial pack legitimately omits keys wholesale. |
| 100 | for key in sorted(ref_keys & keys): |
| 101 | ref_ph = placeholders(reference[key]) |
| 102 | if placeholders(pack[key]) != ref_ph: |
| 103 | failures.append( |
| 104 | f"{tag}: {key} changed placeholders " |
| 105 | f"(expected {sorted(ref_ph)}, got {sorted(placeholders(pack[key]))})" |
| 106 | ) |
| 107 | if not missing: |
| 108 | print(f"[tui-locale-parity] {tag}: {len(keys)}/{len(ref_keys)} keys — complete") |
| 109 | |
| 110 | if failures: |
| 111 | print("[tui-locale-parity] FAIL") |
| 112 | for failure in failures: |
| 113 | print(f" - {failure}") |
| 114 | return 1 |
| 115 | print("[tui-locale-parity] PASS") |
| 116 | return 0 |
| 117 | |
| 118 | |
| 119 | if __name__ == "__main__": |
| 120 | sys.exit(main()) |
| 121 |