Перейти к содержанию

Правила архитектуры (Architecture)

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


Циклические зависимости (Circular Dependencies)

Идентификатор правила: architecture.circular-dependency

Что измеряет

Обнаруживает ситуации, когда классы зависят друг от друга по кругу. Зависимость означает, что один класс использует другой (через внедрение в конструктор, вызовы методов, указания типов и т.д.).

Прямой цикл (размер 2):

OrderService --> PaymentService --> OrderService

OrderService использует PaymentService, а PaymentService использует OrderService. Ни один из них не может существовать без другого.

Транзитивный цикл (размер 3+):

A --> B --> C --> A

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 отображается как:

Billing\Service → Orders\Service → Billing\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+).

{
  "cycle": ["App\\Billing\\Service", "App\\Orders\\Service", "App\\Billing\\Service"],
  "length": 2,
  "category": "small"
}

Идентичность цикла -- символьный путь нарушения и ключ 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.

Как исправить

  1. Введите интерфейс (инверсия зависимостей). Пусть один класс зависит от абстракции, а не от конкретного класса:

    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,  // нет цикла!
        ) {}
    }
    
  2. Вынесите общую логику в третий класс. Если обоим классам нужны одни и те же данные, извлеките их:

    class OrderRepository
    {
        public function getTotal(int $orderId): float { /* ... */ }
    }
    
    // Оба сервиса зависят от OrderRepository, а не друг от друга
    
  3. Используйте события. Вместо прямых вызовов генерируйте событие, на которое подписывается другой сервис:

    class OrderService
    {
        public function createOrder(Cart $cart): Order
        {
            $order = new Order($cart);
            $this->eventDispatcher->dispatch(new OrderCreated($order));
            return $order;
        }
    }
    
    // PaymentService подписан на OrderCreated -- нет прямой зависимости
    

Совет

Используйте опцию 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, чем дефолт:

architecture:
  max_expanded_layers: 2000

Семантические заметки — 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: any clause, где сработал 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 на каждом прогоне. Вместо ослабления диагностики объявите намерение:

- name: reporting
  patterns: ['App\Reporting\**']
  pending: true

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 объявления: классы, интерфейсы, трейты и енумы, которые прогон действительно измерил. Объявление, для которого ни один коллектор не записал метрик уровня класса, в множество не входит и считается отнесённым.

Это отдельное правило, по умолчанию выключенное, с единственной опцией — режим и есть выключатель:

rules:
  architecture.unassigned-class:
    mode: warn   # ignore (по умолчанию) | warn | error

Оно читает тот же единственный обход классов и рёбер зависимостей, что и 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. Три возможные причины:

  1. Слой перекрыт более широким, объявленным раньше. Паттерн вроде '**' или 'App\**' перед более узким захватывает все классы первым.
  2. Паттерн не совпадает ни с одним классом в анализируемой кодовой базе и ни разу не встречается как конец ребра зависимости. Слой объявлен для неймспейса, которого ещё нет — или неймспейс был переименован.
  3. 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; см. врезку в разделе Режимы покрытия. Три типичные причины:

  1. Опечатка в шаблоне паттерна. App\Modul\{module}\Domain\** вместо App\Module\{module}\Domain\** — ни один класс не совпадает, ни один инстанс не создаётся.
  2. Исключённые модули. Каждый класс-кандидат удалён блоком exclude:, через suppress_paths или просто находится в неанализируемой директории.
  3. Односегментный захват, охватывающий разделители 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.
rules:
  architecture.layer-violation:
    enabled: true
    severity: 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 исключение, по форме похожее на метрику, не должно попутно и незаметно выключать контроль архитектуры. Форма на уровне правила — другой, явный механизм, и на неё это исключение не распространяется:

rules:
  architecture.layer-violation:
    suppress_namespaces:
      - App\Legacy
    suppress_paths:
      - src/Legacy

Это работает, потому что фреймворк (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, исключение поддеревьев внутри слоя и whitelist relations: на allow-целях. Поверхность всё ещё меньше deptrac (один allow-list на слой-источник, нет полного predicate-DSL), но правило закрывает long-tail без второго инструмента в CI.
  • ArchUnit — вдохновение из Java-мира, модель "архитектура как тест". Capture-binding-форма allow ('app-{m}': ['domain-{m}']) концептуально похожа на ArchUnit slices(). На PHP модель ложится не хуже.

Для обоснования текущей политики слоёв — почему шаблоны разворачиваются по наблюдаемым binding tuples, почему capture-binding обязателен, почему relations: whitelist-only — см. ADR 0059: Declared-Layer Policy and Architecture Governance.