Files
felhom.eu/scripts/test_push_scope.py
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

122 lines
5.5 KiB
Python

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