Status view, part 3 (the gate): scripts/status_view_gate.py in repo_gates.py (fast: pre-push + CI) — size outside decide, one Updated paragraph, English outside quotes, cited rows open, no decided row in decide, the P1 and every operator-owned row on the page; WARN-FIRST until 2026-10-27, then it refuses; 39 decoy cases (red-proved per check and on both sides of the date); filed R-1174 (P4, Process & tooling) + its due check 2026-10-27; R-919 owner cell -> operator (the upload and the look); register 152 -> 153

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-20 11:59:48 +02:00
parent 9ec5f76a88
commit 1d7071e8fd
7 changed files with 521 additions and 3 deletions
+284
View File
@@ -0,0 +1,284 @@
#!/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"(?<!\\)\|", line.strip())[1:-1]]
def open_rows(text):
"""{id: (sev, owner)} for every register row. A literal `|` sits inside backticks (register rule)."""
rows = {}
for line in text.split("\n"):
m = REG_ROW.match(line)
if not m:
continue
# Split outside backticks, as register_shape_gate.py does.
cells, cur, tick = [], "", False
for ch in line.strip():
if ch == "`":
tick = not tick
if ch == "|" and not tick:
cells.append(cur)
cur = ""
else:
cur += ch
cells = [c.strip() for c in cells[1:]]
if len(cells) < 8:
continue
rows[m.group(1)] = (cells[2], cells[7])
return rows
def sections(lines):
"""{key: (start, end)} line ranges (heading line included), plus the list of all `## ` lines."""
heads = [i for i, l in enumerate(lines) if l.startswith("## ")]
found = {}
for n, i in enumerate(heads):
end = heads[n + 1] if n + 1 < len(heads) else len(lines)
title = lines[i][3:].strip().lower()
for key, want in SECTIONS.items():
if title.startswith(want) and key not in found:
found[key] = (i, end)
return heads, found
def unquote(s):
for rx in QUOTE_SPANS:
s = rx.sub(" ", s)
return s
def main(argv):
status_path = argv[1] if len(argv) > 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))