8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 105,
|
||
"slug": "editorial-2025-02-practice-ai-code-verification",
|
||
"title": "Зелёный тест не доказывает корректность AI-изменения",
|
||
"excerpt": "Пошаговый способ проверить сгенерированный diff: от контракта и отрицательных сценариев до потребителя, зависимостей и решения о merge.",
|
||
"contentHtml": "<p>Зелёный тест отвечает только на тот вопрос, который в него записали. Если тест проверяет сумму, а потребитель ждёт другое имя поля, изменение может пройти CI и сломать следующий вызов. Если preview возвращает правильный текст, но меняет входной объект, ошибка проявится у другого обработчика. Если проверка роли знает только <code>editor</code>, пустая роль может случайно получить доступ.</p>\n<p>Разберём учебный diff, похожий на тот, который способен предложить AI-ассистент. Цель не в том, чтобы измерить качество конкретной модели, а в том, чтобы сделать решение проверяемым: сначала назвать контракт, затем увидеть контрпример, повторить путь потребителя и только после этого обсуждать merge.</p>\n<figure><img src='/assets/editorial/2025/ai-code-verification-2025-verification-funnel.svg' alt='Последовательность проверки AI-изменения: контракт, статический анализ, тест, review и путь потребителя'><figcaption>Каждый шаг проверяет отдельное свойство diff. Совокупность сигналов не становится гарантией, если они задают один и тот же вопрос.</figcaption></figure>\n<h2>Симптом: тест зелёный, потребитель сломан</h2>\n<p>Представим функцию расчёта счёта. Контракт старого кода прост: на входе два целых значения в копейках, на выходе объект с полем <code>amountCents</code>. Входной объект остаётся неизменным. Потребитель использует именно это имя:</p>\n<pre><code>function renderTotal(invoice) {\n return `${invoice.amountCents} коп.`;\n}\n\nconst invoice = toInvoice({ subtotalCents: 900, taxCents: 100 });\nrenderTotal(invoice);</code></pre>\n<p>Сгенерированная реализация может выглядеть убедительно:</p>\n<pre><code>function 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);</code></pre>\n<p>Этот assert проходит: арифметика верна. Но <code>renderTotal</code> читает <code>amountCents</code>, которого нет. Ошибка не в том, что тест написан на JavaScript. Он проверяет внутреннее промежуточное решение, а не публичную границу. Вторая ловушка — название результата: одно переименованное поле может затронуть несколько consumers, даже если функция изолированно выглядит исправной.</p>\n<h2>Контракт нужно записать до запуска тестов</h2>\n<p>Контракт — это короткое описание наблюдаемого поведения, а не просьба «сделать правильно». Для нашего примера достаточно четырёх условий: форма входа, форма результата, запрет на мутацию и реакция на недопустимые числа. Последнее условие нельзя додумывать: если проект не определил отрицательные значения, сначала нужно решить, допускаются ли они.</p>\n<table><caption>Минимальный контракт учебного mapper</caption><thead><tr><th>Граница</th><th>Условие</th><th>Как увидеть нарушение</th><th>Ограничение</th></tr></thead><tbody><tr><td>Вход</td><td><code>subtotalCents</code> и <code>taxCents</code> — целые числа</td><td>Проверить тип и пример с дробным значением</td><td>Не покрывает валидацию внешнего API</td></tr><tr><td>Результат</td><td>Есть только контрактное поле <code>amountCents</code></td><td>Проверить ключи и вызвать consumer</td><td>Совместимость старого API нужно решать отдельно</td></tr><tr><td>Состояние</td><td>Входной объект не меняется</td><td>Сравнить снимок до и после вызова</td><td>Глубокая мутация вложенных данных требует отдельного теста</td></tr><tr><td>Отказ</td><td>Неверный тип или диапазон отклоняется явно</td><td>Проверить исключение или согласованный результат ошибки</td><td>Точная ошибка зависит от публичного API</td></tr></tbody></table>\n<p>Такая таблица отделяет проверенный факт от решения, которое ещё должен принять владелец API. Не стоит молча добавлять «удобную» нормализацию, округление или обратную совместимость: это новые свойства, а не бесплатное исправление.</p>\n<h2>Сначала получить RED, затем чинить узко</h2>\n<p>Ниже — самодостаточная проверка для Node.js 20 или новее. Встроенный модуль <code>node:test</code> стабилен начиная с Node.js 20. Сохраните реализацию в <code>invoice.mjs</code>, тест — в <code>invoice.test.mjs</code>, затем выполните команды:</p>\n<pre><code>node --version\nnode --check invoice.mjs\nnode --test invoice.test.mjs</code></pre>\n<p>Первый вариант реализации намеренно показывает дефект shape:</p>\n<pre><code>// invoice.mjs\nexport function toInvoice(input) {\n return {\n total: input.subtotalCents + input.taxCents\n };\n}</code></pre>\n<pre><code>// invoice.test.mjs\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\nimport { toInvoice } from './invoice.mjs';\n\ntest('возвращает контрактную форму и не меняет вход', () => {\n const input = { subtotalCents: 900, taxCents: 100 };\n const before = JSON.stringify(input);\n const result = toInvoice(input);\n\n assert.deepEqual(result, { amountCents: 1000 });\n assert.equal(JSON.stringify(input), before);\n});</code></pre>\n<p>Тест должен упасть на неправильной реализации. Это полезный RED: он показывает конкретное расхождение, а не сообщает, что «AI ошибся». После исправления ожидаемый результат такой:</p>\n<pre><code>// invoice.mjs\nexport function toInvoice(input) {\n if (!Number.isInteger(input.subtotalCents) || !Number.isInteger(input.taxCents)) {\n throw new TypeError('amounts must be integers');\n }\n\n return {\n amountCents: input.subtotalCents + input.taxCents\n };\n}</code></pre>\n<p>Команда <code>node --test invoice.test.mjs</code> проверяет только этот модуль и один заданный контракт. Она не доказывает корректность округления, работу базы, права пользователя или совместимость всех вызовов. Эти границы должны появиться в следующих тестах, если они есть в настоящем API.</p>\n<h2>Позитивного сценария недостаточно</h2>\n<p>Отрицательный сценарий выбирают из риска, а не добавляют для количества. Для mapper это может быть дробная сумма, отсутствующее поле и неизменность входа. Для проверки доступа — неизвестная роль и отсутствие роли. Для зависимости — пакет с неверным именем, неожиданная версия или новый транзитивный компонент. Сначала задайте ожидаемое поведение, иначе тест закрепит случайное решение.</p>\n<pre><code>export function canEdit(role) {\n return role === 'editor';\n}\n\nconsole.assert(canEdit('editor') === true);\nconsole.assert(canEdit('viewer') === false);\nconsole.assert(canEdit(undefined) === false);\nconsole.assert(canEdit('admin') === false);</code></pre>\n<p>Здесь действует правило «разрешено только явно названному значению». Но это не полноценная авторизация: пример не проверяет identity provider, tenant, токен, срок действия сессии или серверную границу. Нельзя переносить его как готовый security control. Он лишь фиксирует поведение одной чистой функции, если такая функция действительно является частью вашего контракта.</p>\n<h2>Разные инструменты отвечают на разные вопросы</h2>\n<p>Линтер ищет правила, которые ему известны. Компилятор проверяет синтаксис и типы в пределах настроенной системы. Unit-тест повторяет записанные входы. Интеграционный тест смотрит на соединение компонентов. Review проверяет смысл, архитектуру и стоимость изменения. Ручной путь потребителя показывает то, что реально вызывается дальше. Screenshot может подтвердить отображение, но не форму данных и не разрешение операции.</p>\n<table><caption>Карта свидетельств для небольшого diff</caption><thead><tr><th>Сигнал</th><th>Подтверждает</th><th>Не подтверждает</th><th>Следующий вопрос</th></tr></thead><tbody><tr><td><code>node --check</code> или компилятор</td><td>Код разбирается в выбранной среде</td><td>Бизнес-правило и runtime-данные</td><td>Какие входы нарушают контракт?</td></tr><tr><td>Unit-тест</td><td>Названный пример и его ожидание</td><td>Незаписанные ветки и consumers</td><td>Есть ли отрицательный путь?</td></tr><tr><td>Статический анализ</td><td>Известные паттерны и часть уязвимостей</td><td>Намерение команды и все зависимости</td><td>Какая проверка требует человека?</td></tr><tr><td>Review владельца</td><td>Соответствие задаче и границам</td><td>Полное отсутствие runtime-дефектов</td><td>Что осталось вне scope?</td></tr><tr><td>Путь потребителя</td><td>Совместимость конкретного вызова</td><td>Другие маршруты и нагрузку</td><td>Какие ещё consumers нужно найти?</td></tr></tbody></table>\n<p>Пять зелёных строк в CI не превращаются в математическое доказательство. У них могут быть общие фикстуры, одинаковые предположения и один пропущенный consumer. Ценность проверки растёт, когда инструменты независимы по вопросу: shape, состояние, отказ, безопасность и путь доставки нельзя заменить пятью вариантами happy path.</p>\n<h2>Проверить AI-специфические риски до review</h2>\n<p>У сгенерированного кода есть обычные дефекты и дополнительные источники риска. Ассистент может сослаться на несуществующий API, принять устаревшую сигнатуру, удалить падающий тест вместо исправления причины или добавить правдоподобную, но лишнюю зависимость. Поэтому перед предметным review полезно сравнить diff с документацией проекта и отдельно посмотреть новые пакеты.</p>\n<ol><li><strong>Ограничьте diff.</strong> Запишите изменённые файлы, ожидаемый эффект и владельца границы. Если для проверки нужно пересказывать полсистемы, разделите изменение.</li><li><strong>Сверьте публичный контракт.</strong> Найдите типы, схему, вызывающий код и обратную совместимость. Проверьте не только значение, но и имя поля, форму ошибки и порядок побочных эффектов.</li><li><strong>Запустите дешёвые проверки.</strong> Выполните проверку синтаксиса, узкие тесты и статический анализ на той версии среды, которая указана проектом. Зафиксируйте команду и результат.</li><li><strong>Добавьте контрпример.</strong> Выберите отсутствующее поле, неверный тип, чужую роль, повторный вызов или отказ зависимости — конкретный случай, который относится к границе.</li><li><strong>Проверьте зависимости.</strong> Убедитесь, что пакет существует, его версия и лицензия подходят проекту, а lockfile изменён ожидаемо. Не принимайте название из ответа модели без самостоятельного поиска.</li><li><strong>Повторите маршрут потребителя.</strong> Возьмите безопасный тестовый вход и вызовите следующий слой. Если результат расходится с unit-тестом, остановите merge и объясните расхождение.</li><li><strong>Попросите предметный review.</strong> Вопрос должен звучать как «сохраняется ли форма ответа для consumer X?» или «какое поведение нужно для отсутствующей роли?», а не как «посмотрите AI-код».</li></ol>\n<h2>Когда зелёный статус не даёт права на merge</h2>\n<p>Merge следует остановить, если контракт и код описывают разные формы, отрицательный сценарий не определён, тест удалён или пропущен ради зелёного CI, новая зависимость не подтверждена, либо ручной путь потребителя расходится с unit-тестом. Это не означает автоматический rollback и не доказывает наличие инцидента. Это граница принятия решения: владелец должен либо вернуть код к контракту, либо оформить совместимое изменение, либо добавить недостающее свидетельство.</p>\n<p>Полезная запись решения занимает несколько строк: что изменилось, какой риск проверен, какой риск остался, кто его принимает и каким условием можно закрыть остаток. Если ответ невозможно сформулировать без слов «должно работать», проверка ещё не закончена.</p>\n<h2>Ограничения применимости</h2>\n<p>Описанный маршрут подходит для небольшого локального изменения с понятным входом и выходом. Он не заменяет threat model, тестирование распределённой системы, нагрузочные испытания, аудит лицензий, проверку секретов или ручное решение для регулируемого домена. Для миграции схемы, платежей, авторизации и работы с персональными данными нужны дополнительные владельцы и контрольные точки.</p>\n<p>Примеры вымышлены и не сообщают о production-результате. Они не измеряют вероятность ошибки AI и не доказывают, что любой дефект будет найден. Поведение <code>Number.isInteger</code>, встроенного test runner и команд зависит от версии Node.js; используйте версию проекта и проверяйте её через <code>node --version</code>. Для другого языка замените команды, но сохраните те же вопросы к контракту.</p>\n<h2>Итоговый критерий готовности</h2>\n<p>Небольшое AI-изменение готово к обсуждению merge, когда у него есть проверяемый контракт, проходящий позитивный и отрицательный сценарии, явная проверка побочного эффекта, просмотр потребителя и зафиксированные ограничения. Review должен отделять факт запуска теста от решения о корректности. Такой порядок не делает код безошибочным, зато не позволяет одному зелёному assert выдать локальное совпадение за доказательство всей цепочки.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://docs.github.com/en/copilot/tutorials/review-ai-generated-code' target='_blank' rel='noopener noreferrer'>GitHub Docs: Review AI-generated code</a> — официальная последовательность функциональных проверок, проверки контекста и намерения, анализа зависимостей, AI-специфических ошибок, совместного review и автоматизации. Страница продукта может обновляться.</li><li><a href='https://csrc.nist.gov/pubs/sp/800/218/a/final' target='_blank' rel='noopener noreferrer'>NIST SP 800-218A</a> — профиль безопасной разработки для генеративного AI и dual-use foundation models; опубликован в июле 2024 года и применяется вместе с SSDF 1.1.</li><li><a href='https://doi.org/10.6028/NIST.AI.600-1' target='_blank' rel='noopener noreferrer'>NIST AI 600-1: Generative AI Profile</a> — добровольная рамка управления рисками, которая связывает оценку с конкретным сценарием, стадией жизненного цикла и допустимыми последствиями.</li><li><a href='https://nodejs.org/docs/latest-v20.x/api/test.html' target='_blank' rel='noopener noreferrer'>Node.js 20 documentation: Test runner</a> — официальный контракт встроенного <code>node:test</code> и команда запуска тестов; пример требует Node.js 20+.</li></ul>"
|
||
}
|