Правила архитектуры (Architecture)¶
Правила архитектуры выявляют структурные проблемы в кодовой базе, которые могут привести к кошмарам при поддержке. Эти проблемы часто незаметны в повседневной работе, но причиняют значительную боль, когда нужно провести рефакторинг, протестировать или развернуть части приложения независимо.
Циклические зависимости (Circular Dependencies)¶
Идентификатор правила: architecture.circular-dependency
Что измеряет¶
Обнаруживает ситуации, когда классы зависят друг от друга по кругу. Зависимость означает, что один класс использует другой (через внедрение в конструктор, вызовы методов, указания типов и т.д.).
Прямой цикл (размер 2):
OrderService использует PaymentService, а PaymentService использует OrderService. Ни один из них не может существовать без другого.
Транзитивный цикл (размер 3+):
A зависит от B, B зависит от C, а C зависит обратно от A. Петля длиннее, но проблема та же.
Почему это важно¶
Циклические зависимости вызывают реальные проблемы:
- Невозможно тестировать изолированно. Чтобы протестировать класс A, нужен класс B, которому нужен класс C, которому снова нужен A.
- Невозможно развертывать независимо. Если пакеты A, B и C образуют цикл, они должны всегда развертываться вместе.
- Жесткая связанность. Изменения в любом классе цикла могут сломать все остальные классы в цикле.
- Труднее понять. Нет четкого "верха" или "низа" -- нельзя читать код в линейном порядке.
Пороговые значения¶
| Тип цикла | Серьезность | Значение |
|---|---|---|
| Прямой (размер 2) | Error | Два класса напрямую зависят друг от друга |
| Транзитивный (размер 3+) | Warning | Более длинная цепочка классов образует петлю |
Примечание
Прямые циклы (A зависит от B, B зависит от A) по умолчанию отмечаются как Error, потому что они представляют наиболее жесткую связанность. Транзитивные циклы отмечаются как Warning, так как их обычно легче разорвать.
Как определяется идентичность цикла¶
У цикла нет естественной точки входа, поэтому один из его участников выбирается представителем: класс, чье полное имя идет первым в байтовом порядке (Beta раньше alpha, так как заглавные сортируются первыми). Нарушение сообщается на этом классе, отображаемый путь начинается и заканчивается на нем, и ключом записи baseline служит он.
Выбор сделан осознанно, а не случайно: он зависит только от имен классов, входящих в цикл, поэтому добавление или удаление посторонних файлов никогда не меняет ключ существующего цикла и не обесценивает его запись в baseline. Изменение состава самого цикла ключ поменять может -- этого не избежать при любом способе выбора представителя.
Представитель не является "причиной" цикла: все классы цикла участвуют в нем на равных. Учтите также, что отображаемый путь -- это кратчайшая петля через представителя, а не обход всех участников: класс, лежащий только на более длинном маршруте обратно, в него не попадет. Достоверный размер цикла -- счетчик (N classes) в тексте нарушения.
Как цикл представлен в отчете¶
Сообщение о нарушении и строка Cycle path: в рекомендации отображают путь короткой меткой для каждого класса: Circular dependency (N classes): A → B → A. Участник сохраняет свое короткое имя класса, если ни один другой участник цикла на это имя не заканчивается. Иначе метка удлиняется на целые сегменты namespace, пока не станет различающей, а если даже полное имя участника является суффиксом имени другого -- App\Log\Writer против Acme\App\Log\Writer или класс в глобальном namespace против однофамильца в namespace -- метка привязывается к корню: \App\Log\Writer, как это пишет сам PHP.
Например, цикл между App\Billing\Service и App\Orders\Service отображается как:
а не как бесполезное Service → Service → Service. Разрешение неоднозначности считается по всему составу цикла, а не по одной отображаемой петле, поэтому однофамилец, в петлю не попавший, все равно учитывается, а участник получает одну и ту же метку в любом отображении.
Для циклов категории large (21+ классов) сообщение обрезает отображаемый путь до первых 5 участников плюс ... (N more), а рекомендация обрезает еще сильнее, до 3, указывая на классы точки входа, с которых стоит начать. Петля, которая и так укладывается в лимит, печатается целиком -- отображаемая петля может быть намного короче цикла, которому принадлежит.
Машинные данные о цикле
Рекомендация также несет JSON-трейлер Cycle data:, предназначенный для потребления ИИ-агентами, а не для чтения. Его массив cycle использует полностью квалифицированные имена классов -- короткие метки, применяемые в остальном тексте, неоднозначны между namespace'ами и сделали бы автоматическую обработку бесполезной. length -- число различных классов; category -- small (2-5), medium (6-20) или large (21+).
Идентичность цикла -- символьный путь нарушения и ключ baseline -- от всего этого не зависит: она по-прежнему берется от представителя, описанного выше.
Настройки¶
| Опция | По умолчанию | Описание |
|---|---|---|
enabled |
true |
Включить или выключить правило |
maxCycleSize |
0 |
Максимальный размер цикла для отчета (0 = все размеры) |
directAsError |
true |
Считать прямые циклы (размер 2) ошибками |
ignoreRelations |
[] |
Виды зависимостей или алиасы, рёбра которых не образуют циклов (см. ниже) |
Пример конфигурации¶
# qmx.yaml
rules:
architecture.circular-dependency:
maxCycleSize: 5 # игнорировать очень большие циклы
directAsError: true # прямые циклы -- ошибки
ignoreRelations: [attribute_argument] # ссылки внутри аргументов атрибутов не образуют циклов
Игнорирование видов зависимостей¶
ignoreRelations принимает те же токены, что и relations: слоёв: вид зависимости (attribute_argument, new, ...) или алиас (inheritance, static_access, type_reference, runtime_check). Рёбра перечисленных видов не участвуют в поиске циклов. Ключи YAML ignore_relations и ignore-relations тоже принимаются; в командной строке:
vendor/bin/qmx check src/ --rule-opt='architecture.circular-dependency:ignore-relations=[attribute_argument]'
Типичный случай: сущность называет свой репозиторий в атрибуте (#[ORM\Entity(repositoryClass: UserRepository::class)]), а репозиторий зависит от сущности. Такая петля — маппинг, а не связанность времени выполнения, и attribute_argument убирает её из отчёта о циклах.
- Значение по умолчанию
[]— фильтра нет, вывод не меняется. - Значение должно быть списком: скаляр отклоняется («must be a list»). Неизвестный токен отклоняется по имени (код выхода 3).
- Пара классов остаётся ребром цикла, если хотя бы одна из её записанных зависимостей допущена. Сущность, которая ссылается на репозиторий и в атрибуте, и в коде, сохраняет цикл.
- Фильтр решает, какие циклы существуют, а не как опознаётся оставшийся: его представитель и ключ baseline по-прежнему зависят только от состава. Отображаемый путь строится только по допущенным зависимостям.
maxCycleSizeиdirectAsErrorсудят отфильтрованные циклы. Компонента из трёх классов после игнорирования ребра может стать прямым циклом и будет сообщена как ошибка.
Находки о циклах сейчас относятся ко всему проекту и остаются видимыми в
Git-отчётах, включая --report-strict, независимо от изменённых файлов.
Пример¶
// OrderService.php
class OrderService
{
public function __construct(
private PaymentService $paymentService, // зависит от PaymentService
) {}
public function createOrder(Cart $cart): Order
{
$order = new Order($cart);
$this->paymentService->charge($order);
return $order;
}
public function getOrderTotal(int $orderId): float
{
// ...
return $total;
}
}
// PaymentService.php
class PaymentService
{
public function __construct(
private OrderService $orderService, // зависит от OrderService -- ЦИКЛ!
) {}
public function charge(Order $order): void
{
$total = $this->orderService->getOrderTotal($order->id);
// обработка платежа...
}
}
OrderService зависит от PaymentService, а PaymentService зависит от OrderService. Это прямой цикл размера 2.
Как исправить¶
-
Введите интерфейс (инверсия зависимостей). Пусть один класс зависит от абстракции, а не от конкретного класса:
interface OrderTotalProviderInterface { public function getOrderTotal(int $orderId): float; } class OrderService implements OrderTotalProviderInterface { public function __construct( private PaymentService $paymentService, ) {} public function getOrderTotal(int $orderId): float { /* ... */ } } class PaymentService { public function __construct( private OrderTotalProviderInterface $totalProvider, // нет цикла! ) {} } -
Вынесите общую логику в третий класс. Если обоим классам нужны одни и те же данные, извлеките их:
-
Используйте события. Вместо прямых вызовов генерируйте событие, на которое подписывается другой сервис:
Совет
Используйте опцию maxCycleSize, чтобы сначала сосредоточиться на самых критичных циклах. Прямые циклы (размер 2) легче всего исправить и они наиболее вредны. Начните с них, затем переходите к более крупным циклам.
Нарушения слоёв (Layer Violations)¶
Идентификатор правила: architecture.layer-violation
Что измеряет¶
Обнаруживает зависимости между именованными слоями проекта, которые не разрешены архитектурной политикой.
Слои объявляются как упорядоченный список записей name/patterns. Каждый класс проекта относится не более чем к одному слою на основании совпадения по неймспейсу — если FQN класса совпадает с паттернами нескольких слоёв, побеждает первый по порядку объявления (тот же механизм, что в deptrac, ArchUnit, .gitignore, Apache). Для каждой грани графа зависимостей (extends, implements, тип-хинт, вызов метода и т.д.) правило вычисляет слой источника и слой цели; если грань пересекает два объявленных слоя и allow-list политики этого направления не разрешает — фиксируется нарушение.
Концы вне слоёв (класс, не подходящий ни под один объявленный шаблон) по умолчанию молча игнорируются — это позволяет внедрять правило постепенно: начать с самых важных слоёв и расширять покрытие со временем.
Почему это важно¶
Слоистая архитектура — это контракт: каждому слою разрешено зависеть от фиксированного набора других. Когда контракт размывается, проблемы накапливаются:
- Реализация просачивается через границы. Контроллеры лезут в репозитории, сервисы обходят домен, репозитории вызывают инфраструктуру. Каждый "сокращённый путь" облегчает следующий.
- Рефакторинг становится опасным. Перенос класса ломает код там, где никто не ожидал. "Радиус поражения" растёт неограниченно.
- Тесты перестают быть изолированными. Юнит-тесту сервиса вдруг требуется слой контроллеров из-за случайной зависимости вверх по стеку.
- Архитектурные документы лгут. На диаграмме написано "Controller -> Service -> Repository", а реальные грани образуют сетку. Новички сначала учат диаграмму, потом учат, что кодовая база её игнорирует.
Объявление слоёв в YAML и их проверка в CI превращают архитектурную диаграмму в то, что сборка может верифицировать.
Настройки¶
architecture.layers — это упорядоченный список записей слоёв. У каждой записи есть name и список patterns. Если FQN класса совпадает с паттернами нескольких слоёв, побеждает первый по порядку объявления — тот же механизм, что в deptrac, ArchUnit, .gitignore, Apache.
# qmx.yaml
architecture:
layers:
- name: controller
patterns: ['App\Controller\**']
- name: service
patterns: ['App\Service\**']
- name: repository
patterns: ['App\Repository\**']
- name: domain
patterns: ['App\Domain\**']
- name: doctrine
patterns: ['Doctrine\**'] # вендорный слой как полноправный
allow:
controller: [service] # контроллерам можно только сервисы
service: [domain, repository] # сервисам можно репозитории и домен
repository: [domain, doctrine] # репозиториям -- домен и Doctrine
domain: [] # домен самодостаточен
# Необязательно. Что делать с гранями, источник или цель которых не попали ни в один слой.
# См. раздел "Режимы покрытия (coverage)".
coverage-gap: ignore
Архитектурные шаблоны используют собственный закрытый binding DSL, а не публичный язык exact | subtree | regex. FQN без wildcard выбирает включающее поддерево; * и ? остаются внутри одного сегмента неймспейса, ** может пересекать сегменты, а {module} / {path:**} захватывают значения для шаблонных слоёв. Character classes и raw PCRE отвергаются. Зависимости внутри одного слоя всегда разрешены (изоляция подмодулей намеренно вынесена за рамки MVP).
Порядок и catch-all-идиома. Порядок объявления значим. Сначала указывайте узкие слои, потом широкие — App\Service\Internal\** до App\Service\**. Чтобы захватить всё оставшееся, объявите финальный слой с паттерном **:
architecture:
layers:
- name: service
patterns: ['App\Service\**']
- name: catchall
patterns: ['**'] # ловит каждый оставшийся класс
allow:
service: [catchall]
catchall: []
Catch-all-слой заменяет старую идиому coverage-gap: warn для сценария "покажи всё, что я ещё не классифицировал". Механизм architecture.coverage-gap по-прежнему работает (см. "Режимы покрытия" ниже), но при наличии catch-all-слоя он обычно не нужен.
Семантика слияния YAML. Если и пресет, и проектный конфиг определяют architecture.layers, последний источник заменяет весь список целиком — порядок задаёт намерения пользователя, а слияние двух упорядоченных списков тихо это намерение бы разрушило. Карта architecture.allow сливается по имени слоя-источника, а список целей одного слоя-источника целиком заменяется более поздним источником, который его написал, — включая [], что не разрешает этому слою ничего. Верхний источник не может удалить запись нижнего. Каждое имя источника в allow сверяется со слоями, которые объявляет layers, после слияния всех источников, поэтому файл может разрешать слой, объявленный его пресетом; имя, записанное с ~, всё равно проверяется. Скаляры вроде architecture.coverage-gap переопределяются поздним источником. Общие для всех ключей конфигурации правила — в разделе Как сливаются слои.
Пример конфигурации с вендорным и общим слоями¶
architecture:
layers:
- name: domain
patterns: ['App\Domain\**']
- name: app
patterns: ['App\Application\**']
- name: infra
patterns: ['App\Infrastructure\**']
- name: web
patterns: ['App\UserInterface\Web\**']
- name: cli
patterns: ['App\UserInterface\Cli\**']
- name: symfony
patterns: ['Symfony\**']
- name: doctrine
patterns: ['Doctrine\**']
allow:
domain: []
app: [domain]
infra: [domain, app, doctrine]
web: [app, symfony]
cli: [app, symfony]
# symfony и doctrine отсутствуют -- это "листовые" вендорные слои, обходить которые никто не вправе
Architecture membership patterns и публичные селекторы взаимно отвергают
чужую форму. Здесь пишите patterns: ['App\Domain'], а для graph/discovery/
suppression используйте явный subtree:App\Domain. Mapping {subtree: ...}
не является membership pattern; строка Architecture DSL не заменяет
публичный exact | subtree | regex selector. Allow selectors — отдельный
язык имён слоёв с собственными captures и bindings.
У template с match: any каждый положительный patterns должен давать
capture tuple; captureless pattern отвергается. У match: all captureless
pattern допустим как дополнительное условие. Static layers не получают
этого template-ограничения.
Принадлежность за пределами namespace-паттернов¶
У принадлежности к слою шесть видов критериев. Значения внутри одного вида
объединяются через OR; match: any (по умолчанию) принимает совпадение любого
вида, а match: all требует всех записанных видов. Пропущенный вид не добавляет
условия.
| Критерий | Срабатывает, когда… |
|---|---|
patterns |
Наблюдённый FQN класса совпадает с паттерном Architecture DSL. |
suffix |
Короткое имя класса оканчивается перечисленным буквальным суффиксом. |
attributes |
Заголовок собственного объявления класса несёт перечисленный FQN атрибута. |
member_attributes |
Собственный метод, свойство, параметр, константа класса, enum case или property hook (включая его параметр) несёт перечисленный FQN атрибута. |
implements |
Перечисленный интерфейс входит в замыкание супертипов класса или интерфейса. |
extends |
Перечисленный класс входит в цепочку родителей; для интерфейса — перечисленный интерфейс входит в замыкание интерфейсов-родителей. |
attributes и member_attributes разделены: метод с #[\Override] не
удовлетворяет классовому attributes. Promoted-параметр может нести и
параметрический, и property site; сайты вложенного callable или анонимного
класса не становятся фактами объявления охватывающего именованного класса.
Звено наследования вне анализируемых путей читается из точно размещённого исходника Composer-установки анализируемого проекта без загрузки или исполнения файла. Неотображённое, непрочитанное, условное или неразрешённое объявление остаётся обрывом. Каждая ветвь родителей/интерфейсов проходит не более 256 звеньев; дальнейшая связь сохраняется как обрыв. Найденное до обрыва совпадение остаётся совпадением; отсутствие критерия, который обрыв может скрыть, остаётся неразрешимым. Внешнее наследование не доказывает собственные атрибуты внешнего класса.
Неразрешённый слой не отзывает позднее совпадение, а неразрешённый exclude:
не отзывает совпадение своего слоя. Назначение сохраняется вместе с
architecture.doubted-assignment. Когда ни один слой не
совпал, но один не смог ответить, coverage и debug сообщают undecided, а не
«неклассифицирован». patterns и suffix читают имя и разрешимы всегда.
Наследование встроенных типов PHP берётся из поставляемой таблицы независимо
от расширений раннера. Enum включает UnitEnum, backed enum — ещё и
BackedEnum. Неявный Stringable выводится из фактов объявлений, родителей,
интерфейсов и вложенных trait use: класс с __toString() из трейта совпадает,
а сам трейт Stringable не становится. Интерфейсы включают Stringable в оба
интерфейсных замыкания; классы — через implements. Неразрешённая trait
adaptation, дающая методу псевдоним __toString, оставляет этот вопрос под
сомнением. Выведенные факты не создают dependency edges и не увеличивают coupling.
Все шесть видов принимают строку или список. Атрибутные критерии и критерии
наследования требуют FQN; глобальное имя пишется с ведущим слешем, например
extends: '\Exception'. Ведущий слеш убирается. Встроенные типы PHP сохраняют
свою идентичность независимо от регистра; ваши и вендорные FQN критериев
сравниваются точно с наблюдённым каноническим написанием объявления. Ошибочный
регистр может получить did-you-mean, но не принимается молча.
# Дружественный к миграции дефолт (match: any)
- name: repository
patterns: ['App\Repository\**']
suffix: ['Repository']
implements: ['Doctrine\Persistence\ObjectRepository']
# Принадлежит, если класс живёт в App\Repository ИЛИ оканчивается на Repository,
# ИЛИ реализует ObjectRepository.
# Строгая конвенция (match: all)
- name: command-handler
match: all
attributes: ['App\Messenger\AsCommandHandler']
suffix: ['Handler']
patterns: ['App\Handler\**']
# Принадлежит только если все три условия выполнены одновременно.
# Сочетание extends и implements
- name: domain-aggregate
match: all
extends: ['App\Domain\AggregateRoot']
implements: ['App\Domain\HasIdentity']
Опущенный критерий считается тривиально удовлетворённым при match: all — не нужно писать пустое patterns: [] чтобы его выключить. Имена атрибутов должны быть полностью квалифицированы (парсер откажется от голого Entity); implements и extends обходят цепочку супертипов, поэтому объявление базового интерфейса или класса покрывает всех потомков без перечисления.
Вложенные анонимные классы
Факты объявления принадлежат именно объявляющему классу. Заголовок или члены вложенного анонимного класса не назначают охватывающий класс; обычные dependency edges при этом остаются в графе.
Шаблонные слои¶
Перечислять domain-Order, domain-Inventory, domain-Billing, … в YAML перестаёт масштабироваться, как только в проекте появляется больше горстки bounded contexts. Phase 2 позволяет одной записи слоя нести переменную захвата (capture variable) в имени и паттернах; после фазы Collection движок проходит по обнаруженному множеству классов, фиксирует, какие наборы значений (binding tuples) реально появляются, и создаёт по одному конкретному слою на каждый набор — никогда не делая декартова произведения.
architecture:
layers:
- name: 'domain-{module}'
patterns: ['App\Module\{module}\Domain\**']
- name: 'app-{module}'
patterns: ['App\Module\{module}\Application\**']
- name: shared-kernel
patterns: ['App\Shared\**']
allow:
'domain-*': [shared-kernel]
'app-*':
- 'domain-*' # ПЕРМИССИВНО — любой app-* может зависеть от любого domain-*
- shared-kernel
Конкретные слои из шаблона появляются на позиции шаблона в объявленном списке, в лексикографическом порядке захваченных значений. Селекторы в allow-list для развёрнутых слоёв используют существующую glob-форму ('domain-*': [...]).
Грамматика переменных захвата¶
- Ссылка имеет вид
{name}, гдеnameсоответствует[A-Za-z_][A-Za-z0-9_]*(как PHP-идентификатор). Имена регистрозависимы. - Захваченное значение по умолчанию матчит один сегмент namespace —
[^\\]+, без обратных слэшей. Регистр сохраняется ровно в том виде, в котором значение появляется в FQN класса. - Для многосегментного захвата используйте явную форму
{name:**}— она матчит один или более сегментов. - Cross-segment-захват (
{name:**}) можно использовать в паттернах и relations, но нельзя встраивать в имя слоя: развёрнутое имя должно соответствовать[A-Za-z][A-Za-z0-9_-]*, а многосегментное значение содержит\. Для встраивания в имя слоя используйте{name}(односегментный захват). - Переменные в шаблоне имени ОБЯЗАНЫ присутствовать как минимум в одном capture-producing критерии. Повторное использование одной и той же переменной в разных критериях привязывает её к одному значению (co-binding в пределах записи слоя).
- Переменные в разных записях слоёв независимы — глобального namespace переменных нет.
- Имена слоёв и паттерны не могут содержать литеральные
*,?,[,{,}вне синтаксиса селектора — эти символы зарезервированы. - Несбалансированные скобки (
'domain-{module') отклоняются на этапе загрузки конфигурации сConfigLoadException, а не молча трактуются как exact-match.
Same-instance allows (capture-binding в allow-list)¶
Wildcard-allow вида 'app-*': ['domain-*'] позволяет app-Order зависеть от каждого domain-X, разрушая изоляцию bounded contexts. Phase 2 вводит capture-binding для этого случая:
allow:
'app-{m}':
- 'domain-{m}' # только same-{m} — app-Order может использовать domain-Order, НЕ domain-Inventory
- shared-kernel
{m} со стороны источника устанавливает binding; {m} со стороны цели требует то же захваченное значение. Имя переменной локально для записи — {m} здесь не связан ни с каким {m} в других местах.
Запись с wildcard на обеих сторонах вроде 'domain-*': ['domain-*'] всё ещё легальна, но поднимает configuration-load warning через user logger — почти наверняка вы имели в виду 'domain-{m}': ['domain-{m}']. Чтобы заглушить warning, когда all-to-all действительно намерен, переключитесь на long-form и поставьте allow_cross_instance: true:
allow:
'domain-*':
- target: 'domain-*'
allow_cross_instance: true # подтверждение — любой domain-* может зависеть от любого domain-*
Exact-граф allow должен быть ацикличным¶
При загрузке конфигурации Qualimetrix проецирует каждую allow-запись с exact
источником и exact целью в объявленный граф слоёв. Этот граф обязан быть DAG.
Exact-ссылка на себя, взаимная пара или более длинный направленный цикл сразу
завершаются ConfigLoadException; анализ не запускается. Это проверка
объявленной топологии модулей, отдельная от
architecture.circular-dependency, который ищет фактически существующие циклы
между классами.
allow:
application: [domain]
domain: [application] # отклонено: application -> domain -> application
Раньше exact-ссылки на себя молча удалялись, а взаимные exact-разрешения давали
только warning. Удалите избыточные self-edge. Для цикла удалите или перенаправьте как
минимум одно allow-ребро, чтобы направление зависимостей модулей стало
ацикличным. Разные фильтры relations: не делают встречные разрешения
ацикличными.
Glob- и captured-селекторы не проецируются в этот статический граф. Их конкретные слои могут появиться только после observation-driven раскрытия шаблонов, поэтому проекция текста селектора создала бы вымышленные рёбра. Wildcard-записи, похожие на self-allow, остаются легальными и сохраняют warning, описанный выше.
Лимиты раскрытия¶
Кумулятивное раскрытие по всем шаблонам ограничено architecture.max_expanded_layers (по умолчанию 500). Патологически широкие шаблоны, превышающие предел, отклоняются на стадии раскрытия с понятной ошибкой (шаблон, итоговое количество, текущий предел). Поднимайте предел явно, когда монорепо легитимно содержит больше bounded contexts, чем дефолт:
Семантические заметки — match: any | all и не-pattern-критерии¶
Шаблонный слой не может сочетать не-pattern-критерий с match: any. Переменные захвата несут только patterns, поэтому suffix, attributes, member_attributes, implements и extends копируются в каждый развёрнутый слой дословно. Под match: all это ровно то, что нужно: критерий сужает каждый инстанс внутри области, которую уже задал его собственный подставленный паттерн. Под match: any он вместо этого объединяется с этим паттерном через OR, поэтому каждый инстанс несёт одну и ту же проектную сеть, и первый инстанс в порядке раскрытия — а это алфавит значений биндинга, а не то, что вы написали, — забирает себе всё, что сеть поймала. Поэтому конфигурация отвергается при загрузке:
Configuration error: architecture.layers[0] ("aggregate-{module}"): "suffix" cannot be
combined with "match: any" on a template layer. Only "patterns" carry the capture
variables, so it would be copied into every expanded layer unchanged and, OR-ed with
the substituted pattern, would make every instance claim the same classes project-wide
— the instance that wins one is then decided by binding-value order rather than by the
declaration. Add "match: all" so the criterion narrows each instance, or declare a
static layer if the criterion really is meant to apply project-wide.
Это отказ, а не тихое сужение: привязать критерий к собственному паттерну инстанса означало бы сделать его подмножеством этого паттерна, то есть инертным — выражение, которое выглядит работающим и не делает ничего. Добавьте match: all, если хотели сузить каждый инстанс, или объявите статический (нешаблонный) слой, если критерий действительно задуман как проектный.
| Mode | Capture-producing-паттерны | Не-pattern-критерии (suffix / attributes / member_attributes / implements / extends) |
|---|---|---|
any (дефолт) |
Хотя бы один должен совпасть для биндинга | Отвергаются — конфигурация не загружается |
all |
Каждый capture-producing-паттерн должен совпасть (биндинги согласованно объединяются) | Каждый объявленный не-pattern-критерий тоже должен совпасть — AND-фильтр поверх биндингов |
Паттерны без захватов на шаблоне требуют match: all. Они фильтруют наблюдённые bindings до раскрытия; match: any отклоняет их вместо расширения паттерна без tuple. Значения развёрнутого вида patterns сохраняют OR-семантику. Задай match: all, сочетая capturing scope с непаттерновыми критериями:
- name: 'aggregate-{module}'
match: all
patterns: ['App\Module\{module}\Domain\**']
suffix: ['Aggregate']
# Tuple наблюдается только для модулей с классом, который совпадает И с
# capture-паттерном, И с суффиксом `Aggregate`.
Исключение поддеревьев внутри слоя (exclude:)¶
Слой может нести блок exclude: той же формы, что и положительные критерии (patterns, suffix, attributes, member_attributes, implements, extends). Классы, попавшие под exclude-блок, удаляются из слоя независимо от положительной принадлежности — exclude: это жёсткий фильтр, который запускается после положительных критериев.
- name: service
patterns: ['App\Service\**']
exclude:
patterns: ['App\Service\Legacy\**']
suffix: ['LegacyService']
match: any # дефолт — класс исключается, если совпал ХОТЯ БЫ ОДИН exclude-критерий
Блок exclude:, на который прогон не может ответить, — критерий extends / implements / attributes для класса, чья цепочка наследования выходит за paths, — класс не удаляет: он остаётся в слое, а сомнение публикуется так, как описано в разделе Назначения под сомнением. Удаление оставило бы его рёбра без проверки allow-списком — это худшая из двух ошибок. О таком блоке не сообщается и как о не убравшем ничего — см. Когда clause не убирает ничего.
exclude.match: all тоже поддерживается — полезно для узких случаев «исключить суффикс X только внутри namespace Y». Блок, в котором ничего не записано, — exclude: {}, exclude: [] или критерии, записанные только как ~, — ничего не исключает, как и любая другая пустая карта в конфигурации; блок, в котором записан только match, или критерий, записанный пустым списком (patterns: []), — ошибка конфигурации. Для шаблонных слоёв exclude-критерии могут ссылаться на те же переменные захвата, что и имя слоя (exclude: { patterns: ['App\Module\{module}\Generated\**'] }) — они фильтруют внутри same-binding-инстанса. Exclude не может вводить новые переменные захвата, не появляющиеся в имени слоя.
При declaration-order matching того же эффекта часто можно добиться, объявив более узкий слой раньше. exclude: правильный инструмент, когда исключённое поддерево должно остаться по-настоящему неклассифицированным (чтобы провалиться в catch-all или диагностику покрытия) или когда положительные критерии смешивают patterns с suffix/implements/extends и одна ранняя запись не может чисто выразить вырез.
Классы, которые убрал clause, достаются следующему совпавшему с ними слою, и этот слой может объявить тот же паттерн. Это простейшая форма выреза:
- name: repo-plain
patterns: ['App\Repository\**']
exclude:
extends: ['Doctrine\ORM\EntityRepository']
- name: repo-doctrine
patterns: ['App\Repository\**'] # получает ровно то, что исключил repo-plain
В остальных случаях два слоя с одинаковым паттерном отклоняются при загрузке: первый забирает каждый класс, который называет паттерн, и второму не достаётся ни одного. Место освобождает только exclude: более раннего слоя — собственный exclude: более позднего сужает то, что берёт он сам, а не то, что уже взял ранний, — и ещё ранний слой с match: all, сужающий паттерн другим критерием. Третий слой с тем же паттерном отклоняется за слоем, получившим вырезанные классы. Досталось ли получателю хоть что-то — факт о коде, и о нём во время прогона сообщает architecture.unreachable-layer. Слои, которые загрузка принимает на повторённом паттерне, — ровно те, о которых не сообщает architecture.potential-shadow: проигрывать классы, оставшиеся у раннего слоя, — это то, ради чего получатель объявлен.
Когда clause не убирает ничего¶
exclude:, не совпавший ни с одним классом, — это молчаливый ущерб: слой
держит всё, что поймали его положительные критерии, то есть больше, чем просит
декларация, и каждый вывод об этом слое — какие рёбра ему разрешены, какое
покрытие он закрывает — сделан по более широкому множеству.
architecture.unmatched-exclude сообщает об этом с severity warning, по
одной находке на декларацию:
The "exclude" clause of layer "service" (patterns: "App\Service\Legacy\**")
removed no class from it, while the layer's own criteria (patterns:
"App\Service\**") matched 12 symbol(s).
Обычные причины: переименованный namespace, под который clause не обновили, опечатка в паттерне и вырез, чьи классы удалили при рефакторинге.
Чего канал намеренно не делает:
- Он ничего не говорит о слое, чьи собственные критерии не совпали ни с чем.
Clause вычисляется только после успеха положительных критериев, поэтому там
счётчик нулевой по другой причине — а такой слой уже сообщает
architecture.unreachable-layer. Слой сpending: trueпропускается по той же причине, по которой его пропускает та диагностика. - Он сообщает про clause, а не про отдельный критерий. При дефолтном
match: anyclause, где сработалsuffix, аpatternsне сработали ни разу, классы всё же убрал, и про этот паттерн канал молчит. - Clause шаблона судится один раз — по всем слоям, в которые он
развернулся. Один
exclude:подdomain-{module}становится слоем на модуль, и clause, вырезающий классы в одном модуле, делает свою работу и там, где другому модулю вырезать нечего. Удаление, которое советовала бы находка на модуль, сломало бы модуль, где clause работает. Поэтому счётчики суммируются: находка появляется, только если clause не убрал ничего нигде, а шаблон совпал хоть где-то. - Он не судит clause, на который прогон не смог ответить. Исключение по
extends,implementsилиattributesне имеет ответа для класса, чья цепочка наследования выходит заpaths. Не доказано, что такой clause не убирает ничего: класс, о котором он не смог ответить, может оказаться ровно тем, ради которого clause написан. Поэтому, пока хоть один пойманный слоем класс оставляет его без ответа, о clause не сообщается, и удалить его никто не советует. Оставленное им сомнение показывается в разделе Назначения под сомнением. - Он судится только при полной измеренной области деклараций. Вместе с
architecture.unreachable-layerиarchitecture.empty-templateон спрашивает отсутствие деклараций. Пропущенные PHP, авторское снятие PHP, generated-исключения и неизвестный состав кода удерживают ответ. Полный поимённый список PHP может покрыть пути, но снятие PHP всё ещё удерживает этот вопрос. Без пригодного Composer-состава целый корень может установить полноту; произвольная часть не считается всем проектом. Отчёт называет причины в охвате проекта.
Это обычная проектная находка producer architecture.layer-declaration: она учитывает fail_on, точный выбор канала и baseline. Inline-директива не подавляет проектный aggregate без объявления-субъекта. Выключение architecture.layer-violation её не выключает.
Слой, заделанный впрок (pending:)¶
Слой, который намеренно ни с чем не совпадает — граница модуля, объявленная до того, как модуль написан, или слой, временно опустошённый рефакторингом в полёте, — иначе публиковал бы architecture.unreachable-layer на каждом прогоне. Вместо ослабления диагностики объявите намерение:
pending: true подавляет architecture.unreachable-layer только для этого слоя. Больше не меняется ничего: рёбра allow-list, покрытие, гейт неотнесённых объявлений и любая другая диагностика ведут себя ровно так, как если бы ключа не было. Значение должно быть настоящим булевым — что угодно иное является ошибкой конфигурации, а не «истинной» строкой, — и ключ отвергается на шаблонном слое: его инстансы существуют только потому, что tuple был замечен в анализируемом коде, и потому всегда с чем-то совпадают. Шаблон, развернувшийся в ноль инстансов, — это architecture.empty-template, до которой pending намеренно не дотягивается.
Флаг — не бессрочный отказ от проверки. Как только критерии слоя с чем-нибудь совпадут, об этом скажет architecture.pending-layer-matched.
Ограничение разрешённых зависимостей по типу связи (relations:)¶
Allow-list Phase 1 отвечает «может ли A зависеть от B?» через yes/no. Long-form-цель allow в Phase 2 добавляет необязательный whitelist relations:, ограничивающий, как зависимость может быть выражена.
allow:
domain:
- target: contracts
relations: [implements, extends] # только наследование — никаких method calls или инстанцирования
- target: vendor
relations: [extends] # только сабклассинг вендорных типов
Голые allow-записи (allow: { domain: [contracts] }) сохраняют семантику «любая связь разрешена» — полная обратная совместимость.
Доступные токены связи приходят из двух источников. Прямые значения зеркалят Qualimetrix\Analysis\Evidence\DependencyModel\Contract\DependencyType:
extends, implements, trait_use,
new,
static_call, static_property_fetch, class_const_fetch,
type_hint, property_type, constant_type,
catch, instanceof,
attribute, attribute_argument
Алиасы — это сокращения configuration-уровня, раскрывающиеся в составляющие прямые значения:
| Алиас | Раскрывается в |
|---|---|
inheritance |
extends, implements, trait_use |
static_access |
static_call, static_property_fetch, class_const_fetch |
type_reference |
type_hint, property_type, constant_type |
runtime_check |
catch, instanceof |
attribute и attribute_argument стоят особняком — ни в какую группу они не входят. Алиасы и прямые значения можно смешивать в одном relations:-списке; после раскрытия дубликаты убираются. Прямые значения валидируются против DependencyType::cases() рефлективно, поэтому добавление нового вида зависимости в коллектор автоматически становится принимаемым в YAML без релиза.
Анонимный класс, вложенный в именованный, вносит свои рёбра extends,
implements, trait_use и attribute в фильтрацию relations: точно так
же, как это делали бы рёбра именованного класса, с охватывающим классом в
качестве источника ребра — relations: ограничивает как может быть
выражена зависимость, и этот вопрос не зависит от того, принадлежит ли цель
охватывающему классу или анонимному классу, вложенному в него. Это
единственное место, где два случая остаются симметричными: принадлежность
(выше) трактует их по-разному, relations: — нет.
relations: ~ — то же, что не писать relations:: цель разрешает любую связь.
Когда несколько allow-целей в одном источнике резолвятся в один и тот же целевой слой (например, через перекрывающиеся glob-селекторы), их разрешения объединяются (UNION). Если хоть одна совпадающая запись использует bare/short-form (без relations:), объединение становится «все связи разрешены» — short-form доминирует.
Замечание. В коллекторе сейчас нет вида связи instance method-call — только
static_call. Отслеживайте instance-вызовы через более широкий алиасtype_reference, если ваша политика должна их ограничивать.
Type shape не является relation token. Nullable, union, intersection и DNF
сохраняют позицию: параметр/return — type_hint, property или promoted
parameter — property_type, typed constant — constant_type. Graph JSON всегда публикует shape как object, сопоставляющий каждой позиции
отсортированные наблюдённые формы (single, nullable, union,
intersection, dnf), например {"property_type": ["nullable"],
"type_hint": ["union"]}. У ребра без type-shape фактов — shape: {}.
union_type и intersection_type больше не принимаются в relations:.
У аргументов атрибутов собственный вид связи¶
Всё, на что ссылаются внутри аргументов атрибута — Repo::class, Foo::BAR, case перечисления, new Foo(), а также сигнатура и тело замыкания или стрелочной функции там (PHP 8.5), — записывается как attribute_argument («uses in attribute argument») вместо new, class_const_fetch и т. п. Имя самого атрибута сохраняет вид attribute; extends, implements и trait_use сохраняют свои виды; типовые рёбра внутри аргументов не несут shape. Значения связанности (CBO, Ce/Ca, instability, ClassRank, связанность пространств имён) не меняются: они считают различные цели, а не рёбра.
attribute_argument не входит ни в один алиас. Список relations:, который раньше допускал такую ссылку через static_access, new или другой алиас, теперь даёт architecture.layer-violation, например Layer "domain" must not depend on layer "infra" (App\Domain\E → App\Infra\Repo, uses in attribute argument). Чтобы разрешить её, добавьте attribute_argument в relations:. У затронутых записей baseline меняется edge.type, поэтому каждая выглядит как одна устаревшая и одна новая запись; см. Baseline.
Режимы покрытия (coverage)¶
architecture.coverage-gap определяет, что делать, когда анализируемый логический
класс не относится ни к одному объявленному слою или когда у грани зависимости
не классифицирован источник либо цель. Изолированные анализируемые классы
учитываются даже без единого ребра зависимости.
Диагностика конфигурации, а не долг кода
architecture.coverage-gap — одна из пяти архитектурных диагностик, которые
сигнализируют об ошибке в самой конфигурации архитектуры, а не о долге в
анализируемом коде: остальные четыре — architecture.unreachable-layer,
architecture.pending-layer-matched, architecture.potential-shadow и
architecture.empty-template. Все
пять валят прогон безусловно, как только срабатывают: fail_on для них
не учитывается вообще, даже fail_on: none, и ни одну из пяти нельзя
принять в baseline или заглушить @qmx-ignore. Опция severity на любой из
них выглядела бы переключателем поведения, ничего при этом не меняя, —
поэтому её ни у одной нет. Что остаётся, чтобы отказаться от них:
coverage-gap: ignore конкретно для этой диагностики и блок exclude: внутри
слоя. Сам architecture.layer-violation этим не затронут — он сообщает о
реальном долге кода и остаётся подавляемым и бейзлайнящимся как обычно.
| Режим | Поведение |
|---|---|
ignore (default) |
Классы вне слоёв и неклассифицированные концы граней молча пропускаются. Позволяет внедрять правило постепенно, без шума. |
warn |
Одна сводная диагностика architecture.coverage-gap за прогон с severity Warning, со списком примеров неклассифицированных классов. Валит прогон, как только срабатывает. |
error |
То же самое, но с severity Error. Валит прогон, как только срабатывает — так же, как warn; выбирайте его, чтобы отразить fail-closed намерение прямо в конфиге. |
Сообщение диагностики выглядит так:
Architecture coverage-gap: 12 edge(s) with unmatched source layer, 5 edge(s) with unmatched target layer,
3 class(es) outside all declared layers.
Examples of unclassified classes: App\Legacy\Foo, App\Legacy\Bar, App\Legacy\Baz. ...
Когда часть пропуска не была разрешена, об этом говорит второе предложение, называющее примеры:
Architecture coverage-gap: 2 edge(s) with unmatched source layer, 2 edge(s) with unmatched target layer,
3 class(es) outside all declared layers. 2 of them could not be decided: a declared
"extends"/"implements"/"attributes" criterion reads facts this run did not collect, because the symbol
or a link in its inheritance chain is outside the analysed paths — for example App\Web\OrderController,
Vendor\Lib\Middle.
Когда о пропуске сообщается и при этом часть назначений стоит на слое, на который прогон не смог полностью ответить, — более раннем слое, оставшемся без ответа, или собственном exclude: назначенного слоя, — ещё одно предложение считает их:
2 assigned class(es) rest on a layer the run could not fully decide — for example App\Domain\LegacyGateway,
App\Web\OrderController.
В пропуск они не входят. Сами по себе они диагностику не вызывают никогда, а рекомендация отсылает к architecture.doubted-assignment, который сообщает о них отдельно.
Рекомендация при этом говорит, что каждая правка делает с неразрешимой частью, — одним предложением для анализируемых классов и одним для символов вне анализируемых путей, и каждое только если такой символ среди них есть:
For the undecided ones, a layer declared after the one that could not answer (a catch-all included)
covers them, but as a guess: each may belong to the unanswered layer. For an analysed class, widening
paths to include the declaration where its chain stops decides it — "qmx debug:layer-assignment <class>"
names that declaration. A symbol outside the analysed paths is decided by analysing it when it is your
own code — widening paths to include it — and otherwise by a patterns layer for its namespace declared
before the layer that could not answer.
Чтобы убрать диагностику для известного набора неклассифицированных классов, объявите catch-all слой, покрывающий их (или согласитесь с пропуском, оставив coverage-gap: ignore). Catch-all глушит и неразрешимую часть — неразрешимый слой не мешает совпасть более позднему, — но глушит он её, назначая такие классы в catch-all, а это догадка, а не ответ. Дальше класс судится allow-списком catch-all, хотя на деле может принадлежать слою, оставшемуся без ответа. Чтобы назначение было верным, проанализируйте объявление, на котором цепочка обрывается: расширьте paths, если это ваш собственный код. Вендорный тип, который сам оказался неразрешимым (дальний конец ребра зависимости), решается patterns-слоем для его неймспейса, объявленным до слоя, не сумевшего на него ответить. debug:layer-assignment называет место обрыва цепочки и в любом случае продолжает называть неразрешённый слой рядом с назначением.
Назначения под сомнением¶
Неразрешённый слой никогда не отзывает совпадение, поэтому символ может стоять в слое, пока слой, от которого зависит назначение, остался без ответа: слой, объявленный до назначенного, чей критерий extends / implements / attributes не удалось разрешить, или собственный exclude: назначенного слоя. Назначение остаётся, его рёбра судятся; прогон не может сказать лишь одного — изменил бы его слой, оставшийся без ответа, или нет. Слой, объявленный после назначенного, сомнением не считается — побеждает первое совпадение, так что владеть символом он не мог, — кроме случая, когда без ответа остался собственный exclude: назначенного слоя: блок может убрать символ, и тогда им владеет более поздний слой.
architecture.doubted-assignment считает такие символы, а рядом с ними — символы, которые не попали ни в один слой только потому, что какой-то слой не смог о них ответить: их рёбра не судит ни один allow-список, а принадлежать они могут этому слою. Это сведение, а не пропуск: находка выдаётся с severity info, поэтому прогон она не валит никогда, что бы ни говорил fail_on, и публикуется при любом режиме coverage-gap — она говорит о том, верно ли членство, а не о том, сколько кода покрывают слои. Одна находка за прогон разбивает каждый счёт на анализируемые классы и символы вне анализируемых путей, называет каждый слой, не сумевший ответить, с числом оставленных им под сомнением символов, и приводит примеры каждого вида — не больше 10 на вид, по имени:
3 assigned symbol(s) rest on a layer the run could not fully decide (1 analysed class(es), 2 outside the
analysed paths). Layers that could not answer: "models" (3 assigned in doubt). Examples, at most 10 of
each kind by name — analysed: App\Web\OrderController; outside the analysed paths:
Symfony\Component\HttpFoundation\Request, Symfony\Component\HttpFoundation\Response. Each assignment
stands and its edges are judged against its layer's allow-list; whether a layer the run could not answer
— an earlier one, or the assigned layer's own "exclude" — would change it is unknown.
Когда неразрешённый exclude: удерживает символ, которым владел бы более поздний слой, если бы блок этот символ убрал, такой слой тоже называется — отдельным списком: Layers that would own some of them if an unanswered "exclude" removed them: "repos" (1 assigned in doubt). Вместе два списка называют каждый слой, который architecture.unreachable-layer пропускает из-за сомнения.
Рекомендация называет только то, что относится к присутствующим видам:
- Для анализируемого класса
debug:layer-assignmentназывает слой без ответа и место обрыва цепочки; расширениеpathsдо этого объявления снимает сомнение. - Для символа вне анализируемых путей всё зависит от того, чей это код. «Вне анализируемых путей» — факт о прогоне: прогон по одному каталогу оставляет снаружи и собственные классы проекта, а их сомнение снимает анализ — расширение
pathsили прогон по всему проекту. Класс зависимости — обычно вендорный класс на дальнем конце ребра — решаетсяpatterns-слоем для его неймспейса, объявленным до слоя, не сумевшего ответить.debug:layer-assignmentне поможет ни в том, ни в другом случае: класс, который прогон не анализировал, команда отвергает, — поэтому слои называет сама находка.
Прогон по части проекта поэтому даёт другие сомнения, чем прогон по всему проекту, а не меньше: собственный класс, который полный прогон решает, может оказаться под сомнением, если прогон его не читал.
Больше всего сомнений даёт самая обычная раскладка: extends:-слой над вендорным базовым классом объявлен первым, а patterns-слои для своего и вендорного кода — после него. Тогда под сомнением каждый вендорный класс на конце ребра: extends:-слой не может ответить о классе, который прогон не читал. Если объявить вендорные patterns-слои первыми, эти сомнения уходят. Раскладка без сомнений вовсе объявляет extends:-слой последним, а его популяцию вырезает из более ранних слоёв через exclude:, на который прогон всегда может ответить (patterns или suffix).
Канал публикует architecture.layer-violation, поэтому отключение этого правила глушит и его. Чтобы отказаться только от этого канала, перечислите architecture.doubted-assignment в disabled_rules (или передайте --disable-rule=architecture.doubted-assignment).
Неотнесённые объявления¶
Rule ID: architecture.unassigned-class
Это правило отвечает на вопрос, который
architecture.coverage-gap выразить не может: каждое ли объявление, которое я
проанализировал, отнесено к слою? Coverage считает ещё и концы рёбер
зависимостей, а среди них есть классы вне paths: — Symfony\...,
PHPUnit\..., — которые ни один слой классифицировать не может, так что его
число определяется чужим кодом. Этот гейт считает только проанализированные
class-like объявления: классы, интерфейсы, трейты и енумы, которые прогон
действительно измерил. Объявление, для которого ни один коллектор не записал
метрик уровня класса, в множество не входит и считается отнесённым.
Это отдельное правило, по умолчанию неактивное из-за mode: ignore.
Владелец объявляет mode, а общий enabled управляет допуском producer:
Оно читает тот же единственный обход классов и рёбер зависимостей, что и
architecture.layer-violation, поэтому включение не добавляет прогону обхода.
Общий enabled и собственный mode отвечают за допуск producer и возможность
публикации. enabled: false с mode: warn законно и выключает producer;
явный enabled: true с mode: ignore отказывает как включение неактивного producer.
| Режим | Поведение |
|---|---|
ignore (по умолчанию) |
Множество даже не собирается. Диагностики нет. |
warn |
Один сводный violation за прогон с severity Warning и примерами неотнесённых объявлений. |
error |
Та же диагностика с severity Error. |
В отличие от пяти архитектурных диагностик конфигурации, эта сообщает об
обычном долге: она проходит через fail_on как обычно и может быть принята в
baseline. При этом @qmx-ignore до неё всё равно не дотягивается — но по
другой причине, чем до них: это один сводный результат за прогон, отнесённый к
проекту, а не к файлу или объявлению, поэтому инлайн-директиву негде поставить
так, чтобы она его адресовала. Написанный в исходнике @qmx-ignore
architecture.unassigned-class диагностику не убирает и сам превращается в
annotation.unused-directive. Чтобы отказаться от гейта, поставьте
mode: ignore или накройте объявления слоем.
Метрическое значение — абсолютный счёт неотнесённых
объявлений, и именно это делает baseline полезным: процент, который тоже
печатается в сообщении, стоял бы на месте, пока счёт растёт вместе с проектом,
поэтому снижать по ратчету можно только счёт. CLI-алиас —
--unassigned-class-mode.
Сообщение диагностики выглядит так:
3 of 240 analysed class-like declaration(s) (1.3%) are not assigned to any declared layer.
Unassigned declarations: App\Legacy\Bar, App\Legacy\Baz, App\Legacy\Foo. ...
Активный architecture.unassigned-class (mode: warn|error и enabled: true)
требует непустых architecture.layers, включая случай явно записанного [].
Отказ до Discovery называет источник значения. mode: ignore, enabled: false
или итоговый выбор, не запускающий этот producer, этого требования не создаёт.
Диагностика недостижимого слоя¶
architecture.unreachable-layer сообщает о слое, который не владеет ни одним
анализируемым классом и не мог бы получить класс из неразрешённого назначения.
Она называет полную потерю из-за порядка, собственные исключения, совпадения только
внешних типов и неизвестные именованные типы по фактической причине.
pending: true освобождает этот слой. Именованные типы судятся по отдельности:
известный сосед не скрывает опечатку. member_attributes здесь тоже отдельный
критерий.
Vendor-наследование и traits обходятся по точно размещённым объявлениям, читаемым
как данные. Доступная цепочка может установить совпадение; недостающие,
конфликтующие или обрезанные факты остаются неразрешёнными и могут удержать слой
анализируемого класса под сомнением. Само существование внешнего типа не доказывает
атрибуты анализируемого класса. Сомнения объясняет
architecture.doubted-assignment.
Диагностика требует полной измеренной области деклараций, как и
architecture.unmatched-exclude. Пропущенные PHP,
авторское снятие PHP, generated-исключения и неизвестный состав кода удерживают
утверждение об отсутствии. Целый корень может установить полноту без пригодного
Composer-состава, произвольная часть — нет. Отчёт называет измеренные причины,
а не выводит разрешение только из report state.
Это диагностика конфигурации (см. врезку в разделе Режимы покрытия): она валит прогон безусловно, как только срабатывает, и не настраивается, не бейзлайнится и не подавляется @qmx-ignore. Три возможные причины:
- Слой перекрыт более широким, объявленным раньше. Паттерн вроде
'**'или'App\**'перед более узким захватывает все классы первым. - Паттерн не совпадает ни с одним классом в анализируемой кодовой базе и ни разу не встречается как конец ребра зависимости. Слой объявлен для неймспейса, которого ещё нет — или неймспейс был переименован.
- DTO-слой без исходящих зависимостей, в котором пока нет классов. Подсчёт попаданий идёт как по всем проанализированным классам, так и по концам рёбер графа зависимостей (слою источника и слою назначения каждой зафиксированной зависимости), поэтому слой, которому не соответствует ни один проанализированный класс, но который встречается как один из концов ребра зависимости — например, вендорный неймспейс вне
paths:вродеClickHouseDB\**, видимый только как ЦЕЛЬ зависимости, — засчитывается как достигнутый и не порождает эту диагностику. Этот случай возникает только тогда, когда слой реально не совпадает ни с классом, ни с концом ребра.
Для развёрнутых из шаблона слоёв per-instance вариант означает, что конкретный binding tuple был создан, но все классы-кандидаты для этого инстанса перекрыты более ранним слоем или удалены блоком exclude:.
Используйте qmx debug:layer-assignment <class>, чтобы инспектировать конкретные классы при разборе.
Слой, пустой намеренно — объявленный раньше модуля, который он описывает, — ни одна из этих причин не покрывает: объявите его pending: true, и диагностика его пропустит.
Диагностика сработавшего задела¶
architecture.pending-layer-matched публикуется один раз на каждый слой, объявленный pending: true, чьи критерии совпали хотя бы с одним классом или концом ребра зависимости. Это диагностика конфигурации (см. врезку в разделе Режимы покрытия): она валит прогон безусловно, как только срабатывает, и не настраивается, не бейзлайнится и не подавляется @qmx-ignore.
Она существует потому, что pending: true выключает защитную сеть, а выключенная защитная сеть обязана быть временной. Без этой диагностики флаг продолжал бы подавлять architecture.unreachable-layer и после того, как код наконец появился, — и слой до конца жизни проекта молча перестал бы проверяться на опечатки и затенение.
Совпадение засчитывается, даже если слой его не выиграл. Слою-заделу, все классы которого захватывает более широкий слой, объявленный раньше, не назначено ничего, поэтому подсчёт назначений дал бы ноль — ровно в том случае, где декларация врёт громче всего: код есть, а слой, который должен им владеть, затенён. Поэтому диагностика считает все совпадения, выигранные и проигранные. Исправление тогда состоит из двух правок: убрать pending: true и поднять слой выше более широкого.
Диагностика пустого шаблона¶
architecture.empty-template публикуется один раз на каждый шаблонный слой, развернувшийся в ноль конкретных инстансов, когда измеренный охват допускает отсутствие деклараций. Типичные причины — опечатка в паттерне или односегментный {var} там, где binding занимает несколько namespace-сегментов (используйте {var:**}).
Как и architecture.unreachable-layer, диагностика требует полной измеренной области деклараций; пропущенные PHP, авторское или generated-снятие и неизвестный состав кода удерживают её.
Шаблон, развернувшийся в ноль инстансов, тихо отключает связанную с ним политику, поэтому — как и четыре другие диагностики конфигурации — он валит прогон безусловно, а не ждёт severity или настройки fail_on; см. врезку в разделе Режимы покрытия. Три типичные причины:
- Опечатка в шаблоне паттерна.
App\Modul\{module}\Domain\**вместоApp\Module\{module}\Domain\**— ни один класс не совпадает, ни один инстанс не создаётся. - Кандидатного binding tuple нет в измеренной области. Авторское/generated снятие PHP или неполные пути удерживают эту диагностику, вместо доказательства отсутствия модуля.
suppress_pathsограничивает публикацию, а не анализ классов. - Односегментный захват, охватывающий разделители namespace.
App\{path}\Domain\**, гдеpathдолжен захватитьModule\Order(два сегмента). Переключитесь на{path:**}, чтобы разрешить cross-segment-захваты.
Диагностика потенциального затенения¶
architecture.potential-shadow — ошибка конфигурации для сравнимого
поддерева namespace, объявленного за более широким слоем, забирающим его классы.
Пары строятся только между установленными совпадениями; неразрешённый
exclude: не устанавливает затенение. Одна occurrence на пару
(assigned, shadowed) содержит детерминированную выборку до пяти классов.
Три исключения первого совпадения имеют явные имена:
| Исключение | Значение |
|---|---|
narrower-declared-first |
Более узкое объявленное поддерево namespace стоит перед широким, включая финальный catch-all. |
receives-what-it-leaves |
Равный паттерн получает классы, оставленные ранним exclude: или слоем match: all. |
non-pattern-precedence |
Суффиксы, атрибуты, наследование и прочие несравнимые критерии разрешаются порядком объявления. |
Третье — обычный приоритет, а не ошибка конфигурации. Когда достижимый слой
с непаттернным критерием теряет часть классов из-за раннего слоя,
architecture.layer-overlap сообщает об этом с фиксированным info один
раз на пару. Для слоя, не получившего ничего, недостижимость не дублируется.
Если приоритет нежелателен, переставьте или уточните критерии;
debug показывает точное исключение для класса.
Невстреченные типы критериев¶
architecture.unmatched-type — обычная проектная находка warning от
architecture.layer-declaration. Каждый записанный тип attributes,
member_attributes, implements, extends и их аналогов в exclude:
судится отдельно. Известное имя рядом с опечаткой её не скрывает. Разные
авторские позиции дают разные occurrences; развёртка одного значения шаблона
его не умножает, даже если шаблон не создал ни одного инстанса. Сообщение
называет источник конфигурации, путь и доступную строку, с did-you-mean при
наличии наблюдённого или точно размещённого написания. Occurrence
идентифицируется только типом и путём: записавший его файл, импортировавший
файл и строка его не идентифицируют, поэтому перенос критерия в
импортированный файл или добавление пресета не делают запись baseline
устаревшей.
Отсутствие требует полной измеренной области деклараций и прочитанной
Composer-установки. При суженной области или непрочитанной установке находки
нет; если выбран этот точный проектный канал, stderr вместо неё содержит одну
строку с одной либо обеими причинами. Чужой --only-rule, выключение канала,
его project-уровня, producer или architecture.* удерживают и эту строку.
Если все типы встречены, одна непрочитанная установка предупреждения не даёт.
Unreachable-layer и unmatched-exclude сохраняют собственные условия и получают
советы о написании; pattern-советы ограничены буквальным поддеревом namespace
и хвостом \**, который сохраняется.
Диагностики объявления¶
Rule ID: architecture.layer-declaration
У producer собственный enabled, без опций severity и числовых порогов. Он
владеет пятью каналами ошибок конфигурации (architecture.coverage-gap, architecture.unreachable-layer,
architecture.pending-layer-matched, architecture.potential-shadow, architecture.empty-template) и четырьмя
обычными (architecture.unmatched-exclude, architecture.doubted-assignment, architecture.layer-overlap,
architecture.unmatched-type). Опубликованная ошибка
конфигурации всегда валит прогон; baseline и inline-подавление её не принимают.
Вместе с architecture.layer-violation и architecture.unassigned-class
политика слоёв публикует 11 каналов.
architecture.unmatched-exclude, architecture.unreachable-layer и
architecture.empty-template заканчивают сообщение указанием конфигурации,
записавшей architecture.layers (Written by configuration file "…" (imported by
configuration file "qmx.yaml").); architecture.unmatched-type называет автора и
путь своими словами. Слои из общей базы, установленной или нет, судятся:
architecture.layers, записанный в вашем файле, целиком заменяет список базы.
| Дверь выбора | Пять ошибок конфигурации | Четыре обычных канала |
|---|---|---|
Выключить architecture.layer-violation |
Остаются включены через declaration producer | Остаются включены через declaration producer |
| Выбрать только чужое правило | FilterExempt: публикуются | Не публикуются |
| Выключить диагностический канал или его project-уровень | FilterExempt: публикуются | Не публикуются |
Выключить architecture.* |
Declaration producer выключен: не публикуются | Не публикуются |
Выключить architecture.layer-declaration или его enabled |
Не публикуются | Не публикуются |
architecture.layer-violation владеет только запрещёнными рёбрами, а независимый
architecture.unassigned-class — счётом назначений. Политика готовится для
любого запускающегося из этих трёх producers; обход evidence общий. Выбор
не отменяет собственное условие измеренной области канала. Обычные проектные
диагностики можно выключить или принять в baseline; inline-директиве у таких
проектных находок нет объявления-субъекта, до которого она могла бы дотянуться.
Инспекция назначения слоя для одного класса¶
Команда выполняет Discovery и Collection и использует ту же подготовленную
политику, что анализ. Поиск охватывает наблюдённые объявления и концы графа
зависимостей, а не все классы Composer-установки. Варианты ASCII-регистра PHP
разрешаются в наблюдённое каноническое написание; parser-observed имена с
high bytes тоже принимаются. До Collection отвергается только пустое
нормализованное написание. Любое другое неизвестное имя после поиска даёт exit 3,
даже при выключенной политике. Информационный ответ о наблюдённом имени даёт exit 0.
JSON заменяет невалидные байты UTF-8 на U+FFFD и добавляет invalidUtf8Replaced,
число исправленных строк. При валидном UTF-8 это поле отсутствует.
Для проекта со слоем service (App\Service\**) перед rest (App\**)
JSON имеет следующую форму:
{
"meta": {
"version": "dev-main",
"package": "qmx",
"timestamp": "2026-10-07T13:52:15+00:00",
"docs": "https://qualimetrix.dev",
"llmsTxt": "https://qualimetrix.dev/llms.txt"
},
"fqn": "App\\Service\\UserService",
"assigned": {
"layer": "service",
"criteria": [
"pattern \"App\\Service\\**\""
]
},
"contendingMatches": [],
"shadowed": [
{
"layer": "rest",
"criteria": [
"pattern \"App\\**\""
],
"reported": false,
"exemption": "narrower-declared-first"
}
],
"shadowedBy": "service",
"undecided": [],
"contenders": [],
"chainStopsAt": [],
"hasLayers": true,
"policyDisabled": false,
"edgeEndOnly": false
}
meta содержит tool/version, timestamp и адреса документации. fqn —
наблюдённое написание, а не написание запроса. edgeEndOnly равен true,
когда имя встретилось только в графе, не среди анализируемых объявлений.
assigned: null при непустом undecided означает «прогон не смог решить»,
а не «ни один слой не претендует». contenders называет возможных владельцев,
chainStopsAt — непрочитанные звенья. hasLayers отличает пустую политику
от неклассифицированного наблюдённого имени.
shadowed содержит установленные поздние совпадения; shadowedBy — первое
установленное совпадение, которое может отличаться от предварительного назначения.
Каждый shadow verdict несёт reported и при исключении — точное значение
exemption из таблицы выше. contendingMatches содержит остальные совпадения,
чьё владение зависит от неразрешённых критериев, с reported: false.
Text печатает те же факты, включая First-match exemptions:; hint о
potential-shadow появляется только для установленной докладываемой тени.
Если итоговый enablement не запускает ни один из трёх layer-policy producers,
policyDisabled равен true. Известные имена по-прежнему разрешаются, но text
пишет Architecture layer policy is disabled in this configuration. без
diagnostic/docs hint. JSON сохраняет наблюдённые факты и не добавляет reported
и exemption к shadow/contending matches. Настройте producer options в YAML;
debug-команда не добавляет --disable-rule команды check. При --format=json
ошибка заменяет ответ стандартным error envelope.
Настройки¶
| Опция | По умолчанию | Описание |
|---|---|---|
enabled |
true |
Включить/выключить правило. При выключении правило не обходит граф зависимостей. Также правило является no-op, если architecture.layers пуст. |
severity |
warning |
Severity для каждого зарегистрированного architecture.layer-violation. Допустимо: info, warning, error. |
Находки о нарушениях слоёв относятся к объявлению-источнику. Git-отчёт,
включая --report-strict, сохраняет их только при изменении source-файла.
Изменение только цели не сохраняет исходящее нарушение.
Пять архитектурных диагностик конфигурации — architecture.coverage-gap,
architecture.unreachable-layer, architecture.pending-layer-matched,
architecture.potential-shadow и
architecture.empty-template — не имеют собственных опций severity: они
валят прогон безусловно, минуя fail_on. См. врезку в разделе
Режимы покрытия.
У architecture.unmatched-exclude её тоже нет, но по
обратной причине: это обычная находка с фиксированным warning, и валить ли
из-за неё прогон, решает fail_on.
У architecture.doubted-assignment её нет потому, что
это сведение с фиксированным info, до которого не дотягивается ни один порог
fail_on.
CLI-алиасы: --layer-violation переключает опцию enabled,
--layer-violation-severity задаёт severity, — как и у других правил
архитектуры. Гейт неотнесённых объявлений уехал в собственное правило со своим
алиасом --unassigned-class-mode — см. Неотнесённые объявления.
Пример¶
Запрещено — контроллер обращается напрямую к репозиторию:
// src/Controller/UserController.php
namespace App\Controller;
use App\Repository\UserRepository; // ПЛОХО: controller -> repository
use Symfony\Component\HttpFoundation\Response;
final class UserController
{
public function __construct(private UserRepository $users) {}
public function show(int $id): Response
{
return new Response($this->users->find($id)->getName());
}
}
С политикой controller: [service] это даёт по одному нарушению на каждое место использования (тип-хинт в конструкторе плюс любые вызовы методов) под architecture.layer-violation.
Разрешено — пройти через сервисный слой:
// src/Controller/UserController.php
namespace App\Controller;
use App\Service\UserPresenter; // OK: controller -> service
use Symfony\Component\HttpFoundation\Response;
final class UserController
{
public function __construct(private UserPresenter $presenter) {}
public function show(int $id): Response
{
return new Response($this->presenter->render($id));
}
}
// src/Service/UserPresenter.php
namespace App\Service;
use App\Repository\UserRepository; // OK: service -> repository
final class UserPresenter
{
public function __construct(private UserRepository $users) {}
public function render(int $id): string
{
return $this->users->find($id)->getName();
}
}
Подавление нарушений¶
@qmx-ignore для класса или метода работает так же, как и для любого другого правила:
/**
* Временный шорткат на время внедрения нового презентера.
*
* @qmx-ignore architecture.layer-violation reason="legacy hotfix, см. тикет #1234"
*/
final class LegacyAdminController
{
public function __construct(private UserRepository $users) {}
// ...
}
Чтобы подавить нарушение слоя, адресуйте точный канал: @qmx-ignore architecture.layer-violation на объявлении-источнике подавляет его исходящие проекции. Директива на цели их не подавляет и может быть unused, когда прогон способен судить её область. architecture.layer-violation.* не совпадает ни с чем; architecture.* затрагивает и независимую circular-dependency. Проектные diagnostics выбираются отдельно, как показано в таблице дверей.
Отклонение от оригинальной спецификации
Сопоставление слоёв логическое, а субъект находки — точное объявление-источник.
Для цели без своего объявления создаётся одна находка; для объявленной цели —
по одной проекции на её точное объявление. Эти единицы счёта имеют общую
occurrence identity из точного источника, логической цели и вида зависимости;
выбор или переименование файлов цели её не меняет. Физический файл и число
объявлений неизвестной цели не выводятся. Суженная популяция Run сохраняет
найденный accepted level как not-compared с обычной severity.
Symbol/namespace controls судят источник каждой проекции.
Next-line/file controls читают физическое место зависимости. Повторные
одинаковые рёбра сохраняют одну count-bounded baseline identity; перенос
строки представления не меняет её.
Глобальные и per-rule suppress_namespaces / suppress_paths применяются к источнику нарушения. Путь сравнивается с физическим dependency site, namespace — с объявлением-источником. Исключение только цели не подавляет нарушение. Объявленные project-scoped каналы, в том числе циклы и diagnostics, сохраняют исключение из глобальных фильтров. Форма на уровне правила ограничивает подавление явно названным producer:
rules:
architecture.layer-violation:
suppress_namespaces:
- subtree: App\Legacy
suppress_paths:
- subtree: src/Legacy
Фреймворк (RuleOptionsBuild) извлекает suppress_namespaces / suppress_paths для producer, которые отчитываются о коде, до построения их Options. Исключение — producer, все каналы которого project-scoped (architecture.circular-dependency, architecture.layer-declaration, architecture.unassigned-class): непустой список на нём отказывается при загрузке (exit 3), потому что находки таких правил относятся к проекту и не адресуются ни путём, ни неймспейсом. Для циклов сузить, что считается ребром, можно через ignore-relations (см. выше); что ещё применимо — сказано на странице правила. Такие per-rule исключения учитываются и показываются как исключения других правил; см. «Видимость».
Особенности реализации¶
- Шесть критериев принадлежности, дефолт
match: any. Принадлежность определяется черезpatterns,suffix,attributes,member_attributes,implements,extends— комбинируются для каждой записи черезmatch: any(дефолт) илиmatch: all. Дефолт даёт правилу шанс встретить унаследованный код, где конвенции имён и неймспейсов непоследовательны. См. ADR 0059. - Один слой на класс, declaration-order-сопоставление. Каждый класс попадает не более чем в один слой. Если паттерны двух слоёв подходят к одному классу, побеждает слой, объявленный раньше в
architecture.layers(тот же механизм, что у deptrac, ArchUnit,.gitignore, Apache). Никакой specificity нет — порядок и есть инструмент пользователя для выражения намерений, и движок его не оспаривает. См. ADR 0006. - Шаблоны разворачиваются по наблюдаемым binding tuples, после Collection. Шаблонный слой вроде
'domain-{module}'разворачивается стадиейLayerExpansionStage(между Collection и RuleExecution), производя один конкретныйLayerDefinitionна каждый binding tuple, реально наблюдаемый в коде, — никогда декартова произведения различных значений. Capture-binding в allow-list ('app-{m}': ['domain-{m}']) поставляется в том же релизе, что и сами шаблоны, не как follow-up. См. ADR 0059. relations:— это whitelist; алиасы раскрываются рефлективно. Long-form-цели allow принимают списокrelations:, ограничивающий, какие видыDependencyTypeразрешены. Прямые значения валидируются противDependencyType::cases()рефлективно, поэтому добавление нового вида зависимости в коллектор автоматически становится принимаемым в YAML.forbid_relations:нет — whitelist-only исключает неоднозначность резолвинга и стоимость поддержки параллельного enum.- Вендорные неймспейсы — полноправные слои. Паттерны имён работают на внешних концах рёбер.
extendsиimplementsобходят точно размещённые установленные объявления как данные; недостающие, конфликтующие и обрезанные ветви остаются неразрешёнными. Собственные атрибуты внешнего неанализируемого объявления не выводятся из ancestry. Поставь namespace-pattern fallback перед неразрешённым критерием, если это нужная политика. - Зависимости внутри одного слоя всегда разрешены в MVP. Изоляция подмодулей внутри слоя отложена на Phase 2.
- Точный источник и логическая цель occurrence. Запрещённая зависимость принадлежит точному объявлению источника. Без принадлежащих проекту целей создаётся одна единица счёта; иначе — по единице на точное объявление цели, с общей occurrence из источника, логической цели и вида зависимости. Суженная популяция не ужесточает принятое количество.
- Концы вне слоёв молча игнорируются для целей layer-violation. Их количество отдельно публикуется через режим
coverage-gap, который делит его на два: классы, о которых каждый объявленный критерий ответил «нет», и классы, для которых какой-то критерий вообще не удалось разрешить. Назначения, стоящие на слое, на который прогон не смог полностью ответить, в пропуск не входят; их считаетarchitecture.doubted-assignment. - Включено по умолчанию, но без слоёв ничего не делает. Правило стартует с
enabled: trueи сразу выходит изanalyze(), еслиarchitecture.layersпусто. Поэтому проекты без архитектурной конфигурации не платят за наличие правила. - Защитные сети, а не ошибки неоднозначности. Прежний алгоритм на основе specificity отбраковывал неоднозначные конфигурации при загрузке. При declaration-order-сопоставлении неоднозначности не существует — порядок её разрешает — но пользователь всё ещё может перепутать порядок. Две диагностики ловят это:
architecture.unreachable-layer(слой ничего не захватил) иarchitecture.potential-shadow(более ранний слой тихо отобрал классы у более позднего). Обе — диагностики конфигурации: они валят прогон безусловно и не имеют опции severity (см. врезку в разделе Режимы покрытия). См. отдельные разделы выше.
Ограничения и планы¶
- Нет
forbid_relations:. Phase 2 — whitelist-only:relations:перечисляет, что разрешено, всё остальное неявно запрещено. Ключевое словоforbid_relations:отклоняется как избыточное; если возникнет реальный запрос, его можно добавить позже без поломки whitelist-пользователей. - Нет вида связи instance method-call. Коллектор отслеживает
static_call, но не вызовы методов экземпляра. Используйте более широкий алиасtype_reference, если ваша политика должна ограничивать instance-зависимости. Подключение instance-call-связи требует расширения коллектора и является кандидатом для Phase 3. - Нет per-edge severity. Allow-записи не несут поля
level:— каждое layer-violation использует общую опциюseverityправила. Обходной путь: разбить политику на два именованных правила с разной severity, если нужна более тонкая градация. - Изоляция подмодулей отложена. Сейчас нельзя запретить грани внутри одного слоя. Шаблонные слои уменьшают потребность (
domain-{m}производит по одному слою на модуль, поэтому cross-module-грани естественно cross-layer), но флагallow_same_layer: falseвсё ещё запланирован для команд, которым нужны границы внутри слоя.
Источники¶
Для пользователей, переходящих с отдельных архитектурных инструментов:
- deptrac — ближайший аналог. После Phase 2 Qualimetrix покрывает ту же территорию для распространённых случаев: multi-criterion-принадлежность (
patterns+suffix+attributes+implements+extends), шаблонные слои с capture-binding для DDD bounded contexts, исключение поддеревьев внутри слоя и whitelistrelations:на allow-целях. Поверхность всё ещё меньше deptrac (один allow-list на слой-источник, нет полного predicate-DSL), но правило закрывает long-tail без второго инструмента в CI. - ArchUnit — вдохновение из Java-мира, модель "архитектура как тест". Capture-binding-форма allow (
'app-{m}': ['domain-{m}']) концептуально похожа на ArchUnitslices(). На PHP модель ложится не хуже.
Для обоснования текущей политики слоёв — почему шаблоны разворачиваются по наблюдаемым binding tuples, почему capture-binding обязателен, почему relations: whitelist-only — см. ADR 0059: Declared-Layer Policy and Architecture Governance.
Включение и reportable mode¶
Unassigned-class принимает общий enabled наряду с mode. enabled:false с mode:warn законно и выключено; explicit enabled:true с mode:ignore отказывает. Для отчёта задай warn/error или убери exact enable. Shared layer evidence подготавливается, когда нужен любому законно активному producer; disable соседа этот producer не выключает. Layer-violation сохраняет собственный default-enabled/no-layers short circuit. Формы конфигурации проверяются до discovery.