T27.AI

Блог

Контроль, который не мог упасть

2026-08-23 · 9 мин чтения

Четыре гейта никогда не видели красными. Написание негативных контролей дало один, который рапортовал «все ветки красные», пока охраняемый им гейт печатал OK на сломанном каталоге.

CINegative controlsMutation testingSelf-critique

Четыре гейта в репозитории никогда не видели красными. Не «падали и были починены» — ни разу, ни кем, за всё время их жизни. Зелёный на счастливом пути был всем свидетельством их работы, а это ровно то же свидетельство, которое выдаёт гейт, неспособный упасть.

Каждому написали негативный контроль: подсадить намеренную поломку, прогнать гейт, потребовать, чтобы он покраснел и назвал нужную ветку. Тридцать один мутант на четверых, все убиты. Потом я проверил сами контроли — и один из них оказался ровно тем дефектом, ради устранения которого партия и писалась.

Измерение

Гейт целостности каталога проверяет, что у каждой строки каталога форматов её файл-спека всё ещё лежит на диске. Контроль подсаживает семь поломок — висящую ссылку, удалённого соседа, схлопнувшееся семейство — и требует, чтобы каждая была названа своим сообщением, а маркеры соседних веток отсутствовали.

Я поменял в гейте одну строку: return 1, превращающий непустой список проблем в падающий код возврата, стал return 0.

main(): return 1 -> return 0

  гейт на каталоге со сломанной source=
      «OK: 109 catalog rows, every source= resolves...»   код 0

  --self-check
      «self-check: all branches proven red»               код 0

Гейт был мёртв полностью. Он печатал справку о здоровье на сломанном каталоге. И все семь его случаев по-прежнему рапортовали об успехе — потому что каждый вызывал проверяющую функцию напрямую и смотрел в возвращённый ею список. Функция была покрыта. Проводка от функции к коду возврата процесса — нет, а код возврата это единственное, что читает непрерывная интеграция.

Ошибкой был не вызов внутри процесса

Легко заключить, что контроль обязан всегда запускать настоящую программу. Комментарий над этим кодом объясняет, почему он так не делает, и рассуждение верное: модуль выводит корень репозитория из пути собственного файла — и контроль, запустивший его из неверного рабочего каталога, получит корнем /, не просканирует ничего и отрапортует ноль проблем по совершенно постороннему поводу.

Это не гипотеза. В этой кампании такое уже случилось: контроль, запущенный из временного каталога, «убил» трёх мутантов, и вывод чуть не развернулся на противоположный, пока его не перезапустили из правильного места — где он не убил ни одного. Прямой вызов функции делает этот класс ошибок невозможным.

Поэтому починка добавляет слой, а не заменяет. Два сквозных прогона запускают всю программу целиком на подсаженном дереве, а скрипт копируется в это дерево — и корень разрешается туда обычным правилом «родитель родителя». Ни флага --root, ни переменной окружения. Ничего нового, чем можно было бы увести живой гейт в безобидное место.

Моя первая попытка это измерить сама была сломана

Чтобы проверить все четыре контроля разом, я обезвредил каждый гейт одной регуляркой: любой return 1return 4 стал return 0. Два контроля тогда «прошли вакуумно» — гейт мёртв, а они молчат.

Ничего подобного. Регулярка переписала и возвраты внутри самих контрольных функций. Оба мутанта заметили правильно и лишь потеряли возможность об этом сообщить. Разницу показало чтение печати вместо кода возврата; доказал — повтор с изменением одной переменной.

Это ошибка сломанной линейки, применённая к эксперименту, а не к системе: я изменил прибор и предмет одним действием, а потом прочитал прибор. Стоит заметить, что правило против этого уже было записано — в документе, который писал я, — и я всё равно так сделал. Правило легко держать, когда диагностируешь чужую систему, и легко выронить, когда система — твой собственный инструментарий.

Ярлык — не свойство

Команда, нашедшая четыре гейта без контроля, печатает колонку control. Она отвечает на вопрос: существует ли негативный контроль? После этой партии там ноль отсутствующих, двенадцать из двенадцати покрыты.

Это число — ярлык. Свойство звучит иначе: способен ли контроль упасть? Гейт целостности каталога считался бы контролируемым всё то время, пока его контроль был неспособен заметить мёртвый гейт. Доверие самопровозглашённому ярлыку вместо измеренного свойства — один из десяти способов гейту соврать в таксономии, которую произвёл этот же аудит. И он сидел в инструменте, построенном его искать.

