1c00af607c
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.
265 lines
11 KiB
Python
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:]))
|