Правила архитектуры (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, указывая на классы точки входа, с которых стоит начать. Петля, которая и так укладывается в лимит, печатается целиком -- отображаемая петля может быть намного короче цикла, которому принадлежит.
Info
Рекомендация также несет JSON-трейлер Cycle data:, предназначенный для потребления ИИ-агентами, а не для чтения. Его массив cycle использует полностью квалифицированные имена классов -- короткие метки, применяемые в остальном тексте, неоднозначны между namespace'ами и сделали бы автоматическую обработку бесполезной. length -- число различных классов; category -- small (2-5), medium (6-20) или large (21+).
Идентичность цикла -- символьный путь нарушения и ключ baseline -- от всего этого не зависит: она по-прежнему берется от представителя, описанного выше.
Настройки¶
| Опция | По умолчанию | Описание |
|---|---|---|
enabled |
true |
Включить или выключить правило |
maxCycleSize |
0 |
Максимальный размер цикла для отчета (0 = все размеры) |
directAsError |
true |
Считать прямые циклы (размер 2) ошибками |
Пример конфигурации¶
# qmx.yaml
rules:
architecture.circular-dependency:
maxCycleSize: 5 # игнорировать очень большие циклы
directAsError: true # прямые циклы -- ошибки
Пример¶
// 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
Шаблоны поддерживают и префиксное сопоставление (без подстановок, например App\Controller), и glob-сопоставление (*, **, ?, […]). Зависимости внутри одного слоя всегда разрешены (изоляция подмодулей намеренно вынесена за рамки 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 по-прежнему сливается по слою-источнику, а скаляр 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 отсутствуют -- это "листовые" вендорные слои, обходить которые никто не вправе
Принадлежность за пределами namespace-паттернов¶
Phase 1 определяла принадлежность к слою исключительно по совпадению FQN класса с patterns. Phase 2 добавляет ещё четыре критерия — suffix, attributes, implements, extends — и переключатель match: any | all, управляющий их комбинированием. По умолчанию any, чтобы правило встречало унаследованный код там, где конвенции непоследовательны (*Repository, живущий в App\Service\, всё равно остаётся репозиторием).
| Критерий | Срабатывает, когда… |
|---|---|
patterns |
FQN класса соответствует одному из перечисленных glob-паттернов (поведение Phase 1). |
suffix |
Короткое имя класса оканчивается одной из перечисленных строк (например, Repository, Controller). |
attributes |
Класс помечен одним из перечисленных PHP-атрибутов по FQN (с учётом use-statement-резолвинга). |
implements |
Класс реализует один из перечисленных интерфейсов по FQN — напрямую или транзитивно. |
extends |
Один из перечисленных классов по FQN присутствует где-либо в цепочке родителей. |
Внутри одного критерия списки всегда объединяются как OR (attributes: [A, B] означает «имеет A или B»). match управляет тем, как комбинируются критерии разных видов.
Сокращение для одного значения. Любой из пяти критериев принимает одиночное значение вместо списка из одного элемента — suffix: 'Repository' равносильно suffix: ['Repository']. То же сокращение работает и внутри блока exclude: (exclude: { suffix: 'Bridge' }). Каждый критерий по-прежнему проверяет форму значения: attributes / implements / extends требуют FQN (значение с \), suffix его отвергает, а patterns допускает и то, и другое.
# Дружественный к миграции дефолт (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 обходят цепочку супертипов, поэтому объявление базового интерфейса или класса покрывает всех потомков без перечисления.
Шаблонные слои¶
Перечислять 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-критерии¶
Раскрытие шаблонов mode-aware для не-pattern-критериев (suffix, attributes, implements, extends) и согласовано с runtime-семантикой принадлежности, описанной в разделе Принадлежность за пределами namespace-паттернов.
| Mode | Capture-producing-паттерны | Не-pattern-критерии (suffix / attributes / implements / extends) |
|---|---|---|
any (дефолт) |
Хотя бы один должен совпасть для биндинга | Опциональны — расширяют принадлежность, никогда не сужают её. Класс, давший биндинг через capture-pattern, производит tuple независимо от объявленных не-pattern-критериев |
all |
Каждый capture-producing-паттерн должен совпасть (биндинги согласованно объединяются) | Каждый объявленный не-pattern-критерий тоже должен совпасть — AND-фильтр поверх биндингов |
Изменение поведения. До 0.18 раскрытие игнорировало
matchдля не-pattern-критериев и трактовало их как AND независимо от режима. Подmatch: anyконфиги с непустымиsuffix/attributes/implements/extendsтеперь могут производить больше конкретных слоёв, чем раньше. Пределarchitecture.max_expanded_layersзащищает от непреднамеренного взрыва — поднимите его явно, если проект реально производит больше bounded contexts, чем разрешает дефолт.
Non-capture паттерны (обычные globs без {var}-плейсхолдеров) продолжают работать как чистый AND-фильтр независимо от режима — они описывают, где живёт слой, и никогда не расширяют принадлежность. Чтобы получить строгую membership-семантику в шаблоне, объявите match: all:
- name: 'aggregate-{module}'
match: all
patterns: ['App\Module\{module}\Domain\**']
suffix: ['Aggregate']
# Tuple наблюдается только для модулей с классом, который совпадает И с
# capture-паттерном, И с суффиксом `Aggregate`.
Исключение поддеревьев внутри слоя (exclude:)¶
Слой может нести блок exclude: той же формы, что и положительные критерии (patterns, suffix, attributes, implements, extends). Классы, попавшие под exclude-блок, удаляются из слоя независимо от положительной принадлежности — exclude: это жёсткий фильтр, который запускается после положительных критериев.
- name: service
patterns: ['App\Service\**']
exclude:
patterns: ['App\Service\Legacy\**']
suffix: ['LegacyService']
match: any # дефолт — класс исключается, если совпал ХОТЯ БЫ ОДИН exclude-критерий
exclude.match: all тоже поддерживается — полезно для узких случаев «исключить суффикс X только внутри namespace Y». Блок должен содержать как минимум один критерий (пустой exclude: — ошибка конфигурации). Для шаблонных слоёв exclude-критерии могут ссылаться на те же переменные захвата, что и имя слоя (exclude: { patterns: ['App\Module\{module}\Generated\**'] }) — они фильтруют внутри same-binding-инстанса. Exclude не может вводить новые переменные захвата, не появляющиеся в имени слоя.
При declaration-order matching того же эффекта часто можно добиться, объявив более узкий слой раньше. exclude: правильный инструмент, когда исключённое поддерево должно остаться по-настоящему неклассифицированным (чтобы провалиться в catch-all или диагностику покрытия) или когда положительные критерии смешивают patterns с suffix/implements/extends и одна ранняя запись не может чисто выразить вырез.
Когда 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 не убрал ничего нигде, а шаблон совпал хоть где-то. - Он судится только на прогоне, который способен судить. Как и прочие
каналы про конфигурацию, не привязавшуюся ни к чему, он требует путей,
покрывающих всё, что
composer.jsonобъявляет production-кодом, — корниpsr-4иpsr-0, записиclassmapиfilesнаравне. На более узком прогоне и на проекте, чей манифест не объявляет production-автозагрузки вовсе, канал молчит.
В отличие от архитектурных диагностик конфигурации, эта — обычная находка
правила: она подчиняется fail_on, --disable-rule, @qmx-ignore
architecture.unmatched-exclude и baseline. Публикует её
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, intersection_type, union_type,
catch, instanceof,
attribute
Алиасы — это сокращения configuration-уровня, раскрывающиеся в составляющие прямые значения:
| Алиас | Раскрывается в |
|---|---|
inheritance |
extends, implements, trait_use |
static_access |
static_call, static_property_fetch, class_const_fetch |
type_reference |
type_hint, property_type, intersection_type, union_type |
runtime_check |
catch, instanceof |
attribute стоит особняком — у него нет группы. Алиасы и прямые значения можно смешивать в одном relations:-списке; после раскрытия дубликаты убираются. Прямые значения валидируются против DependencyType::cases() рефлективно, поэтому добавление нового вида зависимости в коллектор автоматически становится принимаемым в YAML без релиза.
Когда несколько allow-целей в одном источнике резолвятся в один и тот же целевой слой (например, через перекрывающиеся glob-селекторы), их разрешения объединяются (UNION). Если хоть одна совпадающая запись использует bare/short-form (без relations:), объединение становится «все связи разрешены» — short-form доминирует.
Замечание. В коллекторе сейчас нет вида связи instance method-call — только
static_call. Отслеживайте instance-вызовы через более широкий алиасtype_reference, если ваша политика должна их ограничивать.
Режимы покрытия (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. ...
Чтобы убрать диагностику для известного набора неклассифицированных классов, объявите catch-all слой, покрывающий их (или согласитесь с пропуском, оставив coverage-gap: ignore).
Неотнесённые объявления¶
Rule ID: architecture.unassigned-class
Это правило отвечает на вопрос, который
architecture.coverage-gap выразить не может: каждое ли объявление, которое я
проанализировал, отнесено к слою? Coverage считает ещё и концы рёбер
зависимостей, а среди них есть классы вне paths: — Symfony\...,
PHPUnit\..., — которые ни один слой классифицировать не может, так что его
число определяется чужим кодом. Этот гейт считает только проанализированные
class-like объявления: классы, интерфейсы, трейты и енумы, которые прогон
действительно измерил. Объявление, для которого ни один коллектор не записал
метрик уровня класса, в множество не входит и считается отнесённым.
Это отдельное правило, по умолчанию выключенное, с единственной опцией — режим и есть выключатель:
Оно читает тот же единственный обход классов и рёбер зависимостей, что и
architecture.layer-violation, поэтому включение не добавляет прогону обхода.
Отдельного ключа enabled нет: mode: ignore — это и есть отказ от правила, а
второй выключатель был бы вторым ответом на один вопрос.
| Режим | Поведение |
|---|---|
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.unreachable-layer публикуется один раз на каждый объявленный слой — или на каждый конкретный инстанс, развёрнутый из шаблона, — чьи паттерны не совпали ни с одним классом, ни с одним концом ребра зависимости за прогон. Это диагностика конфигурации (см. врезку в разделе Режимы покрытия): она валит прогон безусловно, как только срабатывает, и не настраивается, не бейзлайнится и не подавляется @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:**} для cross-segment-захватов).
Шаблон, развернувшийся в ноль инстансов, тихо отключает связанную с ним политику, поэтому — как и четыре другие диагностики конфигурации — он валит прогон безусловно, а не ждёт severity или настройки fail_on; см. врезку в разделе Режимы покрытия. Три типичные причины:
- Опечатка в шаблоне паттерна.
App\Modul\{module}\Domain\**вместоApp\Module\{module}\Domain\**— ни один класс не совпадает, ни один инстанс не создаётся. - Исключённые модули. Каждый класс-кандидат удалён блоком
exclude:, черезsuppress_pathsили просто находится в неанализируемой директории. - Односегментный захват, охватывающий разделители namespace.
App\{path}\Domain\**, гдеpathдолжен захватитьModule\Order(два сегмента). Переключитесь на{path:**}, чтобы разрешить cross-segment-захваты.
Диагностика потенциального затенения¶
architecture.potential-shadow ловит тихий режим отказа declaration-order-сопоставления: более специфичный слой объявлен позже более широкого и потому никогда не выиграет в своей собственной области. Это диагностика конфигурации (см. врезку в разделе Режимы покрытия): она валит прогон безусловно, как только срабатывает, и не настраивается, не бейзлайнится и не подавляется @qmx-ignore.
Само по себе пересечение не диагностируется. «Побеждает первое совпадение» — это заявленный механизм разрешения, тот же, что в deptrac, ArchUnit и .gitignore, поэтому совпадение класса с двумя слоями дефектом не является. В частности, идиома «сначала узкое, потом широкое» (см. раздел «Настройки» выше) вплоть до финального **-catch-all законна и молчит:
architecture:
layers:
- name: service
patterns: ['App\Service\**'] # узкий, объявлен первым — выигрывает здесь
- name: catchall
patterns: ['**'] # широкий, объявлен последним — диагностики нет
Обнаружение доказательное (evidence-based). Правило обходит каждый класс, собирает все слои, чьи критерии совпали, и фиксирует пары (assigned, shadowed), которые реально встречаются в коде. Дальше для каждого такого класса сравниваются два критерия, которые реально сматчились: тот, по которому класс достался победившему слою, и тот, по которому его же сматчил затенённый слой:
| Победивший критерий против затенённого | Поведение |
|---|---|
Строго более специфичный (App\Http\** выиграл у App\**) |
Тишина — задокументированная идиома |
Шире или равен (App\** выиграл у App\Http\**) |
Диагностика |
| Несравнимы (см. ниже) | Диагностика (консервативный дефолт) |
«Более специфичный» определён только для поддеревьев namespace: паттерн-префикс (App\Http), префикс с хвостовым wildcard (App\Http\**, App\Http\*) и catch-all **. Всё остальное несравнимо и диагностику сохраняет: wildcard в середине (App\**\Foo), глоб внутри сегмента (**\*Service), символьные классы, нераскрытые capture-шаблоны и любой критерий не-паттерного вида (suffix, attributes, implements, extends), включая пару из двух разных видов. Ложная тревога стоит одного просмотра конфига; пропущенное затенение стоит слоя, который молча ничем не владеет.
Это по-прежнему ловит затенение любой формы — наложение префиксов, объявленное широким вперёд, кражу по суффиксу (**\*Service затеняет App\Domain\**), любое другое пересечение. Слой, который в итоге не получил ни одного класса, дополнительно сообщается диагностикой architecture.unreachable-layer: именно она срабатывает, когда, например, блок exclude: опустошает слой, который эта диагностика сочла законно более узким.
На каждую пару (assigned, shadowed) публикуется одна диагностика с примером до 5 FQN классов (отсортированных лексикографически). Вывод детерминирован между прогонами — список пар сортируется перед публикацией, поэтому CI-диффы стабильны.
Решение — одно из двух: - Переставить слои так, чтобы более специфичный был объявлен раньше (часто именно это и имелось в виду), или - Сузить более широкий паттерн так, чтобы слои больше не пересекались.
Используйте qmx debug:layer-assignment <class> для проверки исправления по конкретному классу.
Инспекция назначения слоя для одного класса¶
Когда класс попадает в неожиданный слой — или когда нужно проверить исправление диагностики architecture.unreachable-layer или architecture.potential-shadow — используйте команду debug:layer-assignment для покласовой инспекции:
bin/qmx debug:layer-assignment 'App\Service\Foo'
bin/qmx debug:layer-assignment 'App\Service\Foo' --config qmx.yaml
Команда делегирует тот же LayerRegistry::resolveAll(), который использует runtime-правило, — поэтому назначение, которое она показывает, в точности совпадает с тем, что architecture.layer-violation увидит во время анализа: параллельной реализации сопоставления, которая могла бы разойтись с runtime, не существует. Команда обходит сконфигурированные слои в declaration order, показывает слой, к которому класс отнесён, и перечисляет все остальные слои, чьи паттерны тоже совпали бы (потенциальный источник затенения, если бы они были объявлены раньше).
Пример вывода для однозначно отнесённого класса:
Class: App\Service\UserService
Assigned to: service
Matching pattern: App\Service\**
Would also match (in declaration order):
(none — the assignment is unique)
Пример вывода для затенённого класса:
Class: App\Service\Foo
Assigned to: any-foo
Matching pattern: App\**\Foo
Would also match (in declaration order):
- service (pattern: 'App\Service\**')
Diagnostic hint:
Class is shadowed: would have matched 'service' if 'any-foo' was declared later.
See architecture.potential-shadow diagnostic for the broader picture.
Коды выхода соответствуют стандартному соглашению, а 0 — это утверждение о классе, который прогон анализировал: 0 для любого информационного результата о таком классе (включая "он не соответствует ни одному объявленному слою"), 3 для отказа — пустого или некорректного FQN, ошибки загрузки конфигурации, а также FQN, не называющего ни одной разобранной этой конфигурацией декларации: именно так отсюда выглядит непроанализированный класс, — и 1 только для дефекта, который ввод не мог вызвать.
Настройки¶
| Опция | По умолчанию | Описание |
|---|---|---|
enabled |
true |
Включить/выключить правило. При выключении правило не обходит граф зависимостей. Также правило является no-op, если architecture.layers пуст. |
severity |
warning |
Severity для каждого зарегистрированного architecture.layer-violation. Допустимо: info, warning, error. |
Пять архитектурных диагностик конфигурации — 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.
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. Более короткой формы нет — сопоставление по префиксу убрано, поэтому голое @qmx-ignore architecture — это ошибка, а не замена всей семьи. architecture.* тоже соблазнительна, но неверна: она заодно затронула бы не связанное с этим правило architecture.circular-dependency, а architecture.layer-violation.* не совпадает ни с чем и падает ошибкой — второй канал правила называется architecture.unmatched-exclude, а не чем-то под префиксом architecture.layer-violation., так что под этим префиксом каналов нет. «Все каналы политики слоёв» поэтому невыразимы по замыслу. Политика слоёв публикует восемь каналов, но остальные семь несут собственные имена правил (architecture.coverage-gap, architecture.unassigned-class, architecture.unmatched-exclude, architecture.unreachable-layer, architecture.pending-layer-matched, architecture.potential-shadow, architecture.empty-template), так что ни один селектор их не охватывает. Пять из этих семи суть ошибки конфигурации, которые никакое подавление не принимает; architecture.unassigned-class и architecture.unmatched-exclude — про обычный долг, но оба суть сводные утверждения за прогон, отнесённые к проекту, поэтому инлайн-директива не дотягивается и до них: от них отказываются в конфигурации или принимают в baseline.
Baseline-файл хранит нарушения слоёв по слою-источнику, слою-цели, FQN целевого класса и типу зависимости — не по номеру строки — поэтому переформатирование или перенос места использования внутри того же файла baseline не ломает. Несколько мест использования одной и той же запрещённой грани в baseline схлопываются в одну запись.
Deviation from original spec
Сопоставление политики остаётся логическим, но идентичность нарушения привязана к декларации. Не принадлежащая проекту цель даёт одно нарушение на точной исходной декларации; одна или несколько принадлежащих целей дают по одному нарушению на каждую точную целевую декларацию. Symbol-control применяется независимо к каждой проекции, а next-line и file controls по-прежнему используют физическое место зависимости. Семантическое occurrence объединяет точный источник, логическую цель, тип зависимости и спроецированную цель, поэтому одинаковые рёбра делят одну count-bounded baseline identity без использования строки представления.
Исключение на уровне правила (suppress_namespaces / suppress_paths) тоже работает здесь.
Глобальный suppress_namespaces
намеренно не действует на правила architecture.* (см. предупреждение там) — project-wide
исключение, по форме похожее на метрику, не должно попутно и незаметно выключать контроль
архитектуры. Форма на уровне правила — другой, явный механизм, и на неё это исключение не
распространяется:
Это работает, потому что фреймворк (RuleOptionsFactory) извлекает suppress_namespaces /
suppress_paths для любого имени правила безусловно, ещё до того, как конфигурацию увидит класс
Options самого правила. Явное указание architecture.layer-violation — однозначный, проверяемый
выбор: в отличие от общей записи suppress_namespaces, его нельзя прочитать как «просто исключить
этот неймспейс из метрик» и случайно прихватить архитектурные нарушения заодно. Подавления,
сделанные таким образом, считаются и отображаются так же, как и любое другое исключение на уровне
правила — см. раздел «Видимость» в
руководстве по конфигурации.
Особенности реализации¶
- Пять критериев принадлежности, дефолт
match: any. Принадлежность определяется черезpatterns,suffix,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.- Вендорные неймспейсы — полноправные слои. Объявите слой
doctrineилиsymfonyс паттернамиDoctrine\**/Symfony\**, чтобы писать политику против вендорных граней (например, "Doctrine может использовать только репозиторий"). Вендорные слои ведут себя идентично проектным. - Зависимости внутри одного слоя всегда разрешены в MVP. Изоляция подмодулей внутри слоя отложена на Phase 2.
- Гранулярность отчётности — на каждое место использования. Каждая запрещённая грань из
Qualimetrix\Analysis\Evidence\DependencyModel\Contract\DependencyGraphInterfaceдаёт одно нарушение. Если класс нарушает политику через пять разных вызовов методов — получите пять нарушений. По identity в baseline они схлопываются в одну запись (см. "Подавление нарушений" выше). - Концы вне слоёв молча игнорируются для целей layer-violation. Их количество отдельно публикуется через режим
coverage-gap. - Включено по умолчанию, но без слоёв ничего не делает. Правило стартует с
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.