5270bad76e
gates / gates (push) Successful in 23s
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still Hungarian literals in Go, so an English household clicked an English button and was answered in Hungarian. 226 of them move into the bundle here. A flash was the hard part: it travels inside the redirect URL and is rendered by a DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by an older controller carries prose and is shown verbatim — never a raw key, never dropped. Also converted: page data and view-model text, the internal/api JSON answers, the alert banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at display, and the four page titles built around an app name (R-566 closed). Hungarian is byte-identical, and that is measured rather than read: scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict. Its own first version filtered the capture through an ASCII-Hungarian word list and missed seven real literals — the R-565 class. The filter is gone. Nothing on the wire moved, and wire goldens now hold it there: the report's health warnings and every notify event message stay Hungarian, because the hub MAILS the controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
143 lines
6.5 KiB
Python
143 lines
6.5 KiB
Python
# -*- 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 = 18 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A): 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.
|
|
|
|
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",
|
|
]
|
|
|
|
|
|
def fold(s):
|
|
s = unicodedata.normalize("NFKD", s)
|
|
return "".join(c for c in s if not unicodedata.combining(c)).lower()
|
|
|
|
|
|
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
|
|
if any(stem in fold("Csatlakoztasd újra a meghajtót") for stem in HU_FORMAL):
|
|
print("i18n gate INCONCLUSIVE: negative control failed (an informal te-form sentence matched a formal stem)")
|
|
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 fold(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())
|