{ "index": 105, "slug": "editorial-2025-02-practice-ai-code-verification", "title": "Зелёный тест не доказывает корректность AI-изменения", "excerpt": "Как проверить сгенерированный diff по контракту, отрицательной ветке, побочным эффектам и независимому review — и когда остановить merge.", "contentHtml": "
Сгенерированный diff может пройти линтер и один unit-тест, но сломать потребителя. Типичный симптом — тест проверяет значение, а публичный контракт требует другое имя поля. Другой симптом — preview возвращает правильный текст, но меняет входной объект. Третий — роль editor проходит, а пустая роль тоже получает доступ. Цена ошибки начинается с повторного разбора и задержки merge. В реальной системе она может включать неверные данные, отказ функции или нарушение политики доступа. По одному зелёному статусу цену не определить.
\nТезис простой: результат AI-инструмента — это материал для проверки, а не доказательство. Корректность появляется только тогда, когда заявленный контракт, наблюдаемое поведение и решение ревьюера совпадают. Каждый сигнал должен отвечать на свой вопрос. Тест проверяет названный сценарий. Статический анализ ищет известный паттерн. Review проверяет намерение и границы. Ручное воспроизведение смотрит на путь потребителя.
\nДо запуска тестов запишите, что именно нельзя нарушить. Для mapper это форма результата и имена полей. Для функции preview — владение входным объектом и запрет на скрытую мутацию. Для проверки роли — список разрешённых значений и поведение при отсутствии роли. Для обработчика ошибки — статус, формат ответа и отсутствие утечки деталей.
\nФраза «функция должна вернуть итог» слишком широкая. Рабочая формулировка выглядит так: «при входе с subtotalCents и taxCents вернуть объект { amountCents: number }; вход не менять». В ней есть вход, выход и инвариант. По ней можно написать проверку и понять, где заканчивается её область действия.
contract = {\n input: { subtotalCents: 900, taxCents: 100 },\n output: { amountCents: 1000 },\n invariant: 'input remains unchanged'\n}\nЭтот фрагмент — учебный пример. Он не обращается к репозиторию, базе, CI или production. Его задача — показать форму контракта, а не предсказать поведение конкретной модели.
\nПредставим учебную функцию, которую изменил ассистент:
\nfunction toInvoice(input) {\n return {\n total: input.subtotalCents + input.taxCents\n };\n}\n\nconst result = toInvoice({ subtotalCents: 900, taxCents: 100 });\nconsole.assert(result.total === 1000);\nТест зелёный. Арифметика верна. Но контракт требует amountCents, а consumer читает result.amountCents. Значение становится undefined. Ошибка возникла не в сложной математике. Тест задал слишком узкий вопрос и не проверил форму публичного результата.
Исправленная проверка должна смотреть на поле, доступное потребителю:
\nconst input = { subtotalCents: 900, taxCents: 100 };\nconst result = toInvoice(input);\n\nconsole.assert(result.amountCents === 1000);\nconsole.assert(!('total' in result));\nconsole.assert(input.subtotalCents === 900);\nconsole.assert(input.taxCents === 100);\nВторой пример тоже учебный. Он не доказывает безопасность функции и не заменяет тесты всех реальных потребителей. Он показывает, как превратить контракт в наблюдаемое утверждение.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Тест проверяет число, consumer не находит поле | Проверили значение, но не shape | Сравнить ожидаемые ключи и чтение consumer | Вернуть контрактное поле или отдельно принять изменение API |
| Preview выдаёт верный текст, вход изменился | Не зафиксировано владение input | Сравнить объект до и после вызова | Создать derived value или назвать мутацию отдельной операцией |
| Editor проходит, missing role тоже проходит | Есть accept case, но нет default-deny | Проверить viewer и отсутствие значения | Записать allow-list и добавить отрицательные тесты |
| Линтер чистый, смысл изменения неясен | Паттерн проверен вместо намерения | Сопоставить diff с контрактом и owner boundary | Уменьшить scope и запросить предметный review |
| Ручной сценарий расходится с unit-тестом | Тест не повторяет путь потребителя | Воспроизвести тот же вход от API или UI до результата | Остановить merge до объяснения расхождения |
Статический анализ видит то, для чего у него есть правило. Он может найти присваивание аргументу или подозрительный вызов. Он не знает, разрешена ли мутация в конкретном API. Unit-тест видит только входы и ожидания, которые записал автор. Он не проверяет незаписанный путь. Review видит контекст, но зависит от размера diff, ясности требования и времени ревьюера. Ручная проверка видит один маршрут пользователя и может не охватить редкий вариант.
\nПоэтому пять зелёных сигналов могут повторять одну предпосылку. Например, линтер, unit-тест и screenshot подтверждают, что экран получил число. Ни один из них не подтверждает, что имя публичного поля осталось прежним. Независимость означает не количество инструментов, а разные вопросы и разные способы обнаружить нарушение.
\nНужно заранее написать blind spot каждого шага. Контракт не доказывает реализацию. Тест не доказывает покрытие всех consumers. Review не доказывает отсутствие runtime-ошибки. Такой список не делает систему безопасной сам по себе. Он не даёт зелёному статусу большего смысла, чем тот, который реально проверен.
\nОтрицательная ветка должна следовать из контракта. Если неизвестная роль не должна получать доступ, проверяйте и viewer, и отсутствие роли. Если preview не меняет вход, проверяйте равенство объекта после вызова. Если поле нельзя переименовывать без совместимости, проверяйте ключ результата и чтение старого consumer.
function canEdit(actorRole) {\n return actorRole === 'editor';\n}\n\nconsole.assert(canEdit('editor') === true);\nconsole.assert(canEdit('viewer') === false);\nconsole.assert(canEdit(undefined) === false);\nЭто не threat model и не доказательство безопасности авторизации. В примере нет identity provider, tenant boundary, токена или реального хранилища прав. Он проверяет только заявленное правило для трёх фиксированных входов. Если production-контракт шире, пример нужно расширить фактическими условиями, а не переносить его verdict.
\nПрактический stop condition таков: contract, отрицательный тест и rationale ревьюера описывают разные результаты. Тогда merge preparation блокируется. Это не автоматический revert и не rollback. Ничего доставленного система не меняет. Команда только прекращает движение спорного diff, пока владелец границы не выберет одно из действий: вернуть код к контракту, оформить совместимое изменение или собрать недостающее свидетельство.
\nЕсли расхождение нельзя объяснить одним предложением, не передавайте diff на approval. Approval должен отвечать на конкретный вопрос: можно ли принять переименование поля, кто владеет совместимостью, почему мутация разрешена или какое правило действует для отсутствующей роли. Человек утверждает осознанное исключение, а не пустоту в проверке.
\nAI-проверка не заменяет знание домена, threat model, тесты реальных интеграций и ответственность владельца. Ассистент может придумать несуществующий API, пропустить условие, удалить падающий тест или предложить зависимость с неподходящей лицензией. Статический инструмент может не понимать бизнес-правило. Ручной review тоже может пропустить ошибку.
\nВсе функции и данные в примерах вымышлены и предназначены только для обучения. В статье нет production-измерений, утверждений о качестве конкретной модели и обещания, что описанный набор шагов обнаружит любой дефект. Перед применением нужно заменить учебный контракт фактическим API, входами, ролями и условиями доставки.
\nНебольшой diff готов к следующему решению, когда выполнены четыре условия: контракт записан; позитивный и отрицательный сценарии проходят на одних и тех же входных данных; побочный эффект либо запрещён и проверен, либо назван частью контракта; ревьюер может объяснить, что проверено и что осталось вне scope. Если хотя бы одно условие не выполнено, статус «зелёный» описывает запуск инструмента, а не готовность изменения.
\n