From 1c00af607c4cc1b61274b80184aebe695f555f45 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 1 Sep 2026 11:53:18 +0200 Subject: [PATCH] R-404: block the push that can create the golden debt, notify the one that cannot push_scope.py classifies a push as code or documents from an ALLOW-LIST of document paths - everything else, including any new top-level directory, is code. Every uncertainty (first push, force-push, merge commit, empty range, unreadable stdin) answers code: guessing 'documents' would hand out the exemption by accident. repo_gates.py gains a fifth GATES field and --scope=code|docs. On a documents-only push a golden-currency CONVICTION prints as ADVISORY in its own block and does not refuse; every other gate still refuses every push, and golden-currency still refuses a push touching code. The gate itself is UNCHANGED - its verdict, exit codes and wording are byte-identical. What changed is who is refused. Measured on git 2.47.3: a pre-push hook receives on stdin, one line per ref; a first push carries an all-zero remote sha and a deletion an all-zero local sha. Both land on code. --- .gitea/workflows/gates.yml | 68 +++++++- .githooks/pre-push | 50 +++++- scripts/push_scope.py | 264 +++++++++++++++++++++++++++++++ scripts/repo_gates.py | 140 ++++++++++++---- scripts/test_push_scope.py | 121 ++++++++++++++ scripts/test_repo_gates_scope.py | 161 +++++++++++++++++++ 6 files changed, 773 insertions(+), 31 deletions(-) create mode 100644 scripts/push_scope.py create mode 100644 scripts/test_push_scope.py create mode 100644 scripts/test_repo_gates_scope.py diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml index cb297218..c0e73d5c 100644 --- a/.gitea/workflows/gates.yml +++ b/.gitea/workflows/gates.yml @@ -92,11 +92,77 @@ jobs: git checkout -q FETCH_HEAD echo "controller CHANGELOG at $(git rev-parse --short=12 HEAD): $(head -1 CHANGELOG.md)" + - name: Classify the push - code or documents (R-404) + # ONE RULE, NOT TWO. The pre-push hook exempts a golden-currency CONVICTION on a + # documents-only push; if CI did not do the same, a drill night would still produce red CI + # runs indistinguishable from real ones, which is R-417 exactly and is half the reason this + # change exists. + # + # CI CANNOT USE A COMMIT RANGE. The checkout above is `--depth 1` of a single SHA, so there + # is no history here to diff against — `git diff before..after` would fail, and deepening + # the fetch to make it work would slow every run to solve a problem the push event has + # already answered. So the file list comes from the push event payload instead, and is fed + # to the SAME classifier the hook uses (`--files-from`), so there is one implementation of + # "what counts as a document" and not two. + # + # FAIL CLOSED, EVERY PATH. No payload, no `commits` array, an empty array, unreadable JSON, + # a missing classifier — all write `code`, which is exactly today's behaviour. This step can + # therefore only ever make CI as strict as it is now, never looser. That is also why it is + # safe to ship before it has been observed on a real push: the untested direction is the + # safe one. + run: | + set -u + python3 - > /tmp/pushed-files.txt <<'PY' || : > /tmp/pushed-files.txt + import json, os, sys + path = os.environ.get("GITHUB_EVENT_PATH", "") + if not path or not os.path.isfile(path): + sys.stderr.write("no GITHUB_EVENT_PATH - the file list is unknown\n") + raise SystemExit(0) + try: + ev = json.load(open(path)) + except Exception as e: + sys.stderr.write("event payload unreadable: %s\n" % e) + raise SystemExit(0) + commits = ev.get("commits") or [] + if not commits: + sys.stderr.write("the payload carries no commits array - unknown\n") + raise SystemExit(0) + seen = [] + for c in commits: + for key in ("added", "modified", "removed"): + for f in (c.get(key) or []): + if f not in seen: + seen.append(f) + sys.stderr.write("%d commit(s), %d distinct path(s) in the payload\n" + % (len(commits), len(seen))) + for f in seen: + print(f) + PY + echo "--- paths the push event reported ---" + cat /tmp/pushed-files.txt + echo "-------------------------------------" + if [ -s /tmp/pushed-files.txt ] && [ -f scripts/push_scope.py ]; then + SCOPE=$(python3 scripts/push_scope.py --files-from /tmp/pushed-files.txt) || SCOPE=code + else + echo "no usable file list - treating this push as CODE (fail-closed)" + SCOPE=code + fi + [ "$SCOPE" = "docs" ] || SCOPE=code + echo "PUSH_SCOPE=$SCOPE" >> "$GITHUB_ENV" + echo "scope: $SCOPE" + - name: Run the gate entry point # The ONLY thing CI runs. No go build, no go test, no linting, no deploy — those are either # already reliably run by a person or none of CI's business. The exit code IS the result: # no `|| true`, no pipe that could swallow it. - run: python3 scripts/repo_gates.py --fast + # + # A documents-only run that convicts ONLY on golden-currency prints the advisory and stays + # green. THE DEBT IS NOT HIDDEN WHEN THAT HAPPENS — three things still carry it: the + # advisory block in this run's own log, `STATUS.md`, and the controller repo's golden-notice, + # which prints at the moment a release is committed, where someone can actually act on it. + # Those are the compensating controls that make this green honest. Every other gate still + # fails this job on any push, and golden-currency still fails it on a push touching code. + run: python3 scripts/repo_gates.py --fast --scope="${PUSH_SCOPE:-code}" - name: Alarm on failure # THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO diff --git a/.githooks/pre-push b/.githooks/pre-push index c5b008fa..af96460e 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -16,11 +16,35 @@ # * 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. +# * ONE GATE IS ADVISORY ON A DOCUMENTS-ONLY PUSH (R-404, 2026-09-01) — golden-currency, and +# only it. On a push whose whole range touches documents, the register, STATUS, +# reports or drill evidence, a golden-currency CONVICTION is printed loudly as +# ADVISORY and does not refuse the push. Every other gate still refuses every +# push, and golden-currency still refuses a push that touches code. +# WHY: the gate never looks at the push — it compares the controller's newest +# CHANGELOG heading against this repo's bake evidence, so it returns the same +# verdict whatever you are pushing. The controller's code is in one repo and its +# register lives here, so EVERY controller change produces a documents-only push +# here; and the push that PAYS the debt (a bake record under documentation/tests/) +# is itself documents-only, so blocking here blocked the cure. `--no-verify` had +# been used thirteen times, each with a recorded reason. This removes the reason, +# not the hatch. +# The scope is decided by scripts/push_scope.py, which fails closed to `code`. # 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. +# +# Measured 2026-09-01 (git 2.47.3, throwaway local remote, probe removed): git hands this hook its +# ref updates on STDIN as ` `, one line per ref, +# four whitespace-separated fields. Observed directly: +# ordinary push refs/heads/master refs/heads/master +# FIRST push refs/heads/master refs/heads/master 0000000000000000000000000000000000000000 +# two refs two lines, one per ref +# deletion (delete) 0000000000000000000000000000000000000000 refs/heads/side +# The all-zero cases are exactly why the classifier fails closed: a first push has no range to diff +# and a deletion has no content, so neither can be exempted. set -u root=$(git rev-parse --show-toplevel 2>/dev/null) || { @@ -70,8 +94,30 @@ if ! command -v python3 >/dev/null 2>&1; then exit 1 fi -echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast ..." -python3 "scripts/repo_gates.py" --fast +# ── SCOPE (R-404) ──────────────────────────────────────────────────────────────────────────────── +# Read git's ref updates from stdin and ask the classifier what kind of push this is. EVERY failure +# path here answers `code`, which is today's behaviour — this can make the hook stricter than +# intended, never looser. The classifier prints its reasoning on stderr, so a surprising verdict is +# arguable rather than mysterious. +# +# STDIN IS CONSUMED EXACTLY ONCE, here, into a variable. A second reader would get nothing and the +# classifier would answer `code` for a reason that has nothing to do with the push. +refs=$(cat) + +scope=code +if [ ! -f "scripts/push_scope.py" ]; then + echo "pre-push [felhom.eu]: scripts/push_scope.py is ABSENT - treating this push as CODE." >&2 +else + # stdout is the verdict word; the classifier's reasoning goes to stderr and is left visible on + # purpose, so a surprising verdict can be argued with instead of guessed at. + scope=$(printf '%s\n' "$refs" | python3 "scripts/push_scope.py" --prepush-stdin) || scope=code +fi +# Anything that is not exactly "docs" takes the strict path. This is the fail-closed hinge: an empty +# variable, a crashed classifier, a typo and an unexpected word all land on `code`. +[ "$scope" = "docs" ] || scope=code + +echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast --scope=$scope ..." +python3 "scripts/repo_gates.py" --fast --scope="$scope" rc=$? if [ "$rc" -ne 0 ]; then echo "pre-push [felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2 diff --git a/scripts/push_scope.py b/scripts/push_scope.py new file mode 100644 index 00000000..9e42ece0 --- /dev/null +++ b/scripts/push_scope.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""push_scope.py — is this push CODE or DOCUMENTS? (R-404, R-417) + + python3 scripts/push_scope.py --range .. # what the pre-push hook uses + python3 scripts/push_scope.py --files-from - # a newline path list on stdin (CI) + python3 scripts/push_scope.py --range HEAD~5..HEAD --explain + +The verdict word — `code` or `docs` — goes to STDOUT and nothing else does, so a caller can write +`scope=$(python3 scripts/push_scope.py --range "$r")`. The REASONING goes to stderr, always, because +a classifier that prints only a verdict is the kind nobody can argue with at two in the morning. + +WHY THIS EXISTS. `golden_currency_gate.py` never looks at the push: it compares the controller's +newest CHANGELOG heading against the bake evidence in this repo and returns the same verdict whatever +you are pushing. That is correct for a standing invariant and wrong as a push gate, because the +controller's code lives in one repo and its register, architecture and status live in this one — so +**every controller change produces a documents-only push here by construction**, and drills and +spikes add more. Measured 2026-08-31: `--no-verify` had been used eight times, each with a recorded +reason. Five more followed on the night of 2026-09-01 (R-417). A guard correctly bypassed thirteen +times has taught everyone to bypass it. + +The ruling (R-404) is NOT to narrow the gate — its verdict is true and must stay loud. It is to +change **who is refused**: block the push that can create the debt, notify the push that cannot. +This file answers only the question *which kind of push is this?* + +⚠ ALLOW-LIST, NEVER A DENY-LIST — and the red-proof P3 pins it. +A deny-list of code paths says "documents" for anything it has not heard of, so the first new +top-level directory silently inherits the exemption. This lists what is a document and calls +EVERYTHING else code. A new directory is therefore code until someone deliberately adds it here. + +⚠ FAIL CLOSED. Every uncertainty answers `code`: an unreadable range, a first push with an all-zero +remote sha, a force-push, a merge commit, an empty range, a git error, absent stdin. A classifier +that guesses "documents" when it does not know hands out the exemption by accident, which is the +one outcome worse than the status quo. + +WHY `scripts/` IS CODE. The gates are code — including this file and the change that introduced it. +This task's own push is therefore blocked-eligible, which is the intended shape: a session that +changes the gates does not get to exempt itself. +""" +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ZERO = "0" * 40 + +# ── THE ALLOW-LIST ─────────────────────────────────────────────────────────────────────────────── +# Directory prefixes whose whole subtree is documents. +DOC_PREFIXES = ( + "documentation/", # architecture, runbooks, audits, backlog, operations, tests (bake evidence) + ".claude/", # path-scoped rule files — instructions, not product +) +# Exact basenames that are documents wherever they sit. These are instruction and record files by +# this project's own convention; none of them is read by any running program. +DOC_BASENAMES = frozenset(( + "CLAUDE.md", # instructions + "REUSE.md", # the reuse map + "STATUS.md", # the operator view + "CONTEXT.md", # current state + "MEMORY.md", # the memory index +)) +# Basename prefixes, for the session reports (REPORT.md, REPORT-.md). +DOC_BASENAME_PREFIXES = ("REPORT",) + + +def classify_path(path): + """(is_doc, reason). The reason is printed for code paths — that is the debuggable half.""" + p = path.replace("\\", "/") + # NOT lstrip("./") — lstrip takes a SET of characters, so it eats the leading dot of + # ".claude/rules/hub.md" and the whole rules tree silently becomes code. Caught by P1. + while p.startswith("./"): + p = p[2:] + for pref in DOC_PREFIXES: + if p.startswith(pref): + return True, "under %s" % pref + base = p.rsplit("/", 1)[-1] + if base in DOC_BASENAMES: + return True, "%s is an instruction/record file" % base + for pref in DOC_BASENAME_PREFIXES: + if base.startswith(pref) and base.endswith(".md"): + return True, "%s is a session report" % base + return False, "not on the document allow-list" + + +def classify_files(paths): + """(scope, doc_paths, code_paths). Any single code path makes the whole push code.""" + docs, code = [], [] + for p in paths: + if not p: + continue + is_doc, _why = classify_path(p) + (docs if is_doc else code).append(p) + return ("code" if code else "docs"), docs, code + + +def _git(args): + return subprocess.check_output(["git"] + args, cwd=ROOT, + stderr=subprocess.STDOUT).decode("utf-8", "replace") + + +def files_in_range(rng, log): + """Paths changed in `rng`, or None when the range cannot be trusted. None means CODE.""" + if ".." not in rng: + log("range %r has no '..' — cannot be trusted" % rng) + return None + old, new = rng.split("..", 1) + old, new = old.strip(), new.strip() + if not old or not new: + log("range %r is missing an end" % rng) + return None + if set(old) == {"0"}: + log("the remote sha is all zeros — this is a FIRST PUSH of this ref, so there is no " + "range to diff and nothing can be exempted") + return None + if set(new) == {"0"}: + log("the local sha is all zeros — this is a ref DELETION") + return None + try: + _git(["cat-file", "-e", old + "^{commit}"]) + _git(["cat-file", "-e", new + "^{commit}"]) + except Exception as e: + log("one end of the range is not an object in this clone (%s)" % _short(e)) + return None + # A force-push rewrites history, so the range is not what will land. + try: + subprocess.check_call(["git", "merge-base", "--is-ancestor", old, new], + cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + log("the remote sha is NOT an ancestor of the local sha — a force-push or a rewritten " + "history; the range does not describe what will land") + return None + try: + revs = _git(["rev-list", "%s..%s" % (old, new)]).split() + except Exception as e: + log("git rev-list failed (%s)" % _short(e)) + return None + if not revs: + log("the range is EMPTY — nothing to classify") + return None + for rev in revs: + try: + parents = _git(["rev-list", "--parents", "-n", "1", rev]).split()[1:] + except Exception as e: + log("cannot read parents of %s (%s)" % (rev[:8], _short(e))) + return None + if len(parents) > 1: + log("%s is a MERGE commit — a merge's diff depends on which parent you pick, so the " + "range is not a reliable description of what changed" % rev[:8]) + return None + try: + out = _git(["diff", "--name-only", "%s..%s" % (old, new)]) + except Exception as e: + log("git diff failed (%s)" % _short(e)) + return None + return [l for l in out.splitlines() if l.strip()] + + +def _short(e): + s = str(e) + return s[:120].replace("\n", " ") + + +def ranges_from_prepush_stdin(text, log): + """Git hands a pre-push hook lines of ` `. + + MEASURED against git 2.47.3 on DooPlex 2026-09-01 — see the hook header. A line whose local sha + is all zeros is a deletion; a line whose remote sha is all zeros is a new ref. + """ + out = [] + for raw in text.splitlines(): + parts = raw.split() + if len(parts) != 4: + if raw.strip(): + log("unparseable pre-push stdin line %r (expected 4 fields, got %d)" + % (raw[:80], len(parts))) + return None + continue + _lref, lsha, _rref, rsha = parts + out.append("%s..%s" % (rsha, lsha)) + if not out: + log("pre-push stdin carried no ref updates") + return None + return out + + +def main(argv): + explain = "--explain" in argv + argv = [a for a in argv if a != "--explain"] + notes = [] + + def log(msg): + notes.append(msg) + + paths = None + if "--files-from" in argv: + i = argv.index("--files-from") + src = argv[i + 1] if len(argv) > i + 1 else "-" + try: + text = sys.stdin.read() if src == "-" else open(src).read() + paths = [l.strip() for l in text.splitlines() if l.strip()] + if not paths: + log("the file list was EMPTY") + paths = None + except Exception as e: + log("cannot read the file list (%s)" % _short(e)) + paths = None + elif "--range" in argv: + i = argv.index("--range") + if len(argv) > i + 1: + paths = files_in_range(argv[i + 1], log) + else: + log("--range given with no value") + elif "--prepush-stdin" in argv: + try: + text = sys.stdin.read() + except Exception as e: + log("cannot read stdin (%s)" % _short(e)) + text = "" + rngs = ranges_from_prepush_stdin(text, log) + if rngs is None: + paths = None + else: + paths = [] + for r in rngs: + got = files_in_range(r, log) + if got is None: + paths = None + break + paths.extend(got) + else: + sys.stderr.write(__doc__.split("WHY THIS EXISTS")[0]) + sys.stdout.write("code\n") + return 0 + + if paths is None: + sys.stderr.write("push_scope: CODE (fail-closed)\n") + for n in notes: + sys.stderr.write(" reason: %s\n" % n) + sys.stderr.write(" A push whose contents cannot be established is treated as CODE. " + "Guessing 'documents' would hand out the exemption by accident.\n") + sys.stdout.write("code\n") + return 0 + + scope, docs, code = classify_files(paths) + sys.stderr.write("push_scope: %s (%d file(s): %d document, %d code)\n" + % (scope.upper(), len(paths), len(docs), len(code))) + for n in notes: + sys.stderr.write(" note: %s\n" % n) + if code: + sys.stderr.write(" CODE because these are not on the document allow-list:\n") + for p in code[:20]: + sys.stderr.write(" %s\n" % p) + if len(code) > 20: + sys.stderr.write(" ... and %d more\n" % (len(code) - 20)) + if explain and docs: + sys.stderr.write(" documents:\n") + for p in docs[:40]: + sys.stderr.write(" %s (%s)\n" % (p, classify_path(p)[1])) + sys.stdout.write(scope + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/repo_gates.py b/scripts/repo_gates.py index fb684074..a94302cb 100644 --- a/scripts/repo_gates.py +++ b/scripts/repo_gates.py @@ -87,36 +87,49 @@ 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) +# (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), - ("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True), - ("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True), - ("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True), - ("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True), - ("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True), - ("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True), - ("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True), + ("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), + ("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), + ("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), + ("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), + ("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), + ("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): @@ -142,46 +155,90 @@ 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 + 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. - return subprocess.call([sys.executable, path] + args, cwd=ROOT) + # 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 - unknown = [a for a in argv if a != "--fast"] - if unknown: - print("unknown argument(s): %s" % " ".join(unknown)) - print("usage: python3 scripts/repo_gates.py [--fast]") + 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" % (len(selected), " [--fast]" if fast else "")) + 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 = [(label, run_gate(label, path, args)) for label, path, args, _f in selected] + 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 - for label, rc in results: + 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") + print("\nall felhom.eu gates OK" + (" (with %d advisory — see above)" % len(advisories) + if advisories else "")) 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)] + 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: @@ -189,5 +246,32 @@ def main(argv): 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:])) diff --git a/scripts/test_push_scope.py b/scripts/test_push_scope.py new file mode 100644 index 00000000..8837de11 --- /dev/null +++ b/scripts/test_push_scope.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""test_push_scope.py — the classifier, and the allow-list property that makes it safe (R-404). + +P3 IS THE LOAD-BEARING CASE. A deny-list of code paths answers "documents" for anything it has not +heard of, so the first new top-level directory inherits the golden-currency exemption silently. Its +red-proof is written out below and was run. + +Run: python3 scripts/test_push_scope.py Exit 0 all pass · 1 a case failed. +""" +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) +import push_scope # noqa: E402 + +DOCS = [ + "documentation/backlog/OPEN-ITEMS.md", + "documentation/architecture/07-backup-architecture.md", + "documentation/tests/golden-0.232.0-2026-09-01/bake.log", # the push that CLEARS the debt + "documentation/runbooks/RUNBOOK-manual-build.md", + "STATUS.md", "CONTEXT.md", "CLAUDE.md", "REUSE.md", + "REPORT.md", "REPORT-soak.md", + ".claude/rules/hub.md", +] +CODE = [ + "hub/internal/api/handler.go", + "website/index.html", + "manifests/hub.yaml", + "scripts/repo_gates.py", # a gate change is code + "scripts/push_scope.py", # including this task's own + "scripts/felhom-host-install.sh", + ".gitea/workflows/gates.yml", + "hub/CHANGELOG.md", # a CHANGELOG under a code tree is part of that tree +] + + +def main(): + fails = [] + + # --- P1: every document path classifies as a document ------------------------------------ + for p in DOCS: + is_doc, why = push_scope.classify_path(p) + if not is_doc: + fails.append("P1: %s should be a DOCUMENT (%s)" % (p, why)) + if not [f for f in fails if f.startswith("P1")]: + print("P1 ok: %d document paths classify as documents" % len(DOCS)) + + # --- P2: every code path classifies as code ------------------------------------------------ + for p in CODE: + is_doc, why = push_scope.classify_path(p) + if is_doc: + fails.append("P2: %s should be CODE (%s)" % (p, why)) + if not [f for f in fails if f.startswith("P2")]: + print("P2 ok: %d code paths classify as code" % len(CODE)) + + # --- P3: a NEW, UNKNOWN top-level path is CODE <- the allow-list property ---------------- + # RED-PROOF (run 2026-09-01, recorded in REPORT.md): replacing classify_path's allow-list with a + # deny-list — `return (not p.startswith(("hub/","website/","manifests/","scripts/"))), "..."` — + # makes every one of these classify as a DOCUMENT and P3 fails naming them. + unknown = ["terraform/main.tf", "cmd/newthing/main.go", "Makefile", + "docs/readme.md", "documentation-old/x.md", "src/app.py", ".github/workflows/ci.yml"] + for p in unknown: + is_doc, _ = push_scope.classify_path(p) + if is_doc: + fails.append("P3: %s is NOT on the allow-list and must be CODE. An allow-list that " + "lets an unknown path through is a deny-list wearing a hat." % p) + if not [f for f in fails if f.startswith("P3")]: + print("P3 ok: %d unknown paths default to code (allow-list, not deny-list)" % len(unknown)) + + # POSITIVE CONTROL for P3: the check can distinguish. If classify_path said "code" to + # everything, P1 would already have failed — but assert it here too so P3 cannot pass vacuously. + if push_scope.classify_path("documentation/x.md")[0] is not True: + fails.append("P3 CONTROL: classify_path says code to everything; P3 proves nothing") + + # --- P4: a mixed range is code ------------------------------------------------------------- + scope, docs, code = push_scope.classify_files( + ["documentation/backlog/OPEN-ITEMS.md", "STATUS.md", "hub/internal/api/handler.go"]) + if scope != "code": + fails.append("P4: a range containing ONE code file must be CODE; got %r" % scope) + elif len(docs) != 2 or len(code) != 1: + fails.append("P4: the split is wrong: docs=%r code=%r" % (docs, code)) + else: + print("P4 ok: 2 documents + 1 code file -> code") + + # --- P5: uncertainty is code --------------------------------------------------------------- + notes = [] + cases = { + "no '..'": "abcdef", + "all-zero remote": push_scope.ZERO + "..abcdef", + "all-zero local": "abcdef.." + push_scope.ZERO, + "missing end": "..abcdef", + "unknown objects": "dead" * 10 + ".." + "beef" * 10, + } + for name, rng in cases.items(): + got = push_scope.files_in_range(rng, notes.append) + if got is not None: + fails.append("P5 (%s): %r must be untrustworthy (None -> code); got %r" % (name, rng, got)) + if not [f for f in fails if f.startswith("P5")]: + print("P5 ok: %d untrustworthy ranges all return None (-> code)" % len(cases)) + if not notes: + fails.append("P5: not one reason was logged. A fail-closed verdict with no stated reason " + "is the thing this classifier exists to avoid") + + # --- P6: an empty file list is code, not an empty 'docs' ----------------------------------- + scope, _, _ = push_scope.classify_files([]) + if scope != "docs": + pass # classify_files on [] is 'docs' by construction; the EMPTY-list guard lives in main() + print("P6 note: classify_files([]) == %r; the empty-input guard is in main(), covered by P5" % scope) + + if fails: + print() + for f in fails: + print("FAIL: %s" % f) + return 1 + print("\npush_scope tests OK — allow-list holds, uncertainty answers code") + return 0 + + +sys.exit(main()) diff --git a/scripts/test_repo_gates_scope.py b/scripts/test_repo_gates_scope.py new file mode 100644 index 00000000..dcb6b9db --- /dev/null +++ b/scripts/test_repo_gates_scope.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""test_repo_gates_scope.py — R-404's acceptance test: the exemption is ONE GATE WIDE. + +R3 IS THE ONE THAT MATTERS AND IT WAS WRITTEN FIRST. Everything else here is scaffolding around +it. The change this file guards makes `repo_gates.py` let a documents-only push through when +`golden-currency` convicts. The danger is not that the exemption exists — it is that it quietly +becomes general, at which point a documents push stops being checked for anything and nobody finds +out until a broken document ships. + +WHY STUB GATES AND NOT THE REAL ONES. The runner's DECISION is the thing under test: given a set of +exit codes and a scope, what does it exit and what does it print? Driving that with the real gates +would make the test depend on the repo being in a particular state — the R-410 lesson, where a gate +that read a directory NAME passed with no bake behind it. Stubs make the input exact. + +Run: python3 scripts/test_repo_gates_scope.py +Exit 0 all pass · 1 a case failed. +""" +import io +import os +import shutil +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "scripts")) + +import repo_gates # noqa: E402 + + +class Capture(object): + """Collect stdout while the runner writes to it.""" + + def __enter__(self): + self._real = sys.stdout + self.buf = io.StringIO() + sys.stdout = self.buf + return self + + def __exit__(self, *a): + sys.stdout = self._real + self.text = self.buf.getvalue() + return False + + +def stub(tmp, name, rc): + """A gate that does nothing but exit with `rc`, and says so, so output is readable.""" + p = os.path.join(tmp, name) + with io.open(p, "w", encoding="utf-8") as fh: + fh.write(u"import sys\nprint('stub %s exiting %d')\nsys.exit(%d)\n" % (name, rc, rc)) + return p + + +def run(gates, argv): + """Run the runner's main() with GATES swapped for `gates`. Returns (exit code, output).""" + saved = repo_gates.GATES + repo_gates.GATES = gates + try: + with Capture() as cap: + rc = repo_gates.main(argv) + return rc, cap.text + finally: + repo_gates.GATES = saved + + +def main(): + tmp = tempfile.mkdtemp(prefix="gatescope-") + fails = [] + try: + ok = stub(tmp, "ok.py", 0) + bad = stub(tmp, "bad.py", 1) + inc = stub(tmp, "inc.py", 2) + + # The real table's shape, with the fifth field: exemptible on a documents-only push. + def table(golden_rc, other_rc=0): + return [ + ("site", {0: ok, 1: bad, 2: inc}[other_rc], [], True, False), + ("golden-currency", {0: ok, 1: bad, 2: inc}[golden_rc], [], True, True), + ] + + # --- R1: docs scope, golden-currency convicting -> ADVISORY, exit 0 ------------------- + rc, out = run(table(1), ["--fast", "--scope=docs"]) + if rc != 0: + fails.append("R1: a documents-only push with only golden-currency convicting must be " + "ALLOWED; got exit %d" % rc) + elif "ADVISORY" not in out: + fails.append("R1: the word ADVISORY never appeared. A silent pass is a SILENCING, " + "not a re-aim — the whole point is that it still says so:\n%s" % out) + else: + print("R1 ok: docs + golden-currency convicting -> exit 0, ADVISORY printed") + + # --- R2: code scope, same conviction -> refused --------------------------------------- + rc, out = run(table(1), ["--fast", "--scope=code"]) + if rc == 0: + fails.append("R2: a CODE push with golden-currency convicting must still be REFUSED") + elif "ADVISORY" in out: + fails.append("R2: a code push must not print ADVISORY — it is refused, not advised") + else: + print("R2 ok: code + golden-currency convicting -> exit %d" % rc) + + # --- R3: SCENARIO C -- the exemption is ONE GATE WIDE --------------------------------- + # A documents push with a NON-exemptible gate convicting must still be refused. + # RED-PROOF: mark every gate exemptible (change the 5th field of 'site' to True) and this + # fails, which is exactly what a general exemption would look like in production. + rc, out = run(table(0, other_rc=1), ["--fast", "--scope=docs"]) + if rc == 0: + fails.append("R3 (SCENARIO C): a documents-only push with the SITE gate convicting was " + "ALLOWED. The exemption has become general — every other gate must still " + "block every push:\n%s" % out) + else: + print("R3 ok: docs + a NON-exemptible gate convicting -> exit %d (refused)" % rc) + + # --- R4: inconclusive is not a conviction, in either scope ----------------------------- + rc_docs, out_docs = run(table(2), ["--fast", "--scope=docs"]) + rc_code, _ = run(table(2), ["--fast", "--scope=code"]) + if rc_docs != rc_code: + fails.append("R4: an INCONCLUSIVE golden-currency must behave identically in both " + "scopes (docs=%d code=%d) — the exemption is for CONVICTIONS, and an " + "undetermined result was never a conviction" % (rc_docs, rc_code)) + elif rc_docs == 0: + fails.append("R4: INCONCLUSIVE must not pass in either scope; got exit 0") + elif "ADVISORY" in out_docs: + fails.append("R4: an inconclusive gate must not be rendered as ADVISORY") + else: + print("R4 ok: INCONCLUSIVE unchanged in both scopes (exit %d)" % rc_docs) + + # --- R5: no --scope behaves exactly like today ---------------------------------------- + rc_none, out_none = run(table(1), ["--fast"]) + rc_code2, out_code2 = run(table(1), ["--fast", "--scope=code"]) + if rc_none != rc_code2: + fails.append("R5: an unscoped run must be IDENTICAL to --scope=code (a human typing " + "the command must see today's behaviour); got %d vs %d" % (rc_none, rc_code2)) + else: + # compare the summary lines, ignoring the header that names the scope + a = [l for l in out_none.splitlines() if l.startswith(" ") and "exit" in l] + b = [l for l in out_code2.splitlines() if l.startswith(" ") and "exit" in l] + if a != b: + fails.append("R5: unscoped and --scope=code printed different verdicts:\n%s\n%s" + % (a, b)) + else: + print("R5 ok: unscoped == --scope=code, exit %d" % rc_none) + + # --- R6: a bad scope value is refused, never silently treated as docs ------------------ + rc, out = run(table(1), ["--fast", "--scope=banana"]) + if rc == 0: + fails.append("R6: an unrecognised --scope value must NOT pass. Fail closed.") + else: + print("R6 ok: --scope=banana refused (exit %d)" % rc) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + if fails: + print() + for f in fails: + print("FAIL: %s" % f) + return 1 + print("\nrepo_gates scope tests OK — the exemption is one gate wide (R3 is the acceptance)") + return 0 + + +sys.exit(main())