1d7071e8fd
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
285 lines
12 KiB
Python
285 lines
12 KiB
Python
#!/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))
|