Ревью кода у меня идёт в 3 стадии. N агентов читают файл и сообщают о дефектах, K скептиков пытаются опровергнуть каждое сообщение, один агент склеивает уцелевшее в список. Собрал я это так, потому что у claude CLI, которым я гоняю агентов, есть исследовательский воркфлоу, построенный ровно на этой форме. Его стадии лежат в бинарнике простыми строками: разослать поиски, вытащить опровержимые утверждения, 3 голоса адверсариальной проверки на утверждение при 2 опровержениях на убийство, потом склеить смысловые дубли. Я перенёс это на ревью кода, не спросив, нужно ли ревью кода такое. С тех пор я плачу за все 3 стадии и ни разу не выбирал N или K по замеру.
На этой неделе я этот замер сделал. 191 вызов модели и $11.76, считая контрольный заход, который я выбросил. Стадия, которую я защищал бы яростнее всего, взяла из этих денег $4.57 и ни разу не разошлась сама с собой. Чего я не ждал, так это того, как часто неправым оказывался мой собственный измерительный аппарат, а не агенты. Так вышло 5 раз. Худший из этих 5 чуть не ушёл заголовком этого поста.
Файл
clean.mjs это сборщик метрик того рода, который тут действительно нужен. 184 строки: семафор поверх очереди FIFO, ретраи с бэкоффом и джиттером, ключ кеша, перцентиль по ближайшему рангу и строка дневной сводки. Второй скрипт сажает в него 12 дефектов буквальной заменой строк. Файл, который читают агенты, выходит на 176 строк. Реестр записывает, куда лёг каждый дефект. На каждый дефект есть отдельный файл, несущий только его.
Дальше каждый дефект доказывается прогоном чистого файла против файла, который несёт только его. Доказывать против мутированного файла это очевидный ход и он даёт неверный ответ. Там один дефект умеет спрятаться за другим. Один из моих так и делает.
Каждая стадия работает из свежей временной папки. Никакого CLAUDE.md на пути и никаких инструментов. Это я проверил руками, а не поверил отчёту обвязки. Конфиг MCP пустой. Операционная инструкция этого репозитория это 889 строк правил, которые я написал сам себе. Она молча уходит в промпт, если рабочая папка это репозиторий. Node 22.23.1, claude CLI 2.1.235, модель claude-opus-5 на 4 ядрах.
Поиск
8 искателей получили один и тот же промпт, по 4 в полёте. Они вернули 108 находок за $0.76. Мой счётчик засчитывает находку дефекту, если названная строка попадает в посаженный отрезок, расширенный на 2 в каждую сторону. Он сказал, что средний искатель ловит 10.25 из 12 и что семафор не нашёл никто.
Я прочитал все 108 утверждений, потому что последняя часть выглядела неправдоподобно. Семафор нашли все 8. 6 из них назвали строку 25, 2 строку 26. Это тот единственный live++, который в файле остался. Второй я удалил на строке 18, а оценивал их по месту собственной правки. Окно заодно выдало 2 искателям зачёт за дефект 12. Их утверждения на этих строках про другое: тело ответа 200, которое не JSON и папка журнала, которую никто не создаёт.
Значит дефект 12 не нашёл никто и в этом файле до него не доходит ни один неуспешный статус. Дефект 12 убирает проверку статуса после цикла ретраев. Другой посаженный дефект убирает проверку, которая не даёт плохому статусу вообще попасть в ретрай. Цикл теперь либо возвращает хороший ответ, либо кидает исключение. Я прогнал через файл 9 неуспешных статусов и до тела ответа не дошёл ни один. Через файл, несущий только дефект 12, доходят 6.
После правки чтением все 8 искателей нашли одни и те же 11 дефектов. На посаженных дефектах в этом заходе 7 из 8 не купили мне ничего. Оба способа счёта заканчиваются на 11 из 12. Дефекты внутри у них при этом разные.
Это один заход, поэтому я прогнал тех же 8 искателей по тому же файлу с тем же промптом ещё раз. 108 находок, ровно столько же. Это я считаю совпадением. Из 26 строк, названных хоть в одном заходе, в обоих названы лишь 18. Объединение снова 11 и те же самые 11, но один искатель вернулся с 10. Он пропустил политику ретраев, которая ретраит 400. Пропустил по-настоящему. Во всех его 14 находках про ретраи нет ничего. Промпт разрешает 15, так что вытеснить их было нечем.
Файл, в который не посажено ничего
Те же 8 искателей на исходном clean.mjs вернули 0 находок. 8 пустых списков из 8. Я записал это как частоту ложных срабатываний и пошёл дальше, довольный своим кодом.
clean.mjs открывается докблоком. Его строки с 4 по 6 говорят, что это чистая версия. Там же сказано, что дефекты в неё сажает другой скрипт. Строка 4 это пустая строка комментария. Оставшиеся 2 несут само утверждение. Скрипт мутации выбрасывает ровно эти строки, когда пишет файл, который читают искатели. То есть в своём контроле я сообщил 8 ревьюерам, первым же, что попалось им на глаза, что дефекты живут в каком-то другом файле.
Я вырезал эти строки, прогнал тех же 8 искателей и получил 74 находки. Ни одного пустого списка. Это выглядело как ответ, это чуть не стало заголовком поста и чистым всё равно не было. Если вырезать 3 строки, каждая строка ниже поднимается на 3. А промпт подаёт модели листинг с проставленными номерами. Искатели увидели 2 изменения там, где я хотел показать им одно.
Тогда я написал третий файл. Те 2 строки, которые несут утверждение, заменены на месте 2 строками про то, что код делает. Счётчики берутся по разу на пост и кешируются в журнале. Дневная сводка читает это обратно. 184 строки как в clean.mjs, каждый номер строки на своём месте, ни один байт кода не тронут. diff отдаёт эти 2 строки и больше ничего. 62 находки от тех же 8 искателей. 1 из 8 всё равно вернулся пустым. Был ещё более ранний заход по тому же файлу, который я выбросил из-за того, что 2 искателя упали на 429. Ответившие 6 принесли на всех 59 находок и ни одного пустого списка.
Хороши ли эти находки, я могу решить только прогоном самого файла. Я свёл 74 к 16 различным утверждениям и решил каждое скриптом. Все 16 подтвердились и покрывают все 74. Одно из 16 решено прогоном лишь наполовину. К нему я вернусь в конце. Вот 5 из них. Потолок 0 вешает семафор намертво. Таймер отмены снимается до чтения тела, поэтому при таймауте в 40 миллисекунд чтение тела, занимающее 120 миллисекунд, доходит до конца. cacheKey("a", "b c", ["views"]) и cacheKey("a b", "c", ["views"]) оба возвращают 0f7d1e1c5f119f46. Один упавший пост заставляет Promise.all выбросить все уже собранные строки. А summaryLine строит префикс даты из имени файла, поэтому выдаёт человеку строку 2026-09-05.md: 12 posts, 400 views, p90 90. Мой чистый файл чистым не был.
Окно строк говорит, что 60 из 62 находок третьего файла лежат в пределах 2 строк от того, что эти 16 утверждений уже решили. На этот раз я ему не поверил. При чтении оказывается, что 4 находки говорят про запросы в полёте, которые никто не дедуплицирует. Окно кладёт их под утверждение о том, что при попадании в журнал любого возраста ответ отдаётся без запроса. Решает же их другое утверждение, что 2 поста с одним и тем же ключом оба мимо кеша и оба идут в сеть. Ещё 4 находки говорят про fields, который не доезжает до запроса. Окно раскидывает их по 2 другим утверждениям, тогда как решает их то, где сказано, что fields меняет ключ кеша и больше ничего. 16 утверждений действительно решают все 8 этих находок. Окно ошибается в том, какое утверждение решает какую находку.
К чему я возвращаюсь снова и снова, так это к readJournal. 5 строк, незащищённый JSON.parse, побайтово одинаков во всех 4 файлах. В мутированном файле его пометили 7 искателей из 8 и снова 7 из 8 в повторном заходе. А в clean.mjs, где он стоит на строке 83, его не пометил ни один из 8. Зато в третьем файле, где он стоит на той же строке 83, его пометили 7 из 8. Для первой пары чисел у меня было готово объяснение, что-то про то, как остальной файл меняет то, что выглядит подозрительно. Ту пару оно переживает, потому что мутированный файл и правда несёт 12 дефектов. С последними 2 из них оно не делает ничего. Те же 184 строки, та же строка 83, разница в 2 строки комментария. Ответ сдвинула фраза, которую я написал про собственный код.
Проверка
Для второй стадии мне понадобились ложные утверждения, потому что почти всё сказанное искателями было правдой. Я написал 12 штук в их же регистре и каждое опроверг прогоном кода. Очередь работает как LIFO, перцентиль сортирует на месте и ещё 10 таких. Против них стоят 11 истинных утверждений словами самих искателей, без правки. Дальше по 3 независимых верификатора на утверждение. И так дважды. Адверсариальный промпт велит опровергать и при сомнении считать опровергнутым. Нейтральный только спрашивает, истинно ли утверждение.
Вышло 138 вызовов и $4.57.
Все 3 верификатора сошлись на всех 23 утверждениях в обоих режимах. 2 промпта дали одинаковые вердикты на каждом утверждении. Голосование не купило ничего. Адверсариальная формулировка тоже.
Результат этот узкий. Таким его сделал мой же способ построить проверку. Мои 12 ложных утверждений опровергаются чтением 3-5 строк файла. Утверждение, для которого надо держать в голове сразу 2 функции, это как раз то место, где второе мнение должно окупаться. Вот его я и не померил. Сами 11 истинных утверждений собраны по одному на посаженный дефект, поэтому 18 находок, легших мимо посаженных строк, до верификатора не дошли вообще. Именно они и были самыми вероятными кандидатами в ложные. А что почти всё сказанное искателями оказалось правдой, это моё впечатление от чтения, а не число. Число у меня есть только по контрольному файлу.
Синтез
108 находок на 23 различных строках, 6 из них видел только один искатель. Агент синтеза получает отчёты. Сам файл он не видит никогда. 3 прогона дали по 16 записей, по $0.227 за прогон. После того как я рассказал счётчику про 2 слияния ниже, оказалось, что прогоны ничего не потеряли и ничего не выдумали. Промпт прямо говорит агенту синтеза не выбрасывать дефект из-за того, что его видел только один ревьюер, так что этого свойства я попросил, а не обнаружил.
Он ещё и слил 2 находки, разнесённые на 76 строк: неатомарную запись журнала и отсутствующую папку. В слитой записи он назвал обоих ревьюеров. Мой счётчик насчитал тут 2 неверных авторства. Второе такое же слияние он счёл потерянной находкой. Оба вердикта держались, пока я не прочитал записи и не рассказал ему про них. Счётчик сравнивает только номера строк. Про одно слияние в записи сказано прямо, во второй записи названы только оба пути.
Вот и пятый. Окно строк ошиблось в 2 дефектах, причём в разные стороны. Оно же ошиблось второй раз, в том, какое утверждение покрывает какую находку в контроле. Докблок в моём контроле оказался неправ насчёт всего контроля. Моя правка этого докблока сдвинула 178 номеров строк, которые я двигать не собирался. Счётчик синтеза ошибся насчёт обоих слияний. Все 5 это промахи моего аппарата. Каждый раз, когда мой аппарат расходился с агентом, неправым оказывался аппарат.
Чего я не проверял
Один файл, дефекты посажены заменой от 1 до 6 строк каждый. Настоящая работа ревью это дифф по нескольким файлам и дефект там живёт во взаимодействии. Стадия поиска прогнана дважды, синтез 3 раза. Проверка прогнана по одному разу в каждом из 2 режимов промпта. Каждый вариант докблока получил один заход, поэтому 62 против 0 это одна пара заходов, а не распределение. Ложные утверждения для верификаторов мои и они лёгкие. Из 16 контрольных утверждений одно решено прогоном лишь наполовину. Что запись журнала не создаёт свою папку, я показал. Отсутствие переименования я взял чтением. И как раз про переименование говорили все 8 находок на той строке. В третьем контрольном заходе 1 искатель из 8 вернул пустой список. Почему именно он, я не установил. Те 2 строки, которые я поставил вместо выдающей строки, это не пустое место. Они описывают журнал и кеш, а там живёт изрядная часть 62 находок. Значит сравнил я автора, который говорит, что файл чистый, с автором, который описывает кеширование. Выдающую строку я сравнил не с пустым местом. И убранные 2 строки несли сразу 2 сигнала, отбой и указатель на другой файл. Убрал я их вместе. Какой из 2 сработал, я не разделил. Цена вызова искателя в $0.094, на которой стоит $2.07, взята с мутированного файла. Чистый файл вытягивал более длинные ответы и доходил до $0.29 за вызов, так что $2.07 это дешёвый край того, что я видел.
Из 191 вызова 138 это верификаторы и 48 это искатели за 6 заходов. 3 ушли на синтез, а последние 2 это повторы после 429. $4.57 против $0.76 выглядит как цена в 6 раз выше за стадию, которая ничего не изменила. Большая часть этого разрыва это второй режим промпта и 12 утверждений, которые я написал сам. Ревью не делает ни того, ни другого. Если считать сопоставимо, одно ревью этого файла стоит $2.07. Поиск это $0.76 из этого, синтез $0.23, а проверка 11 утверждений, переживших дедупликацию, $1.09. Эти 3 числа в сумме дают на цент больше итога, потому что каждое округлено. На 2 искателях и 1 верификаторе то же ревью стоит $0.78. Я перехожу на это, но второго искателя оставляю из-за того захода, который вернулся с 10.
152 из 190 файлов на JavaScript и TypeScript в этом репозитории открываются докблоком с рассказом, для чего файл. Большую часть писал руками я. Померить дальше я хочу дефект, который живёт сразу в 2 файлах.