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.
This commit is contained in:
@@ -92,11 +92,77 @@ jobs:
|
|||||||
git checkout -q FETCH_HEAD
|
git checkout -q FETCH_HEAD
|
||||||
echo "controller CHANGELOG at $(git rev-parse --short=12 HEAD): $(head -1 CHANGELOG.md)"
|
echo "controller CHANGELOG at $(git rev-parse --short=12 HEAD): $(head -1 CHANGELOG.md)"
|
||||||
|
|
||||||
|
- name: Classify the push - code or documents (R-404)
|
||||||
|
# ONE RULE, NOT TWO. The pre-push hook exempts a golden-currency CONVICTION on a
|
||||||
|
# documents-only push; if CI did not do the same, a drill night would still produce red CI
|
||||||
|
# runs indistinguishable from real ones, which is R-417 exactly and is half the reason this
|
||||||
|
# change exists.
|
||||||
|
#
|
||||||
|
# CI CANNOT USE A COMMIT RANGE. The checkout above is `--depth 1` of a single SHA, so there
|
||||||
|
# is no history here to diff against — `git diff before..after` would fail, and deepening
|
||||||
|
# the fetch to make it work would slow every run to solve a problem the push event has
|
||||||
|
# already answered. So the file list comes from the push event payload instead, and is fed
|
||||||
|
# to the SAME classifier the hook uses (`--files-from`), so there is one implementation of
|
||||||
|
# "what counts as a document" and not two.
|
||||||
|
#
|
||||||
|
# FAIL CLOSED, EVERY PATH. No payload, no `commits` array, an empty array, unreadable JSON,
|
||||||
|
# a missing classifier — all write `code`, which is exactly today's behaviour. This step can
|
||||||
|
# therefore only ever make CI as strict as it is now, never looser. That is also why it is
|
||||||
|
# safe to ship before it has been observed on a real push: the untested direction is the
|
||||||
|
# safe one.
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
python3 - > /tmp/pushed-files.txt <<'PY' || : > /tmp/pushed-files.txt
|
||||||
|
import json, os, sys
|
||||||
|
path = os.environ.get("GITHUB_EVENT_PATH", "")
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
sys.stderr.write("no GITHUB_EVENT_PATH - the file list is unknown\n")
|
||||||
|
raise SystemExit(0)
|
||||||
|
try:
|
||||||
|
ev = json.load(open(path))
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("event payload unreadable: %s\n" % e)
|
||||||
|
raise SystemExit(0)
|
||||||
|
commits = ev.get("commits") or []
|
||||||
|
if not commits:
|
||||||
|
sys.stderr.write("the payload carries no commits array - unknown\n")
|
||||||
|
raise SystemExit(0)
|
||||||
|
seen = []
|
||||||
|
for c in commits:
|
||||||
|
for key in ("added", "modified", "removed"):
|
||||||
|
for f in (c.get(key) or []):
|
||||||
|
if f not in seen:
|
||||||
|
seen.append(f)
|
||||||
|
sys.stderr.write("%d commit(s), %d distinct path(s) in the payload\n"
|
||||||
|
% (len(commits), len(seen)))
|
||||||
|
for f in seen:
|
||||||
|
print(f)
|
||||||
|
PY
|
||||||
|
echo "--- paths the push event reported ---"
|
||||||
|
cat /tmp/pushed-files.txt
|
||||||
|
echo "-------------------------------------"
|
||||||
|
if [ -s /tmp/pushed-files.txt ] && [ -f scripts/push_scope.py ]; then
|
||||||
|
SCOPE=$(python3 scripts/push_scope.py --files-from /tmp/pushed-files.txt) || SCOPE=code
|
||||||
|
else
|
||||||
|
echo "no usable file list - treating this push as CODE (fail-closed)"
|
||||||
|
SCOPE=code
|
||||||
|
fi
|
||||||
|
[ "$SCOPE" = "docs" ] || SCOPE=code
|
||||||
|
echo "PUSH_SCOPE=$SCOPE" >> "$GITHUB_ENV"
|
||||||
|
echo "scope: $SCOPE"
|
||||||
|
|
||||||
- name: Run the gate entry point
|
- name: Run the gate entry point
|
||||||
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy — those are either
|
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy — those are either
|
||||||
# already reliably run by a person or none of CI's business. The exit code IS the result:
|
# already reliably run by a person or none of CI's business. The exit code IS the result:
|
||||||
# no `|| true`, no pipe that could swallow it.
|
# no `|| true`, no pipe that could swallow it.
|
||||||
run: python3 scripts/repo_gates.py --fast
|
#
|
||||||
|
# A documents-only run that convicts ONLY on golden-currency prints the advisory and stays
|
||||||
|
# green. THE DEBT IS NOT HIDDEN WHEN THAT HAPPENS — three things still carry it: the
|
||||||
|
# advisory block in this run's own log, `STATUS.md`, and the controller repo's golden-notice,
|
||||||
|
# which prints at the moment a release is committed, where someone can actually act on it.
|
||||||
|
# Those are the compensating controls that make this green honest. Every other gate still
|
||||||
|
# fails this job on any push, and golden-currency still fails it on a push touching code.
|
||||||
|
run: python3 scripts/repo_gates.py --fast --scope="${PUSH_SCOPE:-code}"
|
||||||
|
|
||||||
- name: Alarm on failure
|
- name: Alarm on failure
|
||||||
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
|
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
|
||||||
|
|||||||
+48
-2
@@ -16,11 +16,35 @@
|
|||||||
# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape
|
# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape
|
||||||
# hatch that cannot be reached is one that gets removed the first time it is
|
# hatch that cannot be reached is one that gets removed the first time it is
|
||||||
# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT.
|
# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT.
|
||||||
|
# * ONE GATE IS ADVISORY ON A DOCUMENTS-ONLY PUSH (R-404, 2026-09-01) — golden-currency, and
|
||||||
|
# only it. On a push whose whole range touches documents, the register, STATUS,
|
||||||
|
# reports or drill evidence, a golden-currency CONVICTION is printed loudly as
|
||||||
|
# ADVISORY and does not refuse the push. Every other gate still refuses every
|
||||||
|
# push, and golden-currency still refuses a push that touches code.
|
||||||
|
# WHY: the gate never looks at the push — it compares the controller's newest
|
||||||
|
# CHANGELOG heading against this repo's bake evidence, so it returns the same
|
||||||
|
# verdict whatever you are pushing. The controller's code is in one repo and its
|
||||||
|
# register lives here, so EVERY controller change produces a documents-only push
|
||||||
|
# here; and the push that PAYS the debt (a bake record under documentation/tests/)
|
||||||
|
# is itself documents-only, so blocking here blocked the cure. `--no-verify` had
|
||||||
|
# been used thirteen times, each with a recorded reason. This removes the reason,
|
||||||
|
# not the hatch.
|
||||||
|
# The scope is decided by scripts/push_scope.py, which fails closed to `code`.
|
||||||
# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168.
|
# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168.
|
||||||
#
|
#
|
||||||
# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd
|
# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd
|
||||||
# is the repo root whether `git push` is issued from the root or from any subdirectory. The
|
# is the repo root whether `git push` is issued from the root or from any subdirectory. The
|
||||||
# explicit rev-parse below does not depend on that.
|
# explicit rev-parse below does not depend on that.
|
||||||
|
#
|
||||||
|
# Measured 2026-09-01 (git 2.47.3, throwaway local remote, probe removed): git hands this hook its
|
||||||
|
# ref updates on STDIN as `<local ref> <local sha> <remote ref> <remote sha>`, one line per ref,
|
||||||
|
# four whitespace-separated fields. Observed directly:
|
||||||
|
# ordinary push refs/heads/master <new> refs/heads/master <old>
|
||||||
|
# FIRST push refs/heads/master <new> refs/heads/master 0000000000000000000000000000000000000000
|
||||||
|
# two refs two lines, one per ref
|
||||||
|
# deletion (delete) 0000000000000000000000000000000000000000 refs/heads/side <old>
|
||||||
|
# The all-zero cases are exactly why the classifier fails closed: a first push has no range to diff
|
||||||
|
# and a deletion has no content, so neither can be exempted.
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||||
@@ -70,8 +94,30 @@ if ! command -v python3 >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast ..."
|
# ── SCOPE (R-404) ────────────────────────────────────────────────────────────────────────────────
|
||||||
python3 "scripts/repo_gates.py" --fast
|
# Read git's ref updates from stdin and ask the classifier what kind of push this is. EVERY failure
|
||||||
|
# path here answers `code`, which is today's behaviour — this can make the hook stricter than
|
||||||
|
# intended, never looser. The classifier prints its reasoning on stderr, so a surprising verdict is
|
||||||
|
# arguable rather than mysterious.
|
||||||
|
#
|
||||||
|
# STDIN IS CONSUMED EXACTLY ONCE, here, into a variable. A second reader would get nothing and the
|
||||||
|
# classifier would answer `code` for a reason that has nothing to do with the push.
|
||||||
|
refs=$(cat)
|
||||||
|
|
||||||
|
scope=code
|
||||||
|
if [ ! -f "scripts/push_scope.py" ]; then
|
||||||
|
echo "pre-push [felhom.eu]: scripts/push_scope.py is ABSENT - treating this push as CODE." >&2
|
||||||
|
else
|
||||||
|
# stdout is the verdict word; the classifier's reasoning goes to stderr and is left visible on
|
||||||
|
# purpose, so a surprising verdict can be argued with instead of guessed at.
|
||||||
|
scope=$(printf '%s\n' "$refs" | python3 "scripts/push_scope.py" --prepush-stdin) || scope=code
|
||||||
|
fi
|
||||||
|
# Anything that is not exactly "docs" takes the strict path. This is the fail-closed hinge: an empty
|
||||||
|
# variable, a crashed classifier, a typo and an unexpected word all land on `code`.
|
||||||
|
[ "$scope" = "docs" ] || scope=code
|
||||||
|
|
||||||
|
echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast --scope=$scope ..."
|
||||||
|
python3 "scripts/repo_gates.py" --fast --scope="$scope"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -ne 0 ]; then
|
if [ "$rc" -ne 0 ]; then
|
||||||
echo "pre-push [felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
|
echo "pre-push [felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
#!/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:]))
|
||||||
+112
-28
@@ -87,36 +87,49 @@ import sys
|
|||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
SCRIPTS = os.path.join(ROOT, "scripts")
|
SCRIPTS = os.path.join(ROOT, "scripts")
|
||||||
|
|
||||||
# (label, absolute script path, args, fast)
|
# (label, absolute script path, args, fast, exemptible)
|
||||||
|
#
|
||||||
|
# `exemptible` — R-404, 2026-09-01. TRUE means: on a DOCUMENTS-ONLY push this gate's CONVICTION is
|
||||||
|
# reported as ADVISORY and does not refuse the push. It is TRUE for exactly ONE gate and the list is
|
||||||
|
# meant to stay that length.
|
||||||
|
#
|
||||||
|
# WHY ONLY golden-currency. It is the only gate here that does not examine the push at all: it
|
||||||
|
# compares the controller's newest CHANGELOG heading against this repo's bake evidence and returns
|
||||||
|
# the same verdict whatever you are pushing. Every other gate convicts on something IN the change,
|
||||||
|
# so a documents push that trips one has a broken document and must be refused.
|
||||||
|
#
|
||||||
|
# The gate's own verdict is UNCHANGED — it still runs on every push and still says the same thing.
|
||||||
|
# What changed is who is refused. See scripts/push_scope.py and OPEN-ITEMS.md R-404.
|
||||||
GATES = [
|
GATES = [
|
||||||
("site", os.path.join(SCRIPTS, "site_gates.py"), [], True),
|
("site", os.path.join(SCRIPTS, "site_gates.py"), [], True, False),
|
||||||
("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True),
|
("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True, False),
|
||||||
("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True),
|
("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True, False),
|
||||||
("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True),
|
("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True, False),
|
||||||
("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True),
|
("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True, False),
|
||||||
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True),
|
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True, False),
|
||||||
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True),
|
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True, True),
|
||||||
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True),
|
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True, False),
|
||||||
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
|
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
|
||||||
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
|
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
|
||||||
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True),
|
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False),
|
||||||
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
|
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
|
||||||
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True),
|
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False),
|
||||||
# R-369 — two files held open work and only one called itself the source of truth, so a READY
|
# R-369 — two files held open work and only one called itself the source of truth, so a READY
|
||||||
# finding sat in ROADMAP.md for 25 days invisible to every "grep the register" rule and was
|
# finding sat in ROADMAP.md for 25 days invisible to every "grep the register" rule and was
|
||||||
# rediscovered by an overnight drill. Fast: two file reads.
|
# rediscovered by an overnight drill. Fast: two file reads.
|
||||||
("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True),
|
("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True, False),
|
||||||
# R-405 — the 2026-08-22 compression sweep moved R-87 into CLOSED-ITEMS.md while its own state
|
# R-405 — the 2026-08-22 compression sweep moved R-87 into CLOSED-ITEMS.md while its own state
|
||||||
# cell read READY; R-378 caught six siblings in the same session and missed this one, so it sat
|
# cell read READY; R-378 caught six siblings in the same session and missed this one, so it sat
|
||||||
# in the wrong file for nine days while the ranking paragraph pointed at nothing. Fast: two
|
# in the wrong file for nine days while the ranking paragraph pointed at nothing. Fast: two
|
||||||
# file reads.
|
# file reads.
|
||||||
("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True),
|
("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True, False),
|
||||||
# R-389 — a live finding lived in a REPORT.md observations paragraph and nowhere else, and
|
# R-389 — a live finding lived in a REPORT.md observations paragraph and nowhere else, and
|
||||||
# REPORT.md is overwritten every session. Fast: stdlib file reads.
|
# REPORT.md is overwritten every session. Fast: stdlib file reads.
|
||||||
("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True),
|
("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True, False),
|
||||||
]
|
]
|
||||||
|
|
||||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||||
|
ADVISORY = "ADVISORY" # R-404: a conviction that is reported, loudly, and does not refuse
|
||||||
|
|
||||||
|
|
||||||
def hooks_armed_note(root):
|
def hooks_armed_note(root):
|
||||||
@@ -142,46 +155,90 @@ def run_gate(label, path, args):
|
|||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path))
|
print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path))
|
||||||
print(" A missing gate is a failure, never a skip (fail-closed).")
|
print(" A missing gate is a failure, never a skip (fail-closed).")
|
||||||
return 1
|
return 1, ""
|
||||||
print("\n" + "=" * 78)
|
print("\n" + "=" * 78)
|
||||||
print("== gate: %s (%s%s)" % (label, os.path.basename(path),
|
print("== gate: %s (%s%s)" % (label, os.path.basename(path),
|
||||||
(" " + " ".join(args)) if args else ""))
|
(" " + " ".join(args)) if args else ""))
|
||||||
print("=" * 78, flush=True)
|
print("=" * 78, flush=True)
|
||||||
# stream the gate's own output rather than capturing it — its diagnostics are the point,
|
# Stream the gate's own output rather than capturing it — its diagnostics are the point, and a
|
||||||
# and a runner that swallows them makes a conviction unreadable.
|
# runner that swallows them makes a conviction unreadable. It is ALSO collected, so the advisory
|
||||||
return subprocess.call([sys.executable, path] + args, cwd=ROOT)
|
# block below can quote the gate's own words instead of re-deriving the version itself. Printing
|
||||||
|
# happens line by line as it arrives, so this is a tee and not a capture.
|
||||||
|
proc = subprocess.Popen([sys.executable, path] + args, cwd=ROOT,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||||
|
lines = []
|
||||||
|
for raw in iter(proc.stdout.readline, b""):
|
||||||
|
line = raw.decode("utf-8", "replace").rstrip("\n")
|
||||||
|
lines.append(line)
|
||||||
|
print(line, flush=True)
|
||||||
|
proc.stdout.close()
|
||||||
|
return proc.wait(), "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
fast = "--fast" in argv
|
fast = "--fast" in argv
|
||||||
unknown = [a for a in argv if a != "--fast"]
|
scope = "code"
|
||||||
if unknown:
|
rest = []
|
||||||
print("unknown argument(s): %s" % " ".join(unknown))
|
for a in argv:
|
||||||
print("usage: python3 scripts/repo_gates.py [--fast]")
|
if a == "--fast":
|
||||||
|
continue
|
||||||
|
if a.startswith("--scope="):
|
||||||
|
scope = a.split("=", 1)[1].strip()
|
||||||
|
continue
|
||||||
|
rest.append(a)
|
||||||
|
if rest:
|
||||||
|
print("unknown argument(s): %s" % " ".join(rest))
|
||||||
|
print("usage: python3 scripts/repo_gates.py [--fast] [--scope=code|docs]")
|
||||||
|
return 2
|
||||||
|
if scope not in ("code", "docs"):
|
||||||
|
# Fail closed: an unrecognised scope is never quietly treated as 'docs'.
|
||||||
|
print("unrecognised --scope=%s — expected 'code' or 'docs'." % scope)
|
||||||
|
print("An unrecognised scope is REFUSED rather than assumed, because the only assumption")
|
||||||
|
print("that could be wrong in a dangerous direction is 'docs'.")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
selected = [g for g in GATES if g[3] or not fast]
|
selected = [g for g in GATES if g[3] or not fast]
|
||||||
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
|
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
|
||||||
print("repo_gates (felhom.eu) — %d gate(s)%s" % (len(selected), " [--fast]" if fast else ""))
|
print("repo_gates (felhom.eu) — %d gate(s)%s%s"
|
||||||
|
% (len(selected), " [--fast]" if fast else "",
|
||||||
|
" [scope=docs]" if scope == "docs" else ""))
|
||||||
if skipped:
|
if skipped:
|
||||||
print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped))
|
print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped))
|
||||||
hooks_armed_note(ROOT)
|
hooks_armed_note(ROOT)
|
||||||
|
|
||||||
results = [(label, run_gate(label, path, args)) for label, path, args, _f in selected]
|
results = []
|
||||||
|
for label, path, args, _f, exemptible in selected:
|
||||||
|
rc, text = run_gate(label, path, args)
|
||||||
|
results.append((label, rc, exemptible, text))
|
||||||
|
|
||||||
print("\n" + "=" * 78)
|
print("\n" + "=" * 78)
|
||||||
print("== summary")
|
print("== summary")
|
||||||
print("=" * 78)
|
print("=" * 78)
|
||||||
worst = 0
|
worst = 0
|
||||||
for label, rc in results:
|
advisories = []
|
||||||
|
for label, rc, exemptible, text in results:
|
||||||
|
# An ADVISORY is a CONVICTION (rc == 1) on a documents-only push, for a gate registered as
|
||||||
|
# exemptible. INCONCLUSIVE (rc == 2) is deliberately NOT exemptible: it was never a
|
||||||
|
# conviction, and treating "we could not tell" as "we forgive it" is a different decision
|
||||||
|
# that nobody made.
|
||||||
|
advisory = (scope == "docs" and rc == 1 and exemptible)
|
||||||
|
if advisory:
|
||||||
|
advisories.append((label, text))
|
||||||
|
print(" %-18s %-13s (exit %d)" % (label, ADVISORY, rc))
|
||||||
|
continue
|
||||||
print(" %-18s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
|
print(" %-18s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
worst = 1 if rc == 1 or worst == 1 else 2
|
worst = 1 if rc == 1 or worst == 1 else 2
|
||||||
|
|
||||||
|
if advisories:
|
||||||
|
_print_advisory_block(advisories)
|
||||||
|
|
||||||
if worst == 0:
|
if worst == 0:
|
||||||
print("\nall felhom.eu gates OK")
|
print("\nall felhom.eu gates OK" + (" (with %d advisory — see above)" % len(advisories)
|
||||||
|
if advisories else ""))
|
||||||
return 0
|
return 0
|
||||||
convicted = [l for l, rc in results if rc == 1]
|
convicted = [l for l, rc, _e, _t in results if rc == 1 and not (scope == "docs" and _e)]
|
||||||
undecided = [l for l, rc in results if rc not in (0, 1)]
|
undecided = [l for l, rc, _e, _t in results if rc not in (0, 1)]
|
||||||
if convicted:
|
if convicted:
|
||||||
print("\nCONVICTED: %s" % ", ".join(convicted))
|
print("\nCONVICTED: %s" % ", ".join(convicted))
|
||||||
if undecided:
|
if undecided:
|
||||||
@@ -189,5 +246,32 @@ def main(argv):
|
|||||||
return worst
|
return worst
|
||||||
|
|
||||||
|
|
||||||
|
def _print_advisory_block(advisories):
|
||||||
|
"""Its own block, after the table, because a line inside a table is easy to miss.
|
||||||
|
|
||||||
|
R-404's whole premise is that the warning must NOT go quiet — only its consequence changes. If
|
||||||
|
this block ever stops printing, the change has become a silencing and Scenario A has failed.
|
||||||
|
"""
|
||||||
|
print("\n" + "!" * 78)
|
||||||
|
for label, text in advisories:
|
||||||
|
print("!! ADVISORY — %s convicted, and this push is NOT refused for it." % label)
|
||||||
|
# Quote the gate's own numbers rather than re-deriving them; a second implementation of
|
||||||
|
# "which version owes a golden" is a second thing that can be wrong.
|
||||||
|
for line in text.splitlines():
|
||||||
|
t = line.strip()
|
||||||
|
if t.startswith("newest released controller") or t.startswith("newest golden baked"):
|
||||||
|
print("!! %s" % t)
|
||||||
|
print("!!")
|
||||||
|
print("!! This push touches DOCUMENTS ONLY, so it can neither create this debt nor clear")
|
||||||
|
print("!! it — and the push that DOES clear it (a bake record under documentation/tests/)")
|
||||||
|
print("!! is itself documents-only. Blocking here blocked the cure.")
|
||||||
|
print("!!")
|
||||||
|
print("!! WHAT CLEARS IT: bake a golden per documentation/runbooks/RUNBOOK-manual-build.md")
|
||||||
|
print("!! section 4.1, then vouch it (a THREE-field change: golden_version + agent_version")
|
||||||
|
print("!! + min_agent). The debt stays visible in STATUS.md and in the controller repo's")
|
||||||
|
print("!! own golden-notice until then.")
|
||||||
|
print("!" * 78)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main(sys.argv[1:]))
|
sys.exit(main(sys.argv[1:]))
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""test_repo_gates_scope.py — R-404's acceptance test: the exemption is ONE GATE WIDE.
|
||||||
|
|
||||||
|
R3 IS THE ONE THAT MATTERS AND IT WAS WRITTEN FIRST. Everything else here is scaffolding around
|
||||||
|
it. The change this file guards makes `repo_gates.py` let a documents-only push through when
|
||||||
|
`golden-currency` convicts. The danger is not that the exemption exists — it is that it quietly
|
||||||
|
becomes general, at which point a documents push stops being checked for anything and nobody finds
|
||||||
|
out until a broken document ships.
|
||||||
|
|
||||||
|
WHY STUB GATES AND NOT THE REAL ONES. The runner's DECISION is the thing under test: given a set of
|
||||||
|
exit codes and a scope, what does it exit and what does it print? Driving that with the real gates
|
||||||
|
would make the test depend on the repo being in a particular state — the R-410 lesson, where a gate
|
||||||
|
that read a directory NAME passed with no bake behind it. Stubs make the input exact.
|
||||||
|
|
||||||
|
Run: python3 scripts/test_repo_gates_scope.py
|
||||||
|
Exit 0 all pass · 1 a case failed.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||||||
|
|
||||||
|
import repo_gates # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Capture(object):
|
||||||
|
"""Collect stdout while the runner writes to it."""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self._real = sys.stdout
|
||||||
|
self.buf = io.StringIO()
|
||||||
|
sys.stdout = self.buf
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
sys.stdout = self._real
|
||||||
|
self.text = self.buf.getvalue()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stub(tmp, name, rc):
|
||||||
|
"""A gate that does nothing but exit with `rc`, and says so, so output is readable."""
|
||||||
|
p = os.path.join(tmp, name)
|
||||||
|
with io.open(p, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(u"import sys\nprint('stub %s exiting %d')\nsys.exit(%d)\n" % (name, rc, rc))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def run(gates, argv):
|
||||||
|
"""Run the runner's main() with GATES swapped for `gates`. Returns (exit code, output)."""
|
||||||
|
saved = repo_gates.GATES
|
||||||
|
repo_gates.GATES = gates
|
||||||
|
try:
|
||||||
|
with Capture() as cap:
|
||||||
|
rc = repo_gates.main(argv)
|
||||||
|
return rc, cap.text
|
||||||
|
finally:
|
||||||
|
repo_gates.GATES = saved
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tmp = tempfile.mkdtemp(prefix="gatescope-")
|
||||||
|
fails = []
|
||||||
|
try:
|
||||||
|
ok = stub(tmp, "ok.py", 0)
|
||||||
|
bad = stub(tmp, "bad.py", 1)
|
||||||
|
inc = stub(tmp, "inc.py", 2)
|
||||||
|
|
||||||
|
# The real table's shape, with the fifth field: exemptible on a documents-only push.
|
||||||
|
def table(golden_rc, other_rc=0):
|
||||||
|
return [
|
||||||
|
("site", {0: ok, 1: bad, 2: inc}[other_rc], [], True, False),
|
||||||
|
("golden-currency", {0: ok, 1: bad, 2: inc}[golden_rc], [], True, True),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- R1: docs scope, golden-currency convicting -> ADVISORY, exit 0 -------------------
|
||||||
|
rc, out = run(table(1), ["--fast", "--scope=docs"])
|
||||||
|
if rc != 0:
|
||||||
|
fails.append("R1: a documents-only push with only golden-currency convicting must be "
|
||||||
|
"ALLOWED; got exit %d" % rc)
|
||||||
|
elif "ADVISORY" not in out:
|
||||||
|
fails.append("R1: the word ADVISORY never appeared. A silent pass is a SILENCING, "
|
||||||
|
"not a re-aim — the whole point is that it still says so:\n%s" % out)
|
||||||
|
else:
|
||||||
|
print("R1 ok: docs + golden-currency convicting -> exit 0, ADVISORY printed")
|
||||||
|
|
||||||
|
# --- R2: code scope, same conviction -> refused ---------------------------------------
|
||||||
|
rc, out = run(table(1), ["--fast", "--scope=code"])
|
||||||
|
if rc == 0:
|
||||||
|
fails.append("R2: a CODE push with golden-currency convicting must still be REFUSED")
|
||||||
|
elif "ADVISORY" in out:
|
||||||
|
fails.append("R2: a code push must not print ADVISORY — it is refused, not advised")
|
||||||
|
else:
|
||||||
|
print("R2 ok: code + golden-currency convicting -> exit %d" % rc)
|
||||||
|
|
||||||
|
# --- R3: SCENARIO C -- the exemption is ONE GATE WIDE ---------------------------------
|
||||||
|
# A documents push with a NON-exemptible gate convicting must still be refused.
|
||||||
|
# RED-PROOF: mark every gate exemptible (change the 5th field of 'site' to True) and this
|
||||||
|
# fails, which is exactly what a general exemption would look like in production.
|
||||||
|
rc, out = run(table(0, other_rc=1), ["--fast", "--scope=docs"])
|
||||||
|
if rc == 0:
|
||||||
|
fails.append("R3 (SCENARIO C): a documents-only push with the SITE gate convicting was "
|
||||||
|
"ALLOWED. The exemption has become general — every other gate must still "
|
||||||
|
"block every push:\n%s" % out)
|
||||||
|
else:
|
||||||
|
print("R3 ok: docs + a NON-exemptible gate convicting -> exit %d (refused)" % rc)
|
||||||
|
|
||||||
|
# --- R4: inconclusive is not a conviction, in either scope -----------------------------
|
||||||
|
rc_docs, out_docs = run(table(2), ["--fast", "--scope=docs"])
|
||||||
|
rc_code, _ = run(table(2), ["--fast", "--scope=code"])
|
||||||
|
if rc_docs != rc_code:
|
||||||
|
fails.append("R4: an INCONCLUSIVE golden-currency must behave identically in both "
|
||||||
|
"scopes (docs=%d code=%d) — the exemption is for CONVICTIONS, and an "
|
||||||
|
"undetermined result was never a conviction" % (rc_docs, rc_code))
|
||||||
|
elif rc_docs == 0:
|
||||||
|
fails.append("R4: INCONCLUSIVE must not pass in either scope; got exit 0")
|
||||||
|
elif "ADVISORY" in out_docs:
|
||||||
|
fails.append("R4: an inconclusive gate must not be rendered as ADVISORY")
|
||||||
|
else:
|
||||||
|
print("R4 ok: INCONCLUSIVE unchanged in both scopes (exit %d)" % rc_docs)
|
||||||
|
|
||||||
|
# --- R5: no --scope behaves exactly like today ----------------------------------------
|
||||||
|
rc_none, out_none = run(table(1), ["--fast"])
|
||||||
|
rc_code2, out_code2 = run(table(1), ["--fast", "--scope=code"])
|
||||||
|
if rc_none != rc_code2:
|
||||||
|
fails.append("R5: an unscoped run must be IDENTICAL to --scope=code (a human typing "
|
||||||
|
"the command must see today's behaviour); got %d vs %d" % (rc_none, rc_code2))
|
||||||
|
else:
|
||||||
|
# compare the summary lines, ignoring the header that names the scope
|
||||||
|
a = [l for l in out_none.splitlines() if l.startswith(" ") and "exit" in l]
|
||||||
|
b = [l for l in out_code2.splitlines() if l.startswith(" ") and "exit" in l]
|
||||||
|
if a != b:
|
||||||
|
fails.append("R5: unscoped and --scope=code printed different verdicts:\n%s\n%s"
|
||||||
|
% (a, b))
|
||||||
|
else:
|
||||||
|
print("R5 ok: unscoped == --scope=code, exit %d" % rc_none)
|
||||||
|
|
||||||
|
# --- R6: a bad scope value is refused, never silently treated as docs ------------------
|
||||||
|
rc, out = run(table(1), ["--fast", "--scope=banana"])
|
||||||
|
if rc == 0:
|
||||||
|
fails.append("R6: an unrecognised --scope value must NOT pass. Fail closed.")
|
||||||
|
else:
|
||||||
|
print("R6 ok: --scope=banana refused (exit %d)" % rc)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
if fails:
|
||||||
|
print()
|
||||||
|
for f in fails:
|
||||||
|
print("FAIL: %s" % f)
|
||||||
|
return 1
|
||||||
|
print("\nrepo_gates scope tests OK — the exemption is one gate wide (R3 is the acceptance)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user