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
+13
View File
@@ -1,3 +1,16 @@
## 2026-10-20 — the status page gate, warn-first until 2026-10-27 (`status_view_gate.py`, R-1174)
New gate `scripts/status_view_gate.py`, registered in `repo_gates.py` (FAST set: pre-push hook and CI). It checks root
`STATUS.md` against `documentation/backlog/OPEN-ITEMS.md`: at most 50 lines and 6,000 characters outside the decide
table; exactly one „Updated" paragraph of at most 3 sentences, with no „Earlier" chain; no Hungarian letter outside a
quote; every cited R-n an open row; no decided, closed or done row in the decide table; the P1 row under „What is
broken" or „decide"; every open row whose Owner names the operator under „decide" or „do". **Warn-first:** before
2026-10-27 (`SWITCH_DATE`) a conviction prints a WARNING and exits 0; from that date it exits 1 (a dated check in the
register's DUE-CHECKS block makes the switch visible). No sections, or no register, is INCONCLUSIVE (exit 2) at once.
A clean run prints what it looked at. Decoys: `scripts/test_status_view_gate.py` (39 cases on temp files, run by
`test_gate_decoys.py` and `script_tests_gate.py`); red-proved per check (each check removed → its decoys FAIL) and on
both sides of the date (switch moved later → 14 refusal cases FAIL; one day earlier → 14 warning cases FAIL).
## 2026-10-19 — a dependency and secret scanner on every push, warn-first (`security_scan_gate.py`, R-986)
New shared gate `scripts/security_scan_gate.py`, registered in all four runners (felhom.eu `repo_gates.py` gate 18,
+7
View File
@@ -36,6 +36,8 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
18. security-scan govulncheck (called vulnerabilities) + gitleaks on the pushed commits — WARN-FIRST: it
lists, it never refuses; it touches the network (vuln.go.dev) and says NOT CHECKED
offline, which is why it may sit in the fast set (R-986; shared, felhom.eu/scripts)
19. status-view root STATUS.md is one screen and a view of OPEN-ITEMS.md — WARN-FIRST until 2026-10-27,
then it refuses (R-1174)
**THE `GATES` TABLE BELOW IS THE LIST; THIS IS A POINTER TO IT.** It drifted once already —
it read eleven while thirteen were registered, from 2026-08-24 until 2026-09-01, so
@@ -210,6 +212,11 @@ GATES = [
# never a push. ~10 s here (govulncheck on hub/ + gitleaks on the unpushed commits). CI fetches the pinned
# gitleaks (FELHOM_SCAN_FETCH=1 in gates.yml) and has no Go, so govulncheck says NOT CHECKED there, out loud.
("security-scan", os.path.join(SCRIPTS, "security_scan_gate.py"), [ROOT], True, False),
# R-1174 (2026-10-20) — root STATUS.md is one screen and a VIEW of OPEN-ITEMS.md: size outside the decide table,
# one „Updated" paragraph, English, every cited row open, no decided row in the decide table, the P1 and every
# operator-owned row on the page. WARN-FIRST until 2026-10-27 (exit 0 with a WARNING), a refusal from that date.
# Fast: two file reads.
("status-view", os.path.join(SCRIPTS, "status_view_gate.py"), [], True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
+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))
+19
View File
@@ -116,6 +116,12 @@ COVERS = {
"accepted line with no reason warns; a CALLED govulncheck finding is listed, an imported-only "
"one is not; no tool anywhere is NOT CHECKED out loud, never a silent pass; the clean commit "
"and the accepted line pass. Warn-first: every case exits 0, the LISTING is the fact"),
"status-view": ("R-1174: scripts/test_status_view_gate.py, run from here (temp files, the real tree untouched): one "
"decoy per check, each the shape a real session writes — 50 more lines, news prepended with the "
"old paragraph left behind, an Earlier chain, a Hungarian do-line, a closed row cited, a DECIDED "
"row left in the decide table, the P1 line deleted to meet the size, the Later line deleted — "
"each convicting ON ITS OWN check, warning before 2026-10-27 and refusing from it; the genuine "
"fixture and the REAL page must pass; no sections / no register are INCONCLUSIVE (exit 2)"),
"hub-gofmt": ("R-1160: an unformatted .go file in a SCRATCH module dir is convicted by name; its "
"formatted twin passes; with no gofmt reachable, CI passes OUT LOUD and a dev machine is "
"INCONCLUSIVE, never a silent pass"),
@@ -805,6 +811,19 @@ else:
_n = _ss.stdout.strip().splitlines()[-1] if _ss.stdout.strip() else "?"
print(" ok %-20s %s" % ("security-scan", _n))
# ── status-view (R-1174) ─────────────────────────────────────────────────────────────────────────
#
# Its decoys are temp STATUS/register pairs, so they live in their own file and are RUN from here (security-scan's shape).
ran += 1
_sv = subprocess.run([sys.executable, os.path.join("scripts", "test_status_view_gate.py")],
cwd=ROOT, capture_output=True, text=True)
if _sv.returncode != 0:
fails.append("status-view: its decoy suite FAILED — a decoy did not convict\n%s"
% (_sv.stdout + _sv.stderr)[-800:])
else:
_n = _sv.stdout.strip().splitlines()[-1] if _sv.stdout.strip() else "?"
print(" ok %-20s %s" % ("status-view", _n))
# ── script-tests (R-885) ─────────────────────────────────────────────────────────────────────────
#
# Its decoys are whole fake repos, so they live in their own file and are RUN from here (guide-quote's shape).
+193
View File
@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""Decoy tests for status_view_gate.py (R-1174).
Run: python3 scripts/test_status_view_gate.py Exit 0 every case behaved · 1 a case did not.
A DECOY IS THE LABEL WITHOUT THE FACT (R-421). Each case below takes a GENUINE page — the fixture,
shaped like the real STATUS.md — and breaks ONE check the way a real session would: the „Updated"
paragraph with an „Earlier" chain behind it, a decided row left in the decide table, a Hungarian
do-line, a page that meets the size by deleting the P1 or the „Later" line. Each must convict, and
must convict ON THAT CHECK (its `[check]` tag in the output): a decoy caught by some other check
proves nothing about the check it was built for.
Both directions: the genuine fixture and the REAL STATUS.md against the REAL register must pass.
The date switch is tested on both sides (FELHOM_GATE_TODAY). Fixtures are temp files; nothing here
touches the real tree.
"""
import os
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
GATE = os.path.join(HERE, "status_view_gate.py")
BEFORE = "2026-10-26" # the last warn-only day
AFTER = "2026-10-27" # SWITCH_DATE: convictions refuse
REGISTER = u"""# OPEN-ITEMS — fixture
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
| **R-10** | Security & access | P1 | **Reused logins.** | **NARROWED** | — | rotate | operator |
| **R-11** | Backup & restore | P3 | **Immich off-site size.** | **READY** | D21 | decide | CC |
| **R-12** | Process & tooling | P4 | **A failed disk.** | **OPEN** | — | pull it | operator |
| **R-13** | Business & legal | P4 | **A lawyer.** | WAITING-ON-OPERATOR | — | send | Viktor |
| **R-14** | Install & onboarding | P3 | **The minimum machine.** | WAITING-ON-OPERATOR | — | approve | operator |
| **R-15** | Box system & updates | P3 | **The scratch box.** | OPEN | — | fix | CC |
"""
PAGE = u"""# STATUS — what works, what's broken, what's next
**Ready for the first real tester? Yes.** The laptop is off.
**Updated 2026-10-20 (day).** Hub 0.162.0 is live (your yes). This page is one screen again. A rehearsal runs tonight.
## What works
- A box installs from nothing (map A1).
## What is broken
- **P1:** reused logins (R-10, yours).
- The scratch box (R-15).
## What we work on
- The rehearsal. CC.
## Waiting on you — decide
| # | Row | Question | Pick (why) | The other option costs | If you do nothing | Blocked |
|---|---|---|---|---|---|---|
| D21 | R-11 | Leave out the AI model files now? | **B now** | A: a new restore path | 12 times the data | — |
| D5 | R-14 | Approve the minimum machine? | **Approve** | Change it: one edit | no hardware page | the hardware page |
## Waiting on you — do
**Still needed? (yes/no)**
1. Look at the Calibre-Web page („Adatok és mentés"). Since 2026-10-16.
**The rest**
- Pull the failed disk (R-12). Since 2026-10-12.
- Rotate the reused logins (R-10, P1). Since 2026-10-09.
- Later (yours, not urgent): R-13.
"""
PASSED, FAILED = [], []
def run(page, register=REGISTER, today=BEFORE, raw_page_path=None, raw_reg_path=None):
with tempfile.TemporaryDirectory() as d:
p = raw_page_path or os.path.join(d, "STATUS.md")
r = raw_reg_path or os.path.join(d, "OPEN-ITEMS.md")
if raw_page_path is None and page is not None:
open(p, "w", encoding="utf-8").write(page)
if raw_reg_path is None and register is not None:
open(r, "w", encoding="utf-8").write(register)
env = dict(os.environ)
env.pop("FELHOM_GATE_TODAY", None)
if today:
env["FELHOM_GATE_TODAY"] = today
pr = subprocess.run([sys.executable, GATE, p, r], capture_output=True, text=True, env=env,
encoding="utf-8", errors="replace")
return pr.returncode, pr.stdout + pr.stderr
def mutate(old, new, page=PAGE):
assert page.count(old) == 1, "fixture anchor not unique: %r" % old
return page.replace(old, new)
def case(name, page, expect_rc, must=None, mustnot=None, **kw):
rc, out = run(page, **kw)
ok = rc == expect_rc and (must is None or must in out) and (mustnot is None or mustnot not in out)
(PASSED if ok else FAILED).append(name)
print("%s %s (rc=%d, want %d%s)" % ("PASS" if ok else "FAIL", name, rc, expect_rc,
", must %r" % must if must else ""))
if not ok:
print(" " + out.replace("\n", "\n ")[:1500])
def decoy(name, page, check):
"""A decoy convicts on its own check: WARNING before the switch (exit 0), refusal from it (exit 1)."""
case(name + " / before the switch: warns", page, 0, must="WARNING [%s]" % check, today=BEFORE)
case(name + " / from the switch: refuses", page, 1, must="CONVICTED [%s]" % check, today=AFTER)
# ── the genuine article, both sides of the switch ────────────────────────────────────────────────
case("genuine fixture passes (before)", PAGE, 0, must="status-view: OK", mustnot="WARNING", today=BEFORE)
case("genuine fixture passes (after)", PAGE, 0, must="status-view: OK", today=AFTER)
case("clean run says what it looked at", PAGE, 0, must="lines /", today=BEFORE)
# ── size ─────────────────────────────────────────────────────────────────────────────────────────
decoy("size: 50 more What-works lines", mutate("- A box installs from nothing (map A1).\n",
"- A box installs from nothing (map A1).\n" + "- One more capability, in plain words (map B1).\n" * 50), "size")
decoy("size: few lines, too many characters", mutate("- A box installs from nothing (map A1).\n",
"- A box installs from nothing (map A1). " + "It works well and it is proven live. " * 170 + "\n"), "size")
case("size: a long decide table is exempt", mutate(
"| D5 | R-14 |", "| D5 | R-14 | Approve? | **Approve** | one edit | no page | — |\n" * 60 + "| D6 | R-14 |"),
0, must="status-view: OK", today=AFTER)
# ── updated ──────────────────────────────────────────────────────────────────────────────────────
decoy("updated: news prepended, the old paragraph left behind", mutate(
"**Updated 2026-10-20 (day).**", "**Updated 2026-10-21 (day).** New news.\n\n**Updated 2026-10-20 (day).**"), "updated")
decoy("updated: an Earlier chain inside the paragraph", mutate(
"A rehearsal runs tonight.", "A rehearsal runs tonight. **Earlier:** a lot happened."), "updated")
decoy("updated: four sentences", mutate(
"A rehearsal runs tonight.", "A rehearsal runs tonight. The installer is published."), "updated")
decoy("updated: no Updated paragraph at all", mutate(
"**Updated 2026-10-20 (day).** ", ""), "updated")
case("updated: version numbers are not sentence ends", mutate(
"Hub 0.162.0 is live (your yes).", "Hub 0.162.0 and agent 0.161.0 are live (your yes)."),
0, must="status-view: OK", today=AFTER)
# ── english ──────────────────────────────────────────────────────────────────────────────────────
decoy("english: a Hungarian do-line", mutate(
"**The rest**\n", "**The rest**\n- Nézd meg a Calibre-Web oldalát a demo-hp-n.\n"), "english")
case("english: the same text inside a quote passes", mutate(
"**The rest**\n", "**The rest**\n- Read the line „Nézd meg a Calibre-Web oldalát” on demo-hp.\n"),
0, must="status-view: OK", today=AFTER)
# ── open-ids ─────────────────────────────────────────────────────────────────────────────────────
decoy("open-ids: a closed row cited in What works", mutate(
"(map A1).", "(map A1; R-99 closed it)."), "open-ids")
# ── decide ───────────────────────────────────────────────────────────────────────────────────────
decoy("decide: a DECIDED row left in the table", mutate(
"| D5 | R-14 |", "| D20 | R-11 | **DECIDED 2026-10-20: A** — built and live. | done | — | — | — |\n| D5 | R-14 |"),
"decide")
decoy("decide: a row whose Pick cell reads done", mutate(
"| D5 | R-14 | Approve the minimum machine? | **Approve** |", "| D5 | R-14 | Approve the minimum machine? | done |"),
"decide")
# ── p1 ───────────────────────────────────────────────────────────────────────────────────────────
decoy("p1: the page meets its size by deleting the P1 line", mutate(
"- **P1:** reused logins (R-10, yours).\n", ""), "p1")
decoy("p1: the P1 moved to What works", mutate(
"- **P1:** reused logins (R-10, yours).\n", "", page=mutate(
"- A box installs from nothing (map A1).\n", "- A box installs from nothing (map A1). Logins: R-10.\n")), "p1")
# ── operator ─────────────────────────────────────────────────────────────────────────────────────
decoy("operator: the Later line deleted", mutate("- Later (yours, not urgent): R-13.\n", ""), "operator")
decoy("operator: a waiting row named only in What is broken", mutate(
"- Pull the failed disk (R-12). Since 2026-10-12.\n", "", page=mutate(
"- The scratch box (R-15).\n", "- The scratch box (R-15). The failed disk (R-12).\n")), "operator")
# ── inconclusive: never a pass, no warn period ───────────────────────────────────────────────────
case("inconclusive: a page with no sections", u"# STATUS\n\nAll good.\n", 2, must="INCONCLUSIVE", today=BEFORE)
case("inconclusive: a page missing the do section",
PAGE.split("## Waiting on you — do")[0], 2, must="INCONCLUSIVE", today=BEFORE)
case("inconclusive: an empty register", PAGE, 2, must="INCONCLUSIVE", register=u"# nothing here\n", today=BEFORE)
case("inconclusive: no register file", PAGE, 2, must="INCONCLUSIVE",
raw_reg_path=os.path.join(HERE, "no-such-register.md"), today=BEFORE)
# ── the real page against the real register ──────────────────────────────────────────────────────
case("the real STATUS.md passes against the real register", None, 0, must="status-view: OK",
raw_page_path=os.path.join(ROOT, "STATUS.md"),
raw_reg_path=os.path.join(ROOT, "documentation", "backlog", "OPEN-ITEMS.md"), today=None)
print("\n%d passed, %d failed" % (len(PASSED), len(FAILED)))
if FAILED:
for f in FAILED:
print(" FAILED: " + f)
sys.exit(1)
sys.exit(0)