104ef34f57
gates / gates (push) Successful in 15s
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).
259 lines
13 KiB
Python
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))
|