#!/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:]))