#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Status-view gate — root STATUS.md stays one screen, and stays a VIEW of OPEN-ITEMS.md. Run from the repo root: python3 scripts/status_view_gate.py [STATUS.md] [OPEN-ITEMS.md] Exit 0 clean (or convicted before SWITCH_DATE: a loud WARNING) · 1 convicted · 2 inconclusive. WHY THIS EXISTS. The operator's instruction for STATUS.md is: a plain-language view of OPEN-ITEMS.md, never a second source of truth, one screen except the section that asks them to decide. Nothing checked it, so every session prepended its news and nobody removed anything: on 2026-10-20 the page was 107 lines and about 31,700 characters, the „Updated" paragraph held three weeks of news, eleven decided rows sat in the decide table, and the do list held finished items, statements and Hungarian text. That page was rebuilt the same day; this gate keeps it rebuilt. THE CHECKS (each has a decoy in scripts/test_status_view_gate.py that has been seen to fail): size everything outside „Waiting on you — decide" is at most MAX_LINES lines and MAX_CHARS characters (blank lines count: they take screen space too). updated exactly one „**Updated" paragraph, at most MAX_UPDATED_SENTENCES sentences after its bold lead, and no „Earlier" chain inside it (older news goes to STATUS-HISTORY.md). english no Hungarian letter outside a quote („…", "…", “…”, `…`): a UI text or a page name may be quoted, the page itself is English (STE). open-ids every R-n the page cites is an OPEN row of OPEN-ITEMS.md. decide no row of the decide table is decided, closed or done. p1 every open P1 row appears under „What is broken" or „decide". operator every open row whose Owner cell names the operator appears under „decide" or „do" (a compact „Later:" line of ids counts). WARN-FIRST until SWITCH_DATE (2026-10-27, register row R-1174, with a dated check in the DUE-CHECKS block): before that date a conviction prints a WARNING and exits 0; from that date it exits 1. INCONCLUSIVE (exit 2) has no warn period — a page with no sections or an unreadable register is never a pass (the due-checks rule: a gate that finds nothing to check is not coverage). A clean run says what it looked at (lines, characters, rows checked) — an absent complaint is not evidence (workspace standing rule 3). Stdlib only, no network, no subprocess: --fast, so it runs in the pre-push hook AND in CI. """ import os import re import sys from datetime import date, datetime, timezone for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8", errors="replace") except (AttributeError, ValueError, OSError): # pragma: no cover pass ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_STATUS = os.path.join(ROOT, "STATUS.md") DEFAULT_REGISTER = os.path.join(ROOT, "documentation", "backlog", "OPEN-ITEMS.md") # The one date the warning becomes a refusal. One constant, named in R-1174 and in the DUE-CHECKS block. SWITCH_DATE = date(2026, 10, 27) MAX_LINES = 50 MAX_CHARS = 6000 MAX_UPDATED_SENTENCES = 3 # Section headings, matched on the start of a `## ` line, case-insensitive. SECTIONS = { "works": "what works", "broken": "what is broken", "work_on": "what we work on", "decide": "waiting on you — decide", "do": "waiting on you — do", } HU_LETTERS = re.compile(u"[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]") # A quote is a span the page may carry in Hungarian: a UI text, a page name, a code span. QUOTE_SPANS = [ re.compile(u"„[^\n„]*?[\"”]"), re.compile(u"“[^\n“]*?”"), re.compile(u"\"[^\"\n]*\""), re.compile(u"`[^`\n]*`"), ] RID = re.compile(r"\bR-(\d+)\b") REG_ROW = re.compile(r"^\|\s*\*\*(R-\d+)\*\*\s*\|") OPERATOR = re.compile(r"\b(operator|viktor)\b", re.I) FINISHED = re.compile(r"\b(DECIDED|CLOSED)\b") FINISHED_CELL = ("done", "decided", "closed") SENTENCE_END = re.compile(u"[.!?](?=\\s+[A-Z„\"“(`*]|\\s*$)") def inconclusive(msg, *more): print("STATUS-VIEW GATE INCONCLUSIVE: " + msg) for m in more: print(" " + m) print(" INCONCLUSIVE is not a pass: a gate that finds nothing to check is not coverage.") sys.exit(2) def today_utc(): override = os.environ.get("FELHOM_GATE_TODAY") if override: try: d = datetime.strptime(override.strip(), "%Y-%m-%d").date() except ValueError: inconclusive("FELHOM_GATE_TODAY=%r is not YYYY-MM-DD" % override) 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(path, what): if not os.path.isfile(path): inconclusive("%s not found at %s" % (what, path)) with open(path, encoding="utf-8-sig") as fh: return fh.read() def split_cells(line): return [c.strip() for c in re.split(r"(? 1 else DEFAULT_STATUS reg_path = argv[2] if len(argv) > 2 else DEFAULT_REGISTER today = today_utc() page = read(status_path, "STATUS page") reg = open_rows(read(reg_path, "register")) if not reg: inconclusive("no register rows (`| **R-n** |`) in %s" % reg_path) lines = page.rstrip("\n").split("\n") heads, sec = sections(lines) if not heads: inconclusive("no `## ` sections in %s" % status_path) missing = [SECTIONS[k] for k in SECTIONS if k not in sec] if missing: inconclusive("%s lacks the section(s): %s" % (status_path, ", ".join(missing)), "The checks need every section; the page shape is in the repo CLAUDE.md.") def text_of(*keys): out = [] for k in keys: a, b = sec[k] out.extend(lines[a:b]) return "\n".join(out) convictions = [] d0, d1 = sec["decide"] outside = lines[:d0] + lines[d1:] while outside and not outside[-1].strip(): outside.pop() n_lines = len(outside) n_chars = len("\n".join(outside)) # size if n_lines > MAX_LINES: convictions.append(("size", "%d lines outside „decide” (limit %d)" % (n_lines, MAX_LINES))) if n_chars > MAX_CHARS: convictions.append(("size", "%d characters outside „decide” (limit %d)" % (n_chars, MAX_CHARS))) # updated upd = [i for i, l in enumerate(lines) if "**Updated" in l] if len(upd) != 1: convictions.append(("updated", "%d „**Updated” paragraphs (exactly one is allowed)" % len(upd))) else: a = b = upd[0] while a > 0 and lines[a - 1].strip(): a -= 1 while b + 1 < len(lines) and lines[b + 1].strip(): b += 1 para = " ".join(l.strip() for l in lines[a:b + 1]) body = re.sub(r"^.*?\*\*Updated[^*]*\*\*", "", para, count=1).strip() n_sent = len(SENTENCE_END.findall(body)) if body and not SENTENCE_END.search(body[-1:] + " "): n_sent += 1 # a last sentence without its full stop still counts if n_sent > MAX_UPDATED_SENTENCES: convictions.append(("updated", "the „Updated” paragraph has %d sentences (limit %d)" % (n_sent, MAX_UPDATED_SENTENCES))) if re.search(r"\bEarlier\b", para): convictions.append(("updated", "the „Updated” paragraph carries an „Earlier” chain " "(older news goes to STATUS-HISTORY.md)")) # english for no, l in enumerate(lines, 1): bare = unquote(l) m = HU_LETTERS.search(bare) if m: convictions.append(("english", "line %d: Hungarian outside a quote near %r" % (no, bare[max(0, m.start() - 20):m.start() + 20].strip()))) # open-ids cited = sorted(set("R-" + x for x in RID.findall(page)), key=lambda r: int(r[2:])) for r in cited: if r not in reg: convictions.append(("open-ids", "%s is cited but is not an OPEN row" % r)) # decide a, b = sec["decide"] n_decide = 0 for no in range(a + 1, b): l = lines[no].strip() if not l.startswith("|") or re.match(r"^\|[\s:|-]+\|$", l) or re.match(r"^\|\s*#\s*\|", l): continue n_decide += 1 cells = [c.lower().strip("* ") for c in split_cells(l)] if FINISHED.search(l) or any(c in FINISHED_CELL for c in cells): convictions.append(("decide", "line %d: a decided/closed/done row in the decide table" % (no + 1))) # p1 p1_text = text_of("broken", "decide") p1_rows = sorted(r for r, (sev, _) in reg.items() if sev == "P1") for r in p1_rows: if not re.search(r"\b%s\b" % re.escape(r), p1_text): convictions.append(("p1", "%s (P1) is not under „What is broken” or „decide”" % r)) # operator op_text = text_of("decide", "do") op_rows = sorted((r for r, (_, owner) in reg.items() if OPERATOR.search(owner)), key=lambda r: int(r[2:])) for r in op_rows: if not re.search(r"\b%s\b" % re.escape(r), op_text): convictions.append(("operator", "%s names the operator as owner and is not under „decide” or „do”" % r)) print("status-view: %s" % status_path) print("status-view: %d lines / %d characters outside „decide” (limits %d / %d); decide table %d row(s)" % (n_lines, n_chars, MAX_LINES, MAX_CHARS, n_decide)) print("status-view: %d R-id(s) cited, checked against %d open register rows; %d P1 row(s); " "%d operator-owned row(s)" % (len(cited), len(reg), len(p1_rows), len(op_rows))) if not convictions: print("status-view: OK — one screen, one „Updated” paragraph, English, every cited row open, " "no finished decision, the P1 and every operator-owned row on the page") return 0 refuse = today >= SWITCH_DATE word = "CONVICTED" if refuse else "WARNING" for check, msg in convictions: print("status-view %s [%s]: %s" % (word, check, msg)) if refuse: print("status-view: %d conviction(s) — refused (the warn-first period ended on %s, R-1174)" % (len(convictions), SWITCH_DATE.isoformat())) return 1 print("!! status-view: %d conviction(s) — WARNING ONLY until %s; from that date this gate REFUSES " "the push (R-1174). Fix STATUS.md now." % (len(convictions), SWITCH_DATE.isoformat())) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))