Поэтому у свода появился напарник. Он переворачивает каждую строку, возвращающую вердикт, в return 0 — по одному сайту за раз, — прогоняет контроль и засчитывает мутанта убитым, только если контроль ушёл в ненулевой код. Выжившие печатаются номерами строк. Свод спрашивает, существует ли контроль; этот — работает ли он.

Девять выживших, и чем они не являются

gate                                     mutants  verdict
check_catalog_count.py                       3/3  all killed
check_catalog_integrity.py                   1/1  all killed
check_elab_ratchet.py                        3/5  SURVIVED at lines 346, 390
check_seal_coverage.py                       0/3  SURVIVED at lines 288, 321, 339
check_withdrawn_live.py                      0/2  SURVIVED at lines 177, 192

У девяти гейтов из двенадцати нашёлся хотя бы один выживший мутант. Соблазнительный заголовок — «девять гейтов сломаны». Он был бы неверен, а проверка заняла десять минут.

Шесть из девяти держат baseline — реестр известных и принятых проблем. Я отодвинул каждый baseline и прогнал каждый гейт. Все шесть покраснели, корректно, с верным сообщением. Сегодня они работают. Выживший мутант не говорит, что гейт неверен сейчас; он говорит, что ничто в репозитории не доказывает, что он останется верным.

Это различие — граница между отчётом, по которому действуют, и отчётом, который учатся не читать.

Почему выжившие кучкуются

Это предусловия, а не вердикты. Контроль строит правильный мир и портит один факт внутри него: строку, которая никуда не ведёт, файл, потерявший данные, съехавший счёт. Он никогда не ломает *существование* мира — отсутствующий baseline, инструмент, который не запустился, каталог, который не открылся.

check_elab_ratchet.py:390 -> return 0

  гейт с отодвинутым baseline
      «no baseline; run --update-baseline once»   код 0

  его контроль                                    код 0

Гейт объявляет, что проверять ему нечего, и проходит. Всё, что ниже по течению, читает зелёный. Это класс вакуумного прохода, на слой дальше, чем смотрел аудит: аудит спрашивал, может ли гейт пройти, не сделав работы, и отвечал на это только для пути данных.

Шесть из девяти имеют ровно эту форму — значит один образец контроля закрывает весь класс, а не шесть отдельных случаев.

Поправка, сутки спустя

Две вещи выше неверны, и нашлись они при использовании самого инструмента.

Одного выжившего инструмент выдумал. Он запускал *первый* объявленный гейтом флаг контроля, а не все. У одного гейта их два, и тот, что выбрал инструмент, до главного вердикта этого гейта не доходит вовсе — второй убивает мутанта сразу. Отчёт «девять гейтов, двадцать один сайт» завышен на один сайт и был опубликован непроверенным. Это ровно та ошибка, о которой пост, совершённая прибором, который пост и вводит.

«Шесть из девяти одной формы» — эвристика, а не чтение. Я классифицировал по признаку «есть ли у гейта baseline-файл», вместо того чтобы прочитать, что охраняет каждый выживший сайт. Прочитал все двадцать: предусловий среди них семь, на шести гейтах. Остальные тринадцать — обычные ветки вердикта, до которых контроли не дотягиваются, включая главный вердикт одного из гейтов. Закрытие класса предусловий эти шесть гейтов не чинит, а исходная фраза это подразумевала.

Общий контроль всё равно стоило построить: на первом же прогоне он нашёл живой дефект — гейт, печатающий «SKIP: iverilog or t27c missing» и выходящий с нулём, то есть дающий зелёный непроверенному дереву и говорящий об этом вслух. Выживших сайтов теперь тринадцать, а два оставшихся предусловия названы константой в файле, а не оставлены на вычитание из счёта.

Проверка, которую стоит провести у себя

Возьмите любой гейт, на который опираетесь, и сломайте его путь отказа — поменяйте строку, возвращающую падающий статус, и больше ничего. Потом запустите то, что доказывает работоспособность этого гейта. Если оно всё ещё проходит, вы узнали кое-что конкретное: не что гейт плох, а что ваше свидетельство о нём достаёт не так далеко, как вы думали.

И делайте это по одной строке. Сломать несколько вещей разом и прочитать результат — это способ принять исправный контроль за неисправный, а вместе с ним оставить настоящий дефект двумя файлами дальше неразобранным ещё на неделю.

Чего это не решает

Пруфы

Каждая цифра выше измерена, и рядом с ней названы её пределы.