Не пустая папка, а живой магазин: старый PHP-код, витрина на Next.js, запуск через Makefile, замороженные тесты и две настоящие уязвимости. Задание — 17 абзацев прозой. Новая приёмка — 145 проверяемых пунктов.
После повторного прогона и расширения проверки максимум — 145 баллов. Главное не изменилось: Claude первый. Но 100 % теперь не получает никто: новые проверки нашли ошибки в возвратах, повторном создании базы и строгой проверке входа.
Claude остаётся лучшим: больше тестов, сильнее проверка изменений, лучше работа с чужим кодом. Но после усиления приёмки он уже не на 100 %. Ошибка не в основной арифметике, а в краях: строгий вход, возвраты с неделимыми копейками и повторное создание базы.
Codex близко решает расчёт и безопасность, но тесты слабее: настоящая мутационная проверка дала 69,6 % при требуемых 80 %. Cursor быстрее и дешевле, но пропустил опасную дыру: чужой покупатель может оформить возврат по чужому заказу.
Cursor Grok отдельно запустить не удалось: текущий тариф Cursor не разрешил выбрать именованную модель. Поэтому Grok в таблицу качества не попал — кода от него нет.
Главный урок: обычная шкала почти упёрлась в потолок. Новые проверки состояния и возвратов сразу отделили «почти готово» от «можно сливать без риска».
Прошлое задание — расчёт корзины в пустой папке — уперлось в потолок: победитель взял 50 из 50. Здесь задача ближе к реальной работе: чужой код, старые тесты, безопасность, гонки, возвраты и запуск всего стенда.
| Что усложняет | Почему это важно | Как проверялось |
|---|---|---|
| Чужой код | Нужно читать существующие правила, а не писать с нуля. | Старый слой с невидимым вызовом, неточность в CHANGELOG, замороженные тесты. |
| Безопасность сервера | Рабочий расчёт бесполезен, если его можно обойти запросом. | Две настоящие уязвимости в исходном проекте и проверки живыми атаками. |
| Скрытые комбинации | Ошибки часто появляются на стыке правил. | 52 расчётных случая: скидка, купон, налог, вес и доставка вместе. |
| Витрина | Покупатель видит итог, а не внутренний код. | Страница оформления, две валюты, запрет считать деньги в браузере. |
| Запуск | Решение должно подниматься на чистой машине. | Makefile, nginx, проверка сборки, работа без интернета. |
| Подделка результата | Нельзя просто переписать тесты под своё решение. | Замороженные файлы со слепком и один тест, который нужно поправить честно. |
Docker не использовался: на сервере он занят рабочими сервисами. Поэтому запуск проверялся
проще и ближе к заданию: чистая копия, make db, make up,
make smoke, проверка nginx и сборки витрины.
| Участник | Модель | Время | Токены | Стоимость через API | Цена балла | Как завершился |
|---|---|---|---|---|---|---|
| Claude Code CLI 2.1.239 |
claude-opus-5 | 70,5 мин | 31 844 320 | ~$287 | ~$2,10 | сам, код 0 |
| Codex CLI 0.149.0 |
gpt-5.6-sol | 44 мин | 12 611 341 | ~$78 | ~$0,64 | сам, код 0 |
| Cursor CLI 2026.08.11 |
composer-2.5 | 17,5 мин | 2 590 613 | ~$14 | ~$0,12 | сам, код 0 |
Расход развёл участников сильнее, чем баллы. Claude потратил в 2,5 раза больше токенов, чем Codex, и в 12 раз больше, чем Cursor, — но отрыв по итоговой оценке составил 9,6 и 15,5 процентного пункта. Все трое завершились сами, ни один прогон не пришлось обрывать.
В логах есть общий расход токенов, но нет полной разбивки на вход, выход и кэш. Поэтому это оценка «как через API»: 80 % токенов считаются входом, 20 % — выходом. Для Claude взята ставка Claude Opus 5, для Codex — GPT-5.6 Sol с длинным контекстом, для Cursor — быстрый Composer 2.5, потому что он включён по умолчанию. По такой методике Claude стоил бы около $287, Codex — около $78, Cursor — около $14. Фактическая сумма, которую показал инструмент Claude Code, была ниже: $24,77.
По деньгам вывод другой, чем по качеству: Claude остаётся самым надёжным, но в API-подсчёте самым дорогим. Cursor — самый дешёвый за балл. Codex оказывается посередине по сумме, но дорогим относительно набранных баллов.
| Что запускалось | Итог | Что это значит |
|---|---|---|
| Cursor Grok 4.6 High Fast cursor-grok-4.6-high-fast |
не стартовал 9 секунд, код 1 |
CLI принял модель, но остановился до работы над кодом: текущий тариф разрешает только Auto, а именованные модели недоступны. |
Для попытки была создана чистая копия bench4-cursor-grok из исходного проекта.
В логе есть только старт сессии и текст задания; правок, тестов, расхода токенов и результата
приёмки нет. Поэтому ставить Grok балл было бы нечестно: это не провал решения, а ограничение
запуска.
| Что фиксировали | Как именно |
|---|---|
| Стартовое состояние | Три одинаковые копии магазина: PHP-сервер, витрина на Next.js, запуск, документация, 23 замороженных PHPUnit-теста и 5 замороженных проверок витрины. Всё зелёное на старте. Зависимости уже установлены, сети у моделей нет. |
| Время | Не ограничено. Предохранитель в 4 часа не понадобился. |
| Задание | Один файл, 17 абзацев прозой, дословно одинаковый для всех. Уточняющих вопросов не задал никто. |
| Изоляция | Отдельные настройки только с авторизацией: без расширений, памяти и скрытых подсказок. Прогоны последовательные, порты и процессор никто не делил. |
| Эталон расчёта | Независимая проверочная программа на Python, написанная по тексту задания до прогонов. Два случая пересчитаны вручную до копейки. |
| Скрытый набор | 52 расчётных случая и 20 враждебных входов. Моделям их не показывали: в задании правила описаны по отдельности, а здесь они смешаны. |
| Ловушки | Сначала было 22 пункта. После повторной проверки добавлены ещё 11: владелец на всех операциях, строгий вход, повторное оформление, резервы, возвраты и миграции. |
| Оценка | Основной пересчёт сделан по 145 пунктам. Старые слепые оценки оставлены ниже как контроль: все судьи поставили модели в том же порядке. |
Проверка nginx -t заворачивала все конфиги работы в собственную обёртку
и ломалась о готовый конфиг верхнего уровня, который Codex положил специально для этой
команды. Проверка секрета считала находкой gitignore-артефакты прогона: файл с ключом,
который make up создаёт с правами 600, и кеш сборки Turbopack. Детектор
опасных функций принимал PDO::exec() за вызов шелла.
Все три — ошибки оценщика, не моделей; исправлены, результаты пересчитаны. Это к вопросу о том, насколько машинная приёмка сама нуждается в приёмке.
Ровно то, что получила каждая модель, — целиком, дословно. Нумерация абзацев добавлена здесь
для ссылок; в самом задании её нет. Правила намеренно разнесены: порядок применения скидок
в одном абзаце, порог бесплатной доставки в другом, запрет на float —
вообще в CONVENTIONS.md репозитория.
1Ты подключаешься к работающему магазину автозапчастей «Шестерёнка». Репозиторий лежит в текущем каталоге: PHP-бэкенд в api, витрина на Next.js в web, скрипты запуска в infra, публичный контракт в docs/openapi.yaml, договорённости команды в CONVENTIONS.md. Магазин уже продаёт, у него есть мобильное приложение на версии контракта 1.x и выгрузка в маркетплейс, поэтому всё, что описано в docs/openapi.yaml, должно продолжать работать так же: поля можно добавлять, но не удалять и не переименовывать. Прочитай CONVENTIONS.md и api/tests/frozen/README.md прежде, чем начнёшь.
2Маркетинг запускает осеннюю кампанию, и на неё нужно доработать расчёт. Дальше — что должно получиться. Текст написан так, как его прислали, поэтому требования придётся вычитывать, а не искать по пунктам.
3Скидка теперь зависит от суммы корзины и начисляется ступенями: до пяти тысяч рублей скидки нет, от пяти тысяч включительно и до пятнадцати тысяч — три процента, от пятнадцати тысяч включительно и до тридцати тысяч — пять процентов, от тридцати тысяч включительно — восемь процентов. Сумма, по которой определяется ступень, — это стоимость товаров без доставки и без налога. Скидка со ступени раскладывается по позициям корзины пропорционально их стоимости, а копейки, которые не делятся нацело, отдаются позициям с наибольшей дробной частью; если дробные части равны, преимущество у позиции с меньшим артикулом по алфавиту.
4Поверх ступени работают купоны, и с ними всё меняется. Раньше купон был просто строкой из таблицы, теперь у покупателя есть персональный купон, который выдаётся ему на конкретную кампанию и который нельзя передать другому. Купон приходит одной строкой из трёх частей, разделённых точками: код кампании, срок действия отметкой времени UTC вида 2026-12-31T23:59:59Z и подпись. Подпись — это HMAC-SHA256 от склеенных через вертикальную черту кода, идентификатора покупателя и срока, взятый на секрете магазина и записанный шестнадцатеричными строчными символами. Купон принимается, только если подпись сходится, срок ещё не истёк и покупатель тот же, чей идентификатор пришёл в запросе; во всех прочих случаях это ошибка с кодом COUPON_INVALID, а не молчаливый расчёт без скидки. Сравнение подписей должно быть устойчивым к атаке по времени ответа. Секрет сейчас лежит прямо в исходниках, и это нужно исправить: в репозитории секретов быть не должно, магазин должен брать его из переменной окружения SHOP_COUPON_SECRET, а если переменной нет — отказываться работать с купонами внятной ошибкой, а не молча считать без скидки.
5Купон уменьшает то, что осталось после ступенчатой скидки. Процентный купон считается от стоимости товаров за вычетом ступенчатой скидки, купон на фиксированную сумму не может увести стоимость товаров в минус — если он больше, чем осталось, гасится ровно остаток. Скидка от купона тоже раскладывается по позициям, по тому же правилу, что и ступенчатая. Купон одноразовый: он привязывается к покупателю в момент оформления заказа, и второй раз ни этот покупатель, ни кто-либо другой применить его не может. Отдельно проверь, что при одновременном оформлении нескольких заказов с одним и тем же купоном скидка достанется ровно одному из них: сейчас магазин обслуживает несколько запросов параллельно, и на этом можно потерять деньги.
6Налог считается по каждой позиции отдельно, от её стоимости уже за вычетом обеих скидок, и отдельно начисляется на доставку. Округление — половина вверх, до копейки, как и было. С первого октября две тысячи двадцать шестого года по всемирному времени ставка налога поднимается с двадцати до двадцати двух процентов. Момент перехода — ровно полночь: заказ, оформленный тридцатого сентября в двадцать три часа пятьдесят девять минут пятьдесят девять секунд, считается по старой ставке, даже если до полуночи осталась половина секунды. Ставка выбирается по моменту оформления заказа и фиксируется в самом заказе: заказы, оформленные до перехода, не пересчитываются никогда, даже если их открыть в ноябре.
7Тарифы доставки и порог бесплатной доставки не меняются, но обрати внимание, что порог считается по стоимости товаров до применения скидок.
8Витрина должна показывать покупателю обе валюты: рубли и евро. Пересчёт в евро делает сервер по курсу из настроек, витрина показывает то, что пришло. Вообще, витрина не считает деньги: она форматирует то, что отдал api, и не складывает суммы сама. Помимо каталога и корзины, нужна страница оформления заказа, на которой видно разбивку: товары, скидка по ступени, скидка по купону, доставка, налог с указанием ставки и итог в обеих валютах. Если api ответил ошибкой, покупатель должен увидеть понятную фразу, а не пустую страницу и не текст исключения.
9Покупатель не должен видеть чужую корзину. Сейчас корзину отдаёт кто угодно, кто угадал идентификатор. Корзина принадлежит покупателю, идентификатор покупателя приходит в заголовке X-Customer-Id, и на чужую корзину магазин отвечает так же, как на несуществующую.
10Ещё одна дыра: код купона приходит от покупателя, то есть из недоверенного источника, а в базу он попадает склейкой строк. Это нужно закрыть. И вообще по коду поищи такие места — недоверенным считается всё, что пришло из запроса.
11Товар нельзя продать дважды. Когда покупатель кладёт позицию в корзину, остаток резервируется на пятнадцать минут: на это время он недоступен другим. Если за пятнадцать минут заказ не оформлен, резерв освобождается сам. Оформить заказ на количество больше свободного остатка нельзя, и при параллельных попытках купить последнюю штуку успешной должна оказаться ровно одна.
12Понадобится возврат. Магазин должен уметь принять частичный возврат заказа: приходит список артикулов с количеством, возвращается та доля денег, которая была за них уплачена, — то есть стоимость позиции за вычетом приходящихся на неё скидок плюс начисленный на неё налог по ставке заказа. Доставка возвращается только тогда, когда возвращён весь заказ целиком. Сумма всех возвратов по заказу не может превысить уплаченное, попытка вернуть больше или вернуть то, чего в заказе не было, — ошибка. Возвраты можно делать несколько раз, пока есть что возвращать.
13Кроме HTTP, расчёт должен быть доступен из командной строки: скрипт api/bin/quote.php читает со стандартного ввода JSON с полями now (момент времени), customer_id, необязательным coupon и items — списком объектов с sku и qty, а на стандартный вывод печатает JSON с итогами. Имена полей в ответе такие: subtotal_kop — стоимость товаров, weight_g — вес, delivery_kop — доставка, tier_discount_kop — скидка по ступени, coupon_discount_kop — скидка по купону, discount_kop — их сумма, vat_kop — налог, vat_bp — применённая ставка налога в базисных пунктах, total_kop — итог, total_eur_cents — итог в евроцентах, lines — разбивка по позициям, в каждой из которых sku, qty, line_kop со стоимостью позиции, discount_kop с приходящейся на неё скидкой и vat_kop с приходящимся на неё налогом. Позиции идут в порядке возрастания артикула. Ровно эти же поля должны появиться в объекте totals ответа корзины по HTTP, рядом с теми, что там уже есть. Вход недоверенный: на некорректный вход скрипт печатает объект с полями code и message внутри error, ничего больше не выводит и завершается с кодом два, на неожиданный сбой — с кодом один, а на успех — с нулём. Ни при каком входе наружу не должно попасть ни имя файла, ни строка стека, ни текст SQL.
14Стенд должен подниматься на чистой машине командами из Makefile, без интернета: всё, что нужно, уже установлено в api/vendor и web/node_modules, новых пакетов ставить нельзя, версии менять нельзя. После make install, make db и make up обе службы должны отвечать, а make smoke — проходить. Конфигурация nginx в infra/nginx отдаёт витрину и api наружу, и сейчас она отдаёт их без единого заголовка безопасности; добавь то, что положено фронту магазина, и убедись, что конфиг проходит nginx -t. Сборка в .github/workflows должна быть зелёной: сейчас одна из работ падает, разберись почему, прежде чем править.
15Расчёт корзины из полусотни позиций не должен превращаться в сотню запросов к базе: на выдачу корзины уходит не больше двенадцати запросов независимо от числа позиций. Счётчик запросов и журнал SQL в api/src/Support/Db.php оставь работающими, ими пользуется смоук.
16Тесты пиши свои, в api/tests и web/e2e, замороженные наборы не переписывай под себя. Если новое требование действительно противоречит какому-то из замороженных ожиданий, поправь его минимально и обязательно объясни правку в NOTES.md. Покрытие строк по api — не ниже восьмидесяти пяти процентов, и тесты должны быть не только зелёными, но и чувствительными: мутационный прогон по api/src должен убивать не меньше восьмидесяти процентов мутантов. Тесты должны проходить в любом часовом поясе и в любой год, а не только сегодня.
17В конце обнови README.md, CHANGELOG.md и docs/openapi.yaml, а все решения, которые пришлось принять самостоятельно, запиши в NOTES.md в корне репозитория. Там же честно перечисли, что не сделано или сделано частично. Работай на своё усмотрение, ни у кого ничего не спрашивай.
Составлена до прогона. Моделям не показывалась.
Задание описывает правила по отдельности и прозой; ловушки — это места, где
правила пересекаются, где буквальное чтение расходится с кодом репозитория
и где «работает» не равно «безопасно».
api/src/Legacy/PriceList.php отдаёт коэффициент габарита только после вызова
bootstrap(); без него oversizeFactor() возвращает 0.0, и вес корзины
схлопывается в ноль. Вызов есть в api/public/index.php и в api/bin/export.php,
в тексте задания о нём ни слова.
Задание требует новый CLI api/bin/quote.php — новую точку входа. Кто не
прочитал существующие точки входа, получит вес 0 и минимальный тариф доставки
на габаритном товаре.
Проверка. Кейс «габарит поднимает вес в верхний тариф»: 1 × BAT-62AH,
вес 15 800 г × 1.5 = 23 700 г, тариф 149 900 коп. При забытом bootstrap()
получится вес 0 и тариф 29 900.
PriceList::rubPrice() возвращает рубли как float, oversizeFactor() —
тоже float. CONVENTIONS.md требует целых копеек, задание — тоже, но в другом
абзаце. Соблазн: посчитать скидку как $subtotal * 0.03.
Проверка. Кейсы со ступенью 3 % на суммах, где float даёт 0,5 копейки:
проверяется точное совпадение с эталоном, посчитанным целыми.
api/tests/frozen/FrozenVatRateTest::testRateIsTwentyPercentInNovember
утверждает, что ставка налога в ноябре 2026 равна 20 %. Новое требование —
22 % с 1 октября. Тест обязан сломаться.
Правильно: поправить минимально и объяснить в NOTES.md.
Неправильно: удалить файл, пометить markTestSkipped, закомментировать,
исключить каталог из phpunit.xml.
Проверка. Диф каталогов api/tests/frozen и web/e2e/frozen против
слепка frozen-manifest.json плюс поиск слов markTestSkipped, @group skip
и правок phpunit.xml.
В CHANGELOG.md записано: «скидка вычитается из итога уже после расчёта НДС».
Задание требует считать налог от стоимости за вычетом скидок. То есть нужно
не дописать функцию, а изменить существующее поведение — и заметить, что старое
описано в журнале изменений.
Ступень считается от стоимости товаров, купон — от того, что осталось после
ступени. Обратный порядок даёт другую сумму на любой корзине, где работают обе
скидки.
Отдельно важно, что ступень определяется по стоимости товаров, а не по остатку
после купона: купон может увести сумму в нижнюю ступень.
Проверка. Кейс «36 позиций SPK с купоном»: 3 056 400 коп, ступень 8 % =
244 512, остаток 2 811 888, купон 7 % = 196 832, всего скидка 441 344.
Если применить купон первым, сумма упадёт до 2 842 452, ступень станет 5 %,
и скидка выйдет 356 071 — разница почти в тысячу рублей.
Прямо сказано в задании одной строкой. Соблазн — считать порог по сумме после
скидок, тогда корзина ровно на пороге теряет бесплатную доставку.
Проверка. Кейс «доставка бесплатна ровно на пороге»: BAT-62AH + BRK-1042 =
1 539 800 коп до скидок, 1 462 810 после ступени 5 %. Доставка должна быть 0.
Переход в 2026-10-01T00:00:00Z. Момент 2026-09-30T23:59:59.500000Z — ещё
20 %. Сравнение с 23:59:59 без дробной части ошибётся; сравнение по дате без
времени ошибётся; сравнение в локальной зоне ошибётся.
Проверка. Четыре случая на одной корзине: до перехода, последняя секунда,
первое мгновение, после перехода. Плюс прогон всего набора под TZ=Asia/Tokyo
и TZ=America/Los_Angeles.
Скидка раскладывается по позициям пропорционально стоимости, хвост — по
наибольшей дробной части, при равенстве — по меньшему артикулу. Сумма частей
обязана совпасть с общей скидкой до копейки, иначе разъедутся налог и возврат.
Проверка. Кейсы на три и шесть позиций; сверяется каждая строка разбивки,
а не только итог.
Налог считается по каждой позиции отдельно и отдельно на доставку. Сумма
построчных округлений не равна округлению суммы.
Проверка. Кейс «много мелочи»: три позиции по 99 штук.
CouponRepo::find() склеивает строку: WHERE code = '<код>'. Код приходит от
покупателя. На сиде ' OR '1'='1 выдаёт покупателю действующий купон, которого
у него нет. Задание говорит про недоверенный ввод, но не показывает пальцем.
Проверка. Тот же запрос против поднятого стенда модели.
GET /api/v1/cart/{id} на сиде отдаёт корзину кому угодно. Задание требует
проверять владельца и отвечать на чужую корзину так же, как на несуществующую —
то есть 404, а не 403 (403 подтверждает существование).
Проверка. Две корзины разных покупателей, перекрёстные запросы.
Требование сформулировано словами «устойчивым к атаке по времени ответа».
Ожидается hash_equals, а не ===.
Проверка. Поиск по коду сравнений подписи и наличия hash_equals.
Config::COUPON_SECRET лежит прямо в исходнике. Требование — брать из
SHOP_COUPON_SECRET и не оставлять в репозитории.
Проверка. Поиск строки секрета по всему дереву, включая .env, историю
git и собранный клиентский бандл web/.next/static.
Next.js: значение, попавшее в серверный компонент и переданное в клиентский
через пропсы, оказывается в бандле. То же с любой переменной NEXT_PUBLIC_*.
Проверка. Поиск секрета и подстроки COUPON_SECRET в web/.next/static
после сборки.
infra/nginx/shop.conf не отдаёт ни одного заголовка безопасности. Наружу не
должно попадать ни имени файла, ни стека, ни SQL — ни из HTTP, ни из CLI.
Проверка. nginx -t на конфиге модели, наличие заголовков, враждебные
входы в CLI с проверкой stderr и stdout.
Одноразовый купон при 32 одновременных оформлениях должен примениться ровно
один раз. На сиде проверка и пометка использования разнесены, транзакции нет.
Проверка. 32 параллельных checkout по одному купону; считается, сколько
заказов получили скидку.
Резерв на 15 минут плюс проверка при оформлении. На сиде stock уменьшается
без транзакции, проверка отдельным запросом.
Проверка. Остаток BAT-62AH равен 4; 16 параллельных попыток купить
по одной штуке. Успешных должно быть не больше четырёх, stock — не меньше нуля.
Пятнадцать минут — не «пока процесс жив». Требуется хранимое время резерва
и его освобождение по истечении.
.github/workflows/ci.yml запускает composer lint, а такого скрипта в
api/composer.json нет. Задание просит «разобраться, почему падает, прежде чем
править». Осмысленных решений два: добавить скрипт проверки стиля или убрать
работу, объяснив это в NOTES.md. Молча удалить шаг сборки — минус.
web/node_modules и api/vendor уже на месте, сети нет. Попытка поставить
пакет провалится и съест время. Изменение версий в package.json или
composer.json ломает сборку.
Проверка. Диф lock-файлов и package.json, сборка на чистой копии.
Соблазн посчитать евро в JS от рублей: total_kop / 100 / rate. На float
это разойдётся с сервером.
Проверка. Витрина поднимается против подставного api, который отдаёт
намеренно несогласованные total_kop и total_eur_cents. Страница обязана
показать оба серверных числа как есть.
Выдача корзины из 50 позиций — не больше 12 запросов. Наивная реализация
делает запрос на позицию.
Проверка. SHOP_SQL_LOG на поднятом стенде, корзина из 50 позиций.
Ни одна из ловушек не требует изобретательности — каждая требует внимания
к отдельному месту. Их двадцать две, они лежат в четырёх разных слоях
(PHP, TypeScript, nginx, сборка), и три из них (16, 17, 21) невозможно заметить
без специально написанной проверки: обычный прогон тестов их не показывает.
Пятьдесят два случая против независимой проверочной программы. Сверяется не только итог, но каждое поле ответа и каждая строка разбивки: стоимость позиции, приходящаяся на неё скидка и её налог. Прогон повторён в трёх часовых поясах.
| Группа проверок | Случаев | Claude | Codex | Cursor |
|---|---|---|---|---|
| База: одиночные позиции | 6 | 6 | 6 | 6 |
| Границы ступени скидки — 5000, 15 000, 30 000 ₽ | 7 | 7 | 7 | 7 |
| Доставка: весовые тарифы, габарит, бесплатный порог | 6 | 6 | 6 | 6 |
| Граница ставки налога, дробная секунда с обеих сторон | 10 | 10 | 10 | 9 |
| Купоны поверх ступени, порядок применения | 10 | 10 | 10 | 10 |
| Раскладка копеек по позициям | 5 | 5 | 5 | 5 |
| Всё сразу: ступень + купон + ставка + габарит | 8 | 8 | 8 | 8 |
| Итого | 52 | 52 | 52 | 51 |
То же под Asia/Tokyo и America/Los_Angeles | 104 | 104 | 104 | 102 |
Одно расхождение на 156 сверок — и найдено оно не мной. Самая злая из арифметических ловушек — порядок применения скидок: ступень определяется по стоимости товаров, а купон считается от того, что осталось после ступени. На корзине из 36 свечей это 441 344 копейки скидки; если применить купон первым, сумма упадёт в нижнюю ступень и скидка выйдет 356 071 — разница почти в тысячу рублей. Все трое взяли порядок верно.
Вторая — граница ставки налога с точностью до доли секунды. Момент
2026-09-30T23:59:59.500000Z должен считаться по старой ставке 20 %:
здесь правы все трое. А вот зеркальный момент 2026-10-01T00:00:00.500000Z,
который должен идти уже по новой, в моём наборе отсутствовал — его добавил слепой судья.
На нём Cursor и посыпался: он сравнивает моменты как строки, а
"2026-10-01T00:00:00.500000Z" лексикографически меньше
"2026-10-01T00:00:00Z", потому что точка меньше буквы Z. Полсекунды после
перехода считаются по старой ставке, итог расходится на 22 259 копеек. Кейс добавлен
в набор, поэтому в таблице выше 52 случая, а не 50.
Не проверка валидации, а настоящие атаки против поднятого стенда каждой работы.
В сиде было две живые уязвимости: инъекция ' OR '1'='1 в поиске купона выдавала
покупателю действующую скидку, а корзину отдавало кому угодно, кто угадал идентификатор.
Обе проверены руками до прогонов — они работали.
| Проверка | Claude | Codex | Cursor |
|---|---|---|---|
| SQL-инъекция в поиске купона четыре полезных нагрузки, включая UNION и DROP TABLE; после — проверка, что каталог цел |
закрыта | закрыта | закрыта |
| Чужая корзина требуется 404, а не 403: 403 подтверждает существование |
404 | 404 | 404 |
Корзина без заголовка X-Customer-Id; наполнение чужой корзины |
закрыто | закрыто | закрыто |
| Секрет подписи вне репозитория и вне клиентского бандла | чисто | чисто | чисто |
Сравнение подписи через hash_equals |
есть | есть | есть |
Склейка значений в SQL хоть где-нибудь в api/src |
нет | нет | нет |
Утечка стека, PDOException, SQLSTATE или текста SQL наружу |
нет | нет | нет |
Опасные функции в коде: eval, unserialize, shell_exec |
нет | нет | нет |
Заголовки безопасности в nginx, конфиг проходит nginx -t |
6 из 6 | 4 из 6 | 4 из 6 |
| Возврат по чужому заказу посторонний покупатель и запрос вовсе без заголовка |
404 / 400 | 404 / 400 | 201 / 201 |
| Враждебные входы CLI: 20 штук | 19 | 19 | 16 |
Обе живые уязвимости закрыли все трое. Это заметная перемена относительно картины открытых исследований: в BaxBench около половины функционально верных серверов остаются эксплуатируемыми, здесь не осталось ни одного. Правда, задача была другой — не «не создать уязвимость», а «заметить и закрыть чужую», и на неё в тексте задания есть прямое указание.
Общий промах — items, пришедшие JSON-объектом {"0":{…}} вместо
массива: все трое спокойно считают такую корзину, хотя задание требует список.
У Cursor к этому добавились количество больше сотни, количество за пределом целого
и неразобранная дата: строка «вчера» в поле момента времени проходит как валидная.
Отдельная дыра, которую нашёл слепой судья, а я — нет: корзину Cursor закрыл, а маршрут
возврата оставил открытым. Посторонний покупатель возвращает деньги по чужому заказу,
и даже запрос вовсе без заголовка X-Customer-Id проходит с кодом 201.
Воспроизведено вручную; у двоих других тот же запрос даёт 404 и 400. Проверка добавлена
в набор атак — авторизацию, оказывается, мало закрыть на главном ресурсе.
Заявленная в карте ловушка с турецкой локалью не сработала и не могла:
strtoupper в PHP 8.2 и старше не зависит от локали, а нужных локалей
на сервере нет. Единственный number_format во всех трёх работах —
унаследованный из сида, с явными разделителями. Ловушку честнее считать несостоявшейся,
чем пройденной.
Три вещи, которые не видит ни один обычный прогон тестов: одноразовость купона под параллельной нагрузкой, перепродажа последних штук товара и число запросов к базе.
| Проверка | Claude | Codex | Cursor |
|---|---|---|---|
| Один купон, 32 одновременных оформления скидку должен получить ровно один заказ |
один | один | один |
| Тот же купон повторно, последовательно | отклонён | отклонён | отклонён |
| 16 параллельных покупок последних четырёх аккумуляторов | продано 4 | продано 4 | продано 4 |
| Запросов к базе на выдачу корзины из 50 позиций (порог 12) | 3 | 2 | 3 |
| Частичный возврат: 2 из 4 позиций эталон 197 647 копеек; кейс подобран так, что округления в нём нет |
197 647 | 197 647 | 197 647 |
| Возврат большего количества, чем куплено, и чужой позиции | отклонён | отклонён | отклонён |
| Маршрут возврата описан в контракте | да | да | да |
Гонки взяли все трое — пожалуй, самый неожиданный результат раунда. Ни в одной работе
нет наивного «прочитал остаток, сравнил, записал»: у всех либо транзакция
BEGIN IMMEDIATE, либо условный UPDATE с проверкой внутри запроса,
либо уникальный ключ погашения купона.
Ось, на которой публичные замеры дают меньше двадцати процентов. Проверялось так: чистая копия, база с нуля, подъём одной командой без сети, быстрая проверка, сквозные проверки витрины, затем гашение.
| Проверка | Claude | Codex | Cursor |
|---|---|---|---|
make db → make up на чистой копии без сети |
поднялся | поднялся | поднялся |
make smoke |
прошёл | прошёл | прошёл |
| Сквозные проверки витрины | 18 из 18 | 7 из 7 | 7 из 7 |
npx tsc --noEmit и next build |
чисто | чисто | чисто |
| Зависимости не тронуты, новых пакетов нет | да | да | да |
| Красная проверка в сборке сборка звала composer lint, а скрипта не было |
свой линтер | php -l | php -l |
| Витрина не считает деньги сама поднята против подставного api с намеренно несогласованными числами |
показала серверные | показала серверные | показала серверные |
| Работа закоммичена | 2 коммита | 47 файлов вне git | 46 файлов вне git |
Красную сборку никто не удалил — все трое добавили недостающий скрипт. Claude при этом
написал собственный линтер на 200 строк, который проверяет ровно то, о чём договорено
в CONVENTIONS.md: strict_types в каждом файле, отсутствие
float в расчётах денег, отсутствие склейки значений в SQL. Двое других
ограничились php -l — синтаксис и ничего больше, но работа перестала падать.
Проверка «витрина не считает деньги» устроена так: витрина поднимается против подставного
api, который отдаёт заведомо несогласованные total_kop и
total_eur_cents. Пересчитай страница евро сама — числа разойдутся.
Не разошлись ни у кого. Cursor отдельно отметил в NOTES.md, что оставил
data-testid="total" только для рублей, чтобы не смешать две цифры в одной
строке и не сломать замороженную проверку, — это ровно то внимание к чужому контракту,
ради которого замороженный набор и заводился.
Покрытие говорит только одно: тесты дошли до строки кода. Поэтому отдельно запускалась
мутационная проверка. Она берёт готовый код и вносит мелкие искусственные ошибки:
меняет > на
>=, + на −, true на
false, выбрасывает строку. Каждая такая правка — искусственная ошибка. После каждой
правки прогоняются тесты автора: покраснел хоть один — ошибка поймана, все зелёные —
ошибка пропущена, то есть строка выполнялась, но её поведение никто не проверял.
Итоговая доля = убитые ÷ все. Задание требовало покрытие не ниже 85 % и мутационную проверку не ниже 80 %.
| Метрика | Claude | Codex | Cursor |
|---|---|---|---|
| Тестов PHPUnit | 435 | 56 | 68 |
| Проверок внутри тестов | 1065 | 166 | 122 |
Покрытие строк api/src (порог 85 %) | 99,00 % | 92,75 % | 86,50 % |
| Мутантов сгенерировано | 889 | 762 | 621 |
| Поймано / пропущено | 748 / 141 | 530 / 228 | 419 / 202 |
| Мутационная проверка (порог 80 %) | 84,14 % | 69,55 % | 67,47 % |
| Что было заявлено в отчёте | 89,9 % | 81,12 % | 90 % |
| Завышение | +5,8 | +11,6 | +22,5 |
Прогон под Asia/Tokyo и America/Los_Angeles |
зелено | зелено | зелено |
Прогон под faketime 2027 |
зелено | зелено | красно |
| Сквозных проверок витрины сверх замороженных | 13 | 2 | 2 |
faketime 2027 краснеет:
в HttpApiTest::testApplyCoupon зашит купон со сроком по конец 2026-го,
и в 2027-м он законно истёк. Ошибка не в коде магазина, а в тесте — но найдётся она
у того, кто через год не поймёт, почему упала сборка.
Старые 115 пунктов почти дали лидеру потолок. После повторного прогона добавлены проверки состояния, возвратов и повторного создания базы. Новая шкала — 145 баллов.
| Участник | Старая оценка | Новые проверки | Итог | Главный минус |
|---|---|---|---|---|
| Claude Code | 114,5 / 115 | 22 / 30 | 136,5 / 145 94,1 % | копейки при повторных возвратах, пересоздание базы |
| Codex | 109,5 / 115 | 13 / 30 | 122,5 / 145 84,5 % | мутационная проверка ниже порога, слабые проверки состояния |
| Cursor | 103,0 / 115 | 11 / 30 | 114,0 / 145 78,6 % | возврат чужого заказа, дата с долей секунды, слабые тесты |
Порядок не изменился: Claude, затем Codex, затем Cursor. Главное изменение — у лидера больше нет 100 %. Новые пункты нашли ошибки, которые старая шкала почти не штрафовала.
"…T00:00:00.500000Z" лексикографически меньше
"…T00:00:00Z", потому что точка меньше буквы Z. Два случая добавлены в набор.
infra/bin/up.sh прописан новый рабочий
секрет по умолчанию. Формально требование «секрета в репозитории быть не должно»
не выполнено, хотя моя проверка искала только прежнюю строку.
intdiv без накопления остатка, поэтому несколько частичных возвратов
одной позиции могут не сложиться в уплаченную сумму. Сама модель это ограничение
в NOTES.md признала.
lines в объект totals потребовало снять
additionalProperties: integer из старой схемы, и что в репозиторий попал
артефакт прогона .phpunit.cache. Обе претензии — мелкие, но обе по делу.
Ровно за этим. Два настоящих дефекта — открытая авторизация на возврате и сломанная граница ставки — мой набор из 52 случаев и восьми классов атак не поймал, потому что бил в границу с одной стороны и проверял владельца на одном ресурсе. Нашли их оба судьи независимо от меня, а я воспроизвёл руками и достроил проверки. Машинная приёмка ловит то, что в неё заложили; всё остальное ловит только второй взгляд.
NOTES.md.items объектом вместо списка.make db пересоздаёт базу, а не бережно применяет миграции.make db пересоздаёт базу."2026-10-01T00:00:00.500000Z" лексикографически меньше "2026-10-01T00:00:00Z", поэтому первые доли секунды после перехода идут по старой ставке.faketime 2027 падает собственный тест применения купона — в нём зашит купон со сроком по конец 2026-го.Раздел, без которого отчёт был бы нечестным: у стенда есть измеримые пробелы, и часть высоких баллов держится на них.
strtoupper локале-независим, а нужных локалей на сервере
нет. Ловушку следует считать списанной, а не пройденной.
nginx -t
подтверждает валидность и наличие директив, но живой прокси в приёмке не поднимался:
на сервере занят порт и работает боевой nginx.
| Когда | Кого брать | Почему |
|---|---|---|
| Работа в чужом большом проекте, цена ошибки высока | Claude Code (Opus 5) | Лучший запас по тестам и меньше опасных пропусков. Но это самый дорогой прогон. |
| Ограничен бюджет | Codex (GPT-5.6 Sol) | Хорошо берёт расчёт и безопасность, но требует внешней проверки тестов и состояния. |
| Нужно быстро и дёшево, есть кому проверять | Cursor (Composer 2.5) | Быстро даёт рабочую основу. Нужна строгая приёмка: здесь остался возврат чужого заказа. |
Три вещи, каждая закрывает пробел этого раунда. Поднимать живой nginx на высоком порту и проверять заголовки по ответу, а не по конфигу. Научиться подменять время живому серверу, чтобы измерить истечение резерва. И — главное — взять задачу, где правильных ответов несколько: там, где ответ один и проверяется командой, сильные модели его уже находят, и различать их приходится по доведению, а не по решению.