diff --git a/documentation/audits/I18N-INVENTORY-2026-09-17.md b/documentation/audits/I18N-INVENTORY-2026-09-17.md new file mode 100644 index 00000000..642d0e5e --- /dev/null +++ b/documentation/audits/I18N-INVENTORY-2026-09-17.md @@ -0,0 +1,220 @@ +# I18N INVENTORY — every Hungarian string a household meets, counted (2026-09-17) + +**Run class:** spike, Phase 0 of the localisation starter. **Operator:** Claude Code on DooPlex. +**Baselines:** felhom-controller `89dd3e94b1de` (v0.246.0) · felhom-agent `d9864a94bf62` (v0.132.0) · +felhom.eu `851198af7ff0` (hub v0.117.0) · app-catalog-felhom.eu `94bc5febaca2`. +**Instrument:** `scripts/i18n_inventory.py`, sha256 +`be69b48d4d98ac47c0cec531cae63bee8ef009cacbbe2b5c45725cfb2fd40f0c`. Raw output and per-string JSON: +`audits/i18n-2026-09-17/inventory.md`, `inventory.json`. +**What was counted:** the controller at its baseline commit (a clean worktree of `origin/main`, so the +spike's own conversion is NOT in these numbers), the other three repos at their baselines. +**Design this feeds:** `architecture/10-localisation.md`. + +--- + +## 0. Claims in the starter prompt that live source disproved + +Named first, as the prompt asked. Four were already named in its §1; these are further ones. + +1. **„`fmtTime`, `fmtBytes`, `timeAgo` — none of these exist. The FuncMap has one size helper, + `fmtMB`."** — **Wrong, and it is the claim that matters most for localisation.** All three exist + (`internal/web/funcmap.go` `timeAgo` L265, `fmtTime` L318, `fmtBytes` L403), and so do **nine more + helpers whose output is copy or locale-shaped**: `fmtGB`, `humanBytes`, `timeAgoStr`, `fmtTimeStr`, + `fmtTimeShort`, `fmtDuration`, `nextRunLabel` („ma"/„holnap"), `pruneLabel` („vasárnap"/„naponta"), + `nextPruneLabel`, `statusText`, and `stateLabel` (the app-state words). There are **four** size + helpers (`fmtMB`, `fmtGB`, `fmtBytes`, `humanBytes`), not one. `timeAgo` builds Hungarian with `%d` + („%d perce") — the plural problem lives there. +2. **„`fmtMB`, used 10× in templates"** — **8×** (`grep -c` over templates, and the script agrees). +3. **„~13 `.Format(` calls in `internal/web/*.go` (`2006-01-02` ×6, `2006-01-02 15:04` ×4, `15:04` ×2, + `01-02 15:04` ×1)"** — at the baseline: `2006-01-02` **×4**, `2006-01-02 15:04` **×3**, `15:04` **×2**, + `01-02 15:04` ×1 = **10** layout literals, plus **8** `time.RFC3339` (machine, locale-free). The two + template layouts are as stated and do disagree (`2006. 01. 02. 15:04` vs `2006-01-02 15:04`). +4. **Template line count „1 613 (afternoon) / ~1 838 (morning)"** — **1 553**, by two independent methods + (this script, and `grep -cP` on the concatenated templates under `LC_ALL=C.UTF-8`). Neither prompt + figure was reproduced. Tried: adding typographic quotes „ ” (1 556), adding dashes and ellipsis + – — … (1 751), counting `style.css` too (1 559). The afternoon figure is **+60 (3.9 %)** and its method + is unknown; the morning figure is closest to the dash-inclusive variant. **Lines are the wrong unit + anyway** — see §1: the string count is 1 867. +5. **Go literal count „1 234 in 99 files (afternoon) / ~1 971 in 125 (morning)"** — **1 141 literals in + 84 files** by a Go lexer (comments skipped), first-boot wizard excluded. A `grep` of quoted Hungarian + on non-comment lines gives 1 106 lines in 85 files, so the two methods agree. Counting lines that + hold an accented letter **including comments** gives 1 360 in 107 files; including the wizard and + `cmd/`, 1 375 in 108. The prompt's figures sit between the comment-free and comment-inclusive + counts; **comments explain most of the gap** (Hungarian quoted in Go comments is common here). +6. **Console banner „33 Hungarian lines"** — 33 by accented letter, **34** once ASCII-only Hungarian + words are counted. Not an error; a method difference, stated. + +Confirmed as the prompt states: 36 page templates + `style.css`; 33 carry Hungarian; the second +template set and its one helper (`internal/setup/handlers.go` L79 `timeNow`); 8 wizard pages, **86** +Hungarian lines; `customerMessages` in the hub; 53 catalog files with `first_steps` under `app_info`; +no i18n library, no `Accept-Language`, no bundle anywhere. + +--- + +## 1. Controls — the counts below mean something + +Hungarian is matched by accented letter (the letters written as `\u` escapes, so the script itself is +ASCII) **or** by one of 46 ASCII-only Hungarian words („Fut", „Nincs", „Hiba", „Csak", „Megszakadt"…), +case-insensitive. Every surface prints a positive control (an ASCII fragment of a Hungarian word known +to be there, whose lines the matcher must flag) and a negative control (a fragment known absent; an +English sentence that must not be flagged). + +| surface | positive fragment | lines holding it | flagged | negative hits | English line flagged | verdict | +|---|---|---|---|---|---|---| +| controller templates | `pult` | 20 | 20 | 0 | no | ok | +| controller Go | `ment` | 832 | 278 | 0 | no | ok | +| hub customer Go | `ment` | 35 | 18 | 0 | no | ok | +| installer banner | `Felhom` | 10 | 5 | 0 | no | ok | +| catalog | `Alkalmaz` | 5 | 5 | 0 | no | ok | +| guide | `Felhom` | 14 | 13 | 0 | no | ok | + +**Measured limit of the ASCII word list.** The spike's conversion tool first used a 40-word list and +**missed eight Hungarian strings on the three pages** („Megszakadt:", „Elavult", „Csak x86", +„Pi kompatibilis", „Rendszermonitor", „Most nem", „sikertelen", „ismeretlen"); a by-eye review of what +was left found them, and they were added. **An ASCII-only Hungarian word that is not on the list is +still invisible to every count here.** The counts below are therefore floors, not totals. + +--- + +## 2. The tables + +### 2.1 Controller page templates (`internal/web/templates/`, 36 files) + +**1 867 strings: 1 161 text runs + 88 attribute values + 618 inline-JS string literals.** 1 553 lines +carry an accented letter. A *run* is text up to the next block tag or control action — the unit a +translator needs. + +| template | HU lines | text | attr | JS | +|---|---|---|---|---| +| deploy.html | 156 | 87 | 8 | 77 | +| storage.html | 110 | 43 | 9 | 98 | +| debug.html | 92 | 41 | 16 | 79 | +| layout.html (on every page) | 88 | 35 | 1 | 70 | +| backups_remote.html | 77 | 87 | 0 | 10 | +| settings_system.html | 84 | 58 | 0 | 36 | +| backups_apps.html | 80 | 79 | 7 | 6 | +| … 26 more | | | | | + +Full per-file table: `audits/i18n-2026-09-17/inventory.md`. **One third of all template copy (618) is +inside `", re.S | re.I) +STYLE_RE = re.compile(r"]*>(.*?)", re.S | re.I) +TAG_RE = re.compile(r"<[a-zA-Z!/][^>]*>", re.S) +ATTR_RE = re.compile(r'\s([a-zA-Z][a-zA-Z0-9:_-]*)\s*=\s*"([^"]*)"', re.S) +JS_STR_RE = re.compile(r"'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"") +NUM_PARAM_RE = re.compile(r"\{\{\s*(len\b|\.\w*(Count|Num|Total|Days|Hours|Minutes|Size|N)\b)") + + +def line_of(src: str, pos: int) -> int: + return src.count("\n", 0, pos) + 1 + + +def scan_template(f: Path, root: Path): + src = read(f) + rel = str(f.relative_to(root)) + out = {"file": rel, "hu_lines": sum(1 for l in src.split("\n") if has_hu(l)), + "text": [], "attr": [], "js": [], "compare": [], "numeric": []} + + # Mask script/style bodies (keep newlines so line numbers survive). + masked = src + for m in list(SCRIPT_RE.finditer(src)): + for jm in JS_STR_RE.finditer(m.group(1)): + s = jm.group(0)[1:-1] + if is_hu_text(ACTION_RE.sub("", s)): + out["js"].append({"line": line_of(src, m.start(1) + jm.start()), "text": s[:160]}) + def blank(m): + return re.sub(r"[^\n]", " ", m.group(0)) + masked = SCRIPT_RE.sub(blank, masked) + masked = STYLE_RE.sub(blank, masked) + + # A compare is an eq/ne action whose OWN string arguments are Hungarian -- parsed inside the one + # action, never across the text between two actions. + for m in ACTION_RE.finditer(masked): + act = m.group(0) + if not re.search(r"\b(eq|ne)\b", act): + continue + for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', act): + if is_hu_text(lit): + out["compare"].append({"line": line_of(src, m.start()), "text": lit}) + + for tm in TAG_RE.finditer(masked): + for am in ATTR_RE.finditer(tm.group(0)): + name, val = am.group(1).lower(), am.group(2) + if name in ("class", "id", "href", "src", "style", "name", "type", "for", "action", "method"): + continue + if is_hu_text(ACTION_RE.sub("", val)): + out["attr"].append({"line": line_of(src, tm.start()), "attr": name, "text": val[:160]}) + + # Text nodes: what lies between tags, split at control actions (if/range/...), with the + # remaining value actions kept in the message (they are its parameters). + text_only = TAG_RE.sub(lambda m: "\x00" + re.sub(r"[^\n]", " ", m.group(0))[1:], masked) + pos = 0 + for chunk in text_only.split("\x00"): + start = pos + pos += len(chunk) + 1 + for piece in CONTROL_RE.split(chunk): + if piece is None: + continue + p = piece.strip() + if not p or p in ("if", "else", "end", "range", "with", "define", "block", "template"): + continue + visible = ACTION_RE.sub("", p).strip() + if visible and is_hu_text(visible): + ln = line_of(src, start + max(chunk.find(p[:20]), 0)) + out["text"].append({"line": ln, "text": " ".join(p.split())[:160]}) + if NUM_PARAM_RE.search(p): + out["numeric"].append({"line": ln, "text": " ".join(p.split())[:160]}) + return out + + +# --------------------------------------------------------------------------------------------------- +# Other surfaces +# --------------------------------------------------------------------------------------------------- + +def walk_yaml(node, path=""): + if isinstance(node, dict): + for k, v in node.items(): + yield from walk_yaml(v, f"{path}.{k}" if path else str(k)) + elif isinstance(node, list): + for v in node: + yield from walk_yaml(v, path + "[]") + elif isinstance(node, str): + yield path, node + + +def controls(name: str, blobs: list[str], pos_fragment: str): + """Positive: pos_fragment (ASCII) occurs, and some line holding it is flagged Hungarian. + Negative: NEG_FRAGMENT occurs nowhere, and an ASCII English line is not flagged.""" + pos_lines = [l for b in blobs for l in b.split("\n") if pos_fragment in l] + pos_flagged = sum(1 for l in pos_lines if is_hu_text(l)) + neg = sum(b.count(NEG_FRAGMENT) for b in blobs) + ok = len(pos_lines) > 0 and pos_flagged > 0 and neg == 0 and not is_hu_text(NEG_ASCII_LINE) + return {"surface": name, "positive_fragment": pos_fragment, "positive_lines": len(pos_lines), + "positive_flagged": pos_flagged, "negative_fragment": NEG_FRAGMENT, "negative_hits": neg, + "negative_ascii_line_flagged": is_hu_text(NEG_ASCII_LINE), "ok": ok} + + +def top10(strings): + c = collections.Counter(" ".join(s.split()) for s in strings) + return [(t, n) for t, n in c.most_common(10) if n > 1] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--root", default="/mnt/5_hdd/felhom.eu/git") + ap.add_argument("--json", default=None) + args = ap.parse_args() + root = Path(args.root) + ctl = root / "felhom-controller" / "controller" + hub = root / "felhom.eu" / "hub" + report = {"script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "surfaces": {}} + ctrls = [] + + # 1-3. Controller templates (+ inline JS) ------------------------------------------------------- + tdir = ctl / "internal" / "web" / "templates" + tfiles = sorted(tdir.glob("*.html")) + tres = [scan_template(f, root) for f in tfiles] + report["surfaces"]["templates"] = tres + ctrls.append(controls("controller templates", [read(f) for f in tfiles], "pult")) + + # 2. Controller Go (non-test), excluding the first-boot wizard ------------------------------------ + gofiles = sorted(p for p in ctl.rglob("*.go") + if not p.name.endswith("_test.go") and "/internal/setup/" not in str(p)) + gores = scan_go_files(gofiles, root) + report["surfaces"]["controller_go"] = gores + indirect = go_indirect_compares(gofiles, root) + report["surfaces"]["controller_go_indirect_compares"] = indirect + ctrls.append(controls("controller Go", [read(f) for f in gofiles], "ment")) + + # 4. Hub customer surfaces ------------------------------------------------------------------------ + hub_customer = sorted(list((hub / "internal" / "notify").glob("*.go")) + + list((hub / "internal" / "web").glob("selfbind*.go")) + + [hub / "internal" / "web" / "customer_reset.go", hub / "internal" / "claim" / "engine.go"]) + hub_customer = [p for p in hub_customer if p.exists() and not p.name.endswith("_test.go")] + hubres = scan_go_files(hub_customer, root) + report["surfaces"]["hub_customer_go"] = hubres + hub_all = sorted(p for p in hub.rglob("*.go") if not p.name.endswith("_test.go")) + hub_all_rows = scan_go_files(hub_all, root) + hub_tpl = [scan_template(f, root) for f in sorted((hub / "internal" / "web" / "templates").glob("*.html"))] + report["surfaces"]["hub_operator_templates"] = hub_tpl + ctrls.append(controls("hub customer Go", [read(f) for f in hub_customer], "ment")) + + # 5. Installer ------------------------------------------------------------------------------------ + banner = root / "felhom.eu" / "scripts" / "iso" / "felhom-bootstrap.sh" + btext = read(banner) + banner_lines = [{"line": i + 1, "text": l.strip()[:160]} for i, l in enumerate(btext.split("\n")) if is_hu_text(l)] + dl = root / "felhom.eu" / "website" / "letoltes.html" + dlres = scan_template(dl, root) if dl.exists() else None + report["surfaces"]["installer_banner"] = banner_lines + report["surfaces"]["download_page"] = dlres + ctrls.append(controls("installer banner", [btext], "Felhom")) + + # 6. Catalog -------------------------------------------------------------------------------------- + cat = [] + catfiles = sorted((root / "app-catalog-felhom.eu" / "templates").glob("*/.felhom.yml")) + for f in catfiles: + try: + doc = yaml.safe_load(read(f)) or {} + except yaml.YAMLError as e: + cat.append({"file": str(f.relative_to(root)), "path": "", "text": str(e)[:120]}) + continue + for p, v in walk_yaml(doc): + if is_hu_text(v): + cat.append({"file": str(f.relative_to(root)), "path": re.sub(r"\[\]", "[]", p), "text": v[:160], + "words": len(v.split())}) + report["surfaces"]["catalog"] = cat + ctrls.append(controls("catalog", [read(f) for f in catfiles], "Alkalmaz")) + + # 7. Guide ---------------------------------------------------------------------------------------- + guide = root / "felhom.eu" / "documentation" / "runbooks" / "VOLUNTEER-first-hour.md" + gtext = read(guide) + paras = [p for p in re.split(r"\n\s*\n", gtext) if p.strip()] + report["surfaces"]["guide"] = {"file": str(guide.relative_to(root)), "paragraphs": len(paras), + "hu_paragraphs": sum(1 for p in paras if is_hu_text(p)), + "words": len(gtext.split())} + ctrls.append(controls("guide", [gtext], "Felhom")) + + # 8. First-boot wizard ---------------------------------------------------------------------------- + sdir = ctl / "internal" / "setup" + stpl = [scan_template(f, root) for f in sorted((sdir / "templates").glob("*.html"))] + sgo = scan_go_files(sorted(p for p in sdir.rglob("*.go") if not p.name.endswith("_test.go")), root) + report["surfaces"]["setup_wizard"] = {"templates": stpl, "go": sgo} + + # Dates and sizes --------------------------------------------------------------------------------- + fmt_sites = [] + for f in sorted((ctl / "internal" / "web").glob("*.go")): + if f.name.endswith("_test.go"): + continue + for i, l in enumerate(read(f).split("\n"), 1): + for m in re.finditer(r'\.Format\("([^"]*)"\)', l): + fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1)}) + for f in tfiles: + for i, l in enumerate(read(f).split("\n"), 1): + for m in re.finditer(r'\.Format\s+"([^"]*)"|Format\s+"([^"]*)"', l): + fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1) or m.group(2)}) + fmtmb = [] + for f in tfiles + sorted((ctl / "internal" / "web").glob("*.go")): + if f.name.endswith("_test.go"): + continue + for i, l in enumerate(read(f).split("\n"), 1): + if "fmtMB" in l: + fmtmb.append({"file": str(f.relative_to(root)), "line": i}) + ctl_fmt_elsewhere = 0 + for f in ctl.rglob("*.go"): + if f.name.endswith("_test.go") or "/internal/web/" in str(f): + continue + ctl_fmt_elsewhere += len(re.findall(r'\.Format\("', read(f))) + report["dates"] = fmt_sites + report["fmtMB"] = fmtmb + report["format_calls_outside_web"] = ctl_fmt_elsewhere + report["controls"] = ctrls + + # --------------------------------------------------------------------------------------------- + # Markdown summary + # --------------------------------------------------------------------------------------------- + P = print + P(f"script sha256: {report['script_sha256']}\n") + P("## Controls (ASCII fragment, positive + negative)\n") + P("| surface | positive fragment | lines holding it | of those flagged | negative fragment hits | ASCII English line flagged | verdict |") + P("|---|---|---|---|---|---|---|") + for c in ctrls: + P(f"| {c['surface']} | `{c['positive_fragment']}` | {c['positive_lines']} | {c['positive_flagged']} | " + f"{c['negative_hits']} | {c['negative_ascii_line_flagged']} | {'ok' if c['ok'] else 'BROKEN'} |") + + def tsum(rs): + return (sum(r["hu_lines"] for r in rs), sum(len(r["text"]) for r in rs), sum(len(r["attr"]) for r in rs), + sum(len(r["js"]) for r in rs), sum(len(r["compare"]) for r in rs), sum(len(r["numeric"]) for r in rs), + sum(1 for r in rs if r["hu_lines"] or r["text"] or r["attr"] or r["js"])) + + P("\n## Controller page templates\n") + hl, tx, at, js, cm, nu, withhu = tsum(tres) + P(f"{len(tres)} files; {withhu} carry Hungarian. Lines with an accented letter: **{hl}**. " + f"Strings: **{tx} text runs + {at} attribute values + {js} inline-JS literals = {tx + at + js}**. " + f"Template compares against Hungarian: {cm}. Text runs with a numeric parameter: {nu}.\n") + P("| template | HU lines | text | attr | JS | compare |") + P("|---|---|---|---|---|---|") + for r in sorted(tres, key=lambda r: -(len(r["text"]) + len(r["attr"]) + len(r["js"]))): + P(f"| {Path(r['file']).name} | {r['hu_lines']} | {len(r['text'])} | {len(r['attr'])} | {len(r['js'])} | {len(r['compare'])} |") + P("\nMost repeated (text + attr):") + for t, n in top10([x["text"] for r in tres for x in r["text"] + r["attr"]]): + P(f"- {n}x `{t}`") + + def gosum(rows, label): + k = collections.Counter(r["kind"] for r in rows) + files = len({r["file"] for r in rows}) + P(f"\n## {label}\n") + P(f"**{len(rows)} literals in {files} files.** shown: {k['shown']}, error (may reach a page): {k['error']}, " + f"log only: {k['log']}, compared: {k['compare']}. Format strings with parameters (non-log): " + f"{sum(1 for r in rows if r['format'] and r['kind'] != 'log')}; numeric %d (non-log, plural risk): " + f"{sum(1 for r in rows if r['numeric'] and r['kind'] != 'log')}; concatenated (non-log): " + f"{sum(1 for r in rows if r['concat'] and r['kind'] != 'log')}; raw multi-line blocks: " + f"{sum(1 for r in rows if r['multiline'])}.\n") + byfile = collections.Counter(r["file"] for r in rows if r["kind"] != "log") + P("| file (top 15, non-log) | literals |") + P("|---|---|") + for fname, n in byfile.most_common(15): + P(f"| {fname} | {n} |") + P("\nMost repeated (non-log):") + for t, n in top10([r["text"] for r in rows if r["kind"] != "log"]): + P(f"- {n}x `{t}`") + return k + + gosum(gores, "Controller Go (non-test, first-boot wizard excluded)") + P("\n### Compared, not shown (controller Go)\n") + for r in gores: + if r["kind"] == "compare": + P(f"- {r['file']}:{r['line']} `{r['text']}`") + for r in indirect: + P(f"- {r['file']}:{r['line']} via `{r['name']}` (defined {r['defined']}) `{r['text']}`") + P("\n### Compared, not shown (templates)\n") + for r in tres: + for x in r["compare"]: + P(f"- {r['file']}:{x['line']} `{x['text']}`") + + gosum(hubres, "Hub customer surfaces (notify, self-bind, reset, claim)") + hk = collections.Counter(r["kind"] for r in hub_all_rows) + hl2, tx2, at2, js2, _, _, _ = tsum(hub_tpl) + P(f"\nFor scale, NOT in scope: all hub Go non-test = {len(hub_all_rows)} literals " + f"(shown {hk['shown']}, error {hk['error']}, log {hk['log']}, compare {hk['compare']}); " + f"hub operator templates = {tx2 + at2 + js2} strings, {hl2} HU lines.") + + P("\n## Installer\n") + P(f"Console banner `{banner.relative_to(root)}`: **{len(banner_lines)}** Hungarian lines.") + if dlres: + P(f"Download page `{dl.relative_to(root)}`: {len(dlres['text'])} text + {len(dlres['attr'])} attr + " + f"{len(dlres['js'])} JS = **{len(dlres['text']) + len(dlres['attr']) + len(dlres['js'])}** strings, " + f"{dlres['hu_lines']} HU lines.") + + P("\n## Catalog\n") + byfield = collections.Counter(re.sub(r"^.*?\b(app_info\.\w+|deploy_fields\[\]\.\w+|\w+)$", r"\1", r["path"]) for r in cat) + apps = len({r["file"] for r in cat}) + words = sum(r.get("words", 0) for r in cat) + P(f"{len(catfiles)} `.felhom.yml`; {apps} carry Hungarian; **{len(cat)} strings, ~{words} words**.\n") + P("| field | strings |") + P("|---|---|") + for fld, n in byfield.most_common(20): + P(f"| {fld} | {n} |") + + g = report["surfaces"]["guide"] + P(f"\n## Guide\n\n`{g['file']}`: {g['paragraphs']} paragraphs ({g['hu_paragraphs']} Hungarian), ~{g['words']} words.") + + P("\n## First-boot wizard (reported separately -- never merged into the dashboard count)\n") + shl, stx, sat, sjs, scm, _, _ = tsum(stpl) + sk = collections.Counter(r["kind"] for r in sgo) + P(f"{len(stpl)} templates: {shl} HU lines; {stx} text + {sat} attr + {sjs} JS = **{stx + sat + sjs}** strings. " + f"Go: {len(sgo)} literals (shown {sk['shown']}, error {sk['error']}, log {sk['log']}, compare {sk['compare']}).") + + P("\n## Dates and sizes\n") + lay = collections.Counter((Path(x["file"]).suffix, x["layout"]) for x in fmt_sites) + P("| where | layout | sites |") + P("|---|---|---|") + for (suf, l), n in sorted(lay.items(), key=lambda kv: -kv[1]): + P(f"| {'template' if suf == '.html' else 'internal/web Go'} | `{l}` | {n} |") + P(f"\n`fmtMB` sites: {sum(1 for x in fmtmb if x['file'].endswith('.html'))} in templates, " + f"{sum(1 for x in fmtmb if x['file'].endswith('.go'))} in Go. `.Format(\"` calls in the controller outside " + f"internal/web: {ctl_fmt_elsewhere}.") + + if args.json: + Path(args.json).write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8") + bad = [c for c in ctrls if not c["ok"]] + if bad: + P("\nCONTROLS BROKEN: " + ", ".join(c["surface"] for c in bad), file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/wire_contract_gate.py b/scripts/wire_contract_gate.py index a5bde6c5..7313ff31 100644 --- a/scripts/wire_contract_gate.py +++ b/scripts/wire_contract_gate.py @@ -208,6 +208,14 @@ ALLOWLIST = { "reached, never on a skip, a missing snapshot or a restore error, so 'presence is not success' " "does not bite here. **Delete with its siblings.**"), + (_CH, "language"): ( + "controller v0.247.0 (i18n spike): the household's dashboard language, published so the hub's " + "customer e-mails can follow it — plan slice 3 in documentation/architecture/10-localisation.md. " + "No hub release reads it yet; the hub stores the raw report, so it is visible there. RECORDED " + "HERE ALTHOUGH THE GATE PASSES WITHOUT IT: the token `language` occurs hub-side only inside a " + "Go COMMENT (hub/internal/web/configs.go), and receiver_tokens() does not strip comments — the " + "row filed 2026-09-17 for that blind spot names this field as its found case. **Delete this " + "entry when slice 3 reads the field.**"), (_CH, "offsite.last_integrity_depth"): ( "R-399, controller v0.228.0: the DEPTH the verdict was reached at, published beside " "last_integrity_ok above and unconsumed for the identical reason. It matters because 'checked, "