due-checks gate (R-341), floor raise recorded (R-343), snapshot coverage (R-342)
gates / gates (push) Successful in 14s
gates / gates (push) Successful in 14s
PART 1+2 — dated checks stop being wishes. R-341 booked two measurements as prose in a register row; nothing read those dates and nothing would have objected when they passed. The dates now live in a DUE-CHECKS block INSIDE OPEN-ITEMS.md (inside, so no sidecar can drift from it) and a new gate reads them. Registered as #10 in repo_gates.py, --fast, so it runs in BOTH the pre-push hook and CI. exit 0 nothing due (prints pending count + nearest date; empty block too) exit 1 a row is due/overdue (due <= today, UTC -- due TODAY counts), or a row names an item with no R-row exit 2 block absent/duplicated/unparseable -- INCONCLUSIVE, never 0 It REFUSES rather than warns, and its docstring states the limitation: it is NOT a scheduler, it fires on the next push, not on the date. 37 tests. BOTH red-proofs run and reverted -- and the first one earned its keep by catching a hollow assertion of MINE rather than confirming the gate: flipping <= to < left a due-today row in neither bucket, min() raised on an empty list, and the TRACEBACK exited 1, so "rc == 1" passed while the boundary was wrong. An exit code cannot tell a verdict from a crash. The test now asserts the conviction banner and the absence of a traceback, and the gate returns 2 rather than crashing if that partition breaks again. PART 3 — the floor raise, and the premise was WRONG. Read back from the store (not the form): min_controller_version = 0.216.0 @ 12:36:58Z, zero per-customer overrides, no "managed floor HELD" line. But read 5 shows the raise was NOT a no-op: demo-felhom had been on 0.214.0 since 12 Aug and auto-updated 0.214.0 -> 0.216.0 at 12:37:07Z -- NINE SECONDS after the save, exactly the immediate action publish-train rule 2 documents. No error events followed; it restarted clean. R-343 is therefore filed OPEN, not CLOSED: the closing condition was all five reads clean and no directive served. It went well, but a record calling it inert when it moved a customer box is what misleads the next reader. The row also states why the floor was behind -- rule 2 policy, not drift, earned by the 2026-07-11 skew onto Peti's box -- and cites ResolveManagedFloor (store.go:2068) plus the two build-felhom-iso.sh facts (build-time at :267, fails open at :78-82) rather than asserting them. Two boxes are below the floor and neither reports: drill-r50 (blocked, powered off) and peti-felhom (host row deleted). peti-felhom was NOT contacted -- its row records that a report from a deleted host 401s and is not persisted, so the raise cannot reach it. PART 4 — R-342 filed READY, quoting stop2-snapshot.txt verbatim: Hetzner server snapshot 421440873 covers /dev/sda only; /mnt/pbs-datastore is a separate Volume that snapshots exclude, so a rollback restores software state and NOT the datastore. Fine for that upgrade; the safeguard for any future procedure that could touch the datastore does not exist and is Viktor's call. Also: CLAUDE.md's gate list named 6 of 10 registered gates -- completed rather than appending a 7th to a wrong list (124 -> 128 effective, ceiling 200). Capability map deliberately unchanged; no row cites a floor or golden version. repo_gates.py fully green, 10/10.
This commit is contained in:
@@ -1,3 +1,36 @@
|
||||
## due_checks_gate.py v1.0.0 — a dated check becomes a thing that bites (2026-08-18, R-341)
|
||||
|
||||
**New gate, registered as #10 in `repo_gates.py` (`--fast`).** R-341 booked two dated measurements —
|
||||
+24 h and +7 d — as a sentence inside a register row. Nothing read those dates, and nothing would
|
||||
have objected when they passed. That is the same shape as R-242 (a rule filed without a mechanism,
|
||||
which recurred the next day) and as the R-29 census, where the checks nobody was told to run were the
|
||||
ones failing for weeks.
|
||||
|
||||
**The dates now live in a `DUE-CHECKS` block INSIDE `documentation/backlog/OPEN-ITEMS.md`** — inside,
|
||||
so there is no sidecar to drift from the register. The block is an index; the command and the
|
||||
preconditions stay in the R-row.
|
||||
|
||||
**Exit-code semantics** (the runner's 0/1/2 taxonomy):
|
||||
|
||||
| exit | when |
|
||||
|---|---|
|
||||
| **0** | nothing due — prints the pending count and the nearest date, because a passing run that says nothing teaches nobody what it watched. A well-formed EMPTY block is also 0, with its own message |
|
||||
| **1** | a row is due or overdue (`due <= today`, UTC — **due today counts as due**), or a row names an item with no `\| **R-xxx** \|` row in the register |
|
||||
| **2** | the block is absent, duplicated, or a row does not parse — INCONCLUSIVE, naming the exact line. **Never 0**: a gate that finds nothing to check and reports success is the inert-seam failure |
|
||||
|
||||
**It REFUSES rather than warns**, deliberately — a warning is what gets scrolled past.
|
||||
|
||||
**THE LIMITATION, stated in the docstring so it cannot be forgotten: this is NOT a scheduler.** It
|
||||
fires on the next push, not on the date. A real scheduler was deliberately not built; the mitigation
|
||||
is that CI runs the same entry point on every push and e-mails the operator on failure.
|
||||
|
||||
Tests: `scripts/test_due_checks_gate.py`, 37 assertions. Two companion red-proofs were run and
|
||||
reverted. **The first one found a hollow assertion in this suite rather than confirming it:** flipping
|
||||
the boundary `<=` to `<` left the due-today row in neither bucket, `min()` raised on an empty list,
|
||||
and the traceback exited 1 — so the exit-code check passed while the boundary was wrong. The test now
|
||||
asserts the conviction banner and the absence of a traceback, and the gate returns 2 instead of
|
||||
crashing if that partition is ever broken again.
|
||||
|
||||
## felhom-host-install.sh v1.28.0 — the removal genuinely reverses the installation (2026-08-13, R-316)
|
||||
|
||||
**v1.27.0's fix worked exactly once per machine, and this is the measurement.** Three full cycles on
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Due-checks gate — a dated check in the register becomes a thing that BITES.
|
||||
|
||||
Run from the repo root: python3 scripts/due_checks_gate.py [path/to/OPEN-ITEMS.md]
|
||||
Exit 0 clean · 1 convicted (something is due or overdue) · 2 inconclusive (cannot evaluate).
|
||||
|
||||
WHY THIS EXISTS, and why the register alone was not enough.
|
||||
|
||||
R-341 was filed on 2026-08-18 with two dated checks — *+24 h* and *+7 d* — written as prose inside
|
||||
the row. **Nothing read those dates and nothing would have objected when they passed.** This
|
||||
project's own standard, stated in the workspace CLAUDE.md, is that *a rule without a mechanism is a
|
||||
wish*, and it has been earned repeatedly: R-242 was filed as a mechanism-less rule and recurred the
|
||||
next day; the R-29 gate census found two checks nobody was told to run had been failing for weeks,
|
||||
one since 14 July. A date sitting in a paragraph is exactly that shape.
|
||||
|
||||
So the dates move into a machine-readable block **inside `OPEN-ITEMS.md`** and this gate reads them.
|
||||
The block lives in the register rather than in a sidecar file deliberately: a sidecar is a second
|
||||
source of truth, and the two drift the moment someone edits one.
|
||||
|
||||
⚠ THE HONEST LIMITATION — read this before trusting it, and do not let it be forgotten.
|
||||
|
||||
**This is NOT a scheduler.** It fires when someone next runs the gates — i.e. on the next push, via
|
||||
`.githooks/pre-push` and CI — **not when the date arrives**. If nobody pushes for a week after a
|
||||
check comes due, nothing speaks for that week. A real scheduler (systemd timer, cron, a hub job) was
|
||||
deliberately NOT built here: it is a larger design with its own failure modes, and the push-triggered
|
||||
version was accepted knowing this. **The mitigation is that CI runs the same entry point on every
|
||||
push and e-mails the operator on failure**, so the first push after a due date turns into a message
|
||||
rather than a silent pass. If the gap between pushes ever becomes the problem, that is the argument
|
||||
for the scheduler, and this paragraph is the record that it was a choice.
|
||||
|
||||
It also cannot tell you whether a check was done WELL — only that a row is still sitting there. The
|
||||
row is cleared by hand when the measurement is taken and its result recorded in the R-row.
|
||||
|
||||
THE RULES, stated so a boundary is not silently re-decided later:
|
||||
|
||||
* **UTC, always.** `datetime.now(timezone.utc).date()`. A local-time comparison would make this
|
||||
gate fire on a different day for the operator in CEST than for CI, and "it passed on my machine"
|
||||
is not a property a gate may have.
|
||||
* **Due TODAY counts as DUE** (`due <= today`, not `<`). A check scheduled for the 19th is meant to
|
||||
happen on the 19th; letting the 19th pass silently and convicting only on the 20th would make the
|
||||
gate a day late by design.
|
||||
* **An orphaned R-number is a conviction, not a warning.** If the block cites an item that has no
|
||||
row in the register, the coupling is broken — and an item whose row has vanished while its dated
|
||||
check remains is precisely how an item gets lost, which is the failure the register exists to end.
|
||||
* **FAIL-CLOSED, and 2 is not 0.** A missing, duplicated or unparseable block exits 2
|
||||
(INCONCLUSIVE). A gate that quietly finds nothing to check reads as coverage while providing
|
||||
none — the inert-seam failure this project has shipped four times. The runner reports 2
|
||||
distinctly for exactly this reason.
|
||||
* **A passing run still says what it looked at.** An absent log line is not evidence of correct
|
||||
behaviour (workspace standing rule 3), so a clean run prints the pending count and the nearest
|
||||
due date rather than going quiet.
|
||||
|
||||
Stdlib only; no network, no subprocess — so it qualifies as `--fast` and therefore runs in BOTH the
|
||||
pre-push hook and CI. A non-fast gate would run in neither, which is the R-29 failure the runner
|
||||
ended.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_REGISTER = os.path.join(ROOT, "documentation", "backlog", "OPEN-ITEMS.md")
|
||||
|
||||
BEGIN = "DUE-CHECKS-BEGIN"
|
||||
END = "DUE-CHECKS-END"
|
||||
|
||||
# `| R-341 | 2026-08-19 | ep0 proxy fd count … |` — three cells, item / date / what.
|
||||
ROW_RE = re.compile(r"^\|\s*(?P<item>[A-Za-z0-9][A-Za-z0-9._-]*)\s*\|"
|
||||
r"\s*(?P<due>[^|]*?)\s*\|"
|
||||
r"\s*(?P<what>[^|]*?)\s*\|\s*$")
|
||||
# The header and its separator, which are not data.
|
||||
HEADER_RE = re.compile(r"^\|\s*item\s*\|", re.I)
|
||||
SEP_RE = re.compile(r"^\|[\s:|-]+\|$")
|
||||
# `| **R-341** | …` anywhere above — the register's own row shape.
|
||||
def item_row_re(item):
|
||||
return re.compile(r"^\|\s*\*\*" + re.escape(item) + r"\*\*\s*\|")
|
||||
|
||||
|
||||
def today_utc():
|
||||
"""Today's date in UTC. Injectable via FELHOM_GATE_TODAY for the test suite.
|
||||
|
||||
The env override follows instructions_gate.py's existing convention rather than inventing a
|
||||
second one — a test suite that cannot control 'today' can only test the boring branch.
|
||||
"""
|
||||
override = os.environ.get("FELHOM_GATE_TODAY")
|
||||
if override:
|
||||
try:
|
||||
return datetime.strptime(override.strip(), "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: FELHOM_GATE_TODAY=%r is not YYYY-MM-DD" % override)
|
||||
sys.exit(2)
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def read_register(path):
|
||||
if not os.path.isfile(path):
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: register not found at %s" % path)
|
||||
print(" A missing register is never a pass — the gate cannot know what is due.")
|
||||
sys.exit(2)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return fh.read().split("\n")
|
||||
|
||||
|
||||
def extract_block(lines, path):
|
||||
"""Return (block_lines, first_line_no). Exits 2 on absent/duplicated markers."""
|
||||
begins = [i for i, l in enumerate(lines) if BEGIN in l]
|
||||
ends = [i for i, l in enumerate(lines) if END in l]
|
||||
if len(begins) == 0 or len(ends) == 0:
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: no %s / %s block in %s" % (BEGIN, END, path))
|
||||
print(" Expected a machine-readable block; found begin=%d end=%d." % (len(begins), len(ends)))
|
||||
print(" This is INCONCLUSIVE, not a pass: a gate that finds nothing to check and reports")
|
||||
print(" success is the inert-seam failure. Restore the block or remove this gate.")
|
||||
sys.exit(2)
|
||||
if len(begins) > 1 or len(ends) > 1:
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: the block appears more than once in %s" % path)
|
||||
print(" begin markers on lines: %s" % ", ".join(str(i + 1) for i in begins))
|
||||
print(" end markers on lines: %s" % ", ".join(str(i + 1) for i in ends))
|
||||
print(" Two blocks are two sources of truth and the gate will not guess which one counts.")
|
||||
sys.exit(2)
|
||||
b, e = begins[0], ends[0]
|
||||
if e <= b:
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: %s (line %d) appears before %s (line %d) in %s"
|
||||
% (END, e + 1, BEGIN, b + 1, path))
|
||||
sys.exit(2)
|
||||
# The BEGIN marker lives INSIDE an HTML comment that documents the block, and that comment
|
||||
# normally runs on for several lines. So the block does not start in neutral text — it starts
|
||||
# mid-comment whenever the marker's own line has not closed it. Getting this wrong is what the
|
||||
# first run of this gate did: it read the comment's second line as a malformed table row.
|
||||
return lines[b + 1:e], b + 2, ("-->" not in lines[b])
|
||||
|
||||
|
||||
def parse_rows(block, first_no, path, in_comment=False):
|
||||
"""[(item, date, what, line_no)] — exits 2 naming the exact line on any unparseable row.
|
||||
|
||||
`in_comment` carries whether the block opens inside the marker's own HTML comment.
|
||||
"""
|
||||
rows = []
|
||||
for off, raw in enumerate(block):
|
||||
line_no = first_no + off
|
||||
s = raw.strip()
|
||||
if in_comment:
|
||||
# Still inside a comment; it ends on the line carrying '-->'.
|
||||
if "-->" in s:
|
||||
in_comment = False
|
||||
continue
|
||||
if not s:
|
||||
continue
|
||||
if s.startswith("<!--"):
|
||||
if "-->" not in s:
|
||||
in_comment = True
|
||||
continue
|
||||
if HEADER_RE.match(s) or SEP_RE.match(s):
|
||||
continue
|
||||
if not s.startswith("|"):
|
||||
# Prose inside the block is a malformed block, not a comment. Say which line.
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: %s line %d is not a table row and not a comment:"
|
||||
% (path, line_no))
|
||||
print(" %s" % s[:120])
|
||||
sys.exit(2)
|
||||
m = ROW_RE.match(s)
|
||||
if not m:
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: %s line %d does not parse as "
|
||||
"`| item | due | what |`:" % (path, line_no))
|
||||
print(" %s" % s[:120])
|
||||
sys.exit(2)
|
||||
item = m.group("item").strip()
|
||||
due_s = m.group("due").strip()
|
||||
try:
|
||||
due = datetime.strptime(due_s, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: %s line %d has due date %r, expected YYYY-MM-DD"
|
||||
% (path, line_no, due_s))
|
||||
sys.exit(2)
|
||||
rows.append((item, due, m.group("what").strip(), line_no))
|
||||
return rows
|
||||
|
||||
|
||||
def main(argv):
|
||||
path = argv[1] if len(argv) > 1 else DEFAULT_REGISTER
|
||||
lines = read_register(path)
|
||||
block_lines, first_no, opens_in_comment = extract_block(lines, path)
|
||||
rows = parse_rows(block_lines, first_no, path, opens_in_comment)
|
||||
today = today_utc()
|
||||
|
||||
if not rows:
|
||||
# Distinguishable from the missing-block case above, and deliberately so: a well-formed
|
||||
# empty block means "nothing is pending", a missing one means "the gate lost its input".
|
||||
print("due-checks gate OK — no dated checks pending (the block is present and empty).")
|
||||
print(" today (UTC): %s register: %s" % (today.isoformat(), os.path.relpath(path, ROOT)))
|
||||
return 0
|
||||
|
||||
# The coupling check runs over every row before any verdict: an orphan is a conviction even if
|
||||
# its date is far away, because the breakage is the missing row, not the timing.
|
||||
text = "\n".join(lines)
|
||||
orphans = []
|
||||
for item, due, what, line_no in rows:
|
||||
if not item_row_re(item).search(text, 0) and not any(
|
||||
item_row_re(item).match(l) for l in lines):
|
||||
orphans.append((item, due, line_no))
|
||||
if orphans:
|
||||
print("DUE-CHECKS GATE FAILED: a dated check names an item with no row in the register.")
|
||||
for item, due, line_no in orphans:
|
||||
print(" %-8s due %s (block line %d) — no `| **%s** |` row found"
|
||||
% (item, due.isoformat(), line_no, item))
|
||||
print("")
|
||||
print("A dated check whose item does not exist is how an item gets lost, which is the")
|
||||
print("failure the register exists to end. Restore the row, or remove the dated check.")
|
||||
return 1
|
||||
|
||||
due_now = [r for r in rows if r[1] <= today]
|
||||
pending = [r for r in rows if r[1] > today]
|
||||
|
||||
if due_now:
|
||||
print("DUE-CHECKS GATE FAILED: %d dated check(s) are due or overdue as of %s (UTC)."
|
||||
% (len(due_now), today.isoformat()))
|
||||
print("")
|
||||
for item, due, what, line_no in sorted(due_now, key=lambda r: r[1]):
|
||||
overdue = (today - due).days
|
||||
when = "DUE TODAY" if overdue == 0 else "%d day(s) OVERDUE" % overdue
|
||||
print(" %-8s due %s %s" % (item, due.isoformat(), when))
|
||||
print(" measure: %s" % what)
|
||||
print(" the command and its preconditions are in the %s row of %s"
|
||||
% (item, os.path.relpath(path, ROOT)))
|
||||
print("")
|
||||
print("Take the measurement, record the result in that R-row, then remove the row from the")
|
||||
print("DUE-CHECKS block. Moving the date instead is allowed — state the reason in the R-row.")
|
||||
print("NOTE: this gate fires on a PUSH, not on the date; it may be later than the date.")
|
||||
return 1
|
||||
|
||||
if not pending:
|
||||
# Unreachable while the boundary is `<=` / `>`, which partition the rows exactly — but a
|
||||
# gate must never end in a traceback, and this branch is not theatre: the red-proof that
|
||||
# flipped `<=` to `<` landed here and CRASHED with `min() iterable argument is empty`,
|
||||
# exiting 1 for the wrong reason and making the boundary test pass on a lie. A crash is
|
||||
# never a verdict; if the partition is ever broken again, say so as INCONCLUSIVE.
|
||||
print("DUE-CHECKS GATE INCONCLUSIVE: %d row(s) parsed but none classified as due or "
|
||||
"pending — the date comparison is broken." % len(rows))
|
||||
return 2
|
||||
nearest = min(pending, key=lambda r: r[1])
|
||||
print("due-checks gate OK — %d dated check(s) pending, none due yet." % len(pending))
|
||||
print(" today (UTC): %s" % today.isoformat())
|
||||
print(" nearest: %s due %s (in %d day(s)) — %s"
|
||||
% (nearest[0], nearest[1].isoformat(), (nearest[1] - today).days, nearest[2][:70]))
|
||||
print(" (fires on the next PUSH after a date passes, not on the date itself — by design)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -15,6 +15,21 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
|
||||
5. reuse-refs every path cited by this repo's REUSE.md still resolves
|
||||
6. instructions CLAUDE.md length/versions/TEMPORARY, rule-file scoping, workspace-copy identity
|
||||
7. golden-currency a released controller has a golden carrying it (R-242)
|
||||
8. wire-contract every emitted field is decodable by its receiver (G-1)
|
||||
9. hub-copy the hub's customer-facing words, against the retired-name list (R-324)
|
||||
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
|
||||
|
||||
WHY 10 IS HERE (2026-08-18, R-341). R-341 booked two dated measurements — +24 h and +7 d —
|
||||
as a sentence inside a register row. Nothing read those dates, and nothing would have said a word
|
||||
when they passed; the row would simply have gone quiet and stayed that way. That is the same shape
|
||||
as R-242 (a rule filed without a mechanism, which recurred the next day) and as the R-29 census
|
||||
finding below, where the checks nobody was told to run were the ones that had been failing for
|
||||
weeks. The dates now live in a machine-readable block INSIDE OPEN-ITEMS.md — inside, so there is no
|
||||
sidecar to drift from the register — and this gate refuses the push once one comes due. It REFUSES
|
||||
rather than warns, deliberately: a warning is the thing that gets scrolled past, and this repo has
|
||||
the census to prove it. It is `--fast` (stdlib file read, no network) so it runs in both the
|
||||
pre-push hook and CI. **It is not a scheduler and its docstring says so** — it fires on the next
|
||||
push after a date passes, not on the date.
|
||||
|
||||
WHY 7 IS HERE (2026-08-08, R-242). R-242 was filed as a rule with no mechanism — *a controller
|
||||
release is not finished until a golden carries it* — and RECURRED THE NEXT DAY: v0.206.0 shipped
|
||||
@@ -72,6 +87,8 @@ GATES = [
|
||||
# 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.
|
||||
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True),
|
||||
# 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),
|
||||
]
|
||||
|
||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Fixture tests for due_checks_gate.py.
|
||||
|
||||
Run: python3 scripts/test_due_checks_gate.py
|
||||
|
||||
Every test asserts the EFFECT — the exit code AND that the message names the item and the reason —
|
||||
not merely that the gate ran. A gate that refuses a push without saying WHICH check is overdue sends
|
||||
the reader back to the register to guess, which is the state this gate exists to end.
|
||||
|
||||
Group G drives `repo_gates.py` itself and asserts the gate appears in the runner's output. It
|
||||
deliberately does NOT grep repo_gates.py's source: a commented-out registration still contains the
|
||||
string, so a source grep would pass on exactly the inert-seam failure this project has shipped
|
||||
before. Only running the runner proves the gate is wired.
|
||||
|
||||
Fixtures are temp files; nothing here touches the real register.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
GATE = os.path.join(HERE, "due_checks_gate.py")
|
||||
RUNNER = os.path.join(HERE, "repo_gates.py")
|
||||
|
||||
PASSED = []
|
||||
FAILED = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
(PASSED if cond else FAILED).append(name + ((" — " + detail) if detail else ""))
|
||||
print((" PASS " if cond else " FAIL ") + name + ((" " + detail) if detail else ""))
|
||||
|
||||
|
||||
def run_gate(register_path, today="2026-08-20"):
|
||||
env = dict(os.environ, FELHOM_GATE_TODAY=today)
|
||||
p = subprocess.run([sys.executable, GATE, register_path],
|
||||
capture_output=True, text=True, env=env)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
|
||||
HEADER = "| item | due (UTC) | what to measure |\n|---|---|---|\n"
|
||||
|
||||
|
||||
def make_register(tmp, rows_block, item_rows=("R-341",), markers=1, name="OPEN-ITEMS.md"):
|
||||
"""Build a fixture register: some R-rows, then `markers` copies of the delimited block."""
|
||||
path = os.path.join(tmp, name)
|
||||
body = ["# OPEN-ITEMS (fixture)", ""]
|
||||
for it in item_rows:
|
||||
body.append("| **%s** | some description | WATCHING | — | do a thing | CC |" % it)
|
||||
body.append("")
|
||||
for _ in range(markers):
|
||||
body.append("<!-- DUE-CHECKS-BEGIN — machine-readable. Parsed by scripts/due_checks_gate.py.")
|
||||
body.append(" One row per dated check. Dates are UTC. -->")
|
||||
body.append(rows_block.rstrip("\n"))
|
||||
body.append("<!-- DUE-CHECKS-END -->")
|
||||
body.append("")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(body) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
# ── Group A — overdue refuses the push ───────────────────────────────────────────────────────
|
||||
def test_a_overdue_exits_1_and_names_item_and_days():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-19 | fd count + split |\n")
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("A: overdue exits 1", rc == 1, "rc=%d" % rc)
|
||||
check("A: names the item", "R-341" in out)
|
||||
check("A: names the due date", "2026-08-19" in out)
|
||||
check("A: says how many days overdue", "1 day(s) OVERDUE" in out)
|
||||
check("A: points at the R-row for the command", "R-341 row of" in out)
|
||||
|
||||
|
||||
# ── Group B — a future check is silent but not mute ──────────────────────────────────────────
|
||||
def test_b_future_exits_0_and_still_reports_what_it_saw():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-25 | fd count + split |\n")
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("B: future exits 0", rc == 0, "rc=%d" % rc)
|
||||
check("B: prints the pending count", "1 dated check(s) pending" in out)
|
||||
check("B: prints the nearest due date", "2026-08-25" in out)
|
||||
check("B: a passing run is not mute", "nearest:" in out)
|
||||
|
||||
|
||||
# ── Group C — due exactly today counts as due ────────────────────────────────────────────────
|
||||
def test_c_due_today_is_due():
|
||||
"""The boundary is `due <= today`. Assert the CONVICTION, not merely a non-zero exit.
|
||||
|
||||
The first version of this test asserted only `rc == 1`, and the red-proof caught it being
|
||||
hollow: flipping `<=` to `<` sent the row into neither bucket, `min()` raised on an empty
|
||||
list, and the traceback exited 1 — so the test passed while the boundary was wrong. An exit
|
||||
code alone cannot distinguish a verdict from a crash, which is why the banner is asserted and
|
||||
a traceback is explicitly ruled out.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-19 | fd count + split |\n")
|
||||
rc, out = run_gate(reg, today="2026-08-19")
|
||||
check("C: due TODAY exits 1 (boundary is <=)", rc == 1, "rc=%d" % rc)
|
||||
check("C: exits 1 as a VERDICT, not a traceback", "DUE-CHECKS GATE FAILED" in out)
|
||||
check("C: did not crash", "Traceback" not in out)
|
||||
check("C: says DUE TODAY rather than overdue", "DUE TODAY" in out)
|
||||
|
||||
|
||||
def test_c_gate_never_ends_in_a_traceback():
|
||||
"""Whatever the input, the gate exits 0/1/2 with a message — never a Python traceback."""
|
||||
cases = {
|
||||
"overdue": "| R-341 | 2026-08-19 | x |\n",
|
||||
"future": "| R-341 | 2099-01-01 | x |\n",
|
||||
"empty": "",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name, rows in cases.items():
|
||||
reg = make_register(tmp, HEADER + rows, name="OPEN-%s.md" % name)
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("C: %s ends in a verdict not a traceback" % name,
|
||||
"Traceback" not in out and rc in (0, 1, 2), "rc=%d" % rc)
|
||||
|
||||
|
||||
# ── Group D — an orphaned row ────────────────────────────────────────────────────────────────
|
||||
def test_d_orphaned_item_is_a_conviction():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "| R-999 | 2026-09-30 | something |\n",
|
||||
item_rows=("R-341",))
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("D: orphan exits non-zero", rc != 0, "rc=%d" % rc)
|
||||
check("D: names the orphaned item", "R-999" in out)
|
||||
check("D: names the broken coupling", "no row in the register" in out
|
||||
or "no `| **R-999** |` row found" in out)
|
||||
|
||||
|
||||
def test_d_orphan_convicts_even_when_date_is_far_away():
|
||||
"""The breakage is the missing row, not the timing — so a far-future orphan still convicts."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "| R-888 | 2099-01-01 | far future |\n")
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("D: far-future orphan still convicts", rc != 0, "rc=%d" % rc)
|
||||
|
||||
|
||||
# ── Group E — malformed or missing block is INCONCLUSIVE, never a pass ───────────────────────
|
||||
def test_e_missing_block_exits_2():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "OPEN-ITEMS.md")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("# OPEN-ITEMS (fixture)\n\n| **R-341** | d | s | — | a | CC |\n")
|
||||
rc, out = run_gate(path, today="2026-08-20")
|
||||
check("E: missing block exits 2 (NOT 0)", rc == 2, "rc=%d" % rc)
|
||||
check("E: says inconclusive", "INCONCLUSIVE" in out)
|
||||
check("E: explains why 0 would be wrong", "inert-seam" in out or "not a pass" in out)
|
||||
|
||||
|
||||
def test_e_missing_register_exits_2():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rc, out = run_gate(os.path.join(tmp, "nope.md"), today="2026-08-20")
|
||||
check("E: absent register exits 2", rc == 2, "rc=%d" % rc)
|
||||
|
||||
|
||||
def test_e_malformed_row_exits_2_naming_the_line():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "| R-341 | not-a-date | fd count |\n")
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("E: bad date exits 2", rc == 2, "rc=%d" % rc)
|
||||
check("E: names the offending value", "not-a-date" in out)
|
||||
check("E: names the exact line", "line " in out)
|
||||
|
||||
|
||||
def test_e_non_row_prose_in_block_exits_2_naming_the_line():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "this is prose, not a row\n")
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("E: prose inside the block exits 2", rc == 2, "rc=%d" % rc)
|
||||
check("E: quotes the offending line", "this is prose" in out)
|
||||
|
||||
|
||||
def test_e_duplicated_block_exits_2():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-25 | fd |\n", markers=2)
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("E: duplicated block exits 2", rc == 2, "rc=%d" % rc)
|
||||
check("E: says two sources of truth", "more than once" in out)
|
||||
|
||||
|
||||
# ── Group F — a well-formed empty block is a PASS, distinguishable from E ────────────────────
|
||||
def test_f_empty_block_exits_0_with_its_own_message():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg = make_register(tmp, HEADER)
|
||||
rc, out = run_gate(reg, today="2026-08-20")
|
||||
check("F: empty block exits 0", rc == 0, "rc=%d" % rc)
|
||||
check("F: says no dated checks pending", "no dated checks pending" in out)
|
||||
check("F: distinguishable from a missing block", "INCONCLUSIVE" not in out)
|
||||
|
||||
|
||||
# ── Group G — registration, proven by DRIVING the runner (never a source grep) ────────────────
|
||||
def test_g_gate_is_registered_in_the_runner():
|
||||
"""A gate that passes its own tests and is not wired into repo_gates.py is inert.
|
||||
|
||||
This runs the runner and looks for the gate's label in its OUTPUT. A grep over
|
||||
repo_gates.py's source would also match a commented-out line, which is precisely the
|
||||
failure mode being guarded against.
|
||||
"""
|
||||
p = subprocess.run([sys.executable, RUNNER, "--fast"],
|
||||
capture_output=True, text=True, cwd=ROOT)
|
||||
out = p.stdout + p.stderr
|
||||
check("G: runner names the due-checks gate in its output", "due-checks" in out)
|
||||
check("G: runner reports a verdict line for it",
|
||||
any(l.strip().startswith("due-checks") and ("OK" in l or "FAILED" in l
|
||||
or "INCONCLUSIVE" in l)
|
||||
for l in out.splitlines()))
|
||||
check("G: runner invoked the gate script by name",
|
||||
"due_checks_gate.py" in out)
|
||||
|
||||
|
||||
def main():
|
||||
print("test_due_checks_gate")
|
||||
for fn in sorted((v for k, v in globals().items() if k.startswith("test_")),
|
||||
key=lambda f: f.__name__):
|
||||
fn()
|
||||
print("")
|
||||
print("passed: %d failed: %d" % (len(PASSED), len(FAILED)))
|
||||
if FAILED:
|
||||
print("")
|
||||
for f in FAILED:
|
||||
print(" FAILED: %s" % f)
|
||||
return 1 if FAILED else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user