From 201363b2a78139fb678482f773e29d21c4f9d1cb Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 6 Sep 2026 08:04:37 +0300 Subject: [PATCH] Give the battery a per-run log and refuse to report on a run whose log is missing, with the guard's exit code held to a non-zero literal --- platform/.gitignore | 2 +- platform/Makefile | 43 ++-- platform/docs/DEFECT_REGISTER.md | 1 + platform/docs/platform-PROGRESS.md | 88 ++++++++- platform/internal/gates/battery_test.go | 250 +++++++++++++++++++----- 5 files changed, 314 insertions(+), 70 deletions(-) diff --git a/platform/.gitignore b/platform/.gitignore index 12c6f34f..073e5cfb 100644 --- a/platform/.gitignore +++ b/platform/.gitignore @@ -1,3 +1,3 @@ # Рабочий артефакт `make check`: рецепт пишет его в корень зоны и сам удаляет, # но прерванный прогон оставляет — а коммитит зону оркестратор. -.check.log +.check.log* diff --git a/platform/Makefile b/platform/Makefile index 55cba5d2..634e6c5e 100644 --- a/platform/Makefile +++ b/platform/Makefile @@ -117,23 +117,42 @@ conditions: # It also prints the register gate's findings (internal/gates, `ALARM` lines): open rows whose weight # says minor and whose words say money, silence or a hold — the class a reconnaissance that counts # only `major` never sees. +# +# ⛔ THE LOG IS PER-RUN AND ITS ABSENCE IS RED, and both halves were bought by a defect. Every line +# this recipe prints is a GREP over that file, and grep answers a missing file exactly as it answers a +# clean one: with nothing. So the recipe used to reach its `else` and print «every test ran: no host +# condition was missing» over a run whose log had vanished — a bill of health for a measurement that +# never happened. Observed 06.09: three `grep: .check.log: No such file or directory` followed by that +# very line, on a run with FIVE skips. +# +# The cause was the FIXED name: two batteries in one directory — normal here, the zone and the +# orchestrator both run it — and whichever finished first deleted the other's evidence mid-recipe. +# `$$$$` is the shell's PID, so runs no longer share a file. The guard below is the second half and +# the one that matters: it covers every OTHER way a log can go missing (a failed redirect, a full +# disk, a hand), and it turns «nothing to read» into a failure instead of a clean bill. ⚠ The two are +# not redundant — the unique name removes today's cause, the guard removes the CLASS. check: build vet fmt lint sqlc-check - @$(GO) test ./... -race -count=1 -v > .check.log 2>&1; status=$$?; \ - grep -E '^(ok|FAIL|\?)' .check.log || true; \ - if grep -q 'ALARM PD-' .check.log; then \ - echo "--- open register rows below major that carry alarm markers (internal/gates) ---"; \ - grep -o 'ALARM PD-.*' .check.log; fi; \ - if [ $$status -ne 0 ]; then \ - echo "--- FAILURES ---"; grep -E '^(---|[[:space:]]+---) FAIL' .check.log; \ - echo "--- the log is kept at .check.log: the message under a failing test is where it says what to do ---"; \ + @log=.check.log.$$$$; \ + $(GO) test ./... -race -count=1 -v > "$$log" 2>&1; status=$$?; \ + if [ ! -s "$$log" ]; then \ + echo "--- THE BATTERY LEFT NO LOG ($$log): every line below would be a grep over a file that is not there ---"; \ + echo "--- and a missing file is INDISTINGUISHABLE from a clean one to grep, so this exits RED rather than printing a bill of health nobody measured ---"; \ exit 1; fi; \ - if grep -q -- '--- SKIP' .check.log; then \ - echo "--- did NOT run: $$(grep -c -- '--- SKIP' .check.log) skipped. Host conditions the battery reads (from the test sources), each with what it opens: ---"; \ + grep -E '^(ok|FAIL|\?)' "$$log" || true; \ + if grep -q 'ALARM PD-' "$$log"; then \ + echo "--- open register rows below major that carry alarm markers (internal/gates) ---"; \ + grep -o 'ALARM PD-.*' "$$log"; fi; \ + if [ $$status -ne 0 ]; then \ + echo "--- FAILURES ---"; grep -E '^(---|[[:space:]]+---) FAIL' "$$log"; \ + echo "--- the log is kept at $$log: the message under a failing test is where it says what to do ---"; \ + exit 1; fi; \ + if grep -q -- '--- SKIP' "$$log"; then \ + echo "--- did NOT run: $$(grep -c -- '--- SKIP' "$$log") skipped. Host conditions the battery reads (from the test sources), each with what it opens: ---"; \ $(MAKE) --no-print-directory -s conditions; \ echo "--- skipped tests ---"; \ - grep -- '--- SKIP' .check.log; \ + grep -- '--- SKIP' "$$log"; \ else echo "--- every test ran: no host condition was missing ---"; fi; \ - rm -f .check.log + rm -f "$$log" # Not in `check`: fuzzing is time-boxed exploration, not a gate. The seed corpus runs as an # ordinary test on every `check`; this target is for going deeper on the decoder. diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 7a036198..5eef1249 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -71,6 +71,7 @@ | PD-420 | bug | minor | `internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | **Тест гонки холда против релиза краснеет под ПАРАЛЛЕЛЬНЫМИ батареями, а зона ратифицировала рецепт, который их требует.** `D39.159` §2 предписывает сажать мутации в КОПИЮ дерева, и всякая сессия, которая делает это всерьёз, гоняет несколько батарей разом. Замер пака P11: при трёх параллельных прогонах краснеет в ЧИСТЫХ копиях (2 раза из 15); в СЕРИЙНОМ прогоне зелен — пере-проверено трижды подряд отдельным прогоном, все три `ok`. ⚠ **ВТОРАЯ ТОЧКА, 29.08, внешнее ревью:** упал 1 раз из 4 ПОЛНЫХ прогонов зоны со всеми гейтами — то есть краснеет и без параллельных копий, просто редко. Изолированно 5/5, пакет целиком 2/2, три последующих полных прогона чистые; сообщение поймать не удалось, и ревьюер честно остановился на «редком флейке контенции», диагноз НЕ установлен. Пакет ни одним коммитом эры P11 не тронут. ⚠ Обе точки вместе сдвигают формулировку: это не «краснеет от параллельной нагрузки», а «редкая гонка, которую нагрузка делает вероятнее», — и мой первый диагноз («флейк параллельных батарей») был выведен из совпадения, ровно как в `PD-423`. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: **красная ЧИСТАЯ копия маскирует дельту**, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ **пак P12 (30–31.08): пере-замерена ПОСЛЕ фикса `PD-369`, серийно — 80 из 80 зелёных, 0 FAIL, 0 SKIP** (`-count=80`, счёт по `^--- PASS`). Это снимает ПЕРВУЮ из двух точек строки по построению: флейк был не в тесте, а в том, что исчерпание раундов отвечало 500, и тест это честно ловил. ⚠ **ВТОРАЯ точка (редкая гонка контенции, 1 из 4 полных прогонов, диагноз НЕ установлен) пере-замерена под ТРЕМЯ параллельными полными батареями в чистых копиях: 18/18 пакетов в каждой, 0 красных, EXIT=0 ×3.** Это ОДНА точка, а не опровержение: строка сама говорит, что краснеет редко (1 из 4), и три чистых прогона такой частоты не исключают. Строка остаётся открытой на ней с дополненным замером; закрыть её может только серия, а не прогон. | open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами | | PD-423 | standards | minor | `internal/runner/systemd_test.go` `TestARunIsBoundedByItsOwnCgroup`, `docs/STACK_DECISIONS.md` «Гейты батареи» | **Батарея зоны требует ЧЕТВЁРТОГО условия хоста, которого рецепт не называет: пользовательский менеджер systemd должен РЕАЛЬНО применять `MemoryMax` к транзиентным юнитам.** Замерено на этом хосте 29.08: тест трижды подряд зелен в полных батареях (`baseline2`, `final2`, `final4`), затем **пять раз подряд красен в изоляции** — при неизменном коде пакета, которого пак не касался вовсе. Причина установлена ВНЕ батареи и вне Go: `systemd-run --user --scope -p MemoryMax=64M …` даёт процессу спокойно занять 400 МиБ и выйти с кодом 0. ⚠ **МЕХАНИЗМ уточнён приёмкой оркестратора №19, и уточнение решает, воспроизводимо ли это:** «systemd не применяет потолок» верно по симптому и мимо по причине. Свойство ДОЕХАЛО — `systemctl --user show -p MemoryMax` печатает `67108864`; делегирование в порядке — `memory pids` и в `cgroup.controllers`, и в `subtree_control`. Пропал не потолок, а cgroup-КАТАЛОГ: в `app.slice` нет ни одного scope-каталога, а `cut -d: -f3 /proc/self/cgroup` для оболочки даёт **`/init.scope`**. То есть вызывающий процесс живёт ВНЕ `user@.service`; `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся в исходном cgroup, и лимита не получает никто — молча. ⚠ Отсюда и наблюдение «условие отваливается между двумя прогонами одной сессии»: оно зависит от того, из какого cgroup стартовал прогон. Тест при этом ПРАВ и его сообщение точное («check that the leaf cgroup of tm-runs.slice has memory.max»): он ловит ровно то, ради чего написан, — что потолок памяти прогона на этом хосте иллюзорен. ⚠ Следствие для процесса, а не только для теста: рецепт `STACK_DECISIONS` называет три условия батареи, а их четыре, и четвёртое — свойство ХОСТА, которое может отвалиться между двумя прогонами в одной сессии, что здесь и произошло. Сессия, наступившая на это, потратит время на поиск дефекта в своём диффе. ⚠ Предложенная этой строкой формулировка четвёртого условия («вызывающий процесс обязан жить ВНУТРИ `user@.service`», проверка `cut -d: -f3 /proc/self/cgroup` ≠ `/init.scope`) ОПРОВЕРГНУТА точками 2 и 3 ниже и СНЯТА из рецепта (`PD-432`). Живой остаток лечения — дать тесту различать «хост не применяет лимит» (честный скип с причиной) и «раннер не передал лимит» (настоящий отказ) ⚠⚠ **ВТОРАЯ ТОЧКА, 29.08, и она ПРОТИВОРЕЧИТ механизму выше — строку не закрывать, а пере-проверить.** Пак `sqlc` на ТОМ ЖЕ хосте и при том же `cut -d: -f3 /proc/self/cgroup` = **`/init.scope`** получил тест **зелёным 5 из 5 в ИЗОЛЯЦИИ** (протокол, в котором приёмка №19 видела 5 из 5 красных) плюс трижды в полных батареях; скипов в этих прогонах **ноль** — проверено по логам, то есть `systemdOrSkip` и проверка `python3` не срабатывали и тест НЕ был пустым: он требует настоящего `oom-kill` после касания 400 МиБ под `MemoryMax=64M`. Прямая проба механизма: `systemd-run --user --scope -p MemoryMax=64M -- sh -c 'cut -d: -f3 /proc/self/cgroup'` печатает **`/user.slice/user-1000.slice/user@1000.service/app.slice/run-….scope`**, то есть процесс ВСЁ-ТАКИ попадает внутрь `user@.service`, а не остаётся в исходном cgroup. Сам `tm-runs.slice` при этом существует и лежит глубже, чем ищут: `user@1000.service/**tm.slice**/tm-runs.slice` (`cgroup.controllers` = `memory pids`). **Следствие практическое:** предложенная этой же строкой одна команда-проверка (`/proc/self/cgroup` не должен давать `/init.scope`) на этом хосте даёт ЛОЖНЫЙ ОТРИЦАТЕЛЬНЫЙ — она говорит «условие не выполнено» там, где лимит применяется и тест честно зелёный. Значит cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает, диагноз строки неполон, и в рецепт `STACK_DECISIONS` эту команду в нынешнем виде вносить нельзя. Что различает две точки — не установлено; кандидат — состояние `cgroup.subtree_control` целевого среза в момент прогона (сейчас у `tm-runs.slice` он пуст, а systemd включает контроллер сам при старте юнита с лимитом). ⚠ **ТРЕТЬЯ ТОЧКА, пак P12 (30–31.08), и она согласна со второй:** у оболочки этой сессии `cut -d: -f3 /proc/self/cgroup` даёт **`/`** (не `/init.scope` и не путь внутри `user@.service`), а `TestARunIsBoundedByItsOwnCgroup` при этом ЗЕЛЁН в полной батарее со скипами 0 — проверено четырьмя полными прогонами. Прямая проба `systemd-run --user --scope` кладёт процесс в `…/user@1000.service/app.slice/run-….scope`; `tm-runs.slice` существует, `cgroup.controllers` = `memory pids`, а его `cgroup.subtree_control` ПУСТ — и тест всё равно зелен. То есть команда-проверка не предсказывает условие ни в одну сторону, и она **СНЯТА из рецепта** `STACK_DECISIONS` этим паком (см. `PD-432`). Что различает точки — по-прежнему НЕ УСТАНОВЛЕНО; строка остаётся открытой на диагнозе, а не на рецепте. | open | пак P11 (финальная батарея; воспроизведено голым `systemd-run` вне Go) | | PD-250 | vuln | minor | `cmd/tmplatformd/main.go` (слушатель метрик) | **`/metrics` отдаётся БЕЗ аутентификации; вся защита — привязка к `127.0.0.1`.** Для одной VM это честная граница, и она записана (STACK §24). Но на хосте с несколькими пользователями любой локальный процесс читает оперативную картину сервиса, а на деплое, где слушатель однажды переедет на `0.0.0.0` «чтобы Prometheus дотянулся», защиты не останется вовсе. Денег в метриках нет (D39.84), поэтому это minor, а не major. Лечение — bearer-токен на слушателе или mTLS, решать при первом внешнем Prometheus ⚠ **ПАК P8-REVIEW 24.08: это ТОТ ЖЕ факт, что `PD-179`, и он стоит в регистре в ДВУХ статусах одновременно** (`accepted-risk(платформа P5, 11.08)` против `open`). Код и ручка одни: `cmd/tmplatformd/main.go` `serveMetrics` без аутентификации, дефолт `127.0.0.1:9464`; рантбук `deploy/README.md` называет строкой риска именно `PD-179`. Своя добавка у этой строки есть (многопользовательский хост), но статус один факт должен нести один. Предложение пака: свести ⚠⚠ **Условие сведения, найденное рефутером:** у `PD-179` довод про ОДНУ VM, а добавка этой строки — многопользовательский хост, где любой локальный непривилегированный процесс скрейпит экспозицию, — в `PD-179` ОТСУТСТВУЕТ. Плюс при сведении из выборок безопасности исчезает класс `vuln` (у `PD-179` он `hardening`). Сводить только ВМЕСТЕ с перенесённой фразой и с пометкой класса | open | research/28 §9 (пинг оркестратора №17), сверено P7 | +| PD-454 | bug | minor | `Makefile` цель `check`; гейт `internal/gates.TestTheBatteryCannotReportCleanlinessWithoutItsLog` | **БАТАРЕЯ УМЕЛА ВЫДАТЬ ЧИСТУЮ СПРАВКУ О ПРОГОНЕ, КОТОРОГО НЕ ИЗМЕРЯЛА.** Каждая строка, которую печатает `make check` о прогоне — список пакетов, ряды `ALARM`, список падений, счёт скипов, — есть ГРЕП по одному файлу. Греп отвечает на ОТСУТСТВУЮЩИЙ файл ровно тем же, чем на чистый: ничем. Поэтому рецепт доходил до финального `else` и печатал «every test ran: no host condition was missing» о прогоне, чей лог исчез. ⚠ **Наблюдено 06.09, а не выведено:** три подряд `grep: .check.log: No such file or directory`, следом эта самая строка — на прогоне, где скипов было ПЯТЬ. Код возврата при этом 0, потому что статус берётся от `go test` ДО грепов. Причина — ФИКСИРОВАННОЕ имя лога, общее для всех прогонов в каталоге, а два прогона в одном каталоге здесь НОРМА: батарею гоняют и зона, и оркестратор, и первый закончивший удаляет улику второго посреди рецепта. ⇒ лечение двумя половинами, и они не дублируют друг друга: имя лога стало ПОПРОГОННЫМ (`$$` — PID шелла), что снимает сегодняшнюю ПРИЧИНУ, и добавлен гвард «нет лога ⇒ выйти красным ДО первого чтения», что снимает КЛАСС — отказавшийся редирект, полный диск, рука. Проверено исполнением: подменённый `GO`, не пишущий лога, даёт красный выход и две строки объяснения вместо справки. ⚠ Гейт держит ФОРМУ, а не эту починку: лог может быть попрогонным или нет, гвард может быть `-s` или `-f`, но ни одно чтение не смеет идти раньше проверки, которая умеет выйти. **ПЯТЬ** мутаций ловятся — гвард удалён · гвард после первого чтения · тест есть, `exit` убран · **`exit 1` → `exit 0`** · **`exit` без кода**. ⚠ **Две последние добавлены ВТОРОЙ редакцией гейта, и нашёл их не я, а приёмка оркестратора:** первая редакция требовала лишь, чтобы после гварда был шаг, начинающийся с `exit`, и `exit 0` проходил зелёным — то есть батарея объявляла вслух, что улики нет, и возвращала УСПЕХ. Замерено им исполнением (`exit 0` + подменённый `GO`: `MAKE-EXIT = 0` при напечатанном «THE BATTERY LEFT NO LOG»), воспроизведено зоной. **Это тот же дефект, переодетый, и в одном отношении ХУЖЕ исходного:** прежняя ложная справка была СТРОКОЙ, которую человек ловит глазами, эта — КОД ВОЗВРАТА, который потребляет машина (CI, лендинг). ⚠ И сообщение гейта обещало «exits RED», проверяя лишь наличие выхода, — та же болезнь, что он лечит, в нём самом; формулировка подтянута под проверяемое. Теперь требуется ЛИТЕРАЛ ненулевого кода: голый `exit` несёт статус предыдущей команды (`echo`, то есть ноль), а `exit $$var` из рецепта не судится вовсе. ⚠ Первая редакция гейта СЧИТАЛА ЧТЕНИЕМ слово `grep` внутри объясняющего `echo` самого гварда — ровно тот substring-vs-invocation капкан, о котором соседний гейт пишет абзацем выше; поймано прогоном, не рассуждением. **Девятая форма ложной зелени (`D39.202`)** ⚠ **ЛЕЧЕНИЕ В ДЕРЕВЕ, статус флипает ЛЕНДИНГ** | open | замер зоны 06.09, заказ оркестратора | ## Открытые — info | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index a9f44c51..5b433ef8 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -297,6 +297,58 @@ r.ordered_units is not null then` — считают полосу в ЮНИТА у ре-прохода — не дефект: он купил ноль глав и доставил ноль, и `ordered_chapters: 0` рядом делает это чтение однозначным; `null` там был бы ХУЖЕ, потому что `null` уже занят под «меряется в юнитах». +### ⛔ ОТДЕЛЬНЫЙ АКТ 06.09 — ПРИБОР ПЕРЕСТАЛ УМЕТЬ ВЫДАВАТЬ ЧИСТУЮ СПРАВКУ О ТОМ, ЧЕГО НЕ ИЗМЕРЯЛ + +Заказан оркестратором отдельно от дофикса по канону, ПОСЛЕ его закрытия — чтобы не смешивать предметы +посреди лендинга. Ряд регистра **`PD-454`**. + +**ЧТО БЫЛО.** Каждая строка, которую `make check` печатает о прогоне — список пакетов, ряды `ALARM`, +список падений, счёт скипов, — есть ГРЕП по одному файлу. Греп отвечает на ОТСУТСТВУЮЩИЙ файл ровно +тем же, чем на чистый: ничем. Поэтому рецепт доходил до финального `else` и печатал +«every test ran: no host condition was missing» о прогоне, чей лог исчез. **Наблюдено, а не выведено:** +три подряд `grep: .check.log: No such file or directory`, следом эта самая строка — на прогоне, где +скипов было ПЯТЬ. Код возврата 0, потому что статус берётся от `go test` ДО грепов. + +**ПРИЧИНА.** Фиксированное имя лога, общее для всех прогонов в каталоге. Два прогона в одном каталоге +здесь НОРМА — батарею гоняют и зона, и оркестратор, — и первый закончивший удаляет улику второго +посреди рецепта. + +**ЛЕЧЕНИЕ ДВУМЯ ПОЛОВИНАМИ, и они не дублируют друг друга.** Имя лога стало ПОПРОГОННЫМ (`$$` — PID +шелла): снимает сегодняшнюю ПРИЧИНУ. Гвард «нет лога ⇒ выйти красным ДО первого чтения»: снимает +КЛАСС — отказавший редирект, полный диск, рука. ⚠ Проверено ИСПОЛНЕНИЕМ, а не рассуждением: подменённый +`GO`, который не пишет лога, даёт красный выход и две строки объяснения вместо справки. + +**ГЕЙТ ДЕРЖИТ ОДНУ БЕЗОПАСНУЮ ФОРМУ И ГОВОРИТ ЭТО ПРЯМО** — +`gates.TestTheBatteryCannotReportCleanlinessWithoutItsLog`: проверка лога, затем выход с ЛИТЕРАЛЬНЫМ +ненулевым кодом, затем чтения. Внутри формы детали свободны (имя попрогонное или нет, `-s`/`-f`/`-e`), +а иная — пусть и верная — форма покраснеет НАМЕРЕННО: пере-кроить единственный рецепт, через который +отчитывается вся батарея, должен человек и пере-пиньнуть, а не проскочить молча. Имя лога гейт читает +ИЗ РЕЦЕПТА (из редиректа), чтобы переименование не заставило его смотреть на другой файл и промолчать. +**ПЯТЬ мутаций ловятся, все проверены исполнением:** гвард удалён · гвард после первого чтения · тест +есть, `exit` убран · `exit 1` → `exit 0` · `exit` без кода. + +⛔ **ВТОРАЯ РЕДАКЦИЯ ГЕЙТА, И НАШЛА ЕЁ НЕ Я, А ПРИЁМКА.** Первая требовала лишь, чтобы после гварда был +шаг, начинающийся с `exit`, — и `exit 0` проходил ЗЕЛЁНЫМ. Оркестратор замерил исполнением: с `exit 0` +и подменённым `GO` батарея печатает «THE BATTERY LEFT NO LOG» и **возвращает `MAKE-EXIT = 0`**. +Воспроизведено мной, файл восстановлен по sha256. ⇒ **тот же дефект, переодетый, и в одном отношении +ХУЖЕ исходного:** прежняя ложная справка была СТРОКОЙ, которую человек ловит глазами, эта — КОД +ВОЗВРАТА, который потребляет машина: CI, скрипт, лендинг. ⚠ И моё собственное сообщение об ошибке +обещало «so this exits RED», проверяя лишь НАЛИЧИЕ выхода, — та же болезнь, что гейт лечит, внутри +него самого; формулировка подтянута под проверяемое. Теперь требуется ЛИТЕРАЛ: голый `exit` несёт +статус предыдущей команды (`echo`, то есть ноль), а `exit $var` из рецепта не судится вовсе — оба +отвергаются, а не угадываются. Сквозная проверка после правки: `MAKE-EXIT=2`, громкая строка +напечатана один раз, ложная справка — ноль раз. + +⚠ **И ГЕЙТ ПОЙМАЛ САМ СЕБЯ.** Его первая редакция считала ЧТЕНИЕМ слово `grep` внутри объясняющего +`echo` того самого гварда — и объявила гвард опоздавшим. Это ровно тот substring-vs-invocation капкан, +о котором соседний гейт в этом же файле пишет отдельным абзацем; я в него вошла, цитируя его. +Поймано прогоном на второй минуте, не рассуждением. ⇒ различается ВЫЗОВ, а не слово. + +⚠ **Заодно снят второй носитель одной грамматики:** разбор рецепта Makefile был написан внутри +соседнего гейта, и вторая копия его же в новом тесте была бы ровно тем «вторым носителем, свободным +разойтись», который этот пак вычищал из денежного пути. Вынесен в `makeRecipe(t, target)`, оба гейта +читают один разбор; прежний тест зелен без правок. + ### ⛔ ТО ЖЕ РАСЩЕПЛЕНИЕ В СОСЕДНИХ ПОЛЯХ — ПРОВЕРЕНО МЕХАНИЧЕСКИ, НЕ ГЛАЗАМИ Заказ оркестратора: «проверь заодно, нет ли того же расщепления в соседних полях». Сделан сканер по @@ -731,10 +783,15 @@ ordered_units: integer|null // НОВОЕ: сколько юнитов ку TM_PLATFORM_TEST_PGDUMP/PGRESTORE · достижимый пользовательский systemd load average 5.3 перед стартом — режим, в котором время не врёт (D39.197 п.5) -make check → MAKE-EXIT=2 +make check → MAKE-EXIT=0 ⟵ ПЕРЕ-СНЯТО 06.09 ПОСЛЕ ДОФИКСА ПО КАНОНУ, красных НЕТ golangci-lint: 0 issues · gofmt чист · go vet чист · sqlc diff чист - ПАКЕТОВ 21: ok 20, FAIL 1 - ТЕСТОВ верхнеуровневых 850: PASS 844 · FAIL 1 · SKIP 5 (плюс 252 подтеста) + ПАКЕТОВ 20: ok 20, FAIL 0 + ТЕСТОВ верхнеуровневых 852: PASS 847 · FAIL 0 · SKIP 5 + (847-й — новый гейт прибора, `TestTheBatteryCannotReportCleanlinessWithoutItsLog`) + ⚠ ИСПР.: прежняя редакция этого блока писала «ПАКЕТОВ 21». Их ДВАДЦАТЬ. Двадцать первым + я посчитала лишнюю строку `FAIL` в выводе `make` — он печатает и падение ПАКЕТА, и + итоговое голое `FAIL`. Ошибка ровно того же рода, что ловил четвёртый заход: величина + взята из вывода прибора, не разобрав, что прибор печатает. ⚠ счёт снят из `.check.log` (`make check` гонит `-v`); `go test` БЕЗ `-v` строк `--- SKIP` не печатает вовсе, и греп по нему даёт ЛОЖНЫЙ НОЛЬ — этой ошибкой четвёртый заход уже был пойман @@ -742,12 +799,25 @@ ALARM PD-count: 12 (baseline 12) — ПЕРЕ-СНЯТА базой: PD-375 и P по прямой инструкции самого гейта («ОБА ОБЯЗАНЫ ПОКИНУТЬ КЛАСС НА ЛЕНДИНГЕ») и прецеденту PD-168; было 14 (baseline 14) с двумя объявленными исключениями -КРАСНЫЙ ОДИН, И ОН РАТИФИЦИРОВАН: - internal/gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified - «this build announces contract 0.12.0 and the ratified canon is 0.11.0» - Истинная причина: пара «код + канон» не закрыта по НОВОМУ минору. Порядок ратифицирован — - код первым, канон вторым (эррата 04.09-в: обратный порядок стоил суток лжи на проводе). - Гасит его канон, а не подгонка константы. +КРАСНЫХ НЕТ. Единственный красный этого пака — `internal/gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` + («announces 0.12.0, canon is 0.11.0») — ПОГАШЕН оркестратором вторым актом: канон на диске + 0.12.0, пара сомкнулась. Порядок был ратифицирован — код первым, канон вторым (эррата 04.09-в: + обратный порядок стоил суток лжи на проводе), и он сработал как задумано. + +⛔ ДЕФЕКТ САМОГО ПРИБОРА, найденный этим же прогоном и НЕ ПОЧИНЕННЫЙ (передан оркестратору): + `make check` пишет лог в ФИКСИРОВАННЫЙ путь `platform/.check.log` и удаляет его при зелени. Два + прогона в одном каталоге — а сегодня они шли — молча уничтожают улику друг друга, и провал этот + НЕ ГРОМКИЙ: греп по `--- SKIP` не находит файла, и Makefile печатает + «--- every test ran: no host condition was missing ---». **Это ЛОЖНАЯ ЧИСТАЯ СПРАВКА:** скипы были + (пять), а строка сказала, что не было ни одного. Наблюдено 06.09 в прогоне v16 — три подряд + `grep: .check.log: No such file or directory`, и следом чистая справка. ⇒ числа ниже сняты НЕ из + `make check`, а собственным `go test ./... -race -count=1 -v` в файл вне каталога зоны, которого + чужой прогон не достанет. Починка мелкая (падать громко, если лога нет, либо уникальное имя), но + Makefile читает гейт `gates.TestTheBatteryNamesEveryHostConditionItsTestsRead`, и правку прибора + посреди чужого лендинга зона в свой дофикс НЕ внесла. ✅ **ПОЧИНЕНО ОТДЕЛЬНЫМ АКТОМ** после закрытия + лендинга, по заказу оркестратора — секция «ПРИБОР ПЕРЕСТАЛ УМЕТЬ…» выше, ряд `PD-454`. Числа ниже + сняты УЖЕ ПОСЛЕ этой починки, и `make check` в том же прогоне напечатал честное + «did NOT run: 5 skipped» вместо прежней чистой справки. СКИПОВ ПЯТЬ, ВСЕ НАЗВАНЫ, ВСЕ ОДНОЙ ПРИЧИНЫ — артефакта контраста (`mining-contrast.zh.txt`) нет на этом хосте ни под одним путём: он многомегабайтный, поставляется деплоем и намеренно не в git. diff --git a/platform/internal/gates/battery_test.go b/platform/internal/gates/battery_test.go index e7318c2a..6cc27cd5 100644 --- a/platform/internal/gates/battery_test.go +++ b/platform/internal/gates/battery_test.go @@ -134,57 +134,10 @@ func TestTheBatteryNamesEveryHostConditionItsTestsRead(t *testing.T) { // vanishes from the hint and BOTH sides fall silent together — the walk would not see it either if // the walk were narrowed to match. So the recipe's own anchors are read here and held to the ones // this gate understands; narrowing either is what goes red, before any test uses the idiom. - recipe, err := os.ReadFile(filepath.Join(zoneRoot, "Makefile")) - if err != nil { - t.Fatal(err) - } - // The recipe is cut the way MAKE cuts it — the target line, then every line that is blank or - // tab-indented — and not at the first blank line: a recipe split into two indented lines with a - // blank between them is legal make with identical output, and a gate that failed on it would send - // the next person who tidies this file looking for a defect that is not there. - lines := strings.Split(string(recipe), "\n") - start := -1 - for i, l := range lines { - if strings.HasPrefix(l, "conditions:") { - start = i - break - } - } - if start < 0 { - t.Fatal("the Makefile declares no `conditions` target: the battery's hint has no source") - } - var recipeLines []string - for _, l := range lines[start+1:] { - if l != "" && !strings.HasPrefix(l, "\t") { - break - } - recipeLines = append(recipeLines, l) - } // The GREP EXPRESSIONS the recipe runs, not words that appear near them: an idiom named in a // comment inside the recipe would satisfy a substring test while the grep beside it had been // narrowed back, which is the hole this check exists to close. - // - // LOGICAL commands, joined the way the shell joins them: a pipeline split across a backslash - // continuation is one command however many lines it occupies, and a check that judged physical - // lines would call a legal reformat a defect — the thing the slice above is careful not to do. - commands := []string{} - pending := "" - for _, l := range recipeLines { - code := strings.TrimPrefix(l, "\t") - if head, _, found := strings.Cut(code, "#"); found { - code = head - } - trimmed := strings.TrimRight(code, " \t") - if strings.HasSuffix(trimmed, `\`) { - pending += strings.TrimSuffix(trimmed, `\`) + " " - continue - } - commands = append(commands, pending+code) - pending = "" - } - if pending != "" { - commands = append(commands, pending) - } + commands := makeRecipe(t, "conditions") // EVERY grep that looks for an environment condition must carry BOTH idioms, and the recipe runs // more than one of them: the names come from one grep and the packages that read each name from // another. An idiom dropped from either is a condition or an attribution that quietly goes @@ -359,3 +312,204 @@ func equal(got, want []string) bool { } return true } + +// makeRecipe is the Makefile's recipe for one target, as the SHELL would see it: one string per +// logical command, comments cut, backslash continuations joined. +// +// It lives in one place because two gates read recipes now, and two hand-written parsers of one +// grammar are a second carrier free to drift from the first — the defect this zone spent a day +// removing from its own money path. +// +// ⚠ The recipe is cut the way MAKE cuts it — the target line, then every line that is blank or +// tab-indented — and NOT at the first blank line: a recipe split into two indented lines with a blank +// between them is legal make with identical output, and a gate that failed on it would send the next +// person who tidies this file looking for a defect that is not there. ⚠ And the joining is what makes +// the unit LOGICAL: a pipeline split across a continuation is one command however many lines it +// occupies, so a legal reformat is not a finding. +func makeRecipe(t *testing.T, target string) []string { + t.Helper() + recipe, err := os.ReadFile(filepath.Join(zoneRoot, "Makefile")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(recipe), "\n") + start := -1 + for i, l := range lines { + if strings.HasPrefix(l, target+":") { + start = i + break + } + } + if start < 0 { + t.Fatalf("the Makefile declares no `%s` target: the gate that reads it has no source", target) + } + var recipeLines []string + for _, l := range lines[start+1:] { + if l != "" && !strings.HasPrefix(l, "\t") { + break + } + recipeLines = append(recipeLines, l) + } + commands := []string{} + pending := "" + for _, l := range recipeLines { + code := strings.TrimPrefix(l, "\t") + if head, _, found := strings.Cut(code, "#"); found { + code = head + } + trimmed := strings.TrimRight(code, " \t") + if strings.HasSuffix(trimmed, `\`) { + pending += strings.TrimSuffix(trimmed, `\`) + " " + continue + } + commands = append(commands, pending+code) + pending = "" + } + if pending != "" { + commands = append(commands, pending) + } + return commands +} + +// ⛔ THE BATTERY MUST NOT BE ABLE TO REPORT CLEANLINESS OVER A LOG IT DID NOT READ. +// +// Every line `make check` prints about the run — the package list, the ALARM rows, the failure list, +// the skip count — is a GREP over one file. Grep answers a MISSING file exactly as it answers a clean +// one: with nothing. So the recipe could reach its final `else` and print «every test ran: no host +// condition was missing» about a run whose log had vanished, which is a bill of health for a +// measurement that never happened. +// +// It is not hypothetical and it is not rare: observed 06.09 on a run with FIVE skips, three +// `grep: .check.log: No such file or directory` followed by that very line. The cause was a FIXED log +// name shared by every run in the directory — and two batteries in one directory is the normal case +// here, the zone and the orchestrator both run one. +// +// So the recipe must TEST the log before it reads it and leave RED when it is not there. This gate +// holds ONE SAFE SHAPE and says so plainly rather than pretending to prove the property: a test of the +// log, then an exit with a LITERAL NON-ZERO code, then the reads. Within that shape the details are +// free — the log may be named per-run or not, the guard may be `-s`, `-f` or `-e`. A different but +// equally correct shape (say, all the reads nested inside `if [ -s log ]; then … else exit 1; fi`) +// will go red here, and that is deliberate: re-shaping the one recipe the whole battery reports +// through should be a decision somebody takes and re-pins, not a silent pass. +// +// Mutation caught: deleting the guard; moving it after the first grep; dropping the `exit` so the +// recipe notices and carries on; and — the one the first edition missed — `exit 0`, which satisfies +// "there is an exit" while returning SUCCESS from a run whose evidence is gone. +func TestTheBatteryCannotReportCleanlinessWithoutItsLog(t *testing.T) { + steps := []string{} + for _, cmd := range makeRecipe(t, "check") { + for _, s := range strings.Split(cmd, ";") { + steps = append(steps, strings.TrimSpace(s)) + } + } + // WHERE THE LOG COMES FROM, read out of the recipe rather than assumed: the redirect of the test + // run names it, so a rename cannot make this gate look at the wrong file and pass. + logRef := "" + for _, s := range steps { + if !strings.Contains(s, "test ./...") { + continue + } + if _, after, found := strings.Cut(s, "> "); found { + logRef = strings.Trim(strings.Fields(after)[0], `"`) + } + } + if logRef == "" { + t.Fatal("the `check` recipe does not redirect its test run to a file this gate can find: " + + "either the battery stopped keeping a log, or its shape moved and this gate now reads nothing") + } + // One shell command per step, with the leading keyword taken off, so that `then exit 1` is read as + // `exit 1` and `then echo …` as an echo. Done once here because every check below asks the same + // question of the same string, and two strippers would drift. + cmds := make([]string, len(steps)) + for i, s := range steps { + cmd := strings.TrimSpace(s) + for { + stripped := cmd + for _, kw := range []string{"then", "else", "do", "{"} { + if rest, found := strings.CutPrefix(stripped, kw); found && strings.TrimLeft(rest, " \t") != rest { + stripped = strings.TrimLeft(rest, " \t") + } + } + if stripped == cmd { + break + } + cmd = stripped + } + cmds[i] = cmd + } + reads, guard, exits := -1, -1, -1 + exitCode := "" + for i, cmd := range cmds { + if !strings.Contains(cmd, logRef) { + continue + } + // A grep INVOCATION, not the word: the recipe's own guard EXPLAINS itself in an `echo`, and the + // explanation says «grep». The first edition of this gate counted that sentence as a read and + // reported the guard as too late — which is the same substring-versus-invocation trap the + // sibling check above spends a paragraph on, walked into by the test that quotes it. + greps := strings.Contains(cmd, "$$(grep") || + (!strings.HasPrefix(cmd, "echo") && strings.Contains(cmd, "grep")) + switch { + case greps && reads < 0: + reads = i + case strings.HasPrefix(cmd, "if [") || strings.HasPrefix(cmd, "[ "): + if guard < 0 && (strings.Contains(cmd, "-s ") || strings.Contains(cmd, "-f ") || strings.Contains(cmd, "-e ")) { + guard = i + } + } + } + // ⛔ THE EXIT'S CODE, not merely its presence — and this half was MISSING from the first edition. + // `exit 0` after the guard satisfied "there is an exit" while leaving the recipe to announce that + // the evidence is gone and then RETURN SUCCESS. That is the very defect this gate exists for, + // wearing different clothes and worse in one respect: the old false clean bill was a LINE a person + // could catch, this one is the EXIT CODE, which is what CI and the landing read. Measured, not + // argued: with `exit 1` mutated to `exit 0` this gate stayed green and `make check` returned 0 + // while printing «THE BATTERY LEFT NO LOG». + // + // A literal is required. `exit` bare carries the previous command's status — here the status of an + // `echo`, which is zero — and `exit $var` cannot be judged from the recipe at all; both are refused + // rather than guessed, because a guard whose code is not readable here is a guard this gate cannot + // promise anything about. + for i, cmd := range cmds { + if guard < 0 || i <= guard { + continue + } + if rest, found := strings.CutPrefix(cmd, "exit"); found { + exits, exitCode = i, strings.TrimSpace(rest) + break + } + } + if reads < 0 { + t.Fatalf("no step of the `check` recipe greps %s: the gate is reading the wrong target or the "+ + "battery no longer reports what the log holds", logRef) + } + if guard < 0 { + t.Fatalf("the `check` recipe reads %s at step %d and never TESTS that it is there. A missing log "+ + "is indistinguishable from a clean one to grep, so the recipe will print «every test ran» "+ + "over a run it did not measure — the defect this gate exists for", logRef, reads) + } + if guard > reads { + t.Errorf("the `check` recipe tests %s at step %d but has already read it at step %d: the first "+ + "read is where the false clean bill starts, so the guard must come before it", logRef, guard, reads) + } + if exits < 0 || exits > reads { + t.Errorf("the `check` recipe tests %s at step %d but does not exit before its first read at step "+ + "%d: noticing the absence and carrying on prints the same bill of health as never looking", + logRef, guard, reads) + return + } + code, err := strconv.Atoi(exitCode) + if err != nil { + t.Errorf("the guard of the `check` recipe exits with %q, which this gate cannot read as a number: "+ + "a bare `exit` carries the previous command's status — an `echo`, so zero — and a variable "+ + "cannot be judged from the recipe. Write a literal, so that what the recipe promises is what "+ + "a reader can check", "exit "+exitCode) + return + } + if code == 0 { + t.Errorf("the guard of the `check` recipe says the log is missing and then exits %d, which is "+ + "SUCCESS: the battery announces that its evidence is gone and reports a green run. That is "+ + "the same false clean bill this gate exists for, moved from a LINE a person reads into the "+ + "EXIT CODE a machine reads — and the landing reads the code", code) + } +}