From 1ce89eb633b4ebebecce1d78f3e313f9b0757964 Mon Sep 17 00:00:00 2001 From: Dmitry Prikotov Date: Wed, 2 Sep 2026 23:16:56 +0700 Subject: [PATCH] =?UTF-8?q?feat(rules):=20forbid=20reserved=20layer=20name?= =?UTF-8?q?s=20as=20nested=20namespace=20segments=20/=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D1=82=D0=B8=D1=82=D1=8C=20=D0=B2=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BB=D0=BE=D1=91=D0=B2=20=D0=B2=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Имена слоёв (Domain, Application, Infrastructure, Integration) — зарезервированные сегменты пути: слой определяется сегментом сразу после имени модуля. Namespace одного слоя не должен содержать имя другого слоя (например, Domain\Service\Integration\*Interface — запрещён). Раньше паттерн Domain\Service\Integration нигде в конвенциях не был определен, но был узаконен инструментами: must_not-исключение в depfile.yaml со комментарием «ports stay in Domain layer» и правило в AGENTS.md потребительского проекта. При этом deptrac-коллекторы матчили слой жадным Module\.*\Integration\.*, переприсваивая такие классы в слой Integration. Изменения: - ReservedLayerSegmentSniff (phpcs): ошибка на само существование namespace с вложенным именем слоя, независимо от зависимостей; - ReservedLayerSegmentRule (deptrac): подписчик PostProcessEvent, который сканирует AstMap и добавляет Error на каждый запрещённый класс — deptrac падает (exit 1) даже в проектах без phpcs; - depfile.yaml: сегмент модуля во всех коллекторах [^\\]+ вместо .* — слой матчится по позиции; must_not-исключение удалено; - layers.md: правило и пункт чек-листа зафиксированы в конвенциях; - тесты: фикстуры сниффа + 10 юнит-тестов deptrac-правила. --- README.md | 1 + bin/run-sniff-tests.php | 1 + config/deptrac/depfile.yaml | 75 ++++----- docs/conventions/layers/layers.md | 2 + src/Deptrac/ReservedLayerSegmentRule.php | 123 ++++++++++++++ .../Namespaces/ReservedLayerSegmentSniff.php | 132 +++++++++++++++ .../Deptrac/ReservedLayerSegmentRuleTest.php | 154 ++++++++++++++++++ ...rSegmentUnitTestInfrastructureInDomain.inc | 7 + ...ayerSegmentUnitTestIntegrationInDomain.inc | 7 + ...ReservedLayerSegmentUnitTestValidGroup.inc | 7 + ...erSegmentUnitTestValidIntegrationLayer.inc | 7 + tests/fixtures.php | 28 ++++ 12 files changed, 507 insertions(+), 37 deletions(-) create mode 100644 src/Deptrac/ReservedLayerSegmentRule.php create mode 100644 src/Sniffs/Namespaces/ReservedLayerSegmentSniff.php create mode 100644 tests/Deptrac/ReservedLayerSegmentRuleTest.php create mode 100644 tests/Namespaces/ReservedLayerSegmentUnitTestInfrastructureInDomain.inc create mode 100644 tests/Namespaces/ReservedLayerSegmentUnitTestIntegrationInDomain.inc create mode 100644 tests/Namespaces/ReservedLayerSegmentUnitTestValidGroup.inc create mode 100644 tests/Namespaces/ReservedLayerSegmentUnitTestValidIntegrationLayer.inc diff --git a/README.md b/README.md index 9d5b3415..4bedbb7a 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ |---|---| | `ServiceContractDependencyRule` | Infrastructure зависит только от Domain-интерфейсов, не от конкретных классов | | `CrossModuleDomainRule` | Домен одного модуля не зависит от домена другого — только через Application DTO | +| `ReservedLayerSegmentRule` | Имена слоёв зарезервированы как сегменты namespace: вложенный слой внутри другого слоя (например, `Domain\Service\Integration\...`) — ошибка на само существование класса, независимо от зависимостей | Готовый `depfile.yaml` с правилами для DDD-слоёв и модульных границ: [`config/deptrac/`](config/deptrac/). Копируется в проект через `coding-standard-init` или вручную. diff --git a/bin/run-sniff-tests.php b/bin/run-sniff-tests.php index 6977a98d..666829be 100644 --- a/bin/run-sniff-tests.php +++ b/bin/run-sniff-tests.php @@ -37,6 +37,7 @@ 'PrikotovCodingStandard.Structure.ServiceStructure', 'PrikotovCodingStandard.Structure.ValueObjectStructure', 'PrikotovCodingStandard.Namespaces.PresentationLayerNamespace', + 'PrikotovCodingStandard.Namespaces.ReservedLayerSegment', ], 'fixtures' => require $packageRoot . '/tests/fixtures.php', ], diff --git a/config/deptrac/depfile.yaml b/config/deptrac/depfile.yaml index 52e9243f..5ef9904a 100644 --- a/config/deptrac/depfile.yaml +++ b/config/deptrac/depfile.yaml @@ -25,77 +25,74 @@ deptrac: - type: bool must: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\.* must_not: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\(Entity\\)?ValueObject\\.*Vo$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\(Entity\\)?ValueObject\\.*Vo$ - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\Specification\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\Specification\\.* - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\Enum\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\Enum\\.* - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\.*Dto$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\.*Dto$ - name: Application collectors: - type: bool must: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\.* must_not: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\.*Dto$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\.*Dto$ - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\Enum\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\Enum\\.* - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Command\\.*\\.*Command$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Command\\.*\\.*Command$ - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Command\\.*\\.*CommandHandler$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Command\\.*\\.*CommandHandler$ - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Query\\.*\\.*Query$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Query\\.*\\.*Query$ - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Query\\.*\\.*QueryHandler$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Query\\.*\\.*QueryHandler$ - name: IntegrationListener collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Integration\\Listener\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Integration\\Listener\\.* - name: Infrastructure collectors: - type: bool must: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Infrastructure\\(?!Model|Listener).*$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Infrastructure\\(?!Model|Listener).*$ must_not: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Infrastructure\\.*Dto$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Infrastructure\\.*Dto$ - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Infrastructure\\Component\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Infrastructure\\Component\\.* - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Infrastructure\\Enum\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Infrastructure\\Enum\\.* - name: InfrastructureComponent collectors: - type: bool must: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Infrastructure\\Component\\.*(Component|ComponentInterface)$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Infrastructure\\Component\\.*(Component|ComponentInterface)$ - name: Integration collectors: - type: bool must: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Integration\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Integration\\.* must_not: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Integration\\Listener\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Integration\\Listener\\.* - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Integration\\.*Dto$ - # Domain\\Service\\Integration — ports (interfaces), stay in Domain layer - - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\Service\\Integration\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Integration\\.*Dto$ - name: Presentation collectors: @@ -108,32 +105,32 @@ deptrac: - name: DomainSpecification collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\Specification\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\Specification\\.* - name: DomainVo collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\(Entity\\)?ValueObject\\.*Vo$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\(Entity\\)?ValueObject\\.*Vo$ - name: DomainEnum collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\Enum\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\Enum\\.* - name: DomainDto collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Domain\\.*Dto$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Domain\\.*Dto$ - name: ApplicationDto collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\.*Dto$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\.*Dto$ - name: ApplicationEnum collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\Enum\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\Enum\\.* - name: ApplicationCommonDto collectors: @@ -143,39 +140,39 @@ deptrac: - name: IntegrationDto collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Integration\\.*Dto$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Integration\\.*Dto$ - name: InfrastructureDto collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Infrastructure\\.*Dto$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Infrastructure\\.*Dto$ - name: InfrastructureEnum collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Infrastructure\\Enum\\.* + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Infrastructure\\Enum\\.* # --- CQRS artifacts --- - name: ApplicationCommand collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Command\\.*\\.*Command$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Command\\.*\\.*Command$ - name: ApplicationCommandHandler collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Command\\.*\\.*CommandHandler$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Command\\.*\\.*CommandHandler$ - name: ApplicationQuery collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Query\\.*\\.*Query$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Query\\.*\\.*Query$ - name: ApplicationQueryHandler collectors: - type: classLike - value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\.*\\Application\\UseCase\\Query\\.*\\.*QueryHandler$ + value: ^(?:[A-Za-z_][A-Za-z0-9_]*\\)?Common\\Module\\[^\\]+\\Application\\UseCase\\Query\\.*\\.*QueryHandler$ ruleset: # DTO boundary @@ -311,6 +308,10 @@ services: autowire: true tags: - { name: kernel.event_subscriber } + - class: PrikotovCodingStandard\Deptrac\ReservedLayerSegmentRule + autowire: true + tags: + - { name: kernel.event_subscriber } - class: PrikotovCodingStandard\Deptrac\CrossModuleDomainRule autowire: true tags: diff --git a/docs/conventions/layers/layers.md b/docs/conventions/layers/layers.md index 67e29120..dcb28f01 100644 --- a/docs/conventions/layers/layers.md +++ b/docs/conventions/layers/layers.md @@ -15,6 +15,7 @@ description: Правила зависимостей между слоями а - Зависимости направлены **только внутрь**, к центру - Внутренние слои не зависят от внешних +- Имена слоёв (`Domain`, `Application`, `Infrastructure`, `Integration`, `Presentation`) зарезервированы как сегменты пути: слой — это сегмент сразу после имени модуля; вложенных повторов имён слоёв в namespace быть не должно (например, `Domain\Service\Integration\...` запрещён) - Внешние слои зависят от внутренних через контракты (`interface`) и согласованные типы ([DTO](../core-patterns/dto.md), [VO](../core-patterns/value-object.md), [Enum](../core-patterns/enum.md)) в рамках разрешённых правил - DI-контейнер связывает интерфейсы с реализациями на уровне конфигурации @@ -130,4 +131,5 @@ Presentation зависит только от Application: - [ ] Infrastructure реализует контракты Domain. - [ ] Integration обращается к Domain (только контракты) и Application. - [ ] Presentation обращается только к Application. +- [ ] Имена слоёв не используются как вложенные сегменты namespace другого слоя. - [ ] Namespace следует паттерну `{ProjectName}\{AppGroup}\Module\...`. diff --git a/src/Deptrac/ReservedLayerSegmentRule.php b/src/Deptrac/ReservedLayerSegmentRule.php new file mode 100644 index 00000000..97be9871 --- /dev/null +++ b/src/Deptrac/ReservedLayerSegmentRule.php @@ -0,0 +1,123 @@ + + */ + private const LAYERS = ['Domain', 'Application', 'Infrastructure', 'Integration']; + + private const MODULE_CLASS_PATTERN = '/^(?:[A-Za-z_][A-Za-z0-9_]*\\\\)?Common\\\\Module\\\\' + . '(?P[A-Za-z][A-Za-z0-9]*)\\\\' + . '(?PDomain|Application|Infrastructure|Integration)\\\\' + . '(?P.+)$/'; + + public function __construct(private readonly AstMapExtractor $astMapExtractor) + { + } + + public static function getSubscribedEvents(): array + { + return [ + PostProcessEvent::class => 'onPostProcessEvent', + ]; + } + + public function onPostProcessEvent(PostProcessEvent $event): void + { + $astMap = $this->astMapExtractor->extract(); + + foreach ($astMap->getClassLikeReferences() as $reference) { + $className = $reference->getToken()->toString(); + $nestedLayer = self::findNestedLayerName($className); + + if ($nestedLayer !== null) { + $event->getResult()->addError(new Error( + sprintf( + 'Class "%s" namespace contains reserved layer name "%s" inside the %s layer.' + . ' Layer names are reserved path segments — rename the group' + . ' or move the code to the %s layer.', + $className, + $nestedLayer, + self::resolveLayer($className), + $nestedLayer, + ) . self::DOC_REF, + )); + } + } + } + + public function ruleName(): string + { + return 'ReservedLayerSegmentRule'; + } + + public function ruleDescription(): string + { + return 'Layer names are reserved namespace segments: a class of one layer' + . ' must not contain another layer name in its namespace;' + . ' such namespaces are forbidden regardless of dependencies.'; + } + + /** + * Returns the reserved layer name nested inside another layer, if any. + */ + public static function findNestedLayerName(string $className): ?string + { + if (1 !== preg_match(self::MODULE_CLASS_PATTERN, $className, $matches)) { + return null; + } + + foreach (explode('\\', $matches['path']) as $segment) { + if (in_array($segment, self::LAYERS, true)) { + return $segment; + } + } + + return null; + } + + /** + * Returns the layer segment — the one right after Module\{ModuleName}. + */ + public static function resolveLayer(string $className): ?string + { + if (1 !== preg_match(self::MODULE_CLASS_PATTERN, $className, $matches)) { + return null; + } + + return $matches['layer']; + } +} diff --git a/src/Sniffs/Namespaces/ReservedLayerSegmentSniff.php b/src/Sniffs/Namespaces/ReservedLayerSegmentSniff.php new file mode 100644 index 00000000..802de81a --- /dev/null +++ b/src/Sniffs/Namespaces/ReservedLayerSegmentSniff.php @@ -0,0 +1,132 @@ + + */ + private const LAYERS = ['Domain', 'Application', 'Infrastructure', 'Integration']; + + private const DOC_REF = ' See: docs/conventions/layers/layers.md'; + + public function register(): array + { + return [T_NAMESPACE]; + } + + public function process(File $phpcsFile, $stackPtr): void + { + $namespace = $this->extractNamespace($phpcsFile, $stackPtr); + if ($namespace === null) { + return; + } + + $layer = $this->resolveLayer($namespace); + if ($layer === null) { + return; + } + + $nestedLayer = $this->findNestedLayerName($namespace); + if ($nestedLayer === null) { + return; + } + + $phpcsFile->addError( + sprintf( + 'Namespace "%s" contains reserved layer name "%s" inside the %s layer.' + . ' Layer names are reserved path segments — rename the group' + . ' or move the code to the %s layer.', + $namespace, + $nestedLayer, + $layer, + $nestedLayer, + ) . self::DOC_REF, + $stackPtr, + self::ERROR_NESTED_LAYER_NAME, + ); + } + + private function extractNamespace(File $phpcsFile, int $stackPtr): ?string + { + $tokens = $phpcsFile->getTokens(); + $end = $phpcsFile->findNext([T_SEMICOLON, T_OPEN_CURLY_BRACKET], $stackPtr + 1); + if ($end === false) { + return null; + } + + $namespace = trim($phpcsFile->getTokensAsString($stackPtr + 1, $end - $stackPtr - 1)); + + return $namespace !== '' ? $namespace : null; + } + + /** + * Resolves the layer segment — the one right after Module\{ModuleName}. + */ + private function resolveLayer(string $namespace): ?string + { + $parts = explode('\\', $namespace); + $layerIndex = $this->findModuleIndex($parts); + + if ($layerIndex === null || !isset($parts[$layerIndex + 2])) { + return null; + } + + $layer = $parts[$layerIndex + 2]; + + return in_array($layer, self::LAYERS, true) ? $layer : null; + } + + private function findNestedLayerName(string $namespace): ?string + { + $parts = explode('\\', $namespace); + $layerIndex = $this->findModuleIndex($parts); + + if ($layerIndex === null) { + return null; + } + + // Check segments after the layer segment for reserved layer names + for ($i = $layerIndex + 3; $i < count($parts); $i++) { + if (in_array($parts[$i], self::LAYERS, true)) { + return $parts[$i]; + } + } + + return null; + } + + /** + * @param list $parts + */ + private function findModuleIndex(array $parts): ?int + { + $moduleIndex = array_search('Module', $parts, true); + if ($moduleIndex === false) { + return null; + } + + // Need at least Module\{ModuleName}\{Layer} + if (!isset($parts[$moduleIndex + 2])) { + return null; + } + + return $moduleIndex; + } +} diff --git a/tests/Deptrac/ReservedLayerSegmentRuleTest.php b/tests/Deptrac/ReservedLayerSegmentRuleTest.php new file mode 100644 index 00000000..aa099811 --- /dev/null +++ b/tests/Deptrac/ReservedLayerSegmentRuleTest.php @@ -0,0 +1,154 @@ +createRule([ + 'Task\Common\Module\Source\Domain\Service\Integration\SourcePublicationSnapshotResolverInterface', + 'Task\Common\Module\Source\Domain\Service\SourcePublication\ValidInterface', + ])->onPostProcessEvent($event); + + $errors = $result->errors(); + + self::assertCount(1, $errors); + self::assertStringContainsString( + 'Task\Common\Module\Source\Domain\Service\Integration\SourcePublicationSnapshotResolverInterface', + (string) $errors[0], + ); + self::assertStringContainsString('docs/conventions/layers/layers.md', (string) $errors[0]); + } + + public function testAddsNoErrorForValidClasses(): void + { + $result = new AnalysisResult(); + $event = new PostProcessEvent($result); + + $this->createRule([ + 'Task\Common\Module\Source\Domain\Service\SourcePublication\ValidInterface', + 'Task\Common\Module\Source\Integration\Service\ExchangeRateConnector', + ])->onPostProcessEvent($event); + + self::assertSame([], $result->errors()); + } + + /** + * @param list $classNames + */ + private function createRule(array $classNames): ReservedLayerSegmentRule + { + $references = []; + foreach ($classNames as $className) { + $references[] = new ClassLikeReference(ClassLikeToken::fromFQCN($className)); + } + + $astMap = $this->createMock(AstMap::class); + $astMap->method('getClassLikeReferences')->willReturn($references); + + $extractor = $this->createMock(AstMapExtractor::class); + $extractor->method('extract')->willReturn($astMap); + + return new ReservedLayerSegmentRule($extractor); + } +} diff --git a/tests/Namespaces/ReservedLayerSegmentUnitTestInfrastructureInDomain.inc b/tests/Namespaces/ReservedLayerSegmentUnitTestInfrastructureInDomain.inc new file mode 100644 index 00000000..310881f0 --- /dev/null +++ b/tests/Namespaces/ReservedLayerSegmentUnitTestInfrastructureInDomain.inc @@ -0,0 +1,7 @@ + [], 'warnings' => [], ], + // ReservedLayerSegmentSniff — Integration group inside Domain layer (forbidden) + [ + 'file' => __DIR__ . '/Namespaces/ReservedLayerSegmentUnitTestIntegrationInDomain.inc', + 'errors' => [ + 3 => 1, + ], + 'warnings' => [], + ], + // ReservedLayerSegmentSniff — Infrastructure group inside Domain layer (forbidden) + [ + 'file' => __DIR__ . '/Namespaces/ReservedLayerSegmentUnitTestInfrastructureInDomain.inc', + 'errors' => [ + 3 => 1, + ], + 'warnings' => [], + ], + // ReservedLayerSegmentSniff — domain group name inside Domain layer (valid) + [ + 'file' => __DIR__ . '/Namespaces/ReservedLayerSegmentUnitTestValidGroup.inc', + 'errors' => [], + 'warnings' => [], + ], + // ReservedLayerSegmentSniff — Integration layer itself (valid) + [ + 'file' => __DIR__ . '/Namespaces/ReservedLayerSegmentUnitTestValidIntegrationLayer.inc', + 'errors' => [], + 'warnings' => [], + ], [ 'file' => __DIR__ . '/Application/CommandQueryStructureUnitTest.inc', 'errors' => [