#!/usr/bin/env python3 # -*- coding: utf-8 -*- """check-copy-i18n.py — the catalog's copy is frozen in Hungarian and sound in English. Run from the repo root (or through `scripts/catalog_gates.py`, which is the entry point): python3 scripts/check-copy-i18n.py # every app python3 scripts/check-copy-i18n.py privatebin # named apps only (FREEZE still runs on all) python3 scripts/check-copy-i18n.py --capture-freeze # (re)write the freeze from disk python3 scripts/check-copy-i18n.py --add-app NAME --reason "..." # admit a NEW app Exit 0 clean · 1 convicted · 2 INCONCLUSIVE (freeze or templates missing) — never a pass. ──────────────────────────────────────────────────────────────────────────────────────────────── WHY THIS GATE EXISTS (localisation slice 5, R-560; design: felhom.eu/.../10-localisation.md §7). The catalog carries 1 032 customer-facing strings across 53 apps — 832 of them with a Hungarian letter in them. Slice 5 adds an English twin for each, as an `i18n: {en: …}` block inside the SAME `.felhom.yml`. Two things can go wrong, and neither is visible by reading a diff: 1. **A Hungarian byte moves.** The product's first rule is that a household who never switches language cannot tell a localisation release happened (§1). A translator "fixing a typo while they are in there" breaks that silently — the Hungarian page renders, it just renders something nobody signed off. CHECK 1 compares every Hungarian copy string against `copy_freeze/hu.json`, captured before the first translation. 2. **The English is not English, or is not the same app.** An accented word left behind, a „Jelentkezz be" that has no accents and therefore hides from every accent-based search, an `env_var` invented in the translation, a credential rewritten into something that does not log in, a retrieval promise the Hungarian never made. CHECKS 2-4. WHAT IS *NOT* CHECKED, AND IS THE REVIEWER'S JOB: whether the English says the same thing as the Hungarian, and whether an app's „first steps" match that app's real English screens. No gate can answer either. The second is listed per app in the session REPORT as "unverified UI labels". HUNGARIAN IS MATCHED BY ASCII-FOLDED STEMS (the workspace rule — an accented pattern returns a false 0 through an ssh/pct chain), and every run prints a POSITIVE and a NEGATIVE control for the matcher. """ import io import json import os import re import sys import unicodedata ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TEMPLATES = os.path.join(ROOT, "templates") FREEZE_PATH = os.path.join(ROOT, "scripts", "copy_freeze", "hu.json") def use_root(path): """Point the gate at another checkout. `scripts/test_gate_decoys.py` runs THIS script against a scratch clone; without this it would read the real tree and judge the wrong files — the "constant-for-measurement" decoy shape, committed by the gate itself.""" global ROOT, TEMPLATES, FREEZE_PATH ROOT = os.path.abspath(path) TEMPLATES = os.path.join(ROOT, "templates") FREEZE_PATH = os.path.join(ROOT, "scripts", "copy_freeze", "hu.json") SUPPORTED_LANGS = ("en",) # ── The ratchet ────────────────────────────────────────────────────────────────────────────────── # # How many copy strings still have no English. It convicts ABOVE (a push that translated nothing it # claimed to) *and* BELOW (a push that translated more than it lowered the ceiling for) — a ratchet # that cannot be loosened by forgetting it, the same shape as the controller's EN_MISSING_CEILING # and HU_FORMAL_CEILING. # # Measured on 94bc5febaca2, before any translation: 1 032 copy strings, none with English. # 1032 → 943 (pilot) → 624 (batch 1) → 307 (batch 2) → 1 (batch 3, 16 apps) — ALL 53 APPS DONE # # THE FLOOR IS 1, NOT 0, UNTIL R-593 IS FIXED. papra's `deploy_fields[AUTH_SECRET].description` # is a Hungarian DEFECT — it describes a session-signing key as „the app's subdomain". A # localisation release may not change Hungarian bytes, and translating a wrong sentence faithfully # would ship the error in a second language, so that ONE field is deliberately left untranslated # and falls back to the Hungarian. Fixing R-593 is what lets this reach 0. EN_MISSING_CEILING = 1 # ── What counts as COPY ────────────────────────────────────────────────────────────────────────── # # Measured, not assumed: a YAML walk of all 53 files on 94bc5febaca2 found copy in exactly these # places and nowhere else. `display_name` is NOT copy — an app's name is not translated (operator # ruling 7). `docs_url`, `help_url`, every `env_var`, `type`, `default`, `generate`, `path`, `role`, # image, port and healthcheck field is configuration. # # NOTE against the task spec that commissioned this gate: it listed `deploy_fields[].placeholder` # nowhere, and there are 13 of them (all `/mnt/felhom-drives/hdd_1`, no Hungarian). They are copy — # a placeholder is shown to the customer — so they are frozen and translatable here. def fold(s): s = unicodedata.normalize("NFKD", s) return "".join(c for c in s if not unicodedata.combining(c)).lower() HU_LETTER = re.compile(r"[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]") # ASCII-only Hungarian. THIS LIST IS THE REASON THE GATE IS NOT JUST AN ACCENT SCAN: 200 of the # catalog's 1 032 copy strings carry no accent at all, and ~120 of those are Hungarian — # „Aldomain" (53×), „A szerver domain neve" (53×), „Jelentkezz be: …" (5×), „Magyar", „Angol", # „Titkos kulcs", „Oszd meg a linket", „Csatlakoztasd …". An accent-only check passes every one of # them in an English block. (The task spec said there were THREE such strings, „Igen"/„Nem"/„Nincs"; # measured, those three do not occur in the catalog at all and the real count is ~120.) HU_ASCII_STEMS = [ "aldomain", "a szerver domain", "jelentkezz be", "oszd meg", "csatlakoztasd", "nyisd meg", "ird be", "allitsd be", "kattints", "hozz letre", "valaszd", "masold", "regisztralj", "magyar", "angol", "titkos kulcs", "felhasznalonev", "jelszo", "nyelv", "kompatibilis", "beallitas", "alkalmazas", "szerver", "fajl", "mappa", "megosztas", "mentes", ] # Second person, plain — no begging. Same rule and same regex as the controller's bundle gate. EN_FORBIDDEN = re.compile(r"\b(please|kindly)\b", re.I) # The retrieval promise, English half. Copied from felhom.eu/scripts/customer_copy_vocab.py # (RETRIEVAL_STEMS_EN) rather than imported: this repo has no dependency on the sibling clone and a # gate that is INCONCLUSIVE whenever a sibling is missing gets bypassed. DRIFT IS CHECKED — the # gate reads the sibling when it is present and fails if the two lists disagree. _ADV = r"(?:\s+\w+ly|\s+still|\s+always|\s+then)?" RETRIEVAL_STEMS_EN = [ r"\b(?:can|could|will\s+be\s+able\s+to|are\s+able\s+to|is\s+able\s+to)" + _ADV + r"(?:\s+be)?" + _ADV + r"\s+(?:restor|recover|retriev|un-?seal|open)", r"\b(?:can|could|will)" + _ADV + r"(?:\s+be\s+able\s+to)?" + _ADV + r"\s+(?:get|bring|have)\s+\w+\s+back", r"\b(?:restorable|recoverable|retrievable)\b", r"\b(?:are|is|remain|remains|stay|stays)" + _ADV + r"\s+(?:restor|recover|retriev)able", ] RETRIEVAL_STEMS_HU = ["visszaállíthat", "visszaszerezhet", "visszahozhat", "visszanyit"] # A credential token must survive translation byte for byte — the words around it are copy, the # value is a login. Anything with an @, a slash, an underscore or a digit in it, plus the four # account words that are values rather than prose. CRED_TOKEN = re.compile(r"[^\s,;]*[@/_0-9][^\s,;]*") CRED_WORDS = ("admin", "root", "password", "user") # ── The walker ─────────────────────────────────────────────────────────────────────────────────── def copy_strings(meta): """Every customer-facing string of one parsed .felhom.yml, as {dotted path: value}. The path is the KEY-MATCHED address the controller's overlay uses — `deploy_fields[SUBDOMAIN]`, not `deploy_fields[1]` — so inserting a field above another does not renumber the freeze and produce 40 spurious convictions. """ out = {} def add(path, val): if isinstance(val, str): out[path] = val add("description", meta.get("description")) ai = meta.get("app_info") or {} add("app_info.tagline", ai.get("tagline")) add("app_info.default_creds", ai.get("default_creds")) add("app_info.add_people", ai.get("add_people")) # decision 47 (controller >= 0.281.0): how to add a family member for k in ("use_cases", "first_steps", "prerequisites"): for i, v in enumerate(ai.get(k) or []): add("app_info.%s[%d]" % (k, i), v) for i, f in enumerate(meta.get("deploy_fields") or []): key = f.get("env_var") or "#%d" % i for fld in ("label", "description", "placeholder"): add("deploy_fields[%s].%s" % (key, fld), f.get(fld)) for o in f.get("options") or []: add("deploy_fields[%s].options[%s].label" % (key, o.get("value")), o.get("label")) for g in meta.get("optional_config") or []: gk = g.get("group") or "#" add("optional_config[%s].group" % gk, g.get("group")) add("optional_config[%s].description" % gk, g.get("description")) for f in g.get("fields") or []: key = f.get("env_var") or "#" for fld in ("label", "help_text"): add("optional_config[%s].fields[%s].%s" % (gk, key, fld), f.get(fld)) for ig in meta.get("integrations") or []: t = ig.get("target") or "#" add("integrations[%s].label" % t, ig.get("label")) add("integrations[%s].description" % t, ig.get("description")) for dp in meta.get("data_paths") or []: p = dp.get("path") or "#" add("data_paths[%s].label" % p, dp.get("label")) ic = meta.get("initial_credentials") or {} add("initial_credentials.note", ic.get("note")) return out def overlay_strings(ov, hu_meta): """Every string of one language's `i18n` block, addressed the SAME WAY as copy_strings. Returned as (paths_to_values, structural_errors). A structural error is an entry the controller could not match — an `env_var` with no Hungarian twin, an unknown key, a wrong type — and it is a conviction rather than a warning: such an entry is silently inert on a box, which is the worst of both worlds (the translator believes it shipped). """ errs = [] out = {} if not isinstance(ov, dict): return out, ["i18n block is not a mapping"] hu_fields = {f.get("env_var"): f for f in (hu_meta.get("deploy_fields") or [])} hu_groups = {g.get("group"): g for g in (hu_meta.get("optional_config") or [])} hu_targets = {i.get("target") for i in (hu_meta.get("integrations") or [])} hu_paths = {d.get("path") for d in (hu_meta.get("data_paths") or [])} def scalar(path, val, where): if val is None: return if not isinstance(val, str): errs.append("%s must be a string, got %s" % (where, type(val).__name__)) return out[path] = val for k in ov: if k not in ("description", "app_info", "deploy_fields", "optional_config", "integrations", "data_paths", "initial_credentials"): errs.append("unknown key %r in the English block — only copy fields may be translated" % k) scalar("description", ov.get("description"), "description") ai = ov.get("app_info") if ai is not None: if not isinstance(ai, dict): errs.append("app_info must be a mapping") else: for k in ai: if k not in ("tagline", "default_creds", "add_people", "use_cases", "first_steps", "prerequisites"): errs.append("unknown key app_info.%s — `docs_url` and the rest are not copy" % k) scalar("app_info.tagline", ai.get("tagline"), "app_info.tagline") scalar("app_info.default_creds", ai.get("default_creds"), "app_info.default_creds") scalar("app_info.add_people", ai.get("add_people"), "app_info.add_people") hu_ai = hu_meta.get("app_info") or {} for k in ("use_cases", "first_steps", "prerequisites"): lst = ai.get(k) if lst is None: continue if not isinstance(lst, list): errs.append("app_info.%s must be a list" % k) continue for i, v in enumerate(lst): scalar("app_info.%s[%d]" % (k, i), v, "app_info.%s[%d]" % (k, i)) hu_len = len(hu_ai.get(k) or []) if len(lst) != hu_len: errs.append("app_info.%s has %d entries, Hungarian has %d — a list is replaced " "WHOLE, so the counts must match (or the block needs a preceding " "`# en: %s differ because …` comment)" % (k, len(lst), hu_len, k)) df = ov.get("deploy_fields") if df is not None: if not isinstance(df, list): errs.append("deploy_fields must be a list") else: for f in df: if not isinstance(f, dict): errs.append("a deploy_fields entry is not a mapping") continue key = f.get("env_var") if not key: errs.append("a deploy_fields entry has no env_var — entries are matched by key") continue if key not in hu_fields: errs.append("deploy_fields[%s] has no Hungarian twin — it would be INERT" % key) continue for k in f: if k not in ("env_var", "label", "description", "placeholder", "options"): errs.append("unknown key deploy_fields[%s].%s — only copy is translatable" % (key, k)) for fld in ("label", "description", "placeholder"): scalar("deploy_fields[%s].%s" % (key, fld), f.get(fld), "deploy_fields[%s].%s" % (key, fld)) hu_opts = {o.get("value") for o in (hu_fields[key].get("options") or [])} for o in f.get("options") or []: if not isinstance(o, dict) or not o.get("value"): errs.append("deploy_fields[%s]: an option has no value" % key) continue if o["value"] not in hu_opts: errs.append("deploy_fields[%s].options[%s] has no Hungarian twin" % (key, o["value"])) continue scalar("deploy_fields[%s].options[%s].label" % (key, o["value"]), o.get("label"), "deploy_fields[%s].options[%s].label" % (key, o["value"])) oc = ov.get("optional_config") if oc is not None: if not isinstance(oc, list): errs.append("optional_config must be a list") else: for g in oc: if not isinstance(g, dict): errs.append("an optional_config entry is not a mapping") continue mg = g.get("match_group") if not mg: errs.append("an optional_config entry has no match_group — a group has no other " "identity, so the Hungarian group name it translates must be named") continue if mg not in hu_groups: errs.append("optional_config match_group %r has no Hungarian twin" % mg) continue for k in g: if k not in ("match_group", "group", "description", "fields"): errs.append("unknown key optional_config[%s].%s" % (mg, k)) scalar("optional_config[%s].group" % mg, g.get("group"), "optional_config group") scalar("optional_config[%s].description" % mg, g.get("description"), "optional_config description") hu_f = {f.get("env_var") for f in (hu_groups[mg].get("fields") or [])} for f in g.get("fields") or []: if not isinstance(f, dict) or not f.get("env_var"): errs.append("optional_config[%s]: a field has no env_var" % mg) continue if f["env_var"] not in hu_f: errs.append("optional_config[%s].fields[%s] has no Hungarian twin" % (mg, f["env_var"])) continue for fld in ("label", "help_text"): scalar("optional_config[%s].fields[%s].%s" % (mg, f["env_var"], fld), f.get(fld), "optional_config field %s" % fld) ints = ov.get("integrations") if ints is not None: for i in ints if isinstance(ints, list) else []: if not isinstance(i, dict) or not i.get("target"): errs.append("an integrations entry has no target") continue if i["target"] not in hu_targets: errs.append("integrations[%s] has no Hungarian twin" % i["target"]) continue for fld in ("label", "description"): scalar("integrations[%s].%s" % (i["target"], fld), i.get(fld), "integration %s" % fld) dps = ov.get("data_paths") if dps is not None: for d in dps if isinstance(dps, list) else []: if not isinstance(d, dict) or not d.get("path"): errs.append("a data_paths entry has no path") continue if d["path"] not in hu_paths: errs.append("data_paths[%s] has no Hungarian twin" % d["path"]) continue scalar("data_paths[%s].label" % d["path"], d.get("label"), "data_path label") icv = ov.get("initial_credentials") if icv is not None: if not isinstance(icv, dict): errs.append("initial_credentials must be a mapping") else: for k in icv: if k != "note": errs.append("unknown key initial_credentials.%s — only the note is copy" % k) scalar("initial_credentials.note", icv.get("note"), "initial_credentials.note") return out, errs # ── Loading ────────────────────────────────────────────────────────────────────────────────────── def have_yaml(): """PyYAML is NOT on the CI runner, and that is deliberate, not an oversight. `.gitea/workflows/gates.yml` says the runner is "a host-mode container with python3 and git and nothing else", and every gate written before this one uses only the standard library. This gate did not, so its first six pushes each turned CI red and sent an alarm mail while the local pre-push hook was green — the exact disagreement that mail warns about, and it outranks whatever the push was for. See DEGRADED MODE below for what runs instead.""" try: import yaml # noqa: F401 return True except ImportError: return False def load_yaml(path): import yaml with io.open(path, encoding="utf-8") as fh: return yaml.safe_load(fh) def freeze_only_check(freeze, apps, fails): """DEGRADED MODE — the freeze, checked without a YAML parser. It asserts the ONE property that matters most and needs no parse: every frozen Hungarian string still occurs VERBATIM in that app's `.felhom.yml` bytes. A changed, reworded or deleted Hungarian string fails here exactly as it does in full mode. What it CANNOT do, stated rather than implied: it cannot see WHERE a string sits, so a string moved to another field passes; and it cannot read the English block at all, so structure, language, credentials and the coverage ratchet are not checked. Those run in the pre-push hook, which is where they bite anyway — the same division `catalog_gates.py` already uses for engine-major on a shallow clone. MEASURED 2026-09-20: 1 030 of the 1 032 frozen strings appear byte-for-byte in the raw file. The other two are romm help_texts whose YAML source escapes an inner double quote, so the escaped form is accepted too — with that, 1 032 of 1 032 are found. The variant is NOT a loosening: it is the same characters, written the way YAML requires inside a double-quoted scalar. """ for app in apps: frozen = (freeze.get("apps") or {}).get(app) if frozen is None: fails.append("%s: not in the freeze. A NEW app's Hungarian has never been reviewed — " "run `python3 scripts/check-copy-i18n.py --add-app %s --reason \"…\"` in " "the same commit." % (app, app)) continue raw = io.open(os.path.join(TEMPLATES, app, ".felhom.yml"), encoding="utf-8").read() for path, val in sorted(frozen.items()): if val in raw or val.replace('"', '\\"') in raw: continue fails.append("%s: the frozen Hungarian at %s is no longer in the file: %r" % (app, path, val)) def app_dirs(): return sorted(d for d in os.listdir(TEMPLATES) if os.path.isdir(os.path.join(TEMPLATES, d)) and os.path.exists(os.path.join(TEMPLATES, d, ".felhom.yml"))) def read_freeze(): if not os.path.exists(FREEZE_PATH): return None with io.open(FREEZE_PATH, encoding="utf-8") as fh: return json.load(fh) def write_freeze(data): os.makedirs(os.path.dirname(FREEZE_PATH), exist_ok=True) with io.open(FREEZE_PATH, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True) fh.write("\n") def capture(reasons=None): apps = {} for app in app_dirs(): apps[app] = copy_strings(load_yaml(os.path.join(TEMPLATES, app, ".felhom.yml"))) return { "_what": "Every customer-facing Hungarian string in the catalog, frozen. See " "scripts/check-copy-i18n.py. A value here may only change when the Hungarian " "itself is deliberately rewritten — never by a translation.", "_captured_from": "app-catalog-felhom.eu@94bc5febaca2 (2026-09-20), before localisation slice 5", "reasons": reasons or {}, "apps": apps, } # ── The checks ─────────────────────────────────────────────────────────────────────────────────── def check_language(app, path, val, hu_val, display_name, fails): where = "%s / i18n.en.%s" % (app, path) if HU_LETTER.search(val): fails.append("%s: an accented Hungarian letter in the English text: %r" % (where, val)) folded = fold(val) for stem in HU_ASCII_STEMS: # WORD BOUNDARIES. A bare substring test convicts honest English: „ird be" is inside # "the third best", „angol" is inside "Angola". The stems are words, so they are matched # as words — the negative control below is the case that found it. if re.search(r"(?= 3} want |= {w for w in CRED_WORDS if re.search(r"\b%s\b" % w, hu_val, re.I)} for tok in sorted(want): # WORD BOUNDARIES, and they are load-bearing: the first version searched for the token # as a bare substring, and „admin" -> "administrator" passed it. A login is the whole # word or it is a different login. if not re.search(r"(?= 1 and not a.startswith("-") and i not in consumed] # Controls for the ASCII-folded matcher, printed every run (workspace rule). if "jelentkezz be" not in fold("Jelentkezz be: admin"): print("copy-i18n INCONCLUSIVE: positive control failed — folding does not strip accents") return 2 for control in ("Encrypted notes and text sharing", "The third best option is in Angola", # „ird be" / „angol" as substrings "These documents are kept on the server"): # „mentes" / „szerver" as substrings if any(re.search(r"(? ceiling else "BELOW" fails.append( "English coverage: %d strings have no English, ceiling is %d (%s).\n" " %s" % (en_missing_total, ceiling, direction, "Translate the rest, or lower EN_MISSING_CEILING in this file to %d in the " "SAME commit — the ceiling records what was actually pushed." % en_missing_total if direction == "BELOW" else "A push claimed translations it did not make.")) if fails: print("\ncopy-i18n: FAIL — %d finding(s)\n" % len(fails)) for f in fails: print(" - %s" % f) return 1 print("copy-i18n: OK") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))