Циклы графа, копипаст, лишние флаги: архитектурный дрейф виден в вашем CI.

Один PR

Начну с конкретного примера.

В PrincetonUniversity/SPECFEMPP#943 Pull Request назывался Single include header for assembly module. По описанию он должен был упростить зависимости в модуле assembly и разорвать циклические зависимости через forward declarations.

По названию и описанию это правильное архитектурное изменение.

После изменения в include graph появился новый цикл из 7 узлов.

Кейс с include cycle и почему обычный review его не видит

Кейс: как чистка include-зависимостей добавила новый цикл

В PrincetonUniversity/SPECFEMPP#943 PR выглядел как чистка include-структуры:

  • удалили узкий заголовок core/specfem/assembly_mesh.hpp;

  • часть зависимостей заменили на forward declarations;

  • properties_container.hpp разгрузили, вынеся реализацию конструктора в .tpp;

  • source/receiver-код перевели с specfem/assembly_mesh.hpp на общий specfem/assembly.hpp.

До изменения source-заголовки включали маленький фасад:

#include "specfem/assembly_mesh.hpp"

Этот фасад тянул только несколько assembly-заголовков:

#include "assembly/element_types.hpp"
#include "assembly/jacobian_matrix.hpp"
#include "assembly/mesh.hpp"

После PR фасад удалили, а в нескольких местах поставили широкий umbrella-header:

#include "specfem/assembly.hpp"

Именно эта замена расширила include-зависимость.

Было:

