370 lines
21 KiB
Go
370 lines
21 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.
|
||
// ⚠ `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 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры законны: ряд про то, что
|
||
// форма МОЛЧИТ о живом прогоне, и про ХОЛД, потому что отказ случается ровно до его взятия. Ниже
|
||
// major он стоит по той же причине, по которой его цена терпима: денег не теряется и работа не
|
||
// портится — ломается обещание «вердикт ДО клика», то есть продукт, а не деньги.
|
||
"PD-455",
|
||
// PD-456 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры несёт ЗАКОННО: ряд про
|
||
// деньги и про холд, потому что дефект — завышение оценки остатка (юнит, у которого сделан только
|
||
// черновик, входит в цену обеими волнами). Ниже major он стоит потому, что **ошибка направлена
|
||
// ВВЕРХ**: денег не теряется и работа не портится, у человека просят зарезервировать больше
|
||
// нужного. Цена продуктовая — завышенный холд сокращает покупаемое, а на коротком балансе делает
|
||
// покупку невозможной там, где она возможна. Лечение упирается в манифест движка (остаток по
|
||
// ВОЛНАМ, а не только по юнитам), то есть в чужую зону, и одной платформой не закрывается.
|
||
"PD-456",
|
||
"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
|
||
}
|