Files
felhom.eu/scripts/repo_gates.py
T
admin 1e6c387a0b
gates / gates (push) Successful in 17s
R-404 CLOSED with the ruling; R-417 CLOSED by cause removal; R-418/419/420 filed
THE RULING WAS NEITHER OPTION AS FRAMED. Both offered answers - narrow the gate, or leave it and
write waivers - argued about the gate, and the gate was never the problem.

DIAGNOSIS, from live source: golden_currency_gate.py never looks at the push. It compares the
controller's newest CHANGELOG heading against this repo's bake evidence and returns the same
verdict whatever you are pushing - correct for a standing invariant, wrong as a push gate. And
controller_gates.py had NO golden-currency entry at all. So the repo where a release happens never
checked, and the repo that cannot create the debt was refused on every push. 18 of the last 24
pushes here touched no code - measured, and the new classifier agrees EXACTLY - most of them by
construction, because the controller's code is in one repo and its register lives in this one. SIX
of those 18 were bake records, so the push that PAYS the debt is itself documents-only: the gate
was blocking its own cure.

Not the waiver its docstring prescribes: that clause was written for a release nobody wants a
golden for. R-417 was a release we DID want a golden for, on a night the runbook forbade baking. A
waiver would have recorded a lie.

RULING: block the push that can create the debt, notify the push that cannot.

The gate's logic, exit codes and wording are BYTE-IDENTICAL. Only the consequence changed, for one
gate, on one kind of push, with a loud ADVISORY block so nothing goes quiet.

R-242's vouch half is amended in place to say it is UNTOUCHED and still open - a baked-but-unvouched
golden still passes both the gate and the new notice. Do not read R-404's closure as closing it.

FILED: R-418 - this runner's docstring listed ELEVEN gates while THIRTEEN were registered;
one-register and closed-register ran undocumented since 2026-08-24. Enumeration fixed here, the
correspondence is still unenforced. R-419 - observations_gate.py accepts an item whose body merely
CONTAINS "NOT-A-FINDING", even in prose disclaiming it; found by accident when a planted test
observation passed and my live validation proved nothing. R-420 - controller_gates.py could not
express a non-blocking gate at all before today.

Register: OPEN 171 -> 172, CLOSED 158 -> 160.
2026-09-01 12:01:10 +02:00

