Files
felhom.eu/scripts/push_scope.py
T
admin 1c00af607c 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 <local ref> <local sha> <remote ref> <remote sha>
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.
2026-09-01 11:53:18 +02:00

265 lines
11 KiB
Python

#!/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 <A>..<B> # 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-<topic>.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 `<local ref> <local sha> <remote ref> <remote sha>`.
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:]))