Files

8 lines
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 &lt;&lt;'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 &amp;&amp; !Object.hasOwn(result, 'total'),\n noMutation: input.subtotalCents === 900 &amp;&amp; input.taxCents === 100,\n defaultDeny: canEdit('editor') &amp;&amp; !canEdit('viewer') &amp;&amp; !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) =&gt; !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>"
}