diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..0d0fb25 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,47 @@ +#!/bin/sh +# pre-push — refuse a push that carries a broken gate. (2026-08-02, R-29 leg (b) first half.) +# +# Runs this repo's ONE gate entry point in --fast mode: only checks that touch no network and no +# container runtime, so a push stays a push and never pulls images or starts containers. The slow +# gates stay deliberate periodic runs; a hook that takes minutes gets bypassed within a week and +# the bypass becomes the habit. +# +# BOTH LINES BELOW ARE DELIBERATE. An absent log line is not evidence a hook ran — a silent pass is +# equally consistent with "gates green" and "hook never fired", so a passing push says so out loud. +# +# HONEST LIMITS, stated so this is not mistaken for enforcement it cannot provide: +# * per-clone — core.hooksPath is local config and a clone does not carry it. Arm a clone once: +# git config core.hooksPath .githooks +# Any manual entry-point run WARNS when the clone is unarmed. +# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape +# hatch that cannot be reached is one that gets removed the first time it is +# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT. +# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168. +# +# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd +# is the repo root whether `git push` is issued from the root or from any subdirectory. The +# explicit rev-parse below does not depend on that. +set -u + +root=$(git rev-parse --show-toplevel 2>/dev/null) || { + echo "pre-push: FAIL - cannot resolve the repo root (git rev-parse --show-toplevel)." >&2 + exit 1 +} +cd "$root" || exit 1 + +if ! command -v python3 >/dev/null 2>&1; then + echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2 + echo " pass by default. Install python3, or push with --no-verify and say so." >&2 + exit 1 +fi + +echo "pre-push [felhom-controller]: running controller/scripts/controller_gates.py --fast ..." +python3 "controller/scripts/controller_gates.py" --fast +rc=$? +if [ "$rc" -ne 0 ]; then + echo "pre-push [felhom-controller]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2 + echo " 'git push --no-verify' and state that you did in the session report." >&2 +else + echo "pre-push [felhom-controller]: gates OK - push proceeding." +fi +exit $rc diff --git a/CLAUDE.md b/CLAUDE.md index c5c2d82..c6c133d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,23 @@ Per-package helpers/seams/traps: **`REUSE.md`** (maintained same-commit as helpe are implemented during the supervised session itself, on `main`; if a fix can't be verified/shipped, revert + report — never park on a branch. - Code quality: double-check for bugs/edge cases; add debug logging; **ask rather than guess**. -- All UI text is Hungarian (Budapest timezone). Design tokens/gates: use the `felhom-ui-design` - skill; templates must pass `controller/scripts/template_id_gate.py` + `emoji_gate.py`. +- All UI text is Hungarian (Budapest timezone). Design tokens/gates: use the `felhom-ui-design` skill. +- **Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in + this repo.** It is the ONE entry point and runs all seven local gates — `template_id_gate`, + `emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`, + `docker_run_volume_path_gate` — plus `reuse_refs_check` on the repo root, streaming each gate's + own output and exiting non-zero if any fails. `--fast` selects the gates that touch no network and + no container runtime; today that is all of them. A missing gate script is a FAILURE, never a skip. + **Why a runner and not seven invocations** (2026-08-02, R-29): a census of all thirteen gates + across the four repos found that every check a `CLAUDE.md` names was passing, and two of the four + nobody is told to run were failing. This file used to name two of the seven; the other five were + reachable only through a line in `REUSE.md`, and `docker_run_volume_path_gate.py` was RED. + **The shared `reuse_refs_check.py` lives in `felhom.eu/scripts/` and is never copied here** — + a copy would recreate the drift it detects; an absent sibling clone FAILS the gate. + **The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It + is per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run + WARNS when this clone is unarmed. `git push --no-verify` bypasses it deliberately; **say so in the + session report when you use it.** Both facts are why CI is still owed (`OPEN-ITEMS.md` R-168). - Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill. - **Logging**: new leveled lines use `internal/logx` (DEBUG always reaches the debug ring; stdout respects `logging.level`); English, keys-never-values, durations on outcomes — full rules in diff --git a/controller/scripts/controller_gates.py b/controller/scripts/controller_gates.py new file mode 100644 index 0000000..f752c29 --- /dev/null +++ b/controller/scripts/controller_gates.py @@ -0,0 +1,135 @@ +#!/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. 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 `/../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) # /controller — every gate's cwd +REPO = os.path.dirname(CTRL) # — the root REUSE.md lives here +SHARED_REUSE = os.path.join(os.path.dirname(REPO), "felhom.eu", "scripts", "reuse_refs_check.py") + +# (label, absolute script path, args, fast) +GATES = [ + ("template-id", os.path.join(SCRIPTS, "template_id_gate.py"), [], True), + ("emoji", os.path.join(SCRIPTS, "emoji_gate.py"), [], True), + ("native-confirm", os.path.join(SCRIPTS, "native_confirm_gate.py"), [], True), + ("offbox-rename", os.path.join(SCRIPTS, "offbox_rename_gate.py"), [], True), + ("app-row-dedup", os.path.join(SCRIPTS, "app_row_dedup_gate.py"), [], True), + ("mojibake", os.path.join(SCRIPTS, "mojibake_gate.py"), [], True), + ("docker-v", os.path.join(SCRIPTS, "docker_run_volume_path_gate.py"), [], True), + ("reuse-refs", SHARED_REUSE, [REPO], True), +] + +VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} + + +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)) for label, path, args, _f in selected] + + print("\n" + "=" * 78) + print("== summary") + print("=" * 78) + worst = 0 + for label, rc in results: + 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 in results if rc == 1] + undecided = [l for l, rc in results if rc not in (0, 1)] + 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:])) diff --git a/controller/scripts/test_controller_gates.py b/controller/scripts/test_controller_gates.py new file mode 100644 index 0000000..83b24ca --- /dev/null +++ b/controller/scripts/test_controller_gates.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""Seam test for scripts/controller_gates.py. + +Run from controller/: python3 scripts/test_controller_gates.py + +WHY THIS EXISTS. An entry point is a seam by definition: a runner that LISTS a gate but never +executes it is inert and fully green, and this project has shipped an inert seam four times. So +the assertion is on each member gate's OWN distinctive stdout — never on the runner's summary +line, which the runner can print without ever calling anything — plus the exit code, which is a +runner's actual effect. + +Red-proofed 2026-08-02: replacing run_gate's body with `return 0` (the inert runner) turns +test_every_member_gate_actually_ran red while the summary still prints "all controller gates OK". +""" +import os +import subprocess +import sys +import unittest + +SCRIPTS = os.path.dirname(os.path.abspath(__file__)) +CTRL = os.path.dirname(SCRIPTS) +ENTRY = os.path.join(SCRIPTS, "controller_gates.py") + +# (label, a substring only THAT gate can print) +FINGERPRINTS = [ + ("template-id", "integrity gate OK — every JS element-ID reference"), + ("emoji", "emoji gate OK"), + ("native-confirm", "native-confirm gate OK"), + ("offbox-rename", "offbox rename gate OK"), + ("app-row-dedup", "app_row_dedup_gate: OK"), + ("mojibake", "mojibake_gate: OK"), + ("docker-v", "docker -v gate OK"), + ("reuse-refs", "cited paths — exact"), +] + + +class ControllerGatesTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + p = subprocess.run([sys.executable, ENTRY, "--fast"], cwd=CTRL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + cls.rc = p.returncode + cls.out = p.stdout.decode("utf-8", "replace") + + def test_exit_code_is_zero(self): + self.assertEqual(self.rc, 0, self.out) + + def test_every_member_gate_actually_ran(self): + for label, fingerprint in FINGERPRINTS: + self.assertIn(fingerprint, self.out, + "gate %r is listed but its own output never appeared — an inert runner " + "prints the summary without calling anything:\n%s" % (label, self.out)) + + def test_shared_reuse_checker_is_not_copied_into_this_repo(self): + """It lives in felhom.eu/scripts/ and is invoked across the workspace. A copy here would + recreate exactly the drift it exists to detect.""" + self.assertFalse(os.path.exists(os.path.join(SCRIPTS, "reuse_refs_check.py")), + "reuse_refs_check.py must NOT be copied into this repo") + + def test_unknown_argument_is_rejected(self): + p = subprocess.run([sys.executable, ENTRY, "--nope"], cwd=CTRL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + self.assertEqual(p.returncode, 2, p.stdout.decode("utf-8", "replace")) + + +if __name__ == "__main__": + unittest.main(verbosity=2)