9f77865c2a
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
193 lines
10 KiB
Python
193 lines
10 KiB
Python
#!/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())
|