Files
felhom.eu/scripts/register_shape_gate.py
T

193 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""register_shape_gate.py — OPEN-ITEMS.md is a TABLE, and a table has a shape (R-627, 2026-09-22).
WHAT IT CONVICTS ON (a FAIL, exit 1), seven rules (RULE 2 is reserved) and nothing else:
RULE 1 — every register row (`| **R-nnn** | …`) ends with `|`, so it HAS a state cell.
RULE 2 — reserved. **There is deliberately no cell-COUNT rule, and that is a measurement, not an
omission.** The first draft counted cells and convicted 125 innocent rows: register cells
carry literal `|` characters inside their prose and their shell snippets (`owner: CC |
…`, `--format '{{.Names}}|{{.Image}}'`), so splitting a row on `|` does not yield its
cells. A count that cannot be computed is not a check. RULE 1 catches the thing that
actually breaks — a row whose state cell was eaten — because such a row stops ending
with a pipe.
RULE 3 — no `R-` identifier appears on more than one row.
RULE 4 — no blank line sits between two register rows. A blank line ENDS a markdown table, so
the rows after it render as a separate table — and, worse, a script that walks "the
table" stops there.
RULE 5 — (2026-10-03) every register row sits under a table header and splits — on pipes OUTSIDE
backticks, `register_table.split_cells` — into exactly as many cells as that header declares.
RULE 2's measurement still stands for a naive split; a backtick-aware split CAN count, and on
2026-10-03 it did for 437 of 442 rows — the five it could not were real defects (a stray extra
cell, rows written five columns wide), repaired before this rule was registered.
RULE 6 — (2026-10-03) the `Category` cell is EXACTLY one of the eleven names in `CATEGORIES`.
RULE 7 — (2026-10-03) the `Sev` cell is exactly `P1`, `P2`, `P3` or `P4`.
RULE 8 — (2026-10-03) the `State` cell LEADS with one of `register_table.OPEN_STATES`. A finished word
there is `closed_register_gate.py` RULE 3's conviction; any OTHER word is convicted here,
because a state nobody defined ("PARTLY CLOSED", "PLUMBING COMPLETE") is how 18 rows became
unreadable to every script before the 2026-10-03 triage.
WHY RULES 5–8 (2026-10-03, operator request). Every open row now carries one category and one severity
so the register can be read as a list of priorities, and a new row is filed into its category from the
start. A column that a script cannot read reliably is not a column — so the shape is enforced, by the
header's column NAMES, not by positions.
WHY IT EXISTS, and the cost that bought it. On 2026-09-21 the update night appended measured
results to eight existing rows with a regex that matched a row's trailing state cell. On two rows
(**R-446** and **R-458**) it consumed the state cell and did not put it back; the cell reappeared as
a stray FOURTH cell on a duplicated copy of a different row (**R-626** and **R-625**), and a blank
line was left between each pair. The register then reported **317 rows for 315 findings**, two rows
carried no state at all, and two findings existed twice with contradictory state cells.
**Nothing caught it.** `one_register_gate.py` compares OPEN-ITEMS against ROADMAP; `closed_register_gate.py`
forbids an id in BOTH OPEN and CLOSED — neither asks whether the file is a well-formed table, and
neither notices an id duplicated WITHIN OPEN-ITEMS. It was found the next morning by a person
reading the file. **A register that silently mis-states how many findings exist is the one file in
this project that must not be able to do that**, because every standing rule says "grep the register
before minting" and every count in every report is read off it.
WHAT THIS GATE CANNOT SEE — the residual holes, named rather than implied:
1. **A row whose state cell is WRONG but present.** Shape is not meaning. A row that says READY
when it is closed passes here and is `closed_register_gate.py`'s business.
2. **A row whose text was truncated mid-sentence** but still ends with a valid state cell. Only a
human or a diff against the previous commit sees that.
3. **The DUE-CHECKS block and the other tables in this file.** This gate reads register rows only
— lines that begin `| **R-`. The file's other tables are `due_checks_gate.py`'s business.
USAGE
python3 scripts/register_shape_gate.py # the real register
python3 scripts/register_shape_gate.py <file> # any file, for the red-proof
Exit: 0 clean · 1 convicted.
"""
import os
import re
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import register_table # noqa: E402 — the shared row reader (2026-10-03)
# The eleven categories (operator request 2026-10-03; the reviewer's default, operator may reverse).
# The same list, in the same order, heads OPEN-ITEMS.md's "How a row is filed" section.
CATEGORIES = (
"Install & onboarding", "Apps & catalog", "App updates", "Backup & restore", "Storage & devices",
"Security & access", "Box system & updates", "Monitoring & notifications", "Hub & operator",
"Business & legal", "Process & tooling",
)
SEVERITIES = ("P1", "P2", "P3", "P4")
REQUIRED_COLUMNS = ("ID", "Category", "Sev", "What", "State", "Owner")
ROW = re.compile(r'^\| \*\*(R-\d+[a-z]?)\*\* \|') # 2026-09-30: a letter suffix (R-88a, R-88b, R-209a) is a row too — it was skipped, 3 rows unchecked
REG = Path(__file__).resolve().parent.parent / "documentation" / "backlog" / "OPEN-ITEMS.md"
def cells(line):
"""The cells of a markdown table row, as a reader sees them.
A row is `| a | b | c |`. Stripping the outer pipes and splitting on `|` gives the cells; a row
that forgot its trailing pipe yields one fewer, which is exactly RULE 2's symptom, so RULE 1 and
RULE 2 are reported separately rather than collapsed into one confusing message.
"""
t = line.rstrip()
if not t.endswith("|"):
return None
return t.strip("|").split("|")
SEP = re.compile(r'^\|\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|\s*$')
def check(path):
lines = Path(path).read_text(encoding="utf-8").split("\n")
bad = []
seen = {}
rows = 0
for i, line in enumerate(lines, start=1):
m = ROW.match(line)
if not m:
continue
rows += 1
rid = m.group(1)
# RULE 3 first, and ALWAYS — a row that also breaks another rule is still a row, and
# skipping it here would under-count the ids and report phantom duplicates. (Measured: the
# first draft skipped them and claimed 5 duplicates where there were 2.)
if rid in seen:
bad.append((i, rid, f"RULE 3 — duplicate: {rid} already has a row at line {seen[rid]}"))
else:
seen[rid] = i
# RULE 1 — the row must end with `|`, i.e. it must still HAVE a state cell
if not line.rstrip().endswith("|"):
bad.append((i, rid, "RULE 1 — the row does not end with `|`, so it has no state cell. "
"This is exactly what an eaten state cell looks like, and what "
"R-446, R-458 (2026-09-21) and R-254 (2026-08-08, unnoticed for 45 "
"days) each looked like."))
# RULE 4 — look back over blanks to the previous non-blank line
j = i - 2
blanks = 0
while j >= 0 and lines[j].strip() == "":
blanks += 1
j -= 1
if blanks and j >= 0 and ROW.match(lines[j]):
bad.append((i, rid, f"RULE 4 — {blanks} blank line(s) between this row and "
f"{ROW.match(lines[j]).group(1)} at line {j+1}. A blank line ends "
f"the markdown table."))
# RULES 5–8 — by the table's own header (2026-10-03)
for n, rid, columns, cells, _line in register_table.rows(path):
if columns is None:
bad.append((n, rid, "RULE 5 — the row sits under no `| ID | … |` table header, so no column "
"of it can be read by name"))
continue
missing = [c for c in REQUIRED_COLUMNS if c not in columns]
if missing:
bad.append((n, rid, "RULE 5 — its table header lacks the column(s) %s" % ", ".join(missing)))
continue
if cells is None:
continue # RULE 1 has already convicted it
if len(cells) != len(columns):
bad.append((n, rid, "RULE 5 — %d cells where the header declares %d. A literal `|` outside "
"backticks makes a cell; put it inside `code`." % (len(cells), len(columns))))
continue
cat = register_table.cell(columns, cells, "Category")
if cat not in CATEGORIES:
bad.append((n, rid, "RULE 6 — Category %r is not one of the eleven (%s)"
% (cat, " · ".join(CATEGORIES))))
sev = register_table.cell(columns, cells, "Sev")
if sev not in SEVERITIES:
bad.append((n, rid, "RULE 7 — Sev %r is not P1, P2, P3 or P4" % sev))
word = register_table.first_word(register_table.cell(columns, cells, "State") or "")
if word not in register_table.OPEN_STATES and not register_table.CLOSED_FAMILY.match(word):
bad.append((n, rid, "RULE 8 — the State leads with %r, which is not a defined state (%s)"
% (word, " · ".join(register_table.OPEN_STATES))))
return rows, len(seen), bad
def main():
path = sys.argv[1] if len(sys.argv) > 1 else REG
rows, ids, bad = check(path)
print(f"register-shape: {path}")
print(f"register-shape: {rows} register row(s), {ids} distinct R- id(s)")
if rows != ids:
print(f"register-shape: {rows - ids} row(s) are DUPLICATES")
if not bad:
print("register-shape: OK — every row ends with a state cell, every id is unique, no blank "
"line splits the table, and every row has its columns, a known category, a P1–P4 "
"severity and a defined state")
return 0
print(f"register-shape: CONVICTED — {len(bad)} problem(s)")
for line_no, rid, why in bad:
print(f" line {line_no} {rid}: {why}")
return 1
if __name__ == "__main__":
sys.exit(main())