Files
app-catalog-felhom.eu/scripts/check-copy-i18n.py
T
admin 5fe70d1f8a
gates / gates (push) Failing after 2s
English copy — BATCH 2 of 3: 17 apps, 317 strings (R-560 slice 5)
audiobookshelf, code-server, crafty-controller, glance, gokapi, gramps-web,
jellyfin, mealie, navidrome, plex, sonarr, tandoor, termix, uptime-kuma, vikunja,
wger, wishlist. EN_MISSING_CEILING 624 -> 307.

Two lines needed judgement rather than translation, and both are written up in the
CHANGELOG so a later reader does not take them for slips. Jellyfin's setup step tells
the reader to pick Hungarian in the wizard — wrong advice for an English household,
so the English says "choose your language". Mealie's "Hungarian is available too"
becomes "English and Hungarian among them". Neither adds a promise the Hungarian does
not make; the Hungarian is untouched in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 16:32:18 +02:00

594 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, 17 apps) → 307 (batch 2, 17 apps) → (batch 3)
#
# 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 = 307
# ── 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"))
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", "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")
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 load_yaml(path):
import yaml
with io.open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh)
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"(?<![a-z0-9])%s(?![a-z0-9])" % re.escape(stem), folded):
fails.append("%s: ASCII-only Hungarian %r in the English text: %r" % (where, stem, val))
break
if EN_FORBIDDEN.search(val):
fails.append("%s: the product does not beg — no \"please\"/\"kindly\": %r" % (where, val))
for pat in RETRIEVAL_STEMS_EN:
if re.search(pat, val, re.I):
hu_promises = any(s in fold(hu_val) for s in [fold(x) for x in RETRIEVAL_STEMS_HU])
if not hu_promises:
fails.append("%s: an English retrieval promise the Hungarian does not make: %r"
% (where, val))
break
if "Felhom" in hu_val and "Felhom" not in val:
fails.append("%s: the product name „Felhom\" was dropped in translation: %r" % (where, val))
if display_name and display_name in hu_val and display_name not in val:
fails.append("%s: the app name %r was dropped or translated: %r" % (where, display_name, val))
# Credentials survive verbatim — the words around them are copy, the value is a login.
if path.endswith("default_creds") or path.endswith("initial_credentials.note"):
want = {t for t in CRED_TOKEN.findall(hu_val) if len(t) >= 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"(?<![A-Za-z0-9])%s(?![A-Za-z0-9])" % re.escape(tok), val, re.I):
fails.append("%s: the credential token %r is not in the English text: %r"
% (where, tok, val))
def main(argv):
if "--root" in argv:
use_root(argv[argv.index("--root") + 1])
# --expect-missing is for `scripts/test_gate_decoys.py` ONLY: a decoy case that adds a correct
# English block would otherwise trip the ratchet and look like a conviction. The gate run by
# `catalog_gates.py` never passes it, so the ratchet is untouched in every real run.
ceiling = EN_MISSING_CEILING
if "--expect-missing" in argv:
ceiling = int(argv[argv.index("--expect-missing") + 1])
if "--capture-freeze" in argv:
old = read_freeze() or {}
write_freeze(capture(old.get("reasons")))
print("copy-i18n: freeze written to %s (%d apps)" % (FREEZE_PATH, len(app_dirs())))
return 0
if "--add-app" in argv:
i = argv.index("--add-app")
name = argv[i + 1]
if "--reason" not in argv:
print("copy-i18n: --add-app needs --reason \"…\" — a new app's Hungarian has never been "
"reviewed, and the reason is the review")
return 2
reason = argv[argv.index("--reason") + 1]
fr = read_freeze() or capture()
fr.setdefault("reasons", {})[name] = reason
fr["apps"][name] = copy_strings(load_yaml(os.path.join(TEMPLATES, name, ".felhom.yml")))
write_freeze(fr)
print("copy-i18n: %s admitted to the freeze — %s" % (name, reason))
return 0
# Option VALUES are not app names. --root and --expect-missing each consume the argument
# after them, and a scope list that swallowed a path would silently skip every language check.
consumed = set()
for opt in ("--root", "--expect-missing", "--range"):
if opt in argv:
consumed.add(argv.index(opt) + 1)
scope = [a for i, a in enumerate(argv) if i >= 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"(?<![a-z0-9])%s(?![a-z0-9])" % re.escape(st), fold(control))
for st in HU_ASCII_STEMS):
print("copy-i18n INCONCLUSIVE: negative control failed on %r — a clean English "
"sentence convicted" % control)
return 2
print("copy-i18n: matcher controls OK (positive „Jelentkezz be…\" convicts, "
"negative „Encrypted notes…\" does not)")
freeze = read_freeze()
if freeze is None:
print("copy-i18n INCONCLUSIVE: no freeze at %s — run --capture-freeze first" % FREEZE_PATH)
return 2
# Drift check against the sibling's shared vocabulary, when the clone is present.
sib = os.path.join(os.path.dirname(ROOT), "felhom.eu", "scripts")
if os.path.exists(os.path.join(sib, "customer_copy_vocab.py")):
sys.path.insert(0, sib)
try:
import customer_copy_vocab as vocab
if list(vocab.RETRIEVAL_STEMS_EN) != RETRIEVAL_STEMS_EN:
print("copy-i18n: FAIL — RETRIEVAL_STEMS_EN has drifted from "
"felhom.eu/scripts/customer_copy_vocab.py; copy it across")
return 1
print("copy-i18n: retrieval-promise vocabulary matches the sibling clone")
except Exception as exc: # pragma: no cover
print("copy-i18n: note — sibling vocabulary unreadable (%s); using the local copy" % exc)
fails = []
apps = app_dirs()
en_missing_total = 0
per_app = []
for app in apps:
meta = load_yaml(os.path.join(TEMPLATES, app, ".felhom.yml"))
hu = copy_strings(meta)
# CHECK 1 — the Hungarian is frozen. Runs on EVERY app whatever the scope: a scoped push
# that quietly edits a neighbour is exactly the thing worth catching.
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))
else:
for path, val in sorted(hu.items()):
if path not in frozen:
fails.append("%s: NEW Hungarian string %s = %r — the freeze does not know it"
% (app, path, val))
elif frozen[path] != val:
fails.append("%s: Hungarian CHANGED at %s\n frozen: %r\n on disk: %r"
% (app, path, frozen[path], val))
for path in sorted(frozen):
if path not in hu:
fails.append("%s: Hungarian string REMOVED at %s (%r)" % (app, path, frozen[path]))
# CHECKS 2-4 — the English block.
i18n = meta.get("i18n") or {}
en_paths = set()
if i18n:
if not isinstance(i18n, dict):
fails.append("%s: i18n is not a mapping" % app)
else:
for lang in i18n:
if lang not in SUPPORTED_LANGS:
fails.append("%s: i18n.%s — the controller renders %s only"
% (app, lang, "/".join(SUPPORTED_LANGS)))
for lang in SUPPORTED_LANGS:
if lang not in i18n:
continue
vals, errs = overlay_strings(i18n[lang], meta)
for e in errs:
fails.append("%s: %s" % (app, e))
# COVERAGE IS COUNTED FOR EVERY APP, scope or not: the ratchet is a fact
# about the whole catalog, and a scoped run that counted only the named apps
# would report a number that depends on how the gate was invoked — a ratchet
# anybody could loosen by naming one app. Only the LANGUAGE checks are scoped,
# because they are slow-ish and read only what the push touched.
for path, val in sorted(vals.items()):
en_paths.add(path)
if scope and app not in scope:
continue
check_language(app, path, val, hu.get(path, ""),
meta.get("display_name") or "", fails)
missing = [p for p in hu if p not in en_paths]
en_missing_total += len(missing)
per_app.append((app, len(hu) - len(missing), len(hu)))
print("copy-i18n: %d apps · %d Hungarian copy strings frozen · %d with English"
% (len(apps), sum(t for _a, _d, t in per_app),
sum(d for _a, d, _t in per_app)))
done = [p for p in per_app if p[1]]
if done:
print("copy-i18n: translated so far — " +
", ".join("%s %d/%d" % (a, d, t) for a, d, t in done))
# CHECK 5 — the ratchet.
if en_missing_total != ceiling:
direction = "ABOVE" if en_missing_total > 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))