Files
felhom.eu/scripts/due_checks_gate.py
T
admin 104ef34f57
gates / gates (push) Successful in 15s
gates: the today-override announces itself; malformed no longer swallowed
Part 6 of the hub-blindness task, separable and done rather than dropped.

Both due_checks_gate.py and instructions_gate.py read FELHOM_GATE_TODAY so
their suites can control "today", and neither said so. A shell that still has
it exported -- exactly what a session doing gate-test work leaves behind --
made both gates evaluate against a fabricated date and pass in SILENCE. That
is this project's own named failure class: an instrument that can quietly
return the wrong answer is not a measurement. The seam is legitimate and
stays; the silence was the defect.

Both now print a loud line naming the variable, its value, and that the real
date is being ignored, before any verdict.

And instructions_gate.py no longer swallows a MALFORMED override: it used to
fall through to the real date without a word while due_checks_gate.py already
exited 2 on the same input -- one variable, two gates, disagreeing about what
a mistake means. Both exit 2 now.

Tests extended in both suites (42 and 73 assertions, green). Red-proof: the
announcement was deleted from due_checks_gate.py and its two assertions were
seen failing, then reverted.

Also adds REPORT-hub-blindness.md (topic-suffixed; the shared REPORT.md is
left alone per the parallel-session rule).
2026-08-18 19:32:35 +02:00

259 lines
13 KiB
Python

#!/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:
d = 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)
# THE OVERRIDE ANNOUNCES ITSELF. A shell that still has this exported — exactly what a session
# doing gate-test work leaves behind — would otherwise make this gate evaluate a fabricated
# "today" and pass in silence. An instrument that can quietly return the wrong answer is not a
# measurement, so the seam stays and is made loud.
print("!! FELHOM_GATE_TODAY=%s IS SET — this gate is evaluating against that date, NOT "
"today's real UTC date. Unset it for a real run." % override.strip())
return d
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))