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.
122 lines
5.5 KiB
Python
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())
|