291 lines
17 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""repo_gates.py — THE entry point for this repo's gates. Run from the repo root:
python3 scripts/repo_gates.py # every gate
python3 scripts/repo_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. site website HTML: BOM, emoji, nav/footer, analytics, CDN, tokens, cache-busting
2. hostinstall felhom-host-install.sh's five drill-swept invariants (+ R-94's absent-version)
3. hub-confirm no native confirm()/prompt() in hub templates
4. manifest-bearer no bearer-shaped literal anywhere in manifests/
5. reuse-refs every path cited by this repo's REUSE.md still resolves
6. instructions CLAUDE.md length/versions/TEMPORARY, rule-file scoping, workspace-copy identity
7. golden-currency a released controller has a golden carrying it (R-242)
8. wire-contract every emitted field is decodable by its receiver (G-1)
9. hub-copy the hub's customer-facing words, against the retired-name list (R-324)
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
11. one-register open work living outside OPEN-ITEMS.md (R-369)
12. closed-register a CLOSED row whose verdict still reads open, or an id in both (R-405)
13. observations a REPORT.md observation with no register row behind it (R-389)
**THE `GATES` TABLE BELOW IS THE LIST; THIS IS A POINTER TO IT.** It drifted once already —
it read eleven while thirteen were registered, from 2026-08-24 until 2026-09-01, so
`one-register` and `closed-register` ran on every push while being documented nowhere
(R-418). Add a gate here in the same commit, or delete this list rather than let it lie.
SCOPE (R-404, 2026-09-01). `--scope=docs` marks a push whose whole range touches documents.
It changes ONE thing: a CONVICTION by a gate whose fifth `exemptible` field is True — today
`golden-currency`, and only it — prints as ADVISORY and does not refuse the push. The gate
still RUNS and still CONVICTS; its verdict, exit codes and wording are untouched. Every
other gate refuses every push, in every scope. An unscoped run behaves exactly as before.
WHY 11 IS HERE (2026-08-24, R-389). On 2026-08-23 a session measured on live hardware that only the
FIRST broken app per hour reaches the operator — the notification cooldown keys on the event type,
not on the app. It was real, reproducible, and written under `## Observations` in `REPORT.md`. It was
written NOWHERE ELSE. `REPORT.md` is overwritten every session by this project's own convention, so
the finding had a lifetime of exactly one session and had to be re-derived the next day. That is
gate 10's shape one surface over — a commitment recorded in prose that nothing enforces — and the
instruction invited it: PROMPT-TEMPLATE.md §15 asked for observations "documented, not acted on", and
"documented" was satisfied by the paragraph. The template was corrected in the same session; **this
gate is the mechanism that correction points at.** It REFUSES rather than warns, for gate 10's
reason. It PASSES quietly when there is no observations section, deliberately — a gate that taxes
every push is one that gets disabled within a week. `--fast` (stdlib file reads only).
WHY 10 IS HERE (2026-08-18, R-341). R-341 booked two dated measurements — +24 h and +7 d —
as a sentence inside a register row. Nothing read those dates, and nothing would have said a word
when they passed; the row would simply have gone quiet and stayed that way. That is the same shape
as R-242 (a rule filed without a mechanism, which recurred the next day) and as the R-29 census
finding below, where the checks nobody was told to run were the ones that had been failing for
weeks. The dates now live in a machine-readable block INSIDE OPEN-ITEMS.md — inside, so there is no
sidecar to drift from the register — and this gate refuses the push once one comes due. It REFUSES
rather than warns, deliberately: a warning is the thing that gets scrolled past, and this repo has
the census to prove it. It is `--fast` (stdlib file read, no network) so it runs in both the
pre-push hook and CI. **It is not a scheduler and its docstring says so** — it fires on the next
push after a date passes, not on the date.
WHY 7 IS HERE (2026-08-08, R-242). R-242 was filed as a rule with no mechanism — *a controller
release is not finished until a golden carries it* — and RECURRED THE NEXT DAY: v0.206.0 shipped
while the vouched golden still carried 0.205.0, so a machine installed that morning would have got
neither of the R-241 fixes. Two occurrences in two days, the first (R-239) invisible until a walk
measured it from the customer's side. It is `--fast` because both the pre-push hook and CI run only
`--fast`; a non-fast gate would run in neither, which is the R-29 failure this runner ended. That
constraint is why it checks the BAKE and not the vouch — the full reasoning is in its docstring.
WHY 6 IS HERE AND WAS NOT (2026-08-06, R-229 deferred leg). instructions_gate.py LIVES in this
repo's scripts/ and was registered in the controller and agent runners on the day it was written —
but not in this one, because this repo's own CLAUDE.md was still 27 lines over the ceiling and a
registered-but-failing gate refuses every push through .githooks/pre-push. The file was trimmed
(227 -> 115 effective lines, core + .claude/rules/) and the gate registered in the same session.
A check that does not run in the place it applies is the exact failure the R-29 gate census found.
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** — one since 14 July. Neither failure was harmful in effect, which was
checked line by line; nothing would have said so if they had been. 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.
`site_gates.py` is a GATE — eight assertions in one file — and is NOT the model for this file. A
runner that invokes separate gates is the shape that survives; copying site_gates would just add
a ninth monolith.
FAIL-CLOSED. A gate script that is missing is a FAILURE, never a skip, and the exact path tried
is printed. A runner that quietly drops 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, and the operator needs to know which they have.
"""
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(ROOT, "scripts")
# (label, absolute script path, args, fast, exemptible)
#
# `exemptible` — R-404, 2026-09-01. TRUE means: on a DOCUMENTS-ONLY push this gate's CONVICTION is
# reported as ADVISORY and does not refuse the push. It is TRUE for exactly ONE gate and the list is
# meant to stay that length.
#
# WHY ONLY golden-currency. It is the only gate here that does not examine the push at all: it
# compares the controller's newest CHANGELOG heading against this repo's bake evidence and returns
# the same verdict whatever you are pushing. Every other gate convicts on something IN the change,
# so a documents push that trips one has a broken document and must be refused.
#
# The gate's own verdict is UNCHANGED — it still runs on every push and still says the same thing.
# What changed is who is refused. See scripts/push_scope.py and OPEN-ITEMS.md R-404.
GATES = [
("site", os.path.join(SCRIPTS, "site_gates.py"), [], True, False),
("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True, False),
("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True, False),
("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True, False),
("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True, False),
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True, False),
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True, True),
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True, False),
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False),
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False),
# R-369 — two files held open work and only one called itself the source of truth, so a READY
# finding sat in ROADMAP.md for 25 days invisible to every "grep the register" rule and was
# rediscovered by an overnight drill. Fast: two file reads.
("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True, False),
# R-405 — the 2026-08-22 compression sweep moved R-87 into CLOSED-ITEMS.md while its own state
# cell read READY; R-378 caught six siblings in the same session and missed this one, so it sat
# in the wrong file for nine days while the ranking paragraph pointed at nothing. Fast: two
# file reads.
("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True, False),
# R-389 — a live finding lived in a REPORT.md observations paragraph and nowhere else, and
# REPORT.md is overwritten every session. Fast: stdlib file reads.
("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
ADVISORY = "ADVISORY" # R-404: a conviction that is reported, loudly, and does not refuse
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).")
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, and a
# runner that swallows them makes a conviction unreadable. It is ALSO collected, so the advisory
# block below can quote the gate's own words instead of re-deriving the version itself. Printing
# happens line by line as it arrives, so this is a tee and not a capture.
proc = subprocess.Popen([sys.executable, path] + args, cwd=ROOT,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
lines = []
for raw in iter(proc.stdout.readline, b""):
line = raw.decode("utf-8", "replace").rstrip("\n")
lines.append(line)
print(line, flush=True)
proc.stdout.close()
return proc.wait(), "\n".join(lines)
def main(argv):
fast = "--fast" in argv
scope = "code"
rest = []
for a in argv:
if a == "--fast":
continue
if a.startswith("--scope="):
scope = a.split("=", 1)[1].strip()
continue
rest.append(a)
if rest:
print("unknown argument(s): %s" % " ".join(rest))
print("usage: python3 scripts/repo_gates.py [--fast] [--scope=code|docs]")
return 2
if scope not in ("code", "docs"):
# Fail closed: an unrecognised scope is never quietly treated as 'docs'.
print("unrecognised --scope=%s — expected 'code' or 'docs'." % scope)
print("An unrecognised scope is REFUSED rather than assumed, because the only assumption")
print("that could be wrong in a dangerous direction is 'docs'.")
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("repo_gates (felhom.eu) — %d gate(s)%s%s"
% (len(selected), " [--fast]" if fast else "",
" [scope=docs]" if scope == "docs" else ""))
if skipped:
print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped))
hooks_armed_note(ROOT)
results = []
for label, path, args, _f, exemptible in selected:
rc, text = run_gate(label, path, args)
results.append((label, rc, exemptible, text))
print("\n" + "=" * 78)
print("== summary")
print("=" * 78)
worst = 0
advisories = []
for label, rc, exemptible, text in results:
# An ADVISORY is a CONVICTION (rc == 1) on a documents-only push, for a gate registered as
# exemptible. INCONCLUSIVE (rc == 2) is deliberately NOT exemptible: it was never a
# conviction, and treating "we could not tell" as "we forgive it" is a different decision
# that nobody made.
advisory = (scope == "docs" and rc == 1 and exemptible)
if advisory:
advisories.append((label, text))
print(" %-18s %-13s (exit %d)" % (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 advisories:
_print_advisory_block(advisories)
if worst == 0:
print("\nall felhom.eu gates OK" + (" (with %d advisory — see above)" % len(advisories)
if advisories else ""))
return 0
convicted = [l for l, rc, _e, _t in results if rc == 1 and not (scope == "docs" and _e)]
undecided = [l for l, rc, _e, _t 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
def _print_advisory_block(advisories):
"""Its own block, after the table, because a line inside a table is easy to miss.
R-404's whole premise is that the warning must NOT go quiet — only its consequence changes. If
this block ever stops printing, the change has become a silencing and Scenario A has failed.
"""
print("\n" + "!" * 78)
for label, text in advisories:
print("!! ADVISORY — %s convicted, and this push is NOT refused for it." % label)
# Quote the gate's own numbers rather than re-deriving them; a second implementation of
# "which version owes a golden" is a second thing that can be wrong.
for line in text.splitlines():
t = line.strip()
if t.startswith("newest released controller") or t.startswith("newest golden baked"):
print("!! %s" % t)
print("!!")
print("!! This push touches DOCUMENTS ONLY, so it can neither create this debt nor clear")
print("!! it — and the push that DOES clear it (a bake record under documentation/tests/)")
print("!! is itself documents-only. Blocking here blocked the cure.")
print("!!")
print("!! WHAT CLEARS IT: bake a golden per documentation/runbooks/RUNBOOK-manual-build.md")
print("!! section 4.1, then vouch it (a THREE-field change: golden_version + agent_version")
print("!! + min_agent). The debt stays visible in STATUS.md and in the controller repo's")
print("!! own golden-notice until then.")
print("!" * 78)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))