# -*- coding: utf-8 -*- """i18n_missing_gate.py -- the message bundles are complete, consistent and in the product's voice. Run from controller/: python3 scripts/i18n_missing_gate.py Exit 0 clean · 1 convicted · 2 inconclusive (bundles or templates not found). Design: felhom.eu/documentation/architecture/10-localisation.md. The Go tests pin the runtime (parity, fallback, parameters, JS-context safety); this gate pins the SOURCE, before a build: 1. KEYS EXIST. Every `{{T "key"}}` marker in any dashboard template names a key in hu.json. (The loader refuses an undefined key at startup; this names the key at push time instead.) 2. NO ORPHANS. Every en.json key translates a hu.json key (".one"/".other" count for their base). 3. THE ENGLISH GAP ONLY SHRINKS. The number of hu keys with no English is printed and compared with EN_MISSING_CEILING. More than the ceiling convicts (a slice converted copy and did not translate it); LESS also convicts, until the ceiling is lowered to match -- a ratchet that cannot be loosened by forgetting it. Raising it is a decision recorded in 10-localisation.md. 4. VOICE, per language. en -- second person, plain: no "please", no "kindly" (the household is not being begged). hu -- the product speaks in „te". Formal („ön") forms already in the converted copy are known debt (R-516) and COUNTED against HU_FORMAL_CEILING with the same ratchet: a new formal form convicts; fixing one requires lowering the ceiling. The Hungarian copy itself is not changed by the release that introduced this gate (parity rule), which is why this is a ratchet and not a ban. Hungarian is matched by ASCII-folded stems (the workspace rule for Hungarian search), with a positive and a negative control printed on every run. """ import io import json import os import re import sys import unicodedata HERE = os.path.dirname(os.path.abspath(__file__)) CTRL = os.path.dirname(HERE) LOCALES = os.path.join(CTRL, "internal", "i18n", "locales") TEMPLATE_ROOTS = [os.path.join(CTRL, "internal", "web", "templates")] MARKER = re.compile(r'\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}') EN_MISSING_CEILING = 0 HU_FORMAL_CEILING = 0 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0) -> 18 (v0.269.0) -> 17 (v0.283.0, the recovery wizard, R-725) -> 14 (R-516 item 4, the storage pages in the te-form) -> 13 (R-516 item 9, alert.deadapp.group „nézd meg") -> 0 (R-516 item 12, the last eleven in the te-form): the count follows the # conversion, it is not a judgement. Moving a Go literal into hu.json makes an „ön" form the gate could # not see before VISIBLE to it -- the two that arrived are `flash.login.password_changed` („kérjük") # and `alert.deadapp.group` („nézze meg"), both word-for-word what the Go code already said. Slice 2 may # not reword a Hungarian sentence (parity, 10-localisation.md s1), so the ceiling rises with the # measurement and R-516 still owns the words. # 19 (v0.268.0, R-659): `hold.update.no_whole_copy` („kérjük … ne indítsa újra és ne törölje") is the # OPERATOR's copy, given word for word in the brief of 2026-09-24 with its ruling (option A) — not reworded # here. The register question (ön vs te) stays R-516's. # 18 again (v0.269.0, `09` §3 decision 27): the operator moved that sentence to the informal voice. EN_FORBIDDEN = re.compile(r"\b(please|kindly)\b", re.I) # Formal-address stems, ASCII-folded. Each is a verb form or phrase that only the „ön" register uses. HU_FORMAL = [ "olvassa be", "kerjuk", "vegye fel", "ujratelepiti az", "importalnia", "biztosan kikapcsolja", "ellenorizze", "csatlakoztassa", "adjon hozza", "nezze meg", "kattintson", "valassza ki", # Widened 2026-10-06 (R-516, the forms the list above did not see on the debug, storage, # network-storage, security and system pages). Each was in hu.json and is now te-form. "irja be", "adja meg", "adja hozza", "adjon neki", "hozzon letre", "biztosan eltavolitja", "engedelyezze", "probalja", "valasszon", "jelezze", "jegyezze fel", "mentse el", "kapcsolja be", "hasznalja a", "akkor hasznalja", "szabaditson", "bovitse", "irjon be", "toltse", "folytatja?", "biztosan ujrainditja", "ujraindithatja", "alkalmazasai ", "tavolitsa el", "jeloljon ki", "allitsa a", "nem csatolja", "levalasztja a", "visszacsatlakoztatja", "visszaallitja a hianyzo", "erositenie", "frissitsen", "letrehozza a", ] # Third-person statements that contain a formal stem but address nobody (ASCII-folded). Removed from # a value before the stems are matched; a phrase is listed here only with the key that needs it. HU_FORMAL_NOT = [ "sora adja meg", # storage_network.ip_cim_vagy_dns_nev: "the Sharing page's line gives the address" ] def fold(s): s = unicodedata.normalize("NFKD", s) return "".join(c for c in s if not unicodedata.combining(c)).lower() def formal_text(s): f = fold(s) for phrase in HU_FORMAL_NOT: f = f.replace(phrase, " ") return f def load(lang): p = os.path.join(LOCALES, lang + ".json") if not os.path.exists(p): return None return json.load(io.open(p, encoding="utf-8")) def templates(): out = [] for root in TEMPLATE_ROOTS: for dirpath, _dirs, names in os.walk(root): for n in sorted(names): if n.endswith(".html"): out.append(os.path.join(dirpath, n)) return sorted(out) def main(): hu, en = load("hu"), load("en") tpls = templates() if hu is None or en is None or not tpls: print("i18n gate INCONCLUSIVE: bundles or templates not found under %s" % CTRL) return 2 # Controls for the ASCII-folded matcher. if "kerjuk" not in fold("Kérjük, vegye fel"): print("i18n gate INCONCLUSIVE: positive control failed (folding does not strip accents)") return 2 for informal in ("Csatlakoztasd újra a meghajtót", "Írd be, add meg, hozz létre, engedélyezd, próbáld újra. Biztosan eltávolítod?"): if any(stem in fold(informal) for stem in HU_FORMAL): print("i18n gate INCONCLUSIVE: negative control failed (an informal te-form sentence matched a formal stem)") return 2 if not any(stem in formal_text("Írja be a meghajtó nevét") for stem in HU_FORMAL): print("i18n gate INCONCLUSIVE: positive control failed (a widened stem does not match its formal form)") return 2 problems = [] used = 0 for path in tpls: rel = os.path.relpath(path, CTRL) for i, line in enumerate(io.open(path, encoding="utf-8"), 1): for m in MARKER.finditer(line): used += 1 if m.group(1) not in hu: problems.append("%s:%d marker key %r is not in hu.json" % (rel, i, m.group(1))) for k in en: base = re.sub(r"\.(one|other)$", "", k) if k not in hu and base not in hu: problems.append("en.json key %r has no Hungarian source" % k) if EN_FORBIDDEN.search(en[k]): problems.append("en.json %r says %r -- plain second person, no pleading" % (k, EN_FORBIDDEN.search(en[k]).group(0))) missing = [k for k in hu if k not in en and (k + ".other") not in en] formal = [(k, stem) for k in hu for stem in HU_FORMAL if stem in formal_text(hu[k])] print("i18n: %d markers in %d templates; hu %d keys, en %d keys" % (used, len(tpls), len(hu), len(en))) print("i18n: English missing %d (ceiling %d); Hungarian formal forms %d (ceiling %d, R-516)" % (len(missing), EN_MISSING_CEILING, len(formal), HU_FORMAL_CEILING)) for k, stem in formal: print(" formal: %s (%s)" % (k, stem)) if len(missing) > EN_MISSING_CEILING: problems.append("%d Hungarian keys have no English, ceiling is %d: %s" % (len(missing), EN_MISSING_CEILING, ", ".join(missing[:10]))) elif len(missing) < EN_MISSING_CEILING: problems.append("English gap shrank to %d -- lower EN_MISSING_CEILING from %d to match (the ratchet)" % (len(missing), EN_MISSING_CEILING)) if len(formal) > HU_FORMAL_CEILING: problems.append("%d formal (on-form) forms in hu.json, ceiling %d -- the product speaks in the te-form" % (len(formal), HU_FORMAL_CEILING)) elif len(formal) < HU_FORMAL_CEILING: problems.append("formal forms fell to %d -- lower HU_FORMAL_CEILING from %d to match (the ratchet)" % (len(formal), HU_FORMAL_CEILING)) if problems: for p in problems: print(" " + p) print("I18N GATE FAILED: %d problem(s)" % len(problems)) return 1 print("i18n gate OK") return 0 if __name__ == "__main__": sys.exit(main())