<?xml version="1.0" encoding="utf-8" ?><rss version="2.0" xmlns:tt="http://teletype.in/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>rasuvaeff</title><generator>teletype.in</generator><description><![CDATA[rasuvaeff]]></description><image><url>https://img1.teletype.in/files/cd/7d/cd7d3b3e-2aba-416a-bb7a-dbe3b2c44ee4.png</url><title>rasuvaeff</title><link>https://teletype.in/@rasuvaeff</link></image><link>https://teletype.in/@rasuvaeff?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=rasuvaeff</link><atom:link rel="self" type="application/rss+xml" href="https://teletype.in/rss/rasuvaeff?offset=0"></atom:link><atom:link rel="next" type="application/rss+xml" href="https://teletype.in/rss/rasuvaeff?offset=10"></atom:link><atom:link rel="search" type="application/opensearchdescription+xml" title="Teletype" href="https://teletype.in/opensearch.xml"></atom:link><pubDate>Tue, 10 Nov 2026 05:50:53 GMT</pubDate><lastBuildDate>Tue, 10 Nov 2026 05:50:53 GMT</lastBuildDate><item><guid isPermaLink="true">https://teletype.in/@rasuvaeff/cskain</guid><link>https://teletype.in/@rasuvaeff/cskain?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=rasuvaeff</link><comments>https://teletype.in/@rasuvaeff/cskain?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=rasuvaeff#comments</comments><dc:creator>rasuvaeff</dc:creator><title>Как устроен ЦСКА.IN: большой разбор изнутри</title><pubDate>Sun, 08 Nov 2026 08:31:28 GMT</pubDate><media:content medium="image" url="https://img4.teletype.in/files/37/47/3747d9a4-04c9-4f9f-891e-e6ff3d1c00e1.png"></media:content><description><![CDATA[<img src="https://img3.teletype.in/files/a0/38/a038811c-717d-4226-8abb-5c6ca95279ad.png"></img>Сайту ЦСКА.IN больше двадцати лет: он живёт с 2003 года. До сегодняшней версии он работал на CodeIgniter. Следующую версию я сделал на Yii2, но она так и не вышла: её не опубликовали. Современный сайт собран заново, на Yii3 и PHP 8.5, и основная часть его инфраструктуры вынесена в мои собственные Composer-пакеты.]]></description><content:encoded><![CDATA[
  <p id="RbnO">Сайту <a href="https://cska.in/" target="_blank">ЦСКА.IN</a> больше двадцати лет: он живёт с 2003 года. До сегодняшней версии он работал на CodeIgniter. Следующую версию я сделал на Yii2, но она так и не вышла: её не опубликовали. Современный сайт собран заново, на Yii3 и PHP 8.5, и основная часть его инфраструктуры вынесена в мои собственные Composer-пакеты.</p>
  <blockquote id="QI8J">Несколько цифр, чтобы было понятно, о каком масштабе речь: 4549 тестов (3193 юнит- и 1356 интеграционных), мутационный порог 95% при фактических<br /> 96% примерно из 12,5 тысяч мутантов, Lighthouse по мобильному профилю<br /> 93–98 / 100 / 100. Все цифры проверяются сборкой, а не берутся на слово.</blockquote>
  <p id="4wMs">Весь код этого сайта и пакетов я писал вместе с Claude Code, который работает прямо в терминале и в репозитории. Об этом отдельный раздел ближе к концу: как устроена такая работа, чтобы результат можно было проверять, а не просто верить.</p>
  <blockquote id="uMPl">Сайт открыт: <a href="https://cska.in/" target="_blank">cska.in</a>, пакеты и профиль: <a href="https://github.com/rasuvaeff" target="_blank">github.com/rasuvaeff</a>.</blockquote>
  <p id="MepE">Это длинный рассказ о том, что внутри. Я постарался писать не «список модных слов», а то, что реально сделано и зачем: какие технологии работают, по каким правилам написан код, как сайт обновляется без остановки, как устроены живые обновления, как проверяется качество и почему я вынес столько кода в пакеты. В середине есть кусочки настоящего кода из проекта. В конце отдельно про пакеты, которые помогают тестировать: они мне нравятся больше всего.</p>
  <h2 id="r6wg">Что умеет сайт</h2>
  <p id="u3yl">Чтобы было понятно, что вообще нужно было построить:</p>
  <ul id="WFCd">
    <li id="CssK">Матчи трёх видов спорта (футбол, хоккей, баскетбол) и всех команд клуба: ПФК ЦСКА, ПХК ЦСКА, ПБК ЦСКА, ЖФК ЦСКА, Красная Армия, Звезда. Календарь с фильтрами, протоколы матчей, составы, события, счёт по периодам.</li>
    <li id="TmNq">Статистика: турнирные таблицы, итоги игроков и встреч с каждым соперником, судьи, бомбардиры против клуба.</li>
    <li id="VEEH">Гостевая с оценками, цитатами, фото и видео, карточками ссылок, живой лентой.</li>
    <li id="380D">Кумир: голосование за лучших игроков матча.</li>
    <li id="lQPK">Творчество болельщиков, сюжеты (сторис), «день в истории».</li>
    <li id="qgEd">Личные сообщения и друзья, личный кабинет с настройками (какие команды показывать, как показывать гостевую).</li>
    <li id="lKtU">Админка для редакции: справочники, матчи, протоколы, сюжеты, журнал действий.</li>
    <li id="LgVP">Мини-приложение в Telegram, анонсы в канал, личные агенты болельщиков.</li>
  </ul>
  <p id="inO5">Всё это должно было работать быстро, не падать во время матча, когда на сайт приходят все сразу, и не терять ни слова из того, что люди пишут.</p>
  <h2 id="технологический-стек">Технологический стек</h2>
  <p id="LIXP"><strong>PHP 8.5 и Yii3.</strong> Yii3 это не монолитный фреймворк, а набор независимых компонентов: маршрутизатор, контейнер зависимостей, валидатор, права доступа, миграции, представления. Я использую то, что нужно, и заменяю то, что не подошло. Например, большая часть «обвязки» (надёжная доставка, повторы форм, настройки, журнал) в проекте идёт из моих пакетов, а не из Yii.</p>
  <p id="5RpU"><strong><a href="https://rapira.rs/" target="_blank">Rapira</a>.</strong> Это сервер приложений для PHP. Код загружается один раз, воркеры живут между запросами. Воркер на каждое ядро процессора. Плюс: приложение не поднимается заново на каждый запрос. Минус: нельзя держать состояние запроса в статических полях, иначе оно утечёт в следующий запрос. Поэтому всё, что хранит состояние запроса, регистрируется в контейнере со специальным обработчиком сброса, который вызывается между запросами. Есть специальная проверка, что сброс вообще работает (тест пробует отправлять пары запросов, пока они не попадут в один и тот же воркер).</p>
  <p id="VeXb"><strong>Три входа на одном коде.</strong> Сайт, админка и «дверь» для личных агентов болельщиков запускаются как три отдельных воркера на разных портах и поддоменах, но из одного образа Docker и одной кодовой базы. Админка не открывается на публичном домене, а у двери агентов нет ни cookie, ни CSRF: единственная личность это ключ в заголовке.</p>
  <p id="rkfz"><strong>PostgreSQL.</strong> Основная база. Важная идея: правила данных держит сама база. Проверки (<code>CHECK</code>), составные ключи и ограничения не дают записать неправильную строку, даже если ошибся код. Например, счёт, статус матча, принадлежность игрока к стороне матча и вид спорта турнира проверяются ограничениями. Для каждого такого ограничения в проверочном скрипте есть «отказанная» строка, и проверяется, что база отказала именно по этому ограничению, а не по случайному другому.</p>
  <p id="gwod"><strong>Статистика это представления, а не хранимые числа.</strong> Голы, передачи, карточки, минуты игроков считаются из событий матча. Итоги встреч с соперником, таблицы сезона, рекорды команды построены представлениями поверх сыгранных матчей. Поэтому исправил счёт в матче, и все цифры по сайту сами стали верными. Это защищает от вечной проблемы статистических сайтов: «в таблице одно, в карточке игрока другое».</p>
  <p id="fsDU"><strong>Redis.</strong> Кэш чтения (с группами: матчи, справочники, опросы, творчество, сюжеты сбрасываются целыми группами при изменении), ограничители, защита от повторов, подсчёты просмотров. Промах кэша строится под замком: двадцать параллельных запросов к холодной странице матча порождают одно<br /> построение, остальные ждут результата.</p>
  <p id="y683"><strong><a href="https://temporal.io/" target="_blank">Temporal</a>.</strong> Всё, что должно выполниться надёжно и с повторами: письма, перекодирование видео, озвучка работ, карточки ссылок, подготовка карты сайта, расписания (обновление таблиц с сайтов лиг, сюжеты матча дня, закрытие голосований Кумира). Исполнитель Temporal-воркеров это <a href="https://roadrunner.dev/" target="_blank">RoadRunner</a>. Видео вынесено в свою очередь и свой процесс: тяжёлая перекодировка не должна задерживать письма с кодом входа.</p>
  <p id="u5hw"><strong><a href="https://centrifugal.dev/" target="_blank">Centrifugo</a>.</strong> Живые обновления: гостевая, счёт идущего матча, личные сообщения приходят без перезагрузки страницы. Подробно про них ниже: там есть интересная идея про то, что именно ходит по каналу.</p>
  <p id="dsZN"><strong><a href="https://angie.software/" target="_blank">Angie</a>.</strong> Веб-сервер перед приложением (форк nginx): HTTP/3, сжатие brotli, автоматические сертификаты. Он отдаёт статику с годичным кэшем и сам сжимает ответы PHP (Rapira ответы не сжимает).</p>
  <p id="FRUW"><strong>Docker Compose.</strong> И на машине разработчика, и на проде. Один образ, разные команды запуска. Локально всё поднимается одной командой.</p>
  <p id="WYDu"><strong>OpenTelemetry, Jaeger, Prometheus, Grafana.</strong> Трассы запросов, метрики воркеров (сам Rapira отдаёт состояния воркеров, запросы, ошибки, очередь и память), общий дашборд. Дашборд описан скриптом, а не руками в интерфейсе: правка в панели не сохранилась бы.</p>
  <p id="LqBt"><strong>Свой синтез речи и ffmpeg.</strong> Озвучка работ в «Творчестве» делается локально (Vosk), видео перекодируется нашим ffmpeg. Ничего из пользовательских материалов не уходит во внешние сервисы.</p>
  <p id="lCvv"><strong>Фронтенд без тяжёлого фреймворка.</strong> Страницы рисуются на сервере. Стили и скрипты публикуются как ресурсы с хешем в адресе (изменился файл, изменился адрес, можно кэшировать навсегда). Никакого inline-кода в разметке, шрифты свои, без CDN. Скрипты с отложенной загрузкой, лёгкие. По мобильному профилю Lighthouse у сайта производительность 93–98, доступность 100, SEO 100.</p>
  <h2 id="принципы-кода">Принципы кода</h2>
  <p id="BOoJ">Это то, ради чего я переписывал сайт: код должен быть таким, чтобы его можно было менять без страха.</p>
  <h3 id="слои-и-границы">Слои и границы</h3>
  <p id="xG58">Код разложен на слои: предметная область, прикладной слой, инфраструктура, а сверху входы (HTTP, админка, консоль, агенты). Каждая часть сайта (матчи, гостевая, Кумир, аккаунты, статистика) это отдельный вертикальный срез. Границы между слоями и срезами проверяет deptrac: если разработчик подключил в прикладном слое класс инфраструктуры, сборка не пройдёт. Это не «договорённость в README», это падающий тест.</p>
  <p id="180c">Есть и более тонкие правила, которых deptrac не видит, и они оформлены обычными тестами архитектуры. Например:</p>
  <ul id="cbUJ">
    <li id="oCCq">у каждого обработчика ровно один публичный метод <code>handle()</code>;</li>
    <li id="GDxM">команды и запросы это значения, без поведения;</li>
    <li id="6Uk5">никто не берёт контейнер зависимостей в руки и не ходит за сервисами сам;</li>
    <li id="eFQm">время нигде не берётся напрямую, только через интерфейс часов;</li>
    <li id="Z6FN">в приложении нет сырого SQL.</li>
  </ul>
  <h3 id="один-обработчик-на-операцию-и-ожидаемые-отказы-как-значения">Один обработчик на операцию и ожидаемые отказы как значения</h3>
  <p id="lMpn">Вот настоящий обработчик из проекта: автор удаляет своё сообщение в гостевой.<br /><br /><code>final readonly class DeleteMessageHandler<br />{<br />    /** @return Result&lt;null, DeleteRefusal&gt; */<br />    public function handle(DeleteMessageCommand $command): Result<br />    {<br />        $message = $this-&gt;messages-&gt;find($command-&gt;messageId);<br />        $denied = $message instanceof GuestMessage<br />            ? $message-&gt;denyChange($command-&gt;userId, $this-&gt;clock-&gt;now(), sanctioned: ...)<br />            : ChangeDenied::NotFound;<br />        if ($denied instanceof ChangeDenied) {<br />            return Result::err(match ($denied) {<br />                ChangeDenied::NotFound =&gt; DeleteRefusal::NotFound,<br />                ChangeDenied::NotYours =&gt; DeleteRefusal::NotYours,<br />                ChangeDenied::Sanctioned =&gt; DeleteRefusal::Sanctioned,<br />                ChangeDenied::TooLate =&gt; DeleteRefusal::TooLate,<br />                ChangeDenied::HasAnswers =&gt; DeleteRefusal::HasAnswers,<br />            });<br />        }</code></p>
  <p id="zQzd"><code>        // ... удаляем и сообщаем событием<br />        return Result::ok(value: null);<br />    }<br />}</code></p>
  <p id="b10K">Что тут важно:</p>
  <ol id="ivpR">
    <li id="CTOP">Правило «свое сообщение можно удалить первые пять минут и пока на него не ответили» живёт в предметной области (<code>denyChange</code>), а не размазано по контроллерам.</li>
    <li id="fJbA">Отказ это значение с конкретной причиной (<code>TooLate</code>, <code>HasAnswers</code>), а не исключение и не безликое <code>false</code>. Страница по причине говорит человеку, что именно пошло не так, а тест проверяет каждую причину отдельно.</li>
    <li id="PaKV">Время приходит из часов. Тест ставит часы на нужный момент и проверяет границу в пять минут до секунды.</li>
  </ol>
  <h3 id="условия-запросов-как-значения-спецификации">Условия запросов как значения (спецификации)</h3>
  <p id="QpfT"></p>
  <p id="N1mp">В коде приложения нет сырого SQL. Условия собираются из спецификаций. Одна и та же спецификация применяется и к запросу в базе, и к объекту в памяти. Так устроен календарь: правило «какие матчи показать» написано один раз, а специальная проверка на реальных данных убеждается, что SQL и память выдают одно и то же. Пример (упрощённо), как формируется «ближайшие матчи выбранных команд»:</p>
  <pre id="BrS1">CalendarSpecifications::upcoming(
    sportId: null,
    now: $now,
    limit: 5,
    teams: [2 =&gt; [1, 314, 409]],   // хоккей: ЦСКА, Красная Армия, Звезда
);</pre>
  <p id="8Ruk">Из этого вызова получается условие: не сыгран, не идёт, начало не раньше сейчас, отсортировано по времени, не больше пяти. Менять правило календаря можно в одном месте.</p>
  <h3 id="события-после-транзакции">События после транзакции</h3>
  <p id="AD3P">Обработчик сообщает, что случилось (матч изменён, сообщение появилось, голосование закрыто), только после того, как транзакция зафиксирована. Слушатели сбрасывают кэш, публикуют подсказку в канал, ставят письмо в очередь. Если слушатель упал, это уходит в журнал, но не откатывает запись пользователя.</p>
  <h3 id="без-подавлений-и-с-понятными-правилами">Без подавлений и с понятными правилами</h3>
  <p id="y8fS">Psalm работает на самом строгом, первом уровне. Подавлять предупреждения (<code>@psalm-suppress</code>) нельзя ни в коде, ни через базовый файл: причину нужно исправлять. Перед закрытием любой задачи запускается полный набор проверок: стиль, Psalm, deptrac, Rector, проверка ссылок на классы и зависимостей. Тестов нет без <code>#[Covers]</code>: так инструмент мутационного тестирования знает, какие тесты отвечают за какой код.<br /></p>
  <h2 id="надёжность-чтобы-ничего-не-терялось-и-не-дублировалось">Надёжность: чтобы ничего не терялось и не дублировалось</h2>
  <h3 id="outbox-дело-записано-в-той-же-транзакции-что-и-данные">Outbox: дело записано в той же транзакции, что и данные</h3>
  <p id="fM9r">Типичная ошибка: сохранить письмо в базу, а потом отдельно отправить его в очередь. Если между этими шагами что-то упало, письмо потеряно или улетело без записи. Я использую шаблон outbox. Вот как отправляется письмо:</p>
  <pre id="nD1P">$job = $this-&gt;transactions-&gt;run(function () use ($to, $letter): string {
    $id = $this-&gt;outbox-&gt;add($to, $letter-&gt;subject, $text, $html, $this-&gt;clock-&gt;now());

    return $this-&gt;jobs-&gt;enqueue(JobType::MailDeliver, (string) $id, [&#x27;mail&#x27; =&gt; $id]);
});
$this-&gt;jobs-&gt;deliver($job);   // сразу пробуем передать Temporal
</pre>
  <p id="rJvw">Письмо и дело на его отправку записываются вместе или не записываются вовсе. После фиксации приложение сразу пробует запустить воркфлоу. Если Temporal недоступен, дело остаётся в таблице, и фоновый демон подберёт его через секунду. Идентификатор воркфлоу детерминирован (<code>mail.deliver:42</code>), поэтому повторный запуск того же дела безвреден. Текст письма (в нём бывают коды входа) стирается из базы, как только письмо ушло. Так же устроены карточки ссылок, перекодирование видео, уведомления в Telegram.</p>
  <h3 id="идемпотентность-форм">Идемпотентность форм</h3>
  <p id="EbIH">Формы админки и выпуск приглашений в кабинете несут скрытый одноразовый ключ. Двойной клик по кнопке или повторная отправка той же страницы не создадут вторую запись и не отправят второе письмо. При приглашении по почте без этого двойной клик слал бы два письма. Ответ на повтор берётся из Redis (пятнадцать минут), а отказ формы (ошибка проверки) ключ не занимает, чтобы человек мог исправить и отправить снова.</p>
  <h3 id="ограничители-и-размыкатель-цепи">Ограничители и размыкатель цепи</h3>
  <ul id="pi5V">
    <li id="Hd86">Не больше трёх одновременных запусков ffmpeg на построение миниатюр (волну загрузок картинок). Ограничение общее для всех воркеров, оно хранится в Redis. Перекодировка видео — отдельная история: она живёт<br /> в своей очереди и своём процессе и письма с кодом входа не задерживает.</li>
  </ul>
  <ul id="eQZE">
    <li id="ip3M">Вызовы языковой модели идут через размыкатель цепи: после серии сбоев (сеть, ключ, баланс, 5xx) вызовы сразу отказывают на пару минут, а не копятся и не тормозят страницы. Каждый вызов модели ещё и записывается в журнал: промпт, ответ, токены, задержка.</li>
  </ul>
  <h3 id="выкладка-без-остановки-сайта">Выкладка без остановки сайта</h3>
  <p id="MkQ7">Раньше выкладка обновлением всех копий разом показывала посетителю заглушку. Теперь обновление делает один скрипт, и порядок такой:</p>
  <ol id="HKHn">
    <li id="WOEP">Подтянуть код и собрать новый образ (старые копии продолжают работать на старом).</li>
    <li id="8Oei">Применить миграции одним шагом в отдельном контейнере: две новые копии не должны гнать их наперегонки.</li>
    <li id="pAvo">Для сайта, админки и двери агентов по очереди: поднять новые копии рядом со старыми и ждать, пока проверка здоровья даст «готово».</li>
    <li id="0Yw7">Дать Angie заметить новые адреса (пауза в несколько секунд).</li>
    <li id="9f8K">Остановить старые копии, оставив тридцать секунд на запросы, которые уже идут.</li>
    <li id="5iqT">Обновить воркеры Temporal и зарегистрировать расписания заново (повтор безопасен).</li>
    <li id="oFA6">Мягко перезагрузить конфигурацию Angie и проверить главную страницу.</li>
  </ol>
  <p id="FG7f">Если новая копия не стала готова за отведённое время, она удаляется, а сайт продолжает работать на прежней версии, и выкладка завершается с ошибкой. Из этой схемы следует правило для миграций: они обязаны работать и со старым кодом, пока идёт замена. Сначала добавить колонку, использовать её в следующем релизе, удалять и переименовывать только релизом позже. Всё, что несовместимо, выкладывается отдельно, под заглушкой.</p>
  <h2 id="одна-запись-в-гостевой-что-происходит-за-кулисами">Одна запись в гостевой: что происходит за кулисами</h2>
  <p id="SUCC">Хороший способ увидеть, как всё соединяется: проследить за одним сообщением. Для масштаба: гостевой двадцать лет, в нём 479 тысяч сообщений и двадцать две тысячи страниц; поиск по слову в этой куче держит индекс pg_trgm — 35 миллисекунд вместо 283 без него.</p>
  <ol id="sOOe">
    <li id="5Iq1">Человек пишет сообщение со ссылкой на статью. Форма с одноразовым ключом, поэтому двойной клик ничего не дублирует.</li>
    <li id="tGj7">Обработчик проверяет правила (красная карточка, пауза между сообщениями, повтор, длина) и записывает сообщение. Текст разбирается собственной разметкой: допустим только разрешённый набор тегов, всё остальное экранируется.</li>
    <li id="3XFL">После фиксации транзакции обработчик отправляет событие «сообщение появилось».</li>
    <li id="Th1F">Слушатели: сбрасывают кэш этого сообщения, публикуют подсказку в Centrifugo, сообщают агентам, а также запрашивают карточки ссылок.</li>
    <li id="ifLH"><strong>Подсказка, а не текст.</strong> В канал уходит только <code>{тип: сообщение, номер: 123}</code>. Открытые страницы после случайной паузы (от трёх десятых до трёх секунд, подсказки пачкой, не больше десяти сообщений за раз) сами запрашивают это сообщение у сервера. Так сервер отдаёт каждому читателю ровно то, что ему разрешено видеть. Текст, скрытый красной карточкой, не покидает сервер для того, кому его нельзя читать. А случайная пауза нужна, чтобы после гола тысячи страниц не пришли за обновлением в одну и ту же секунду.</li>
    <li id="2drg">Параллельно ссылка попадает в outbox, затем в воркфлоу Temporal, который идёт на страницу по ссылке, достаёт заголовок, описание и картинку и сохраняет карточку.</li>
    <li id="4HJK">Когда карточка готова, сообщению рассылается ещё одно событие «изменилось». Открытые страницы запрашивают сообщение заново, и карточка появляется на месте без перезагрузки.</li>
  </ol>
  <p id="XNfO">Это одна и та же схема для гостевой, комментариев, личных сообщений, счёта матча и сюжетов: отдельное событие, подсказка, запрос за свежим.</p>
  <h3 id="как-карточка-ссылки-защищена-от-подвоха">Как карточка ссылки защищена от подвоха</h3>
  <p id="0BS1">Адрес для карточки вводит посторонний человек, значит, он может попытаться заставить сервер сходить во внутреннюю сеть. Поэтому:</p>
  <ul id="4Fzw">
    <li id="kGeG">разрешены только http и https, только порты 80 и 443;</li>
    <li id="nEoc">допускаются только публичные адреса (внутренние сети, loopback и адрес метаданных облака отвергаются);</li>
    <li id="BmVB">имя хоста разрешается самим клиентом, каждый адрес проверяется той же политикой, и соединение закрепляется за проверенным адресом, чтобы подмена DNS ничего не дала;</li>
    <li id="tGuB">каждое перенаправление проверяется заново, их не больше трёх;</li>
    <li id="VKhP">размер ответа и время ограничены, принимаются только страницы (HTML);</li>
    <li id="ygGV">картинка скачивается к нам, оптимизируется и отдаётся с нашего адреса: читатель на чужой сайт не ходит.</li>
  </ul>
  <p id="Tujn">Текст чужой карточки при выводе всегда экранируется: чужие слова никогда не становятся нашей разметкой.</p>
  <h2 id="данные-откуда-берутся-матчи">Данные: откуда берутся матчи</h2>
  <p id="TarR"><strong>Перенос со старого сайта.</strong> Данные читаются из базы старого сайта и переносятся идемпотентно: повторный запуск ничего не дублирует, а строка, которую пришлось пропустить, попадает в отчёт с причиной. Старая база грязная (сироты, дубли, нулевые даты), поэтому правила для её особенностей записаны в задачах, а итоговые цифры после переноса сверяются с итогами старой базы. Номера болельщиков остались прежними: профиль <code>/user/profile/832</code> как был, так и есть. Старые адреса отвечают переходом (301) на новые.</p>
  <p id="zLHy"><strong>Протоколы матчей.</strong> Источников несколько: API-Football для футбола (составы с номерами, схема расстановки, события, замены), сайты лиг и клубов для хоккея и баскетбола (КХЛ, МХЛ, ВХЛ, бокс-скор), ручной ввод редактором и разбор вставленного текста протокола языковой моделью. Для каждого источника есть проверка формы ответа: если API изменил формат, это видно сразу, а не через неделю по странным данным.</p>
  <p id="CrIC"><strong>Модели не верят.</strong> Это важное правило: разбор протокола из текста языковой моделью даёт только черновик. Ничего не сохраняется само, сохраняет редактор обычной кнопкой. Имена приводятся к справочнику кодом, а не моделью. Счёт считает код (голы по событиям против счёта матча, периоды против итога), и любое расхождение показывается предупреждением. Когда на реальном протоколе модель записала голы наших игроков как гостевые, сторона события теперь определяется составом, а не её ответом. Текст протокола считается чужими данными: в запросе он в ограничителях, а в инструкции прямо сказано не выполнять просьб из него.</p>
  <p id="wgyw"><strong>Автообновление и ручные правки живут вместе.</strong> Во время матча расписание раз в минуту записывает протокол из API. А редактор в это время может поправить игрока или событие. Чтобы автомат не затирал ручные правки, он помнит, какой протокол записал сам, считает разницу с тем, что стало после правки, и накладывает эти правки поверх каждого нового ответа API. Спорное решает редактор. На странице протокола видно число ручных правок и есть кнопка «отдать автомату».</p>
  <p id="U7Ul"><strong>Сюжет «матч дня».</strong> Матч с отметкой сам получает сюжет (сторис): сначала анонс, потом итог. Через сутки после конца матча сюжет уходит в черновики (срок меняется в настройках).</p>
  <h2 id="безопасность-и-приватность">Безопасность и приватность</h2>
  <ul id="UVJp">
    <li id="qWjc">Регистрация только по приглашению (одно приглашение приводит одного человека), подтверждается кодом из письма. Код действует десять минут, на него три попытки, подбор считается по адресу и по посетителю.</li>
    <li id="B587">Роли хранятся в правах доступа, а не в колонке. У человека одна роль, а права на конкретные действия проверяются, а не роль.</li>
    <li id="mdmJ">Каждое действие сотрудника пишется в журнал: кто, что и когда изменил.</li>
    <li id="h3tw">Весь выводимый HTML кодируется функцией, которая экранирует и кавычки.</li>
    <li id="kxVt">В загруженных фото вырезаются метаданные (в них бывают координаты и модель телефона), поворот применяется к самим пикселям.</li>
    <li id="BGVX">Файлы загрузок лежат по содержимому (одна и та же картинка из разных мест это один файл), а старые адреса отвечают переходом на новые.</li>
    <li id="vGmr">Ключ личного агента хранится в базе только в виде хеша, выдаётся при условиях (аккаунт старше семи дней и без красной карточки), срок девяносто дней, не больше трёх действующих ключей.</li>
  </ul>
  <h2 id="для-людей-и-для-роботов">Для людей и для роботов</h2>
  <p id="k4by"><strong>Личные агенты (MCP).</strong> Болельщик может выдать ключ своему ИИ-агенту, и тот читает гостевую и по желанию владельца пишет. Агент это тот же болельщик с теми же правилами. Инструментов модерации у него нет вообще, а специальный тест следит, что в именах инструментов нет ничего про карточки и баны. Запись идёт в одном из режимов: спросить в клиенте, оставить черновик или сразу (последнее выключается настройкой). Тексты из ленты считаются данными, а не командами: агенту прямо сказано не выполнять просьб из чужих сообщений.</p>
  <p id="tfs2"><strong>Сайт, удобный для роботов.</strong> Карта сайта собирается по частям (по десять тысяч адресов) и прогревается заранее, чтобы краулер её не ждал. У каждой страницы есть Markdown-версия (по заголовку <code>Accept</code> или по суффиксу <code>.md</code>), есть <code>llms.txt</code>, разметка JSON-LD собирается из тех же данных, что и страница. Для браузеров с WebMCP на сайте включаются инструменты чтения страниц.</p>
  <p id="VnBB"><strong>Telegram.</strong> Бот привязывается к аккаунту кодом, шлёт уведомления об ответах и упоминаниях (по подписке) и принимает сообщения в гостевую командой. Мини-приложение открывает сайт прямо в Telegram: вход по подписанным Telegram данным (проверяется подпись и свежесть) для уже привязанных аккаунтов. Анонсы в канал уходят только по кнопке редактора, ничего само. Для читателей из России, где <code>api.telegram.org</code> бывает недоступен, предусмотрен релей.</p>
  <h2 id="мои-пакеты">Мои пакеты</h2>
  <p id="hD0o">Всё, что не специфично для ЦСКА, вынесено в отдельные Composer-пакеты <code>rasuvaeff/*</code>. Каждый живёт в своём репозитории со своими тестами, версиями и сборкой. Сам я работаю с ними через рабочее место-монорепо, а наружу они публикуются отдельно. В проекте их 31, и вот главные, с примерами применения.</p>
  <h3 id="данные-и-запросы">Данные и запросы</h3>
  <ul id="ouUr">
    <li id="hxbJ"><strong><a href="https://github.com/rasuvaeff/specification" target="_blank">specification</a>.</strong> Условия запросов как значения. Календарь и статистика строятся на них: правило пишется один раз, работает и в SQL, и над объектом в памяти.</li>
    <li id="6nhq"><strong><a href="https://github.com/rasuvaeff/result" target="_blank">result</a>.</strong> Тип «успех или причина отказа». Обработчики записи возвращают его вместо исключений для ожидаемых ситуаций.</li>
    <li id="Oa00"><strong><a href="https://github.com/rasuvaeff/schema" target="_blank">schema</a>.</strong> Проверка формы входящих данных: ответы внешних API (если формат изменился, сообщение называет путь к поломанному полю), ответ языковой модели, подписанные данные Telegram.</li>
    <li id="Alo2"><strong><a href="https://github.com/rasuvaeff/duration" target="_blank">duration</a>.</strong> Понятные интервалы вместо голых чисел: пауза размыкателя цепи у языковой модели записана как <code>cooldown: Duration::seconds(120)</code>, а не просто <code>120</code>, и никто не гадает, секунды это или миллисекунды.</li>
  </ul>
  <h3 id="надёжность">Надёжность</h3>
  <ul id="yPck">
    <li id="Aszd"><strong><a href="https://github.com/rasuvaeff/yii3-outbox" target="_blank">yii3-outbox</a> и <a href="https://github.com/rasuvaeff/yii3-outbox-db" target="_blank">yii3-outbox-db</a>.</strong> Доставка важных дел без потерь (разобрана выше).</li>
    <li id="e3b3"><strong><a href="https://github.com/rasuvaeff/yii3-idempotency" target="_blank">yii3-idempotency</a> (хранилище в Redis).</strong> Повторная отправка формы не создаёт дубль.</li>
    <li id="CZ4H"><strong><a href="https://github.com/rasuvaeff/circuit-breaker" target="_blank">circuit-breaker</a>.</strong> Размыкатель цепи вокруг языковой модели.</li>
    <li id="59Vy"><strong><a href="https://github.com/rasuvaeff/bulkhead" target="_blank">bulkhead</a>.</strong> Ограничитель одновременных вызовов (ffmpeg для уменьшенных картинок).</li>
    <li id="YF7t"><strong><a href="https://github.com/rasuvaeff/yii3-health-check" target="_blank">yii3-health-check</a> и <a href="https://github.com/rasuvaeff/yii3-maintenance-mode" target="_blank">yii3-maintenance-mode</a>.</strong> Проверки здоровья для выкладки и страница-заглушка на время работ.</li>
  </ul>
  <h3 id="настройки-и-контроль">Настройки и контроль</h3>
  <ul id="xVCT">
    <li id="eVUL"><strong><a href="https://github.com/rasuvaeff/yii3-settings" target="_blank">yii3-settings</a> и <a href="https://github.com/rasuvaeff/yii3-settings-db" target="_blank">yii3-settings-db</a>.</strong> Настройки, которые редактор меняет в админке без выкладки кода: пауза между сообщениями, размер страницы, лимиты, срок жизни сюжета матча дня. Для каждой настройки описаны тип, значение по умолчанию и допустимый диапазон.</li>
    <li id="Tv58"><strong><a href="https://github.com/rasuvaeff/yii3-feature-flags" target="_blank">yii3-feature-flags</a> и <a href="https://github.com/rasuvaeff/yii3-feature-flags-db" target="_blank">yii3-feature-flags-db</a>.</strong> Флаги для сотрудников: включить возможность выборочно, не выкладывая код.</li>
    <li id="zS55"><strong><a href="https://github.com/rasuvaeff/yii3-audit-log" target="_blank">yii3-audit-log</a> и <a href="https://github.com/rasuvaeff/yii3-audit-log-db" target="_blank">yii3-audit-log-db</a>.</strong> Журнал «кто что менял». Секреты в нём маскируются.</li>
    <li id="p0Aj"><strong><a href="https://github.com/rasuvaeff/yii3-api-problem" target="_blank">yii3-api-problem</a> и <a href="https://github.com/rasuvaeff/yii3-correlation-id" target="_blank">yii3-correlation-id</a>.</strong> Единый формат ошибок и сквозной номер запроса в журналах.</li>
  </ul>
  <h3 id="медиа-и-внешний-мир">Медиа и внешний мир</h3>
  <ul id="64jH">
    <li id="3pyX"><strong><a href="https://github.com/rasuvaeff/link-preview" target="_blank">link-preview</a> и <a href="https://github.com/rasuvaeff/pinned-http-client" target="_blank">pinned-http-client</a>.</strong> Карточки ссылок (адаптеры для YouTube, VK, Telegram, Rutube и других, плюс универсальный разбор страниц) с описанной выше защитой от подмены адресов.</li>
    <li id="2rFT"><strong><a href="https://github.com/rasuvaeff/media-converter" target="_blank">media-converter</a>.</strong> ffmpeg из PHP: операции описываются значениями (перекодировать, обрезать, вырезать кадр, подготовить для потоковой выдачи), команда формируется строго, с повторами и ограничениями по времени.</li>
    <li id="Xql9"><strong><a href="https://github.com/rasuvaeff/yii3-centrifugo" target="_blank">yii3-centrifugo</a>.</strong> Живые обновления.</li>
    <li id="9Llx"><strong><a href="https://github.com/rasuvaeff/yii3-seo" target="_blank">yii3-seo</a>.</strong> Метаданные страниц, карта сайта, <code>robots.txt</code>.</li>
    <li id="I0mI"><strong><a href="https://github.com/rasuvaeff/yii3-mcp" target="_blank">yii3-mcp</a>.</strong> Дверь для личных агентов и журнал их вызовов (<code><a href="https://github.com/rasuvaeff/yii3-mcp-audit-log-bridge" target="_blank">yii3-mcp-audit-log-bridge</a></code>).</li>
    <li id="GRvD"><strong><a href="https://github.com/rasuvaeff/yii3-telemetry" target="_blank">yii3-telemetry</a> и <a href="https://github.com/rasuvaeff/yii3-telemetry-otel" target="_blank">yii3-telemetry-otel</a>.</strong> Трассы запросов.</li>
    <li id="R1CL"><strong><a href="https://github.com/rasuvaeff/context" target="_blank">context </a>и <a href="https://github.com/rasuvaeff/context-http" target="_blank">context-http</a>.</strong> Срок и отмена запроса, которые можно прокинуть через цепочку вызовов (пока подключены в служебном маршруте проверки).</li>
  </ul>
  <h3 id="что-даёт-такой-подход">Что даёт такой подход</h3>
  <ul id="LCiO">
    <li id="UqDP">Улучшение или исправление делается один раз и попадает во все проекты, где пакет стоит.</li>
    <li id="kaNS">У каждого пакета свои тесты, свои версии и автоматическая проверка обратной совместимости: ломающее изменение не проскочит в минорной версии.</li>
    <li id="uADe">Сам сайт остаётся тонким слоем: предметная логика ЦСКА и склейка. О том, как устроены outbox или идемпотентность, думать в проекте не нужно: это решено пакетом.</li>
    <li id="E2LX">Пакет читается и проверяется отдельно от всего сайта, поэтому границы понятны и новому человеку.</li>
  </ul>
  <p id="Cur7">Цена тоже есть: пакеты нужно поддерживать, релизить и следить за совместимостью. Я принял эту цену осознанно: она окупается там, где один и тот же механизм нужен в нескольких проектах.</p>
  <h2 id="пакеты-которые-помогают-тестировать">Пакеты, которые помогают тестировать</h2>
  <p id="UXCp">Отдельно про четыре пакета из <code>require-dev</code>. Они делают тесты сильнее, чем «написать проверки руками». Рядом с ними работает фреймворк тестов Testo и Infection для мутаций (они не мои).</p>
  <h3 id="property-testing-тесты-на-свойства"><a href="https://github.com/rasuvaeff/property-testing-core" target="_blank">property-testing</a>: тесты на свойства</h3>
  <p id="GpDa">Обычный тест проверяет несколько примеров, которые придумал человек. Тест на свойства формулирует правило, которое должно выполняться всегда, а генератор придумывает тысячи входов, находит контрпример и сжимает его до самого короткого. Вот настоящий тест из проекта про определение адреса посетителя за прокси:</p>
  <pre id="VFm7">/** слева в заголовке пишет посетитель: он не может перебить
    адрес, добавленный нашим прокси справа */
#[Property(runs: 300)]
public function whatTheVisitorWroteOnTheLeftNeverWins(string $forged, int $last): void
{
    $real = &#x27;203.0.113.&#x27; . $last;

    Assert::same(
        ClientIp::resolve(peer: &#x27;10.0.0.2&#x27;, forwarded: $forged . &#x27;, &#x27; . $real, proxies: &#x27;10.0.0.0/8&#x27;),
        $real,
    );
}
</pre>
  <p id="Wqfl">Что бы посетитель ни подставил в заголовок (триста случайных строк за прогон), настоящий адрес выиграет. А вот тест на разметку гостевой: что бы ни ввёл автор, в итоговой странице допустимы только наши собственные теги:</p>
  <pre id="pi10">#[Property(runs: 800)]
public function onlyOurOwnMarkupSurvives(string $source): void
{
    $html = GuestText::html(source: $source, mentions: [&#x27;kran&#x27; =&gt; 832]);
    // ... все теги в $html должны подходить под список разрешённых шаблонов
}
</pre>
  <p id="OiF2">Что нашлось таким способом:</p>
  <ul id="piNW">
    <li id="abck">Ссылка в гостевой могла вставить в страницу чужой обработчик события (<code>onmouseover</code>): при выводе не экранировались кавычки. Теперь выводится функцией, которая экранирует и кавычки, и отдельный тест архитектуры запрещает старую.</li>
    <li id="5GPs">Транслитерация имён неоднозначна: из одного набора букв («zh», «ia») получаются разные слова, и это нельзя было исправить «улучшением». Находка осталась в тесте как пример.</li>
    <li id="XG0M">Языковая модель иногда отвечает пустым объектом <code>{}</code>. Такой ответ должен отвергаться, а не превращаться в пустой протокол.</li>
  </ul>
  <p id="w2LY">Ещё две важные черты. Найденный контрпример запоминается и проверяется первым при следующих запусках, поэтому регрессия не теряется: даже если «повезло» в тот раз. А для ветвящихся свойств можно потребовать, чтобы каждая ветвь действительно достигалась хотя бы в нескольких процентах прогонов.</p>
  <h3 id="understudy-дублёры-без-магии">understudy: дублёры без магии</h3>
  <p id="Y9pw">Классические мок-фреймворки часто превращают тест в пересказ реализации. <a href="https://github.com/rasuvaeff/understudy" target="_blank">Understudy</a> устроен проще: вызов задаётся замыканием, у дублёра нет ничего сверх контракта.</p>
  <pre id="5jy1">$search = Understudy::for(NicknameSearch::class);
expect(fn () =&gt; $search-&gt;startingWith(&#x27;KR&#x27;, 8))
    -&gt;returns([new NicknameHint(id: 1, nickname: &#x27;KR1&#x27;)]);

// ... вызываем код, который должен спросить поиск ников

Understudy::nothingElse($search);   // и больше ничего не просили
</pre>
  <p id="aPYa">Сначала описываешь ожидаемый вызов с аргументами, потом проверяешь, что он был и ничего лишнего. Дублируем только внешние границы: сеть, почту, Temporal, Centrifugo. <strong>Базу данных в тестах не подменяем никогда.</strong> Тесты репозиториев и всего приложения идут на настоящем PostgreSQL (отдельная база, имя которой обязано заканчиваться на <code>_test</code>, поэтому тест физически не может добраться до рабочей базы), и каждый тест выполняется в транзакции, которая откатывается. Приложение для интеграционных тестов запускается целиком в одном процессе: настоящий PSR-запрос на входе, настоящие ответы на выходе.</p>
  <h3 id="mutation-history-чтобы-качество-тестов-не-падало-тихо">mutation-history: чтобы качество тестов не падало тихо</h3>
  <p id="XSaM">Мутационное тестирование намеренно ломает код (меняет условие, убирает вызов, подменяет константу) и смотрит, заметит ли это хоть один тест. Доля пойманных поломок называется MSI. Если тест не заметил, что условие перевернули, значит, он проверяет не то, что нужно. В проекте порог 95%, на последнем прогоне 96% примерно из 12,5 тысяч мутантов (Domain и Application). Пакет <code><a href="https://github.com/rasuvaeff/mutation-history" target="_blank">mutation-history</a></code> хранит историю прогонов, и каждый следующий сверяется с предыдущим: новый прогон не может быть хуже по тем же местам. Качество не может тихо упасть: новый класс без теста немедленно снижает показатель.</p>
  <p id="WLU5">Из практики: Infection не засчитывает тесты с источниками данных (<code>#[DataProvider]</code>), поэтому для мутационных тестов пишутся обычные методы. А мутант, который ничего не меняет в наблюдаемом поведении, остаётся в отчёте как есть, а не заглушается в настройках.</p>
  <h3 id="rector-named-literals"><a href="https://github.com/rasuvaeff/rector-named-literals" target="_blank">rector-named-literals</a></h3>
  <p id="89wC">Автоматически расставляет имена аргументов (<code>format(format: &#x27;H:i&#x27;)</code>). Мелочь, но весь код читается без заглядывания в сигнатуры, а правило не требует дисциплины от человека: Rector сам предложит исправление, а сборка не пройдёт, пока оно не применено.</p>
  <h3 id="итого-по-тестам">Итого по тестам</h3>
  <ul id="5SPb">
    <li id="WU1o">3193 юнит-теста и 1356 интеграционных, всего 4549.</li>
    <li id="bNFT">Тесты на свойства для правил с большим входом: турнирная таблица Кумира, адрес посетителя за прокси, пути загрузок, разметка гостевой, суточные потолки входа и многое другое.</li>
    <li id="MrMq">Дымовые сценарии: поднимают весь стек и проверяют сайт целиком (живые обновления, письма из воркера, карточки ссылок на поддельном «чужом сайте», выкладка).</li>
    <li id="rbIa">Отдельный нагрузочный стенд — и он уже окупился: однажды он показал, что планирование запроса календаря из десяти соединений стоило дороже самого выполнения (20–50 мс против меньше миллисекунды). Фикс порядка<br />соединений на уровне базы опустил p95 страниц с 234 до 104 мс и вдвое срезал CPU базы.</li>
    <li id="2QpL">Строчное покрытие — 80% (без миграций и импорта, у которых свои проверки против настоящей базы, — 88%). Мы не гонимся за цифрой 100%: строчное покрытие легко обмануть, поэтому порог в проекте мутационный — он проверяет, что тесты вообще замечают поломки.</li>
  </ul>
  <h2 id="как-это-писалось-вместе-с-claude-code">Как это писалось: вместе с Claude Code</h2>
  <p id="MQFT">Коротко: я принимаю решения, Claude Code пишет код, тесты, миграции и документацию, а правдивость результата держат проверки, а не уверенность ответа.</p>
  <p id="er15"><strong>Кто за что отвечает.</strong> Что строим, какие правила действуют, в каком порядке, как должно выглядеть, что выкладываем и когда: это решаю я. Claude Code берёт на себя ремесло: пишет обработчики, тесты, миграции, правит вёрстку, разбирает падения, ведёт документацию, делает ревью. Любое решение, которое меняет поведение сайта для людей, я принимаю сам. Выкладка и публикация только по моей явной команде.</p>
  <p id="tmF7"><strong>Правила лежат в репозитории, а не в моей голове.</strong> В проекте и в каждом пакете есть файл <code>AGENTS.md</code>: что это за код, какие команды запускать, какие ловушки уже известны. Туда попадают «золотые правила». Например: нельзя сказать «готово» без свежего зелёного прогона проверок, нельзя подавлять предупреждения анализатора, любое изменение интерфейса обновляет документацию и примеры. Когда агент или я наступаем на грабли, правило дописывается вместе с датой и описанием случая. Так ошибка перестаёт быть «знанием одного разговора» и становится правилом проекта.</p>
  <p id="4VZe"><strong>Навыки.</strong> В репозитории лежат наборы инструкций под конкретные области: архитектура и слои, правила базы данных, гостевая, работа с API-Football, тестирование, проверка интерфейса в настоящем браузере. Агент загружает нужный, когда берётся за такую задачу. Мои пакеты поставляются вместе со своими навыками и файлом <code>llms.txt</code> (краткий справочник API для ИИ), чтобы помощник использовал пакет правильно, а не угадывал.</p>
  <p id="wFYo"><strong>Проверки как поручитель.</strong> ИИ ошибается, и это нормально. Поэтому доверие даёт не то, насколько уверенно звучит ответ, а то, что зелёное в проверках: юнит- и интеграционные тесты, тесты на свойства, Psalm первого уровня, deptrac, Rector, мутационное тестирование. Если агент что-то сломал, сборка скажет об этом раньше, чем человек. Интерфейс агент проверяет в настоящем браузере: открывает страницу, кликает, делает снимок экрана и смотрит, нет ли ошибок в консоли, при необходимости заводит временный аккаунт и убирает за собой.</p>
  <p id="brSg"><strong>Ограждения.</strong> Опасные команды (например, жёсткий сброс изменений в git) заблокированы хуком, и без моей отмашки не выполнятся. Секреты и ключи, которые мелькнули в разговоре, не сохраняются ни в файлах, ни в памяти. Выкладка на прод и публикация пакетов идут только по явной команде, а если что-то выглядит рискованным, агент сначала называет риск, а потом спрашивает.</p>
  <p id="JrmL"><strong>Что получается на практике.</strong> Скорость ушла в творчество и качество: рутина (перенос данных, тесты на каждую ветку, двуязычная документация пакетов, ревью каждого изменения) больше не стоит часов. Но цена другая: нужно уметь формулировать правила, быстро читать чужой код и не принимать зелёное за правильное, пока не понятно, что именно проверяется. Поэтому половина этого проекта это не код, а правила и проверки вокруг него.</p>
  <h2 id="чего-пока-нет">Чего пока нет</h2>
  <p id="CVhi">Чтобы не создавать иллюзию идеальности. Не доделано: плееры некоторых соцсетей в карточках ссылок, запись через WebMCP (в спецификации пока нет подтверждения действия), собственные метрики приложения по маршрутам (трассы есть, метрик нет), карточки для самых старых сообщений гостевой, которые разбираются постепенно.</p>
  <h2 id="итог">Итог</h2>
  <p id="ceZR">Сайт, которому больше двадцати лет и у которого уже была не вышедшая версия на Yii2, собран заново вместе с Claude Code так, чтобы его можно было дальше развивать без страха что-то сломать. Слои, проверяемые автоматически. База, которая не даёт записать плохую строку. Выкладки без остановки. Надёжная доставка и защита от повторов, вынесенные в пакеты. И много тестов, из которых часть проверяет сами тесты.</p>
  <p id="aI6M">Если интересно посмотреть, как это работает вживую, сайт открыт: <a href="https://cska.in/" target="_blank">cska.in</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@rasuvaeff/llm</guid><link>https://teletype.in/@rasuvaeff/llm?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=rasuvaeff</link><comments>https://teletype.in/@rasuvaeff/llm?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=rasuvaeff#comments</comments><dc:creator>rasuvaeff</dc:creator><title>Полгода с LLM-агентами в монорепо из 95 PHP-пакетов. Полный разбор: что сработало, что взорвалось, что пришлось строить</title><pubDate>Wed, 04 Nov 2026 10:33:40 GMT</pubDate><description><![CDATA[У меня в монорепо ~95 библиотек rasuvaeff/* — спецификации, resilience-примитивы, тестирующие фреймворки, пачка интеграций для Yii3. PHP на хосте нет вообще: всё исполняется в Docker через образ composer:2. Пишут, чинят и релизят это хозяйство в основном LLM-агенты (Claude Code, OpenCode) — я занимаюсь тем, что проектирую правила и ревьюлю. Полгода назад это выглядело как эксперимент. Сейчас это конвейер, и про него есть что рассказать — включая места, где он ломался неожиданным образом.]]></description><content:encoded><![CDATA[
  <p id="tgU9">У меня в монорепо ~95 библиотек <code>rasuvaeff/*</code> — спецификации, resilience-примитивы, тестирующие фреймворки, пачка интеграций для Yii3. PHP на хосте нет вообще: всё исполняется в Docker через образ <code>composer:2</code>. Пишут, чинят и релизят это хозяйство в основном LLM-агенты (Claude Code, OpenCode) — я занимаюсь тем, что проектирую правила и ревьюлю. Полгода назад это выглядело как эксперимент. Сейчас это конвейер, и про него есть что рассказать — включая места, где он ломался неожиданным образом.</p>
  <h2 id="DZGp">1. Память: главный дефицит не интеллект, а преемственность</h2>
  <p id="vSxO">Первая проблема проявилась не в качестве кода, а в повторяемости. Одна и та же ловушка находится агентом заново — каждый раз за полную цену, иногда с новым красивым решением, которое тоже не работает. Человек в проекте через полгода помнит, что «вот тут MySQL ругается». Агент — нет.</p>
  <p id="l0A4"><br />Родился <code>docs/evolved-rules.md</code> — внешняя память репозитория. Сейчас там 50 записей, и каждая — это не абстрактный совет, а разбор конкретного инцидента с датой, пакетом и рецептом. Примеры, чтобы было понятно, какого уровня это вещи:</p>
  <p id="EEvG"><br /><strong>• ER-001:</strong> якорь \z вместо $ в regex-валидации идентификаторов — PCRE $ матчится перед trailing newline, и невалидная строка с \n на конце проходила whitelist. Классика, но пока не наступишь — не узнаешь.</p>
  <p id="FM25"><br /><strong>• ER-049:</strong> литеральный DEFAULT на TEXT-колонке. MySQL и MariaDB отвергают его ошибкой 1101, migrate:up тихо возвращает ноль созданных таблиц — а SQLite и PostgreSQL тот же DDL съедают. В CI один SQLite, поэтому всё зелёное, прод — без схемы. Правило теперь: пакет с миграцией обязан нести кросс-БД тест и матричную джобу с MySQL и PostgreSQL service containers.</p>
  <p id="0cK2"><br /><strong>• ER-044:</strong> setSourceNamespaces() для миграций молча не находил ничего в девяти *-db пакетах — PSR-4-матч по строковому префиксу уводил в пакет-ядро. migrate:up возвращал 0, не создав ни одной таблицы, без единой ошибки. Лечилось upstream-PR в yiisoft/db-migration (наш #350), до этого — только знание ловушки спасало.</p>
  <p id="qUHe"><br />Формат записи важен: не «проверяйте якоря», а «вот пакет, вот дата, вот симптом, вот почему CI это не поймал, вот что делать». Агент перед задачей читает корневой AGENTS.md, который тянет эти правила по теме. Сработало: повторные наступания на известные грабли практически исчезли.</p>
  <h2 id="oUB1">2. Верификация: «должно работать» — не результат</h2>
  <p id="OO8J">В каждом пакетном AGENTS.md золотое правило номер один: never claim &quot;done&quot; without a fresh green <code>composer build,</code> paste the output. Это не бюрократия. Агент, который «уверен» в исправлении и не прогнал гейт, — самый дорогой тип ошибки в конвейере, потому что он выглядит как успех.</p>
  <p id="Zv5Y"><br />composer build — полный конвейер: validate → normalize → require-checker → cs → psalm → тесты. Релиз добавляет rector, BC-check и мутации. И это работает только потому, что гейт дешёвый: одна команда в Docker, десятки секунд на типичный пакет. Правило, которое агенты выполняют плохо, но выполняют быстро, лучше правила идеального, которое тяжело исполнить.</p>
  <p id="40me"><br />Отдельная категория — правила против самообмана агента. Например: <code>@psalm-suppress</code> и baseline запрещены на уровне конвенции, потому что suppress — это способ сказать «я знаю лучше» без доказательств. И это осознанное решение.</p>
  <h2 id="dkk5">3. Мутационное тестирование как детектор лжи в метриках</h2>
  <p id="t7dA">Самая поучительная история этих месяцев. Infection показывал MSI 97% — красиво. Потом заметили расхождение в числах мутантов на одном пакете: 1169 против 2726. Причина: без флага --with-uncovered Infection мутирует только код, «покрытый» с точки зрения Testo — а Testo маппит мутант→тест через атрибут #[Covers], а не через реальное исполнение (ER-003). Из 2726 мутантов 1557 вообще не создавались. Реальный MSI был 41%, и «97%» были артефактом инструмента, а не качеством тестов.</p>
  <p id="uBAJ"><br />Что построили на этом уроке:</p>
  <p id="YUrG"><br />• флаг <code>--with-uncovered</code> обязателен, MSI считается по общему числу мутантов;</p>
  <p id="uvBg"><br />• мутанты на дефолтах параметров в сигнатурах игнорируются через глобальный regex-фильтр — их нельзя убить тестами, они просто шумят в знаменателе (проверено на трёх пакетах: убираются ровно строки вида private int $x = 100;);</p>
  <p id="FIIu"><br />• локальный прогон мутаций обязан совпадать с CI: в CI выключен zend.assertions, \assert() в src не компилируется, его мутанты там Not Covered — а локально исполняются и убиваются. Один пакет дал 95% локально и 93% в CI, прежде чем это отследили;</p>
  <p id="m1oP"><br />• порог minMsi агент не имеет права снижать. Только владелец, только после исчерпания всех рычагов, не ниже 85, с записью причины в infection.json5. Это правило появилось потому, что иначе агент «чинит» красный мутационный гейт самым дешёвым способом — опуская планку.</p>
  <p id="ufbv"><br />Вывод неприятный, но важный: метрика, за которую агент не отвечает, будет им оптимизирована, а не достигнута. Гейт должен быть устроен так, чтобы честный путь был и кратчайшим.</p>
  <h2 id="7N6u">4. Property-based тесты и корпус регрессий</h2>
  <p id="cHNl">Часть пакетов — алгебраические штуки (round-trip сериализаций, монотонность backoff, детерминизм hash-бакетинга), и для них example-тесты не покрывают пространство входов. Под это написано собственное семейство <code>rasuvaeff/property-testing-*</code>: генераторы, shrink, а для stateful-сценариев — <code>Gen::commands() + StateMachine::check()</code>.</p>
  <p id="srNE"><br />Интересна не библиотека, а инфраструктура вокруг неё. Когда property фальсифицируется, контрпример сохраняется в корпус на диске (<code>PROPERTY_DB</code>) — и должен переживать между CI-прогонами, чтобы регрессия ловилась навсегда. И тут выяснилось:</p>
  <p id="SLEc"><br />• combined actions/cache объявляет post-if: &quot;success()&quot; — post-шаг сохранения не выполняется на красном job&#x27;е. А писать в корпус — это ровно падающий прогон. Итого: баг найден, контрпример не сохранён, «навсегда» не работает. Лечится раздельными cache/restore и cache/save с if: !cancelled();</p>
  <p id="UqhN"><br />• при re-run прогона run_id тот же, ключ кэша занят, save молча возвращает warning «Cache already exists». Нужен run_attempt в ключе;</p>
  <p id="RPkD"><br />• GitHub-hosted раннер — каждый раз новая VM; без restore-шага корпус просто не существует в начале следующего job&#x27;а.</p>
  <p id="0omw"><br />Три ловушки в одном механизме, каждая найдена отдельным ревью. Это, кстати, отдельный вывод: полезные фичи CI ломаются не в логике, а в склейке — deprecated-флаги, повторные прогоны, эфемерность раннеров.</p>
  <h2 id="11lR">5. Ошибки на границах: то, что не видит ни один локальный гейт</h2>
  <p id="KbS6">Самый ценный класс находок. Примеры:</p>
  <p id="zZ5n"><br />•<strong> DI-конфликт между пакетами</strong>. yiisoft/config запрещает двум vendor-пакетам определять один ключ в одной группе. Ошибка существует только между пакетами — каждый пакетный тест зелёный, потому что каждый тест видит ровно один di.php. Сначала пытались имитировать merge вручную (require чужого конфига + array_intersect_key) — выяснили, что такая проверка не знает про metadata, слои и модификаторы реального merge. Написали bin/config-merge-harness: собирает одноразовое приложение с path-репозиториями, даёт config-плагину написать настоящий merge-plan и мержит группы теми же модификаторами, что и раннер.</p>
  <p id="EynP"><br />•<strong> Циклическая ссылка из README-рецепта.</strong> yii3-filestorage-db документировал включение дедупликации через StorageInterface::class =&gt; fn (Factory $f) =&gt; ..., а фабрика сама запрашивала StorageInterface — то есть себя. Любой контейнер отвечал CircularReferenceException, при этом зелёными были и харнесс, и оба пакетных теста: каждый грузил ровно один di.php. Нашло только внешнее ревью (CodeRabbit). Мораль: если README описывает переопределение чужого DI-ключа — нужен тест, исполняющий рецепт целиком.</p>
  <p id="gwtA"><br />• <strong>Матричный CI-job и гейт по changes</strong>. Пропущенный своим if: матричный job не разворачивает матрицу: GitHub отчитывается одним check&#x27;ом с сырым именем PHP ${{ matrix.php }} вместо PHP 8.3/PHP 8.4. Если такие имена зарегистрированы в required status checks — PR виснет в «Expected — Waiting for status» навсегда.</p>
  <p id="oZyP"><br />• <strong>Пустая директория в git.</strong> benchmarks/ без файлов не хранится git&#x27;ом; локально всё зелёное (директория-то есть), свежий клон в CI падает «File or directory not found» во всех job&#x27;ах сразу.</p>
  <p id="TBzW"><br />Общая закономерность: <strong>ошибка живёт на границе — между пакетами, между машинами, между «как у меня» и «как на чистом клоне»</strong>. Изнутри своей задачи агент границу не видит, и никакой локальный прогон её не покажет. Лечится только специализированными харнессами и дисциплиной «проверять рецепт из README исполнением, а не чтением».</p>
  <h2 id="lqQV">6. Процесс: ленивая раскатка вместо больших волн</h2>
  <p id="eL3Z">Соблазн, когда нашёл системную проблему в 76 пакетах, — починить все сразу. Мы пробовали: большая волна PR&#x27;ов — это максимальная поверхность для конфликтов, вечные ревью и выгорание на середине. Работает другое — ленивая раскатка: правило фиксируется в шаблонах (templates/), чтобы новые пакеты наследовали правильное сразу, а старые подтягивают его в первом же PR, который их трогает по другому поводу. Правка CI — issue-first не требует, поэтому довесок не раздувает PR.</p>
  <p id="zLhp"><br />Плюс жёсткое разделение: PR строго про доки (README, examples/) не должен нести в себе миграцию CI — unrelated changes смешиваются. Каждое правило в AGENTS.md имеет явное «не применять, если...».</p>
  <p id="Ou24"><br />Такие мелочи — «ровно один ## Unreleased в CHANGELOG, и выше всех версий», «тег ставится на post-merge HEAD и никогда не двигается, потому что Packagist блокирует re-tag навсегда» — по отдельности выглядят паранойей. Вместе они дают то, что конвейер из агентов за полгода не запубликовал ни одного сломанного релиза.</p>
  <h2 id="lmzm">7. Ревью и аудит: люди всё ещё нужны, но иначе</h2>
  <p id="WGvC"><code>bin/package-audit</code> детерминированно проверяет 15+ инвариантов: обязательный набор файлов, отсутствие suppress&#x27;ов, <code>@api </code>на каждом публичном классе, <code>#[Covers]</code> на тестах, упоминание всех публичных типов в README, исполняемость примеров, структуру CHANGELOG. Он не устаёт и не пропускает. Но за эти месяцы дважды вещи, которые не видела вся автоматика, находил CodeRabbit. Вывод не «автоматизируйте всё», а <strong>не стройте систему, претендующую на полноту</strong> — стройте систему, в которой известно, что она не видит, и есть внешний слой поверх.</p>
  <h2 id="FWF8">Итог</h2>
  <p id="xXYs">Агенты не заменили инженера. Они сместили работу с «писать код» на «проектировать гейты, которые плохой код не пропустят, и память, которая переживает контекст сессии». Конкретно у меня это:</p>
  <ol id="ev1E">
    <li id="rKHi"> AGENTS.md как конституция с датированными кейсами, а не как welcome-README.</li>
    <li id="CfOa">Один дешёвый обязательный гейт, лучше десяти необязательных.</li>
    <li id="O1BX"> Метрики, которые агент может только достигать, но не «настраивать».</li>
    <li id="rSsF"> Харнессы для ошибок на границах — то, что не ловится локально в принципе.</li>
    <li id="owvM">Ленивая раскатка вместо волн рефакторинга.</li>
    <li id="Oh2G">Явная карта слепых зон + внешний ревью поверх.</li>
  </ol>
  <p id="i5Zt"><br />И честно: это самая интересная инженерия, которой я занимался за последнее время. Не потому что код стал сложнее — потому что «как сделать, чтобы система с LLM внутри была надёжной» — это задача про архитектуру стимулов и памяти, а не про код.</p>

]]></content:encoded></item></channel></rss>