source/* -> specfem/assembly_mesh.hpp -> assembly/{mesh,jacobian_matrix,element_types}

Стало:

source/* -> specfem/assembly.hpp

А внутри specfem/assembly.hpp уже был путь обратно в source:

core/specfem/assembly.hpp
  -> core/specfem/assembly/assembly.hpp
  -> include/source/interface.hpp
  -> include/source/cosserat_force_source.hpp
  -> core/specfem/assembly.hpp

В результате появилась новая сильносвязная компонента из 7 файлов (выше показано замыкающее ребро на 4 из них):

core/specfem/assembly.hpp
core/specfem/assembly/assembly.hpp
include/source/interface.hpp
include/source/cosserat_force_source.hpp
include/source/external.hpp
include/source/force_source.hpp
include/source/moment_tensor_source.hpp

PR действительно убирал часть старых циклов и прямых зависимостей. Но замена маленького специализированного заголовка на большой umbrella-header создала новый обратный путь.

гениальный PR
гениальный PR

Почему include cycle - плохо

В C++ Core Guidelines это сформулировано прямо: SF.9: Avoid cyclic dependencies among source files.

Цикл превращает несколько файлов в один физически связанный комок. Их сложнее собирать по частям, сложнее тестировать отдельно, сложнее переиспользовать и сложнее менять без побочных эффектов.

John Lakos - C++ инженер и автор Large-Scale C++ Software Design, одной из классических книг про большие кодовые базы. Его главная для нас тема - physical design: важна не только логическая модель классов, но и физическая структура файлов, заголовков и зависимостей. Если include graph цикличен, физические компоненты перестают быть независимыми. Изменение в одном месте начинает тянуть за собой лишнюю пересборку и лишние зависимости.

Та же идея известна как Acyclic Dependencies Principle у Robert C. Martin: граф зависимостей компонентов не должен содержать циклов.

AI code review и статические анализаторы хорошо находят локальные проблемы: подозрительные строки, спорные include, забытые переменные, ошибки в локальной логике. В больших PR такого review может быть на несколько страниц.

Но include cycle — это не строка, а путь в графе. Человеку глазами увидеть его сложно, особенно если цикл проходит через несколько заголовков и выглядит как обычная замена одного include на другой.

Для этой и нескольких других архитектурных проверок мы с Claude Code написали инструмент — archcheck.

Он отвечает на один главный вопрос:

что этот PR сделал с архитектурой C++-проекта?

А дальше раскладывает его на проверяемые сигналы:

  • появился новый цикл;

  • вырос старый цикл;

  • появился заголовок, на который теперь зависит слишком много файлов (god header);

  • добавился копипаст кода;

  • выросла сложность функции;

  • состояние снова выразили новым флагом вместо перехода на машину состояний.

Это детерминированная настраиваемая проверка структурных изменений. В основе — C++ Core Guidelines (SF.7, SF.8, SF.9), Lakos physical design, идея ациклических зависимостей, пороги на количество входящих зависимостей / include-chain и мой опыт с большими C++/C# кодовыми базами.

На чём основаны правила

Основные источники такие:

  • C++ Core Guidelines, раздел SF: Source files: SF.7 про using namespace в header, SF.8 про include guards, SF.9 про циклические зависимости: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#S-source

  • John Lakos, Large-Scale C++ Software Design: physical design, компоненты, уровни, физические зависимости.

  • Robert C. Martin, Acyclic Dependencies Principle: граф зависимостей компонентов не должен содержать циклов.

  • Constraint Decay: The Fragility of LLM Agents in Backend Code Generation: https://arxiv.org/abs/2605.06445 — не про C++, но это исходная рамка про деградацию структурных ограничений у агентов.

О себе

Я 30+ лет пишу на C++ и C#. Вёл команды до тридцати с лишним человек. Высоконагруженные системы, вычислительную геометрию, симуляторы.

За последние два года я постепенно перестал писать код руками, и пытаюсь нащупать способы перестать беспокоиться за результат автоматической кодогенерации. archcheck возник из обсуждения на HN статьи про constraint decay в агентской разработке. Я решил проверить, насколько всё серьёзно.

Не путайте агента с джуном

С появлением агентской разработки фокус программиста смещается. Код генерируется со скоростью и объёмом, которые человеку невозможно переварить. PR становится почти бесплатным для сотрудника, но не для компании. Изменения можно генерировать пачками.

Пока ещё многочисленные бойцы держат последний рубеж: вычитывают агентские изменения глазами на code review. Человек становится узким местом. Он устаёт, выгорает, начинает чувствовать бессмысленность своей работы. Чувство верное.

Человеческое review агентского кода в старом формате плохо масштабируется. Это похоже на попытку проверять AI-картинки хора мальчиков и убеждаться, что у каждого мальчика не больше пяти пальцев. Посмотрел 100 картинок за день, поработал, молодец, иди домой, ты новый Ксеркс — высек море прутиком. Морю всё равно: завтра оно будет таким же. И агенту всё равно — твоя вчерашняя правка не попала в его контекст, и он спокойно ошибётся так же в следующем PR.

Мы реально все сгорим, если не поменяем подход.

Роль человека надо сместить на этап до генерации. Он задаёт контекст: цель, ограничения, архитектурные правила, что нельзя трогать, какие решения уже приняты, как проверять результат кодогенерации.

Простой пример такой настройки. Агент не переиспользует общую функцию Dist для вычисления расстояния между двумя точками из common/geom_utils.h, если этого файла нет в его окне контекста - он просто напишет её заново. Это лечится так: положить в память правило “перед созданием новой функции проверь, нет ли готовой в файлах из папки common”. Так работает контекст-инжиниринг: часть работы делается до генерации. А то, что всё равно пролезло, должно ловиться на выходе — проверкой в CI, которая повторяется на каждом PR и не зависит от памяти автора.

Тогда человек управляет средой, в которой агент пишет код, а не пытается вычитать каждый результат, который выплюнула эта среда.

Что именно проверяет инструмент

archcheck не пытается быть линтером, форматтером или заменой clang-tidy. Его задача: проверить структурные последствия изменения.

Сейчас он делает такие проверки:

  • сканирует .h / .cpp без compile_commands.json;

  • строит include-dependency graph;

  • находит циклы в include graph (SF.9);

  • находит слишком длинные include-цепочки (Lakos.ChainLength);

  • находит заголовки с большим количеством входящих зависимостей (Lakos.GodHeader);

  • проверяет using namespace в headers (SF.7);

  • проверяет наличие #pragma once или include guard в headers (SF.8);

  • сравнивает граф зависимостей с базовой версией;

  • в --diff режиме ловит новые и выросшие циклы и новые god-headers;

  • показывает новые include-связи;

  • показывает рост include-chain и NCCD (normalized cumulative component dependency — показатель накопленной связности компонента);

  • показывает TODO / FIXME / HACK, добавленные PR;

  • показывает, изменялись ли тесты вместе с production-кодом (например: production-код поменяли, а тесты не тронули);

  • показывает рост локальной сложности изменённых функций;

  • показывает разрастание флагов (например: в функцию уже передают dryRun, force и skipValidation, а PR добавляет четвёртый флаг вместо отдельного режима/состояния);

  • показывает новые клоны кода в PR;

  • умеет смотреть историю: какие файлы разрастаются и чаще притягивают проблемные изменения.

Отчёт показывает, какое свойство структуры проекта изменилось в этом PR. Дальше команда сама раздаёт находкам вес: эти просто видны в комментах, эти — warning, а вот эти стопают сборку.

На реальных проектах

Я прогнал archcheck на известных open source C+±базах. Каждый цикл привязан к конкретному коммиту и воспроизводится одной командой.

RocksDB. Публичный db.h включает экспериментальный multi_scan.h, тот включает db.h обратно — и db.h заодно god-header: на него завязано 110 файлов:

include/rocksdb/db.h: [SF.9] cycle: include/rocksdb/db.h → include/rocksdb/multi_scan.h → include/rocksdb/db.h
include/rocksdb/db.h: [Lakos.GodHeader] incoming dependencies 110 exceeds threshold 50

Одно направление лишнее: multi_scan.h использует DB только как указатель и не вызывает его методы inline — цикл чинится одним class DB; forward declaration. Мелкая находка, но она тянет экспериментальный заголовок во всё, что зависит от db.h.

Полный список, включая Windows Terminal, — в docs/findings/ репозитория.

Почему это не про “AI пишет хуже”

За год плотной разработки с Claude Code я приобрел стойкое ощущение, что агента совершенно не заботит архитектура. Его заботит выполнение текущей задачи за минимальную цену в токенах. Fable уже не такой - но корпус писали модели постарше.

Я уверен, что агентский код быстрее размывает архитектуру: больше копипасты, больше флагов, больше зависимостей, больше локальной сложности. У агента нет долгой памяти команды. Он работает в контекстном окне, а контекст деградирует.

Если в структуре уже есть два bool, он легко вставит третий: “так уже было, я не менял”. Опытный программист, возможно, покряхтел бы и перевёл такую структуру на машину состояний.

Я прогнал 1188 C++ OSS-репозиториев и 484 500 коммитов с июня 2024. AI-коммиты размечались только по метаданным: bot author, Co-authored-by, AI committer. Не по тексту коммита.

Первый прогон выглядел страшно. В агрегированных показателях, без поправок, агенты смотрелись примерно в 1.76 раза хуже по росту сложности. Похоже на приговор.

Потом я сделал поправку на репозиторий и на размер изменения.

После этого главный результат схлопнулся до статистического нуля. Для коммитов одинакового размера внутри одного репозитория AI-код не оказался хуже человеческого ни в одной из семи категорий архитектурного drift. По копипасте и добавленным TODO/FIXME agentic-коммиты вышли даже чище. Единственная стабильная разница — agentic-коммиты примерно в 1.3 раза крупнее.

Виноватого корпус не нашёл. Дрейф он нашёл. В истории тех же репозиториев лежит 648 смёрженных структурных событий — выросшие циклы и новые связи между областями проекта. Каждое из них дошло до main:

Коммитов

Смёрженный структурный drift (выросший цикл, новая cross-area связь)

648

— прямой push в main, без PR

304

— через PR

336

— из них: с человеческим review

180

— из них: без review

102

— из них: с AI-review (copilot, coderabbit и др.)

43

Половина этих 648 событий попала в main прямым пушем, без PR. Никакого review там не было вообще — ловить дрейф было просто некому. Вторая половина шла через PR, её смотрели люди или боты — и всё равно смёржили. Ревьюеры не виноваты: цикл видно только на графе зависимостей, а review смотрит на diff. Это разные вещи.

Итого по корпусу: AI не пишет грязнее. Его коммит в среднем в 1.3 раза крупнее — вот и вся разница на уровне одного коммита.

Почему я при этом остался при своём: корпус сравнивает коммиты, а моё убеждение - про дистанцию. Человека ревью учит - коммент ревьюера остаётся в голове и меняет следующий PR. Агента ревью не учит: каждая сессия начинается с чистого контекста, и та же копипаста возвращается снова. На коммит агент не грязнее. На дистанции его некому останавливать - кроме проверки, которая стоит снаружи и не забывает.

Разница появляется на уровне потока: изменений становится намного больше. Это меряют постоянно. Рандомизированный эксперимент с Copilot на 4 867 разработчиках: +26% PR в неделю (Cui, Demirer et al., Management Science). Исследование ~100 тысяч разработчиков GitHub: у автономных агентов +180% коммитов, но до релизов из этого доезжает только +30% — остальное тонет по дороге (NBER WP 35275).

Я проверил то же самое на своём корпусе. Взял репозитории, где появились агенты, сравнил активность до и после, а рядом — контрольную группу без агентов. Результат: после прихода агентов коммитов в 1.67 раза больше, строк на коммит — в 1.4 раза больше. В контрольной группе за то же время — без изменений.

Дрейф вносят и люди, и агенты. Ручное ревью его не ловит, а поток изменений растёт.

Как это работает в CI

Нормальный режим для такой проверки — не «прогнать весь проект и ужаснуться», а сравнить изменение с базой. В старом C++ проекте почти всегда уже есть циклы, странные include, god headers и исторические компромиссы. Проверять надо не абстрактную чистоту репозитория, а дельту конкретного PR. Поэтому важны baseline и diff-режим: они позволяют зафиксировать текущее состояние проекта и смотреть только то, что добавил этот PR, а не тонуть в старом мусоре.

Входной билет - zero config. Запустил на репозитории - получил список структурных проблем: циклы, копипаст, слишком длинные цепочки включений. Запустил в PR через --diff — увидел, что именно этот PR изменил в структуре.

Минимальный пример: три заголовка включают друг друга по кругу. archcheck называет цикл и возвращает код 1.

$ archcheck path/to/src
a.h: [SF.9] cycle: a.h → c.h → b.h → a.h

1 violation(s) (SF.9: 1)

На большом проекте вместе с циклами идут длинные include-цепочки, распухшие заголовки, using namespace в headers. А в PR - сравнение с базой:

archcheck --diff origin/main..HEAD .

Результат появляется там, где принимается решение: в CI, в PR comment, в step summary, в JSON для внутреннего пайплайна.

Живое демо

Отдельная демо-репа — реальный C-проект (monit) с 14 PR. Девять вносят копипасту (точную, целыми файлами, переименованную и частичную Type-3), пять только похожи на неё (перемещение кода, дубль ниже порога, реформат уже существующего клона — их детектор намеренно не трогает).

Когда PR вносит копипасту, archcheck --diff оставляет sticky-комментарий прямо в PR:

archcheck --diff
Gate: 2 advisory finding(s) · gate ok

Findings (advisory)
• util.c:1550 — DRIFT.NEW_CLONE — copy-paste introduced (STRUCTURAL): lines 1550-1558 — clone of util.c:1530-1535
• util.c:1550 — DRIFT.NEW_CLONE — copy-paste introduced (STRUCTURAL): lines 1550-1558 — clone of seed/widgets_copy.c:44-49

Каждая ссылка кликабельна - ведёт и на вставленный блок, и на его источник. Демо

Почему это не заменяет остальные инструменты

Задача archcheck узкая: проверить структурные последствия изменения.

Такого стража обычно вообще нет в CI. А когда агентская разработка увеличивает поток PR, отсутствие этого слоя становится заметнее.

что где
что где

Что дальше

Сегодня в .archcheck.yml настраиваются две вещи. Пороги правил: с какого числа входящих зависимостей заголовок считается распухшим, с какой длины include-цепочка — слишком длинной. И классификация файлов: какие каталоги считать вендором, тестами, генерёнкой, чтобы они не шумели в отчёте.

Следующий слой — правила про модули: «слой UI не лезет в storage», «эти два модуля не зависят друг от друга», «из A в B ходить нельзя». Формат для них уже есть, конфиг с такими правилами читается и проверяется на ошибки. Не хватает последнего шага — чтобы нарушение такого правила порождало находку. Это v0.2.

А дальше я хочу замкнуть контур, которого пока ни у кого не видел. Идея детерминированных архитектурных проверок против непредсказуемого агента уже носится в воздухе — в Java и TypeScript мире её собирают вокруг ArchUnit, агента там уже зовут помочь закодировать правила. Но сами правила человек по-прежнему формулирует из головы. Я хочу два недостающих куска. Первый - синтез: агент сам читает репозиторий, вытаскивает из кода негласные договорённости проекта и предлагает их как формальные правила, а архитектор утверждает. Второй - C++, где такого контура не было вовсе. В итоге агент помогает сформулировать правила, которые сам соблюдать не в состоянии - привет constraint decay. Поэтому правила живут не только в его контексте, а снаружи, в CI. Агент сам выковал пайплайн, который теперь заворачивает его собственные PR.

Попробовать

archcheck открытый, один статический бинарь — без зависимостей и без compile_commands.json.

  • Репозиторий и инструкции: https://github.com/blurman-ai/archcheck

  • CI-интеграция с готовыми сниппетами: docs/ci_usage.md в репозитории

Скачать свежий бинарь из releases, и в CI одной строкой:

archcheck --diff origin/main..HEAD .

Обратная связь

archcheck я с самого начала задумывал как открытый инструмент для команд, а не как поделку для себя. Поэтому мне очень важно, чтобы о нём узнали те, кому он нужен. Мне интереснее всего, как он поможет на больших зрелых проектах - с историей и накопленным легаси. Заводите issue, присылайте PR, пишите, если хотите обсудить или внедрить у себя. Интересно любое реальное применение в CI - и я буду рад помочь его настроить. Присылайте ссылки на свои репозитории, особенно с активной агентской разработкой. Прогоню покоммитный анализ архитектурного дрейфа, пришлю отчёт с тем, что нашлось в истории, и PR с настройкой archcheck в CI - дальше он будет ловить такое в каждом новом PR.