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

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

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

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

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

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

</content></entry></feed>