textmachine/platform/internal/gates/register_test.go

333 lines
17 KiB
Go

package gates
import (
"bufio"
"os"
"regexp"
"sort"
"strings"
"testing"
)
// registerPath is the zone's defect register, read from where it lives rather than from a copy.
const registerPath = zoneRoot + "/docs/DEFECT_REGISTER.md"
// alarmMarkers are word stems whose presence in the SUBSTANCE of an open row says what the row is
// about, whatever its weight column says: money, a hold, silence, a block, invisibility, a 500, a
// panic. The weight column and the substance of a row can disagree — measured 03.09.2026: 96 open
// rows, 2 of them `major`, and a reconnaissance that counts the column reports "two" while rows a
// reader would call alarming number an order of magnitude more.
//
// Stems rather than words, so that inflected forms count (`деньги`/`денежный`, `холд`/`холда`,
// `молча`/`молчит`). The digits are the one marker that needs a boundary: `500` as a substring also
// matches a line number in an anchor (`runs.go:500`), a mutation constant (`500000`) and, from
// PD-500 on, every row's own id — none of which is an HTTP 500.
var alarmMarkers = []string{"деньг", "холд", "молча", "блокир", "невидим", `\b500\b`, "паник"}
// alarmBaseline is the class as it stood when this gate was written (03.09.2026): the ROW IDS, not
// their number. A set rather than a count, because a count is blind in both directions that matter —
// a pack that closes one row of the class and opens another leaves it unchanged, and a predicate
// narrowed until it sees less also leaves it unchanged (smaller, and only the growth direction ever
// went red).
//
// So the gate says two things instead. A row that ENTERS the class is admitted only by a hand adding
// its id here, in the same change, with its reason. A row that LEAVES it must have left for a reason
// the register itself carries — it was closed, or its weight was raised to major; a baseline row that
// is still open, still below major and no longer seen is the predicate having stopped working, and
// that is a failure, not a fall.
//
// The number and the rows are reproduced without Go by:
//
// cd platform && LC_ALL=C.UTF-8 awk -F'|' '/^\| PD-/{
// st=$(NF-2); gsub(/^ +| +$/,"",st); w=$4; gsub(/^ +| +$/,"",w);
// s=w; for(i=6;i<=NF-3;i++) s=s $i; s=tolower(s);
// if(st ~ /^open/ && w !~ /major/){ n=split("деньг холд молча блокир невидим паник",m," "); c=0;
// for(i=1;i<=n;i++) if(s~m[i]) c++;
// if(s ~ /(^|[^0-9A-Za-z:._-])500([^0-9]|$)/) c++;
// if(c>=2){ id=$2; gsub(/^ +| +$/,"",id); print id } }}' docs/DEFECT_REGISTER.md
//
// ⚠ `LC_ALL=C.UTF-8` is part of the command, not decoration: awk's `tolower` leaves Cyrillic alone in
// a C locale, and the count then differs from this file's by the rows whose marker is capitalised.
var alarmBaseline = []string{
"PD-94", "PD-107", "PD-162", "PD-168", "PD-201", "PD-212",
"PD-217", "PD-244", "PD-418", "PD-420", "PD-428", "PD-433",
}
// registerStatus is the register's own status vocabulary (its header), with the annotation some rows
// carry after it («open (наблюдаемость закрыта P5; ops и конфигурация — нет)»). A cell that does not
// even begin with a status is a cell that is not the status: the row was read wrong, and a gate that
// read a row wrong must go red rather than lose that row out of the class it guards.
//
// ⚠ The zone's own `awk` compares the cell to `open` EXACTLY, so the annotated rows are outside every
// number it has ever printed; this gate counts them as open, which is what they are. The divergence
// is a row of the register (PD-439), not something to settle here.
var registerStatus = regexp.MustCompile(`^(open|fixed|accepted-risk|closed)\b`)
type alarmRow struct {
id, weight, title string
markers []string
}
// The gate against a CLASS rather than against a row: every open row whose weight says minor or info
// and whose substance says two of money, hold, silence, block, invisible, 500, panic is NAMED — on
// `make check`, through the `ALARM` lines the Makefile lifts out of the log — and the membership of
// that class cannot change without a hand saying so. What it does not do is judge the weight: that is
// the zone's call, made row by row; this only makes sure the call is made.
//
// Mutation caught: dropping the `open` filter (closed rows flood the class); reading the weight or
// the status from the wrong cell; matching the markers over the whole row (an anchor's line number
// and the provenance cell then admit rows that carry no alarm); adding a row of the class without
// declaring it; dropping a marker from the predicate.
func TestOpenRowsBelowMajorThatCarryAlarmMarkersAreNamedAndDoNotGrowUnnoticed(t *testing.T) {
rows, unreadable, unreadableIn := registerRows(t)
byID := map[string]registerRow{}
alarms := map[string]alarmRow{}
for _, r := range rows {
byID[r.id] = r
if r.status != "open" || atOrAboveMajor(r.weight) {
continue
}
hit := markersOf(r.substance)
if len(hit) >= 2 {
alarms[r.id] = alarmRow{id: r.id, weight: r.weight, title: r.title, markers: hit}
}
}
for _, id := range sortedKeys(alarms) {
a := alarms[id]
t.Logf("ALARM %s [%s] %s — %s", a.id, a.weight, strings.Join(a.markers, ","), a.title)
}
t.Logf("ALARM PD-count: %d open rows below major carry ≥2 distinct alarm markers (baseline %d)", len(alarms), len(alarmBaseline))
declared := map[string]bool{}
for _, id := range alarmBaseline {
declared[id] = true
}
for _, id := range sortedKeys(alarms) {
if !declared[id] {
t.Errorf("%s is a new open row below major carrying alarm markers (%s) and is not in alarmBaseline: "+
"either its weight is major (then it is not of this class), or add its id here in the same change and say why",
id, strings.Join(alarms[id].markers, ","))
}
}
for _, id := range alarmBaseline {
if _, still := alarms[id]; still {
continue
}
r, known := byID[id]
cell, prose := unreadable[id]
switch {
case !known && prose && !strings.HasPrefix(unreadableIn[id], "Открытые"):
// The register's own house style for a ratified closure is prose in the status cell plus a
// move out of the open sections — `PD-59` carries exactly that today. A landing that closes
// a row of this class that way is doing its job, and turning the zone's battery red for it
// would mean the orchestrator cannot land without editing Go. So it is an EXIT, announced
// like every other exit; what makes it safe is the section, which is the register's second
// statement about the same row.
t.Logf("ALARM %s LEFT the class into %q (status cell reads %q, which is prose rather than a status — PD-439) — drop its id from alarmBaseline in the change that lands it",
id, unreadableIn[id], cell)
case !known && prose:
// Still under an OPEN heading: the row is there and its status cell is not a status, so
// nothing can tell whether it is of the class. That is the case PD-439 names, and it is a
// failure rather than an exit.
t.Errorf("%s stands under %q and carries no readable status (%q), so this gate cannot tell whether it is still of the class: the cell has to say one of the register's statuses", id, unreadableIn[id], cell)
case !known:
t.Errorf("%s is in alarmBaseline and no longer a row of the register: an id is stable forever, so this is a gate reading the wrong document", id)
case r.status != "open" || atOrAboveMajor(r.weight):
// ⚠ ALARM, not a plain log: a row leaving the class is exactly the move an accepted risk or
// a closure makes, and it is the one the operator must SEE — the target lifts `ALARM` lines
// out of the battery log and nothing else. Left as a note, the class could be emptied one
// legitimate status change at a time with `make check` silent throughout.
t.Logf("ALARM %s LEFT the class (status %q, weight %q) — drop its id from alarmBaseline in the change that lands it, or say why it stays", id, r.status, r.weight)
default:
t.Errorf("%s is still open and still below major, and the predicate no longer sees it: the class was narrowed rather than the register — say so and re-derive alarmBaseline, or restore the marker that was dropped", id)
}
}
}
// parseRow splits one table row into the fields this gate judges. Counted from the RIGHT because the
// substance of a row legitimately contains pipes inside backticks: the status is the third cell from
// the end, the provenance the second.
//
// What goes into `substance` is the row's own claim about itself — its weight annotation
// («minor, деньги») and its words — and NOT the anchors above them or the provenance below: an anchor
// carries line numbers (`runs.go:500` is not an HTTP 500) and the provenance carries the name of the
// pack that found the row («движковый пак «деньги»» is not a row about money).
func parseRow(line string) (registerRow, bool) {
cells := strings.Split(line, "|")
if len(cells) < 9 {
return registerRow{}, false
}
return registerRow{
id: strings.TrimSpace(cells[1]),
class: strings.TrimSpace(cells[2]),
weight: strings.TrimSpace(cells[3]),
status: strings.TrimSpace(cells[len(cells)-3]),
substance: strings.ToLower(strings.TrimSpace(cells[3]) + "|" + strings.Join(cells[5:len(cells)-3], "|")),
}, true
}
// The predicate on rows written for the purpose, because the live register cannot show what it does
// not contain: today the whole-row reading and the row's-own-words reading pick the same twelve rows,
// so nothing in the register distinguishes them, and the difference is what the class MEANS.
//
// Mutation caught: matching the markers over the whole row (the anchor and the provenance then admit
// rows that carry no alarm); dropping the word boundary from the digits.
func TestTheAlarmPredicateReadsTheRowsOwnWordsAndNotItsAnchors(t *testing.T) {
for name, tc := range map[string]struct {
line string
want int
}{
"the row's own words count": {
"| PD-1 | bug | minor | `internal/runs/reconcile.go` `restart` | **Холд молча остаётся открытым.** | open | приёмка |", 2},
"the weight annotation counts, it is the row's own claim": {
"| PD-2 | bug | minor, деньги | `internal/pgstore/credits.go` | **Резервация не закрывается: холд.** | open | приёмка |", 2},
"an anchor's line number is not an HTTP 500": {
"| PD-3 | bug | minor | `internal/pgstore/runs.go:500`=`select`, `foo.go:1500` | **Холд не виден.** | open | приёмка |", 1},
"a mutation constant is not an HTTP 500 either": {
"| PD-4 | bug | minor | `internal/readmodel/readmodel.go` | **Холд считается по 500000 микро-долларов.** | open | приёмка |", 1},
"the provenance names the pack, not the subject": {
"| PD-5 | doc | info | `internal/pricing` | **Шкала главы не пере-мерена.** | open | движковый пак «деньги», охотник; молча |", 0},
"a real five hundred still counts": {
"| PD-6 | bug | minor | `internal/httpapi/problem.go` | **Каждое чтение книги отвечает 500, и молча.** | open | приёмка |", 2},
} {
t.Run(name, func(t *testing.T) {
r, ok := parseRow(tc.line)
if !ok {
t.Fatalf("the row did not parse: %s", tc.line)
}
if got := markersOf(r.substance); len(got) != tc.want {
t.Errorf("markers %v (%d), want %d — substance read as %q", got, len(got), tc.want, r.substance)
}
})
}
}
// markersOf is the predicate itself: which alarm stems a row's own words carry.
func markersOf(substance string) []string {
var hit []string
for _, m := range alarmMarkers {
if regexp.MustCompile(m).MatchString(substance) {
hit = append(hit, strings.Trim(m, `\b`))
}
}
return hit
}
type registerRow struct {
id, class, weight, status, substance, title, section string
}
// registerRows parses the register's table rows the way the zone's own `awk` does: cells split on
// `|`, counted from the RIGHT because the substance of a row legitimately contains pipes inside
// backticks — the status is the third cell from the end, the provenance the second. What the count
// cannot guarantee is that those cells are the ones meant, so the status is checked against the
// register's own vocabulary and a row that does not parse is a failure: a gate that misreads a row
// silently drops it from the class it is meant to guard.
// atOrAboveMajor is the weight filter, and it is a PREDICATE rather than a substring test because the
// register's weight vocabulary has more above `minor` than the one word: a `**BLOCKER**` row read as
// "below major" both joins a class it does not belong to and turns the battery red with advice
// («either its weight is major») that asks for the loudest row in the register to be quietened.
func atOrAboveMajor(weight string) bool {
w := strings.ToLower(weight)
return strings.Contains(w, "major") || strings.Contains(w, "blocker") || strings.Contains(w, "critical")
}
func registerRows(t *testing.T) (rows []registerRow, unreadableStatus, unreadableSection map[string]string) {
t.Helper()
f, err := os.Open(registerPath)
if err != nil {
// Not skipped: a register the gate cannot read is a gate that stopped gating.
t.Fatalf("the defect register could not be read, so nothing checks its open rows against their weight: %v", err)
}
defer f.Close()
title := regexp.MustCompile(`\*\*(.+?)\*\*`)
var out []registerRow //nolint:prealloc // the row count is not known before the scan
unreadable := map[string]string{}
unreadableIn := map[string]string{}
open := 0
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 1<<20)
// The heading a row stands under is the register's SECOND source about it (its sections are
// «Открытые — major/minor/info», «Принятый риск», «Закрытые — …»). It decides nothing on its own —
// the status cell does — but it is what tells a row whose status cell is prose from a row whose
// status cell was misread.
section, openSection := "", false
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "## ") {
section = strings.TrimPrefix(line, "## ")
openSection = strings.HasPrefix(section, "Открытые")
continue
}
if !strings.HasPrefix(line, "| PD-") {
continue
}
r, ok := parseRow(line)
if !ok {
t.Errorf("register row has too few cells for (id, class, weight, where, substance, status, source): %.80s", line)
continue
}
if !registerStatus.MatchString(r.status) {
// The cell is not a status — a stray `|` in the provenance, or prose written where a
// status belongs. Such a row is invisible to every count that reads this column, so it is
// NAMED here; and it is a FAILURE when the row it hides carries the alarm markers, because
// then the class this gate guards is short by a row and nothing says so.
unreadable[r.id] = r.status
unreadableIn[r.id] = section
if openSection && len(markersOf(r.substance)) >= 2 {
t.Errorf("%s: the status cell reads %q, which is none of the register's statuses — and the row stands under %q carrying alarm markers, so the class is short by a row that nothing else will name",
r.id, r.status, section)
}
continue
}
r.status = strings.Fields(r.status)[0]
if strings.HasPrefix(r.status, "open") {
r.status = "open"
open++
if !openSection {
// Not a failure: the register has a whole section for rows whose status moved before
// they did. Named because the two sources disagreeing is how a closed row keeps being
// counted as open, and because nothing else reads them together.
t.Logf("%s is open and stands under %q: status and section disagree", r.id, section)
}
}
if m := title.FindStringSubmatch(line); m != nil {
r.title = m[1]
}
r.section = section
out = append(out, r)
}
if err := sc.Err(); err != nil {
t.Fatal(err)
}
if len(out) < 100 {
t.Fatalf("parsed %d register rows, fewer than the register is known to hold: the shape moved and this gate reads nothing", len(out))
}
if open < 50 {
t.Fatalf("parsed %d OPEN rows of %d: the register holds far more, so the status cell is being read from the wrong place", open, len(out))
}
if len(unreadable) > 0 {
// Named on every run, because the count is what says whether this is the register's handful of
// prose statuses or the table's shape having moved under the gate.
names := make([]string, 0, len(unreadable))
for id, cell := range unreadable {
names = append(names, id+" ("+cell+")")
}
sort.Strings(names)
t.Logf("%d rows carry no readable status and are outside every count that reads that column (PD-439): %s",
len(unreadable), strings.Join(names, ", "))
}
if len(unreadable) > 10 {
t.Fatalf("%d rows of %d have no readable status: the table's shape moved and this gate is reading the wrong cells", len(unreadable), len(out))
}
return out, unreadable, unreadableIn
}
func sortedKeys(m map[string]alarmRow) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}