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:
@@ -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))
|
||||
Reference in New Issue
Block a user