Files
felhom-controller/controller/scripts/controller_gates.py
T
admin 5270bad76e
gates / gates (push) Successful in 23s
v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
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
2026-09-18 10:15:56 +02:00

181 lines
10 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""controller_gates.py — THE entry point for this repo's gates. Run from `controller/`:
python3 scripts/controller_gates.py # every gate
python3 scripts/controller_gates.py --fast # only gates that touch no network and no
# container runtime (what .githooks/pre-push runs)
Gates, in order (all must pass; **non-zero exit on any failure**):
1. template-id every template id/handle referenced by the dashboard resolves
2. emoji no emoji in the Hungarian UI (design-system v2)
3. native-confirm no native confirm()/prompt() — they freeze browser automation
4. offbox-rename the persisted `offbox` key is never re-guessed as `offbox_target`
5. app-row-dedup no duplicated app-row markup in the dashboard templates
6. mojibake no double-encoded UTF-8 in Hungarian copy
7. docker-v every `docker … -v` mount is a named volume or a proven host path
8. debug-routes every debug-page control resolves to a handler, and back (R-400)
9. reuse-refs every path cited by this repo's REUSE.md still resolves
WHY THIS FILE EXISTS (2026-08-02, closing R-29 leg (a) and half of leg (b)).
A census of all thirteen gate scripts across the four felhom repos found one clean correlation:
**every check a CLAUDE.md tells a person to run was passing, and two of the four nobody is told
to run were failing.** Of the seven gates above, this repo's CLAUDE.md named exactly two; four
were reachable only through a line in REUSE.md, and `docker_run_volume_path_gate.py` — RED at the
time of the census — through one line in REUSE.md and nothing else. The fix is not more gates, it
is one place to run them from. `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical
shape (R-161) and this copies it deliberately rather than inventing a second one.
THE SHARED CHECKER. `reuse_refs_check.py` lives in ONE place — `felhom.eu/scripts/` — and is
invoked here across the workspace at `<repo-root>/../felhom.eu/scripts/`. It is deliberately NOT
copied into this repo: duplicating it would recreate exactly the drift it exists to detect. If the
sibling clone is absent the gate FAILS and prints the path it tried — fail-closed, because a
runner that quietly skips a gate is the inert-seam failure this project has shipped four times.
EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive. This runner exits non-zero
if any gate is non-zero, and reports 2 distinctly as INCONCLUSIVE — an undetermined result is
never a pass, but it is not a conviction either.
"""
import os
import subprocess
import sys
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(SCRIPTS) # <repo>/controller — every gate's cwd
REPO = os.path.dirname(CTRL) # <repo> — the root REUSE.md lives here
SHARED_REUSE = os.path.join(os.path.dirname(REPO), "felhom.eu", "scripts", "reuse_refs_check.py")
SHARED_INSTRUCTIONS = os.path.join(
os.path.dirname(REPO), "felhom.eu", "scripts", "instructions_gate.py")
# R-389 — shared, like the two above: it lives in felhom.eu/scripts/ and is never copied.
SHARED_OBSERVATIONS = os.path.join(
os.path.dirname(REPO), "felhom.eu", "scripts", "observations_gate.py")
# R-404 — the golden NOTICE. Lives here, beside the runner, because it is about THIS repo's
# releases; it imports the felhom.eu gate rather than copying its comparison.
GOLDEN_NOTICE = os.path.join(SCRIPTS, "golden_notice.py")
# (label, absolute script path, args, fast, blocking)
#
# `blocking` — R-404, 2026-09-01. FALSE means this gate REPORTS and never changes the runner's exit
# code. Before this the runner could not express such a gate at all: every registered gate's
# non-zero exit failed the run, so the only way to add a notice was to give it the power to refuse
# a push. That was the wrong trade for the golden notice, whose whole point is that it fires at the
# moment a release is committed — when the golden legitimately does not exist yet and refusing
# would be absurd. The capability was added rather than the notice compromised.
#
# It is FALSE for exactly one gate. Everything else blocks, as it always has.
GATES = [
("template-id", os.path.join(SCRIPTS, "template_id_gate.py"), [], True, True),
("emoji", os.path.join(SCRIPTS, "emoji_gate.py"), [], True, True),
("native-confirm", os.path.join(SCRIPTS, "native_confirm_gate.py"), [], True, True),
("offbox-rename", os.path.join(SCRIPTS, "offbox_rename_gate.py"), [], True, True),
("app-row-dedup", os.path.join(SCRIPTS, "app_row_dedup_gate.py"), [], True, True),
("mojibake", os.path.join(SCRIPTS, "mojibake_gate.py"), [], True, True),
("docker-v", os.path.join(SCRIPTS, "docker_run_volume_path_gate.py"), [], True, True),
("secret-markup", os.path.join(SCRIPTS, "secret_in_markup_gate.py"), [], True, True),
("retrieval-promise", os.path.join(SCRIPTS, "retrieval_promise_gate.py"), [], True, True),
# R-400 — every debug-page control resolves to a handler, and every handler is reachable.
# Registered AFTER the seven dead controls were implemented or deleted: a registered-but-failing
# gate refuses every push, so the order matters here exactly as it did for instructions_gate.
("debug-routes", os.path.join(SCRIPTS, "debug_route_gate.py"), [], True, True),
("reuse-refs", SHARED_REUSE, [REPO], True, True),
("instructions", SHARED_INSTRUCTIONS, [REPO], True, True),
# R-389 — a REPORT.md observation with no register row behind it. Fast: stdlib file reads.
("observations", SHARED_OBSERVATIONS, [REPO], True, True),
# R-470/R-472 — the newest release header states its MinAgent; the hub's floor reads it. Fast.
("minagent-header", os.path.join(SCRIPTS, "minagent_header_gate.py"), [], True, True),
# v0.247.0 — the i18n bundles: keys exist, no orphans, the English gap and the formal-form debt
# only shrink (ratchets), no pleading English. Fast: stdlib file reads.
("i18n", os.path.join(SCRIPTS, "i18n_missing_gate.py"), [], True, True),
# R-557 slice 2 — a Go-side message key may only carry text that existed at the base commit,
# byte for byte. The template half of parity is proved by rendered fixtures; Go-side copy cannot
# be, so it is proved structurally here. Fast: stdlib file reads.
("go-parity", os.path.join(SCRIPTS, "i18n_go_parity.py"), [], True, True),
# R-404 — ADVISORY. Reports the golden debt where it is created; never refuses.
("golden-notice", GOLDEN_NOTICE, [REPO], True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
ADVISORY = "ADVISORY" # R-404: a non-blocking gate — it reports, it never refuses
def hooks_armed_note(root):
"""Print a WARNING (never a failure) when this clone's pre-push hook is not switched on.
core.hooksPath is local config and a clone does not carry it, so an unarmed clone is silent
by construction — this is the only place it becomes visible.
"""
try:
val = subprocess.check_output(["git", "config", "--get", "core.hooksPath"],
cwd=root, stderr=subprocess.DEVNULL).decode().strip()
except Exception:
val = ""
norm = val.replace("\\", "/").rstrip("/")
if norm == ".githooks" or norm.endswith("/.githooks"):
return
print("WARNING: this clone is UNARMED — core.hooksPath is %s, so the pre-push hook will not\n"
" run here. Switch it on once with: git config core.hooksPath .githooks"
% (("'" + val + "'") if val else "unset"))
def run_gate(label, path, args):
if not os.path.exists(path):
print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path))
print(" A missing gate is a failure, never a skip (fail-closed). The reuse-refs")
print(" checker is shared and lives in the felhom.eu sibling clone; it is never copied.")
return 1
print("\n" + "=" * 78)
print("== gate: %s (%s%s)" % (label, os.path.basename(path),
(" " + " ".join(args)) if args else ""))
print("=" * 78, flush=True)
# stream the gate's own output rather than capturing it — its diagnostics are the point.
return subprocess.call([sys.executable, path] + args, cwd=CTRL)
def main(argv):
fast = "--fast" in argv
unknown = [a for a in argv if a != "--fast"]
if unknown:
print("unknown argument(s): %s" % " ".join(unknown))
print("usage: python3 scripts/controller_gates.py [--fast] (run from controller/)")
return 2
selected = [g for g in GATES if g[3] or not fast]
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
print("controller_gates — %d gate(s)%s" % (len(selected), " [--fast]" if fast else ""))
if skipped:
print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped))
hooks_armed_note(REPO)
results = [(label, run_gate(label, path, args), blocking)
for label, path, args, _f, blocking in selected]
print("\n" + "=" * 78)
print("== summary")
print("=" * 78)
worst = 0
for label, rc, blocking in results:
if not blocking:
# A non-blocking gate's exit code is INFORMATION, never a verdict on the push.
print(" %-18s %-13s (exit %d, advisory)" % (label, ADVISORY, rc))
continue
print(" %-18s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
if rc != 0:
worst = 1 if rc == 1 or worst == 1 else 2
if worst == 0:
print("\nall controller gates OK")
return 0
convicted = [l for l, rc, b in results if rc == 1 and b]
undecided = [l for l, rc, b in results if rc not in (0, 1) and b]
if convicted:
print("\nCONVICTED: %s" % ", ".join(convicted))
if undecided:
print("UNDETERMINED (never a pass): %s" % ", ".join(undecided))
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))