Ноль, который сходится
У маршрута до отрисовки было правило: финальная панель ревью должна дать ноль подтверждённых находок выше мелочи. Пять линз — право, безопасность, данные, эксплуатация, согласованность — плюс опровергающий, который обязан снять всё, что снимается. Правило я писал сам и считал его простым. Оно оказалось не простым, а рекурсивным.
Что случилось
Проход первый дал 65 находок. Я закрыл их пачкой правок — не точечно, а одним скриптом, который отказывается писать, если якорь нашёлся не ровно один раз. Пачка легла, круг проверок зелёный, коммит. Проход второй — снова 65. Не потому, что первая пачка была плохой: половина находок второго прохода была *о первой пачке*. Я дописал контракт на восемь областей API, и линза данных пошла в контейнер с той самой СУБД, подняла из спеки все таблицы и вставила строку: у мест за столом первичный ключ по паре (стол, личность), а рядом мой же комментарий обещает «вернувшийся получает новый номер». Вернуться за тот же стол нельзя — ключ не даст. Проверено не чтением, а duplicate key.
Третий проход — 43. Четвёртый — 29. Пятый — 17. Критичных в четвёртом и пятом не было. Но и нуля не было: каждая пачка сама становилась предметом следующего прохода и рождала два-три следствия. В пятом проходе линза безопасности нашла, что в четвёртом я переименовал поле доказательства ПИНа в auth_hash — по имени хранимой колонки. Канон говорит: на узел уходит auth, узел сравнивает его хэш с колонкой. Исполнитель, собирающий по контракту, сравнил бы присланное с колонкой напрямую, и дамп базы стал бы доказательством ПИНа без ПИНа. Это моя ошибка, внесённая при закрытии чужой находки, и поймана она через одну итерацию. Досадно ровно настолько, насколько полезно.
Что поймала машина
В третьем проходе линза согласованности показала дубли ключей в файле контракта. Формат допускает два одинаковых ключа в одном объекте, парсер молча берёт последний, и булево поле «я играю» исчезало из схемы, потому что ниже стояло целое «играют N» с тем же именем. Хуже: незакавыченное описание с запятой внутри фигурных скобок режется на два ключа, и часть текста становится ключом «10).» — синтаксически валидно, содержательно мусор.
Я дописал в ворота обход дерева: дубль ключа — красный, ключ, кончающийся на «).» или «true.» — красный. Ворота тут же нашли шесть таких мест в том же файле, включая четыре, которые я внёс пятнадцатью минутами раньше. В пятом проходе они поймали ещё две запятые — мои, свежие. Проверка, которая ловит автора правила через минуту после правки, — единственный вид проверки, в который я верю.
Был и другой улов. Круг проверок покраснел на пробе, которая не менялась неделю. С pipefail конструкция printf '%s' "$output" | grep -q возвращает код printf, а не grep: grep -q выходит на первом совпадении, printf получает SIGPIPE, и пайплайн красный при найденной подстроке. Неделю это не стреляло, потому что вывод помещался в буфер канала; в тот день кандидатов стало 355, вывод перерос 64 КБ — и стрельнуло. Один и тот же шаблон стоял в двенадцати пробах и в самом скрипте круга. Заменил на here-string во всех.
Между чем выбирали
К пятому проходу вопрос стал не «что ещё чинить», а «когда остановиться». Варианты были такие.
Первый — идти до буквального нуля. Против него говорила арифметика: каждая пачка меньше предыдущей втрое, и находки в ней — следствия пачки, а не старые дыры описания. Ноль наступил бы тогда, когда пачка стала бы настолько малой, что править нечего, — то есть ещё два-три прохода по десять агентов ради запятых.
Второй — объявить сходимость нулём и записать это как решение владельца с датой. За него — тренд 65 → 43 → 29 → 17, ноль критичных два прохода подряд, и то, что последние находки были не о продукте, а о моих формулировках, для которых уже стоят машинные ворота.
Я доложил обоими вариантами. Заказчик спросил: «что значит считать нулем?» Я объяснил рекурсию. Ответ был: «считаем нулём, иди в Э6». Это записано в маршрут как решение владельца, не как моя интерпретация правила.
Про опровергающего
Статистика опровержений по проходам: 4 из 65, 4 из 43, 0 из 29 (в четвёртом проходе я опровергающего не собирал — пять весомых проверил сам, остальные механические, и так и записал), 1 из 17. То есть линзы врут редко — примерно одна находка из пятнадцати не выдерживает проверки. Но врут убедительно: одна линза уверяла, что заголовка гашения версии в контракте нет, — он был, просто ни один ответ на него не ссылался. Опровергающий это разделил на «неверно» и «верно, но не так». Без него я бы поверил первому и не заметил второго.
Что унести
Пачка правок — это изменение, а изменение нужно ревьюить. Правило «ноль после панели» без оговорки о рекурсии недостижимо по построению: сходимость к нулю — это и есть ответ, и решение принять её должно быть чьим-то, а не моим.
И второе: если один класс ошибки возникает дважды, это не повод быть внимательнее. Это повод написать ворота, которые поймают третий раз без участия внимания.