textmachine/platform/internal/gates/register_test.go

383 lines
22 KiB
Go
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.

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.
// ⚠ `PD-168` LEFT the class on 04.09 and its id was dropped here in the same change, which is what
// the gate's own instruction asks for. The row is `fixed(6ae3e76)` — the landing the P13 dofix could
// not see, because on that tree the closure had not happened yet and the session correctly refused
// to write a base against a closure that did not exist. It exists now, and the gate says so:
// `ALARM PD-168 LEFT the class (status "fixed(6ae3e76)", weight "minor, деньги")`.
//
// ⚠ `PD-375` and `PD-422` LEFT on 06.09, by the same instruction and the same precedent. They had
// entered on 05.09 from the TEXT OF THEIR TREATMENT rather than from any new danger — a disposition
// naming money and the hold, because the treatment was about money and the hold — and they were
// declared here for exactly as long as the landing had not happened. It has (`fixed(628cc56)`), so
// the declaration goes with it: an exception kept past its reason is how a baseline stops being read.
// · PD-375 — the carrier (`in.CeilingChapters < bounds.Min`) died with the scale in chapters; the
//
// class «no test executes the load-bearing half» is closed by
// `runs.TestAnOrderTheBalanceCannotCarryIsRefusedBeforeTheHold`.
//
// · PD-422 — the `--resnapshot` condition widened to `BankMoved || HasPriorRun`; pinned by
//
// `runs.TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection`.
var alarmBaseline = []string{
// PD-448 — гонка двух резюмов: проигравший получает отказ вместо прогона. Внесён 05.09 той же
// правкой, что и ряд. Маркеры класса он несёт законно: ряд говорит о ДЕНЬГАХ (холд берётся
// ровно один) и о том, что отказ приходит МОЛЧА для пользователя, дважды нажавшего кнопку.
// Ниже major он стоит потому, что деньги не теряются: отказ случается ДО взятия холда.
"PD-448",
// ⚠ `PD-455` LEFT the class on 11.09 and its id is dropped here in the same change, by the same
// instruction and the same precedent as PD-168 above. It was `open` because the order form was
// SILENT about a live run and said `covers_all` in front of a click the door would refuse; the
// form now carries that refusal as `blocked.code: run_in_flight` from the door's own predicate
// (`runs.startable`, contract 0.14.0), and the row is `fixed`. Pinned by
// `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` and
// `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick`.
// · The silence about a VANISHED DIRECTORY is a different row (`PD-466`, info) and it does not
//
// enter this class: it carries one marker, the hold it never reaches.
// PD-456 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры несёт ЗАКОННО: ряд про
// деньги и про холд, потому что дефект — завышение оценки остатка (юнит, у которого сделан только
// черновик, входит в цену обеими волнами). Ниже major он стоит потому, что **ошибка направлена
// ВВЕРХ**: денег не теряется и работа не портится, у человека просят зарезервировать больше
// нужного. Цена продуктовая — завышенный холд сокращает покупаемое, а на коротком балансе делает
// покупку невозможной там, где она возможна. Лечение упирается в манифест движка (остаток по
// ВОЛНАМ, а не только по юнитам), то есть в чужую зону, и одной платформой не закрывается.
"PD-456",
// PD-465 — вошёл в класс 07.09 приёмкой пака «деньги и правда на экране». Маркеры законны: ряд
// про ДЕНЬГИ и про ХОЛД, потому что неверное доказательство закрывает холд аккаунта над живым
// движком, и про БЛОКИРОВКУ, потому что состояние доходит до оператора только через застрявший
// прогон. Ниже major он стоит по достижимости: окно — миллисекунды между двумя операторами одной
// функции (заявка на спавн коммитится, юнит поднимается строкой ниже), и попасть в него можно,
// только сняв доказательство ровно в них. Расход ограничен потолком того же прогона и аккаунтом
// не эксплуатируем. Лечение требует нового состояния «заявка в полёте», поэтому пак его не строил.
"PD-465",
"PD-94", "PD-107", "PD-162", "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 eleven 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
}