#!/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[A-Za-z0-9][A-Za-z0-9._-]*)\s*\|" r"\s*(?P[^|]*?)\s*\|" r"\s*(?P[^|]*?)\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("" 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))