8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 104,
|
||
"slug": "editorial-2025-02-mechanism-ai-code-verification",
|
||
"title": "Почему зелёный тест не доказывает корректность сгенерированного кода",
|
||
"excerpt": "Разбираем, как проверять сгенерированный diff по контракту, состоянию, отрицательной ветке и пути потребителя — и когда расхождение доказательств должно остановить merge.",
|
||
"contentHtml": "<p>Проблема начинается там, где зелёный unit-тест отвечает только на тот вопрос, который в него записали. Он может подтвердить сумму, но не имя публичного поля; правильную строку — но не отсутствие мутации; доступ редактора — но не отказ неизвестной роли. Поэтому сгенерированный diff принимают не по числу зелёных статусов, а по совпадению контракта, наблюдаемого поведения и решения владельца границы.</p>\n<p>Ниже — учебный разбор небольшого JavaScript-изменения. Он не читает репозиторий, не запускает модель и не описывает реальный инцидент. Все входы фиксированы, а код можно выполнить локально штатным Node.js. Это позволяет проверить сам способ рассуждения и не приписывать примеру доказательств, которых он не собирает.</p>\n<h2>Сначала определите, что должно остаться неизменным</h2>\n<p>Контракт — это не фраза «функция работает правильно». Для проверки diff запишите вход, форму результата и запрещённое побочное действие. Если функция участвует в авторизации, добавьте правило отказа для неизвестного значения. Такой список превращает размытый риск в наблюдаемый вопрос.</p>\n<pre><code>const contract = {\n input: { subtotalCents: 900, taxCents: 100 },\n output: { amountCents: 1000 },\n invariant: 'input remains unchanged',\n deny: [undefined, 'viewer']\n};</code></pre>\n<p>В этом контракте четыре разных утверждения. <code>amountCents</code> задаёт публичную форму ответа, сумма — его значение, <code>input remains unchanged</code> — границу владения состоянием, а <code>deny</code> — отрицательные варианты политики. Один assertion не способен подтвердить их все.</p>\n<p>Официальный Secure Software Development Framework NIST описывает безопасную разработку как набор практик, который встраивается в жизненный цикл продукта. Это полезная рамка для процесса, но не автоматический сертификат корректности конкретного diff. Для локальной проверки всё равно нужно назвать инвариант и способ его опровергнуть.</p>\n<figure><img src=\"/assets/editorial/2025/ai-code-verification-2025-risk-detection-matrix.svg\" alt=\"Матрица связывает четыре риска с контрактной, статической, тестовой, ручной и экспертной проверкой\"><figcaption>Один риск может иметь несколько релевантных способов проверки. Знак в матрице означает подходящее свидетельство, а не гарантию безопасности или корректности.</figcaption></figure>\n<h2>Учебный diff: арифметика верна, контракт нарушен</h2>\n<p>Представим mapper, который готовит данные для счёта. Его контракт требует <code>amountCents</code>, а consumer читает именно это поле. Ассистент сгенерировал короткую функцию и тест, проверяющий только итоговое число:</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>Тест зелёный, потому что арифметика действительно даёт 1000. Но следующий consumer делает <code>result.amountCents</code> и получает <code>undefined</code>. Ошибка находится на границе формы ответа, а не в вычислении. Если заменить контракт в тесте на фактическое поведение функции, можно получить ещё один зелёный статус и сохранить дефект.</p>\n<p>Проверка должна смотреть на потребителя и на ключ результата:</p>\n<pre><code>const input = { subtotalCents: 900, taxCents: 100 };\nconst result = toInvoice(input);\n\nconsole.assert(result.amountCents === 1000);\nconsole.assert(Object.hasOwn(result, 'amountCents'));\nconsole.assert(!Object.hasOwn(result, 'total'));\nconsole.assert(input.subtotalCents === 900);\nconsole.assert(input.taxCents === 100);</code></pre>\n<p>После такого теста причина видна сразу: первая функция вернёт ошибку на assertion для <code>amountCents</code>. При этом проверка не доказывает, что все consumers, сериализаторы и реальные ответы системы используют ту же схему. Она доказывает только перечисленные свойства фиксированного вызова.</p>\n<h2>Воспроизводимый прогон без внешних пакетов</h2>\n<p>Чтобы не спорить о слове «прошло», запустите минимальный независимый скрипт. Он проверяет три свойства: публичное поле, отсутствие мутации входа и default-deny для ролей. Команда использует только встроенный Node.js; версия Node должна поддерживать <code>Object.hasOwn</code> (Node.js 16.9 и новее).</p>\n<pre><code>node --input-type=module <<'NODE'\nfunction toInvoice(input) {\n return { amountCents: input.subtotalCents + input.taxCents };\n}\n\nfunction canEdit(role) {\n return role === 'editor';\n}\n\nconst input = { subtotalCents: 900, taxCents: 100 };\nconst result = toInvoice(input);\nconst checks = {\n shape: result.amountCents === 1000 && !Object.hasOwn(result, 'total'),\n noMutation: input.subtotalCents === 900 && input.taxCents === 100,\n defaultDeny: canEdit('editor') && !canEdit('viewer') && !canEdit(undefined)\n};\n\nfor (const [name, ok] of Object.entries(checks)) {\n console.log((ok ? 'PASS ' : 'FAIL ') + name);\n}\nif (Object.values(checks).some((ok) => !ok)) process.exitCode = 1;\nNODE</code></pre>\n<p>Ожидаемый вывод — три строки <code>PASS</code>. Если изменить возвращаемое поле на <code>total</code>, заменить <code>role === 'editor'</code> на проверку «не viewer» или присвоить что-либо в <code>input</code>, соответствующая строка станет <code>FAIL</code>, а процесс завершится с ненулевым кодом. Это воспроизводимое наблюдение, не измерение «процента качества AI».</p>\n<h2>Сопоставьте риск с прямым свидетельством</h2>\n<table><caption>Какая проверка отвечает на какой вопрос</caption><thead><tr><th>Риск</th><th>Прямое свидетельство</th><th>Что остаётся вне проверки</th><th>Следующее действие</th></tr></thead><tbody><tr><td>Публичное поле переименовано</td><td>Assertion схемы и тест consumer</td><td>Другие consumers и совместимость API</td><td>Сверить контракт всех затронутых границ</td></tr><tr><td>Preview меняет входной объект</td><td>Снимок input до и после вызова</td><td>Мутации глубоко вложенных или внешних объектов</td><td>Уточнить ownership и проверить нужную глубину</td></tr><tr><td>Неизвестная роль получает доступ</td><td>Allow-list плюс тесты viewer и missing role</td><td>Проверка identity, tenant и серверного enforcement</td><td>Подключить владельца политики и threat model</td></tr><tr><td>Секрет или данные уходят в AI-инструмент</td><td>Проверка отправляемого контекста и правил исключения</td><td>Практики конкретного провайдера и конфигурация организации</td><td>Проверить настройки и журнал фактического обмена</td></tr><tr><td>Линтер не видит бизнес-ошибку</td><td>Предметный review и тест правила</td><td>Неназванные сценарии и неверный бизнес-контекст</td><td>Записать invariant и отрицательный путь</td></tr></tbody></table>\n<p>Матрица не складывает свидетельства в одну оценку. У каждого способа есть область действия. Static analysis хорошо находит формальный паттерн, если для него есть правило. Узкий тест делает конкретный вход воспроизводимым. Review проверяет смысл и границы, но зависит от контекста. Ручной сценарий повторяет маршрут потребителя, но покрывает только выбранный путь.</p>\n<h2>Почему независимость важнее количества запусков</h2>\n<p>Два теста независимы, когда способны обнаружить разные нарушения. Тест на сумму и screenshot с отображённой суммой могут повторять одну и ту же предпосылку: «полученное значение достаточно». Ни один из них не обязан заметить, что API ждёт другое имя поля. Тест consumer задаёт уже другой вопрос — может ли следующий участник прочитать результат.</p>\n<p>Генерация кода и теста одним инструментом создаёт дополнительный риск: оба фрагмента могут повторить неверное толкование требования. Поэтому для security-критичных правил нужен источник, не скопированный из того же предположения: явный allow-list, контракт владельца, независимый тестовый вход или проверка специалистом. OWASP описывает ручной code review как дополнение к автоматизированным инструментам там, где нужны бизнес-контекст и анализ логики.</p>\n<p>Документация GitHub отдельно оговаривает границу Copilot code review: такой review оставляет комментарии и не считается обязательным approval для pull request. Для других AI-инструментов правила могут отличаться, поэтому название продукта нельзя превращать в универсальное утверждение. Общий вывод уже: автоматический комментарий не заменяет решение человека, которому принадлежит риск.</p>\n<h2>Проверьте отрицательный путь, а не только happy path</h2>\n<p>Отрицательный тест выводится из правила. Для доступа безопаснее перечислить разрешённые роли, чем разрешать всё, что не равно одной запрещённой роли. Для mapper нужно проверить форму и отсутствие старого ключа. Для функции без побочных эффектов — сравнить состояние после вызова. В каждом случае вопрос звучит как «что должно быть отвергнуто?», а не только «что должно сработать?».</p>\n<pre><code>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 boundary, токена, серверного маршрута и журнала событий. Переносить его verdict в production можно только после добавления фактических условий политики.</p>\n<h2>Порядок ревью одного небольшого diff</h2>\n<ol><li><strong>Сузьте область.</strong> Назовите изменённый компонент, consumer и границу, которую нельзя нарушить.</li><li><strong>Запишите контракт.</strong> Укажите вход, выход, допустимое состояние после вызова и условие отказа.</li><li><strong>Составьте карту рисков.</strong> Отдельно отметьте shape, побочные эффекты, доступ, данные и ошибки внешних зависимостей.</li><li><strong>Выберите прямую проверку.</strong> Используйте assertion, статическое правило, focused test или ручной сценарий только там, где он способен увидеть риск.</li><li><strong>Добавьте отрицательный вход.</strong> Проверьте отсутствующее поле, неизвестную роль, старый формат, повторный вызов или отказ зависимости — согласно контракту.</li><li><strong>Сравните независимые основания.</strong> Убедитесь, что generated code и test не подтверждают одну и ту же ошибочную предпосылку.</li><li><strong>Зафиксируйте blind spot.</strong> Запишите, чего выбранная проверка не доказывает и кто принимает остаточный риск.</li><li><strong>Остановите подготовку merge при расхождении.</strong> Сначала исправьте код, явно измените контракт или получите решение владельца; не подгоняйте assertion под фактический дефект.</li></ol>\n<h2>Когда зелёный статус не даёт права на merge</h2>\n<p>Практический stop condition появляется, если контракт и observed behavior требуют разных результатов, если отрицательная ветка не проверена, если consumer не входит в scope или если единственное evidence сгенерировал тот же источник, что и код. Stop здесь означает прекратить подготовку merge до выяснения границы. Это не команда <code>git revert</code> и не rollback уже доставленной версии.</p>\n<p>Решение можно вернуть в рабочее состояние тремя способами: привести реализацию к существующему контракту; оформить изменение контракта и проверить совместимость потребителей; или собрать недостающее evidence и явно принять остаточный риск. Если после ревью нельзя одним предложением ответить, какой инвариант доказан, статус следует оставить «не готово» независимо от количества зелёных job.</p>\n<h2>Ограничения применимости</h2>\n<p>Схема подходит для небольшого diff, у которого можно назвать границу и фиксированные входы. Она не заменяет интеграционные и end-to-end тесты, анализ зависимостей, threat model, проверку конфигурации, нагрузочное испытание, наблюдение после доставки или аварийный план. Для конкурентного кода одного сравнения input до и после может быть мало: понадобится проверка гонки и конечного состояния.</p>\n<p>Примеры намеренно используют чистые функции и встроенный Node.js. В реальном проекте результаты зависят от версии runtime, схемы API, сериализатора, прав процесса и конкретной конфигурации CI. Команда из статьи не проверяет сеть, секреты и production. Если diff меняет такие границы, расширьте план проверки, а не объявляйте его доказанным по локальному прогону.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://csrc.nist.gov/pubs/sp/800/218/final\" target=\"_blank\" rel=\"noopener\">NIST SP 800-218 SSDF 1.1</a> — рамка практик безопасной разработки, которую встраивают в жизненный цикл продукта.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Secure_Code_Review_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP Secure Code Review Cheat Sheet</a> — границы ручного review, автоматизированных инструментов и проверки бизнес-логики.</li><li><a href=\"https://docs.github.com/en/copilot/how-tos/use-copilot-agents/request-a-code-review/use-code-review\" target=\"_blank\" rel=\"noopener\">GitHub Docs: Using GitHub Copilot code review</a> — конкретное ограничение статуса AI-review в pull request.</li></ul>\n<h2>Критерий готовности</h2>\n<p>Diff можно передавать владельцу на approval, когда для каждого заявленного риска записаны инвариант, прямое свидетельство, отрицательный вход и blind spot. Код, тест и consumer должны описывать один контракт. Если они расходятся, результат проверки — не «почти зелёный», а конкретный вопрос владельцу границы и остановка merge до ответа.</p>"
|
||
}
|