From 570a2b25dd9c39b576d6c5082f7b7eb2faefeb9d Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 13 Aug 2025 16:28:33 +0200 Subject: [PATCH 01/35] refactor architecture sniffer --- Kununu/ArchitectureSniffer/Architecture.php | 59 ++++++++++ .../ArchitectureSniffer.php | 42 +++----- .../Configuration/Group.php | 102 ++++++++++++++++++ .../Configuration/Rules/AbstractRule.php | 16 +++ .../Configuration/Rules/MustBeFinal.php | 32 +++--- .../Configuration/Rules/MustExtend.php | 37 ++----- .../Configuration/Rules/MustImplement.php | 59 ++-------- .../Configuration/Rules/MustOnlyDependOn.php | 29 +++++ .../Rules/MustOnlyDependOnWhitelist.php | 60 ----------- .../MustOnlyHaveOnePublicMethodNamed.php | 17 ++- .../Configuration/Rules/Rule.php | 6 +- .../Configuration/Selector/ClassSelector.php | 8 +- .../Selector/InterfaceClassSelector.php | 7 +- .../Selector/NamespaceSelector.php | 7 +- .../Configuration/Selector/Selectable.php | 2 +- .../Selector/SelectableCollection.php | 71 ++++++++++++ .../Configuration/SubLayer.php | 4 +- .../ArchitectureSniffer/DirectoryFinder.php | 40 ------- .../FollowsFolderStructureRule.php | 84 --------------- .../Helper/ProjectPathResolver.php | 19 ++++ 20 files changed, 360 insertions(+), 341 deletions(-) create mode 100644 Kununu/ArchitectureSniffer/Architecture.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Group.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php delete mode 100644 Kununu/ArchitectureSniffer/DirectoryFinder.php delete mode 100644 Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php create mode 100644 Kununu/ArchitectureSniffer/Helper/ProjectPathResolver.php diff --git a/Kununu/ArchitectureSniffer/Architecture.php b/Kununu/ArchitectureSniffer/Architecture.php new file mode 100644 index 0000000..20444b7 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Architecture.php @@ -0,0 +1,59 @@ + $groups + */ + private function __construct( + private array $groups, + ) { + } + + public static function fromArray(array $data): self + { + if (empty($data) || !is_array($data)) { + throw new InvalidArgumentException('Invalid architecture configuration: "groups" must be a non-empty array.'); + } + + // each group must have an include with at least on fully qualified fqcn or another qualified group + if (!array_filter($data, static fn(array $group) => array_key_exists(Group::INCLUDES_KEY, $group) && !empty($group[Group::INCLUDES_KEY]))) { + throw new InvalidArgumentException('Each group must have an "includes" property with at least one fully qualified fqcn or another qualified group.'); + } + // at least one group with a depends_on property with at least one fqcn or another qualified group + if (!array_filter($data, static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) && !empty($group[Group::DEPENDS_ON_KEY]))) { + throw new InvalidArgumentException('At least one group must have a "dependsOn" property with at least one fqcn or another qualified group.'); + } + // groups with at least one include from a global namespace other than App\\, the depends_on properties must not be defined + if (array_filter($data, static fn(array $group) => !str_starts_with($group['includes'][0], 'App\\'))) { + if (array_filter($data, static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group))) { + throw new InvalidArgumentException('Groups with includes from a global namespace other than App\\ must not have a "dependsOn" property defined.'); + } + } + + $groups = array_map( + static fn(array $groupData) => Group::fromArray($groupData), + $data + ); + + foreach ($groups as $group) { + $group->generateRules(); + } + + return new self($groups); + } + + public function getGroups(): Generator + { + foreach ($this->groups as $group) { + yield $group; + } + } +} diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 1b197b6..0c1d06a 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -3,49 +3,31 @@ namespace Kununu\ArchitectureSniffer; -use InvalidArgumentException; -use JsonException; -use Kununu\ArchitectureSniffer\Configuration\Layer; +use Kununu\ArchitectureSniffer\Helper\ProjectPathResolver; use PHPat\Test\Builder\Rule as PHPatRule; +use Symfony\Component\Yaml\Yaml; final class ArchitectureSniffer { + private const string ARCHITECTURE_FILENAME = 'architecture.yaml'; + /** - * @throws JsonException - * * @return iterable */ public function testArchitecture(): iterable { - $archDefinition = DirectoryFinder::getArchitectureDefinition(); - $layers = $this->validateArchitectureDefinition($archDefinition); - foreach ($layers as $layer) { - foreach ($layer->subLayers as $subLayer) { - foreach ($subLayer->rules as $rule) { - yield $rule->getPHPatRule(); - } + $architecture = $this->getArchitecture(); + foreach ($architecture->getGroups() as $group) { + foreach ($group->getRules() as $rule) { + yield $rule; } } } - /** - * @param array $architectureDefinition - * - * @throws JsonException - * - * @return Layer[] - */ - private function validateArchitectureDefinition(array $architectureDefinition): array + private function getArchitecture(): Architecture { - if (!array_key_exists('architecture', $architectureDefinition)) { - throw new InvalidArgumentException('Invalid architecture definition, missing architecture key'); - } - - $layers = []; - foreach ($architectureDefinition['architecture'] as $layer) { - $layers[] = Layer::fromArray($layer); - } - - return $layers; + return Architecture::fromArray( + Yaml::parseFile(ProjectPathResolver::resolve(self::ARCHITECTURE_FILENAME)) + ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php new file mode 100644 index 0000000..bf12185 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -0,0 +1,102 @@ + + */ + private array $rules = []; + + /** + * @param array $dependsOn + * @param array|null $implements + */ + private function __construct( + private string $name, + private SelectableCollection $includes, + private ?array $dependsOn = null, + private bool $final = false, + private ?Selectable $extends = null, + private ?array $implements = null, + private ?string $mustOnlyHaveOnePublicMethodNamed = null, + ) { + } + + public static function fromArray(array $data): self + { + if (!array_key_exists(self::NAME_KEY, $data) || array_key_exists(self::INCLUDES_KEY, $data)) { + throw new InvalidArgumentException('Group configuration must contain "name" and "includes" keys.'); + } + + return new self( + name: $data[self::NAME_KEY], + includes: SelectableCollection::fromArray($data[self::INCLUDES_KEY], $data[self::NAME_KEY]), + dependsOn: $data[self::DEPENDS_ON_KEY] ?? null, + final: $data[self::FINAL_KEY] ?? false, + extends: $data[self::EXTENDS_KEY] ?? null, + implements: $data[self::IMPLEMENTS_KEY] ?? null, + mustOnlyHaveOnePublicMethodNamed: $data[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null, + ); + } + + public function generateRules(): Generator + { + if ($this->extends) { + $this->rules[] = new Rules\MustExtend( + extensions: SelectableCollection::toSelectable($this->extends), + selectables: $this->includes->getSelectablesByGroup($this->name) + ); + } + + if ($this->implements) { + yield new Rules\MustImplement( + selectables: $this->includes->getSelectablesByGroup($this->name), + interfaces: SelectableCollection::toSelectable($this->implements), + ); + } + + if ($this->final) { + yield new Rules\MustBeFinal( + selectables: $this->includes->getSelectablesByGroup($this->name), + ); + } + + if ($this->dependsOn) { + yield new Rules\MustOnlyDependOn( + selectables: $this->includes->getSelectablesByGroup($this->name), + dependencies: SelectableCollection::toSelectable($this->dependsOn), + ); + } + + if ($this->mustOnlyHaveOnePublicMethodNamed) { + yield new Rules\MustOnlyHaveOnePublicMethodNamed( + selectables: $this->includes->getSelectablesByGroup($this->name), + functionName: $this->mustOnlyHaveOnePublicMethodNamed, + ); + } + } + + public function getRules(): Generator + { + foreach ($this->rules as $rule) { + yield $rule->getPHPatRule($this->name); + } + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php new file mode 100644 index 0000000..5e74bd1 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -0,0 +1,16 @@ +getPHPatSelector(); + } + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 690bc02..8a8beff 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -3,38 +3,32 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use Generator; use InvalidArgumentException; -use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; +use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; -final readonly class MustBeFinal implements Rule +final readonly class MustBeFinal extends AbstractRule { - public const string KEY = 'final'; - - public function __construct(public Selectable $selector) + public function __construct(public Generator $selectables) { - } - - public static function fromArray(Selectable $selector): self - { - if ($selector instanceof InterfaceClassSelector) { - throw new InvalidArgumentException( - 'The class must not be an interface.' - ); + foreach ($this->selectables as $selectable) { + if (!$selectable instanceof ClassSelector) { + throw new InvalidArgumentException( + 'Only classes can be final.' + ); + } } - - return new self($selector); } - public function getPHPatRule(): PHPatRule + public function getPHPatRule(string $groupName): PHPatRule { return PHPat::rule() - ->classes($this->selector->getPHPatSelector()) + ->classes(...$this->getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldBeFinal() - ->because("{$this->selector->getName()} must be final."); + ->because("$groupName must be final."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 29e2437..04cbff7 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -3,49 +3,32 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use Generator; use InvalidArgumentException; -use JsonException; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use Kununu\ArchitectureSniffer\Configuration\Selectors; use PHPat\Test\PHPat; -final readonly class MustExtend implements Rule +final readonly class MustExtend extends AbstractRule { public const string KEY = 'extends'; public function __construct( - public Selectable $selector, - public Selectable $parent, + public Generator $extensions, + public Generator $selectables, ) { - } - - /** - * @param array $data - * - * @throws JsonException - */ - public static function fromArray(Selectable $selector, array $data): self - { - $parent = Selectors::findSelector($data); - - if ($parent instanceof InterfaceClassSelector) { + if ($this->extensions instanceof InterfaceClassSelector) { throw new InvalidArgumentException( - 'The parent class must not be an interface.' + 'Classes can not extend interfaces.' ); } - - return new self($selector, $parent); } - public function getPHPatRule(): \PHPat\Test\Builder\Rule + public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes($this->selector->getPHPatSelector()) + ->classes(...$this->getPHPSelectors($this->selectables)) ->shouldExtend() - ->classes( - $this->parent->getPHPatSelector() - ) - ->because("{$this->selector->getName()} should extend {$this->parent->getName()}."); + ->classes(...$this->getPHPSelectors($this->extensions)) + ->because("$groupName should extend class."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index db76f58..4401950 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -3,68 +3,25 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use InvalidArgumentException; -use JsonException; -use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use Kununu\ArchitectureSniffer\Configuration\Selectors; +use Generator; use PHPat\Selector\Selector; -use PHPat\Selector\SelectorInterface; use PHPat\Test\PHPat; -final readonly class MustImplement implements Rule +final readonly class MustImplement extends AbstractRule { - public const string KEY = 'implements'; - - /** - * @param InterfaceClassSelector[] $interfaces - */ public function __construct( - public Selectable $selector, - public array $interfaces, + public Generator $selectables, + public Generator $interfaces, ) { } - /** - * @param array $data - * - * @throws JsonException - */ - public static function fromArray(Selectable $selector, array $data): self - { - $interfaces = []; - foreach ($data as $interface) { - $interfaceSelector = Selectors::findSelector($interface); - if (!$interfaceSelector instanceof InterfaceClassSelector) { - throw new InvalidArgumentException( - "The {$interfaceSelector->getName()} must be declared as interface." - ); - } - $interfaces[] = $interfaceSelector; - } - - return new self($selector, $interfaces); - } - - public function getPHPatRule(): \PHPat\Test\Builder\Rule + public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { - $interfacesString = implode(', ', array_map( - static fn(Selectable $interface): string => $interface->getName(), - $this->interfaces - )); - return PHPat::rule() - ->classes( - $this->selector->getPHPatSelector(), - ) + ->classes(...$this->getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldImplement() - ->classes( - ...array_map( - static fn(Selectable $interface): SelectorInterface => $interface->getPHPatSelector(), - $this->interfaces - ) - ) - ->because("{$this->selector->getName()} must implement $interfacesString."); + ->classes(...$this->getPHPSelectors($this->interfaces)) + ->because("$groupName must implement interface."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php new file mode 100644 index 0000000..12f0ff1 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -0,0 +1,29 @@ +classes(...$this->getPHPSelectors($this->selectables)) + ->canOnlyDependOn() + ->classes( + Selector::classname('/^\\\\*[^\\\\]+$/', true), + ...$this->getPHPSelectors($this->dependencies) + ) + ->because("$groupName has dependencies outside the allowed list."); + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php deleted file mode 100644 index 082c237..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php +++ /dev/null @@ -1,60 +0,0 @@ - $data - * - * @throws JsonException - */ - public static function fromArray(Selectable $selector, array $data): self - { - $dependencies = []; - foreach ($data as $dependency) { - $dependencySelector = Selectors::findSelector($dependency); - $dependencies[] = $dependencySelector; - } - - return new self($selector, $dependencies); - } - - public function getPHPatRule(): \PHPat\Test\Builder\Rule - { - $dependentsString = implode(', ', array_map( - static fn(Selectable $dependency): string => $dependency->getName(), - $this->dependencyWhitelist - )); - - $selectors = array_map( - static fn(Selectable $dependency) => $dependency->getPHPatSelector(), - $this->dependencyWhitelist - ); - $selectors[] = Selector::classname('/^\\\\*[^\\\\]+$/', true); - - return PHPat::rule() - ->classes($this->selector->getPHPatSelector()) - ->canOnlyDependOn() - ->classes(...$selectors) - ->because("{$this->selector->getName()} should only depend on $dependentsString."); - } -} diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index 23bcab1..a37e1c0 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -3,29 +3,24 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; +use Generator; use PHPat\Test\PHPat; -final readonly class MustOnlyHaveOnePublicMethodNamed implements Rule +final readonly class MustOnlyHaveOnePublicMethodNamed extends AbstractRule { public const string KEY = 'only-one-public-method-named'; public function __construct( - public Selectable $selector, + public Generator $selectables, public string $functionName, ) { } - public static function fromArray(Selectable $base, string $functionName): self - { - return new self($base, $functionName); - } - - public function getPHPatRule(): \PHPat\Test\Builder\Rule + public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes($this->selector->getPHPatSelector()) + ->classes(...$this->getPHPSelectors($this->selectables)) ->shouldHaveOnlyOnePublicMethodNamed($this->functionName) - ->because("{$this->selector->getName()} should only have one public method named $this->functionName."); + ->because("$groupName should only have one public method named $this->functionName."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php index 994b15e..0f701e2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php @@ -3,7 +3,11 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use Generator; + interface Rule { - public function getPHPatRule(): \PHPat\Test\Builder\Rule; + public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule; + + public function getPHPSelectors(Generator $selectors): Generator; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php index d03c1b9..e0a49fd 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -11,9 +11,7 @@ { use RegexTrait; - public const string KEY = 'class'; - - public function __construct(public string $name, public string $class) + public function __construct(public string $class) { } @@ -28,8 +26,8 @@ public function getPHPatSelector(): SelectorInterface return Selector::classname($class, $class !== $this->class); } - public function getName(): string + public function getDefinition(): string { - return $this->name; + return $this->class; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index f06d0b4..6b4336d 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -11,10 +11,7 @@ { use RegexTrait; - public const string KEY = 'interface'; - public function __construct( - public string $name, public string $interface, ) { } @@ -33,8 +30,8 @@ public function getPHPatSelector(): SelectorInterface ); } - public function getName(): string + public function getDefinition(): string { - return $this->name; + return $this->interface; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index 8e7e3f3..f9ff677 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -11,10 +11,7 @@ { use RegexTrait; - public const string KEY = 'namespace'; - public function __construct( - public string $name, public string $namespace, ) { } @@ -30,8 +27,8 @@ public function getPHPatSelector(): SelectorInterface return Selector::inNamespace($namespace, $namespace !== $this->namespace); } - public function getName(): string + public function getDefinition(): string { - return $this->name; + return $this->namespace; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php b/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php index bbf6db0..ef9ed18 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php @@ -9,5 +9,5 @@ interface Selectable { public function getPHPatSelector(): SelectorInterface; - public function getName(): string; + public function getDefinition(): string; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php new file mode 100644 index 0000000..636f09e --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php @@ -0,0 +1,71 @@ +> $groups + */ + public function __construct( + private array $groups = [], + ) { + } + + private static function getSelf(): self + { + if (!isset(self::$singleton)) { + self::$singleton = new self(); + } + + return self::$singleton; + } + + /** + * @param string[] $data + */ + public static function fromArray(array $data, string $groupName): self + { + $collection = self::getSelf(); + + $collection->groups[$groupName] = $data; + + return $collection; + } + + public function getSelectablesByGroup(string $groupName): Generator + { + return self::toSelectable($this->groups[$groupName]); + } + + public static function toSelectable(string|array $fqcnListable): Generator + { + if (is_string($fqcnListable)) { + return self::generateSelectable($fqcnListable); + } + + foreach ($fqcnListable as $fqcn) { + yield self::toSelectable($fqcn); + } + } + + public static function generateSelectable(string $fqcn): Generator + { + if (self::$singleton === null) { + throw new RuntimeException('SelectableCollection is not initialized.'); + } + + return match (true) { + array_key_exists($fqcn, self::$singleton->groups) => self::$singleton->getSelectablesByGroup($fqcn), + str_starts_with($fqcn, '\\') => yield new NamespaceSelector($fqcn), + str_ends_with($fqcn, 'Interface') => yield new InterfaceClassSelector($fqcn), + default => yield new ClassSelector($fqcn), + }; + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/SubLayer.php b/Kununu/ArchitectureSniffer/Configuration/SubLayer.php index 015b22b..56972df 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SubLayer.php +++ b/Kununu/ArchitectureSniffer/Configuration/SubLayer.php @@ -9,7 +9,7 @@ use Kununu\ArchitectureSniffer\Configuration\Rules\MustBeFinal; use Kununu\ArchitectureSniffer\Configuration\Rules\MustExtend; use Kununu\ArchitectureSniffer\Configuration\Rules\MustImplement; -use Kununu\ArchitectureSniffer\Configuration\Rules\MustOnlyDependOnWhitelist; +use Kununu\ArchitectureSniffer\Configuration\Rules\MustOnlyDependOn; use Kununu\ArchitectureSniffer\Configuration\Rules\MustOnlyHaveOnePublicMethodNamed; use Kununu\ArchitectureSniffer\Configuration\Rules\Rule; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; @@ -49,7 +49,7 @@ public static function fromArray(array $subLayer): SubLayer $rules[] = MustBeFinal::fromArray($selector), MustExtend::KEY => $rules[] = MustExtend::fromArray($selector, $item), MustImplement::KEY => $rules[] = MustImplement::fromArray($selector, $item), - MustOnlyDependOnWhitelist::KEY => $rules[] = MustOnlyDependOnWhitelist::fromArray( + MustOnlyDependOn::KEY => $rules[] = MustOnlyDependOn::fromArray( $selector, $item ), diff --git a/Kununu/ArchitectureSniffer/DirectoryFinder.php b/Kununu/ArchitectureSniffer/DirectoryFinder.php deleted file mode 100644 index 85aeef2..0000000 --- a/Kununu/ArchitectureSniffer/DirectoryFinder.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - public static function getArchitectureDefinition(): array - { - $filePath = self::getArchitectureDefinitionFile(); - - if (!file_exists($filePath)) { - throw new InvalidArgumentException( - 'ArchitectureSniffer definition file not found, please create it at ' . $filePath - ); - } - - return Yaml::parseFile($filePath); - } - - public static function getProjectDirectory(): string - { - $directory = dirname(__DIR__); - - return explode('/services', $directory)[0] . '/services'; - } - - public static function getArchitectureDefinitionFile(): string - { - return self::getProjectDirectory() . self::ARCHITECTURE_DEFINITION_FILE; - } -} diff --git a/Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php b/Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php deleted file mode 100644 index 55d2574..0000000 --- a/Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php +++ /dev/null @@ -1,84 +0,0 @@ - - */ -final class FollowsFolderStructureRule implements Rule -{ - /** - * @param array $architectureLayers - * @param array $deprecatedLayers - */ - public function __construct( - private array $architectureLayers = [], - private array $deprecatedLayers = [], - ) { - $archDefinition = DirectoryFinder::getArchitectureDefinition(); - - foreach ($archDefinition['architecture'] as $layer) { - $this->architectureLayers[] = $layer['layer']; - } - - foreach ($archDefinition['deprecated'] as $layer) { - $this->deprecatedLayers[] = $layer['layer']; - } - } - - public function getNodeType(): string - { - return Namespace_::class; - } - - /** - * @throws ShouldNotHappenException - * - * @return RuleError[] - */ - public function processNode(Node $node, Scope $scope): array - { - $directories = array_merge($this->architectureLayers, $this->deprecatedLayers); - $basePath = DirectoryFinder::getProjectDirectory() . '/src'; - - $directory = glob($basePath . '/*'); - - if ($directory === false) { - return [ - RuleErrorBuilder::message("Directory does not exist $basePath/*")->build(), - ]; - } - - $actualDirectories = array_filter($directory, 'is_dir'); - $actualNames = array_map('basename', $actualDirectories); - - // Check for extra directories - $extraDirs = array_diff($actualNames, $directories); - if (!empty($extraDirs)) { - return [ - RuleErrorBuilder::message('Unexpected base directories found: ' . implode(', ', $extraDirs)) - ->build(), - ]; - } - - // Check for missing expected directories - $missingDirs = array_diff($directories, $actualNames); - if (!empty($missingDirs)) { - return [ - RuleErrorBuilder::message('Missing expected base directories: ' . implode(', ', $missingDirs)) - ->build(), - ]; - } - - return []; - } -} diff --git a/Kununu/ArchitectureSniffer/Helper/ProjectPathResolver.php b/Kununu/ArchitectureSniffer/Helper/ProjectPathResolver.php new file mode 100644 index 0000000..f087f74 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Helper/ProjectPathResolver.php @@ -0,0 +1,19 @@ + Date: Wed, 13 Aug 2025 17:58:06 +0200 Subject: [PATCH 02/35] adding some advanced documentation --- .../ArchitectureSniffer.php | 1 + .../{ => Configuration}/Architecture.php | 3 +- .../Configuration/Group.php | 4 +- .../Configuration/Layer.php | 49 --- .../Configuration/Selector/RegexTrait.php | 4 + .../Selector/SelectableCollection.php | 4 +- .../Configuration/SubLayer.php | 74 ----- Kununu/ArchitectureSniffer/README.md | 281 ++++++++++++------ 8 files changed, 205 insertions(+), 215 deletions(-) rename Kununu/ArchitectureSniffer/{ => Configuration}/Architecture.php (96%) delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Layer.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/SubLayer.php diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 0c1d06a..ab8c495 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureSniffer; +use Kununu\ArchitectureSniffer\Configuration\Architecture; use Kununu\ArchitectureSniffer\Helper\ProjectPathResolver; use PHPat\Test\Builder\Rule as PHPatRule; use Symfony\Component\Yaml\Yaml; diff --git a/Kununu/ArchitectureSniffer/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php similarity index 96% rename from Kununu/ArchitectureSniffer/Architecture.php rename to Kununu/ArchitectureSniffer/Configuration/Architecture.php index 20444b7..d495b87 100644 --- a/Kununu/ArchitectureSniffer/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -1,11 +1,10 @@ diff --git a/Kununu/ArchitectureSniffer/Configuration/Layer.php b/Kununu/ArchitectureSniffer/Configuration/Layer.php deleted file mode 100644 index fda7a06..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/Layer.php +++ /dev/null @@ -1,49 +0,0 @@ - $data - * - * @throws JsonException - * @throws Exception - */ - public static function fromArray(array $data): self - { - $selector = Selectors::findSelector($data); - - if (empty($data[self::KEY])) { - throw new InvalidArgumentException('Layer name is missing.'); - } - - return new self( - name: $data[self::KEY], - selector: $selector, - subLayers: array_key_exists(SubLayer::KEY, $data) ? - array_map( - static fn(array $subLayer) => SubLayer::fromArray($subLayer), - $data[SubLayer::KEY], - ) : [], - ); - } -} diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php index 0e2f5bb..9a7b864 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php @@ -8,6 +8,10 @@ trait RegexTrait public function makeRegex(string $path): string { if (str_contains($path, '*')) { + if (str_starts_with($path, '\\')) { + $path = substr($path, 1); + } + $path = str_replace('\\', '\\\\', $path); return '/' . str_replace('*', '.+', $path) . '/'; diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php index 636f09e..2c4d322 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php @@ -63,9 +63,9 @@ public static function generateSelectable(string $fqcn): Generator return match (true) { array_key_exists($fqcn, self::$singleton->groups) => self::$singleton->getSelectablesByGroup($fqcn), - str_starts_with($fqcn, '\\') => yield new NamespaceSelector($fqcn), + str_starts_with($fqcn, '\\') => yield new ClassSelector($fqcn), str_ends_with($fqcn, 'Interface') => yield new InterfaceClassSelector($fqcn), - default => yield new ClassSelector($fqcn), + default => yield new NamespaceSelector($fqcn), }; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/SubLayer.php b/Kununu/ArchitectureSniffer/Configuration/SubLayer.php deleted file mode 100644 index 56972df..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/SubLayer.php +++ /dev/null @@ -1,74 +0,0 @@ - $subLayer - * - * @throws JsonException - * @throws Exception - */ - public static function fromArray(array $subLayer): SubLayer - { - $rules = []; - $selector = Selectors::findSelector($subLayer); - foreach ($subLayer as $key => $item) { - if (in_array($key, Selectors::getValidTypes(), true)) { - continue; - } - match ($key) { - self::NAME_KEY => $name = $item, - MustBeFinal::KEY => $item !== true ?: - $rules[] = MustBeFinal::fromArray($selector), - MustExtend::KEY => $rules[] = MustExtend::fromArray($selector, $item), - MustImplement::KEY => $rules[] = MustImplement::fromArray($selector, $item), - MustOnlyDependOn::KEY => $rules[] = MustOnlyDependOn::fromArray( - $selector, - $item - ), - MustOnlyHaveOnePublicMethodNamed::KEY => $rules[] = MustOnlyHaveOnePublicMethodNamed::fromArray( - $selector, - $item - ), - default => throw new Exception("Unknown key: $key"), - }; - } - - if (empty($name)) { - throw new InvalidArgumentException('Missing name for sub layer'); - } - - return new self( - name: $name, - selector: $selector, - rules: $rules, - ); - } -} diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index cf79e77..d3ef2a5 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -1,106 +1,215 @@ -# Architecture tests -**Purpose:** Every domain has its own architecture. With this test you can make sure that the architecture is followed. This test will check if the architecture is followed by checking the following: -1. The folder structure is followed -2. The naming conventions are followed -3. The classes are in the right namespace -4. Layers only depend on layers they are allowed to -5. The classes are always extending the classes defined -6. The classes are always implementing the classes defined -7. The classes are strictly final -8. The classes are only having one public method with the name defined - -## Get started -1. Install the dependencies +# Architecture Sniffer + +## Overview + +Architecture Sniffer enforces architectural and coding guidelines in PHP projects. It analyzes your codebase to ensure that dependency and structural rules are followed, helping maintain code quality and consistency. + +## Quick Start + +### Prerequisites + +- PHP >= 8.3 +- Composer +- This project (`code-tools`) should be installed as a dev dependency: + ```bash + composer require --dev kununu/code-tools + ``` + +### Installation + +Install via Composer as a dev dependency: + ```bash -composer require --dev phpunit/phpunit phpat/phpat +composer require --dev kununu/code-tools +``` + +### Minimal Configuration + +Create an `architecture.yaml` in your `/services` directory: + +```yaml +architecture: + - name: "$controllers" + includes: + - "App\Controller\*Controller" + depends_on: + - "$services" + + - name: "$services" + includes: + - "App\Service\*Service" ``` -2. Configure phpstan.neon + +## Usage + +### Running with PHPStan + +Add to your `phpstan.neon`: + ```neon includes: - - vendor/phpat/phpat/extension.neon - -parameters: - ... - phpat: - ignore_built_in_classes: false - show_rule_names: true - ... + - vendor/carlosas/phpat/extension.neon services: - - class: Kununu\ArchitectureSniffer\FollowsFolderStructureRule - tags: - - phpstan.rules.rule + - + class: PHPAT\PHPStan\PHPStanExtension + tags: [phpstan.extension] +``` + +Run analysis: - - class: Kununu\ArchitectureSniffer\ArchitectureSniffer - tags: - - phpat.test +```bash +vendor/bin/phpstan analyse ``` -3. Define your architecture rules by creating an `arch_definition.yaml` in the root of your project +### Standalone Usage -### How to define your architecture -#### Requirements for the FollowsFolderStructureRule -```yaml -architecture: - - layer: FirstLayer - - layer: SecondLayer +Refer to [PHPAT documentation](https://github.com/carlosas/phpat) for standalone usage. -deprecated: - - layer: DeprecatedLayer -``` -This will make sure no other folders are created in the root (/src). -In this example the only folders allowed are FirstLayer, SecondLayer and DeprecatedLayer. -The deprecated layer will be kept ignored, in case you are in the process of removing it. +## Configuration + +The `architecture.yaml` file defines architectural groups and their dependencies. Each group represents a logical part of your application and specifies which other groups or classes it can depend on. + +### Example Configuration -#### Require sublayers with the namespace or class definition ```yaml architecture: - - layer: FirstLayer - sublayers: - - name: FirstLayer1 - class: "App\\FirstLayer\\ClassName" - - name: FirstLayer2 - namespace: "App\\FirstLayer\\SubNamespace" - - layer: SecondLayer - sublayers: - - name: SecondLayer1 - class: "App\\SecondLayer\\*\\ClassName" - - name: SecondLayer2 - namespace: "App\\SecondLayer\\*\\SubNamespace" + - name: "$controllers" + final: true + extends: "$baseControllers" + implements: + - "App\Controller\ControllerInterface" + must_only_have_one_public_method_named: "handle" + includes: + - "App\Controller\*Controller" + depends_on: + - "$services" + - "$models" + - "External\Library\SomeClass" + + - name: "$baseControllers" + includes: + - "App\Controller\Base\*BaseController" + + - name: "$services" + final: false + implements: + - "App\Service\ServiceInterface" + includes: + - "App\Service\*Service" + - "$models" + depends_on: + - "$models" + + - name: "$models" + includes: + - "App\Model\*Model" ``` -You can use * to match any class or namespace. -These are used as the base, in which all classes will be checked against the rules defined. -#### Define the rules for the layers - -The following rules are currently available: -- **dependency-whitelist**: This will check that the defined sublayer is only using the classes defined by the Whitelist. -- **extends**: This will check that the defined sublayer is always extending the defined class. -- **implements**: This will check that the defined sublayer is always implementing the defined class. -- **final**: This will check that all classes in defined sublayer are always final. -- **only-one-public-method-named**: This will check that the defined sublayer is only having one public method with the name defined. This is used to make sure that the class is only used as e.g. Controller, Command and etc. + +### Group Properties + +Each group in your `architecture.yaml` configuration can have several properties. Only `name` and `includes` are required; all other properties are optional and trigger specific architectural rules: + +- **name** (required): + - Unique identifier for the group. Prefixing with `$` is recommended to avoid confusion with class names. + - Example: `name: "$controllers"` + +- **includes** (required): + - List of patterns or group names that define which classes/interfaces belong to this group. + - Example: `includes: ["App\\Controller\\*Controller"]` + - **Rule triggered:** Classes matching these patterns are considered part of the group. + +- **depends_on** (optional): + - List of group names or patterns that this group is allowed to depend on. + - Example: `depends_on: ["$services", "App\\Library\\*"]` + - **Rule triggered:** Ensures that classes in this group only depend on allowed groups/classes. Violations are reported if dependencies are outside this list. + - **Important:** If a group includes from a global namespace other than `App\`, it must NOT have a `depends_on` property. This will cause a configuration error. + +- **final** (optional): + - Boolean (`true`/`false`). If `true`, all classes in this group must be declared as `final`. + - Example: `final: true` + - **Rule triggered:** Reports any class in the group that is not declared as `final`. + +- **extends** (optional): + - Group name or class/interface that all classes in this group must extend. + - Example: `extends: "$baseControllers"` or `extends: "App\\BaseController"` + - **Rule triggered:** Reports any class in the group that does not extend the specified base class/group. + +- **implements** (optional): + - List of interfaces that all classes in this group must implement. + - Example: `implements: ["App\\Controller\\ControllerInterface"]` + - **Rule triggered:** Reports any class in the group that does not implement the required interfaces. + +- **must_only_have_one_public_method_named** (optional): + - String. Restricts classes in this group to only one public method with the specified name. + - Example: `must_only_have_one_public_method_named: "handle"` + - **Rule triggered:** Reports any class in the group that has more than one public method or a public method with a different name. + +#### Summary Table +| Property | Required | Type | Description | Rule Triggered | +|----------------------------------|----------|-----------|-----------------------------------------------------------------------------|---------------------------------------------------------------------| +| name | Yes | string | Unique group name (recommended: `$` prefix) | Defines group | +| includes | Yes | array | Patterns or group names for group membership | Group membership | +| depends_on | No | array | Allowed dependencies (snake_case, not camelCase) | Dependency restriction | +| final | No | boolean | Require classes to be `final` | Final class enforcement | +| extends | No | string | Required base class/group | Inheritance enforcement | +| implements | No | array | Required interfaces | Interface implementation enforcement | +| must_only_have_one_public_method_named | No | string | Restrict to one public method with this name | Public method restriction | + +**Note:** +- Property names in YAML must use `snake_case` (e.g., `depends_on`), not camelCase. +- If a group includes from a global namespace other than `App\`, do not define `depends_on` for that group. +- The configuration will fail with a clear error if these rules are violated. + +## Advanced Features + +### Variable Referencing + +- Groups are referenced by their name. +- The `$` prefix is recommended but not required. +- The reference must match the group name exactly. + +### Pattern Matching + +- Use backslashes for namespaces and `*` as a wildcard. +- Internally, `*` is converted to `.+` for regex matching. +- Example: `App\Controller\*Controller` becomes `/App\\Controller\\.+Controller/`. + +### Avoiding Accidental Matches + ```yaml architecture: - - layer: FirstLayer - sub-layers: - - name: FirstLayer1 - class: "App\\FirstLayer\\ClassName" - dependency-whitelist: - - interface: "Doctrine\\ORM\\EntityManagerInterface" - - class: "App\\Application\\*\\Command" - - namespace: "Another\\SubNamespace" - extends: - class: "App\\FirstLayer\\AbstractFirstLayerClass" - implements: - - interface: "App\\FirstLayer\\FirstLayerInterface" - final: true - only-one-public-method-named: "__invoke" -``` + - name: "$repositories" + final: true + implements: + - "App\Repository\RepositoryInterface" + must_only_have_one_public_method_named: "find" + includes: + - "App\Repository\*Repository" + depends_on: + - "$models" + - "App\Model\*Model" -## You are ready to go -You can test your setup by running the following command: -```bash -php services/vendor/bin/phpstan clear-result && php services/vendor/bin/phpstan analyse -c services/phpstan.neon --memory-limit 240M + - name: "$models" + includes: + - "App\Model\*Model" ``` -This will clear the cache and run the tests. -You can run the tests in your directory or in the container. +## Troubleshooting & FAQ + +- Ensure `architecture.yaml` is in your project root. +- Check for typos in group names and references. +- For more help, see [PHPAT issues](https://github.com/carlosas/phpat/issues). + +## Contributing + +Contributions are welcome! Please submit issues or pull requests via GitHub. + +## License + +See [LICENSE](../LICENSE). + +## Further Resources + +- [PHPAT Documentation](https://github.com/carlosas/phpat) +- [Architecture Sniffer (Spryker)](https://github.com/spryker/architecture-sniffer) From 758f8b44a6eac1fff9b56bac62aabc2a909608ae Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 11:28:10 +0200 Subject: [PATCH 03/35] fixing quodana --- .../Configuration/Architecture.php | 50 ++++++++++++++----- .../Configuration/Group.php | 14 +++--- .../Selector/InterfaceClassSelector.php | 5 +- .../Selector/NamespaceSelector.php | 5 +- .../Configuration/Selector/RegexTrait.php | 6 ++- .../Selector/SelectableCollection.php | 9 ++-- Kununu/ArchitectureSniffer/README.md | 22 ++++++++ 7 files changed, 79 insertions(+), 32 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index d495b87..3fa82b8 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -11,29 +11,53 @@ /** * @param array $groups */ - private function __construct( - private array $groups, - ) { + private function __construct(private array $groups) + { } public static function fromArray(array $data): self { - if (empty($data) || !is_array($data)) { - throw new InvalidArgumentException('Invalid architecture configuration: "groups" must be a non-empty array.'); + if (empty($data)) { + throw new InvalidArgumentException( + 'Invalid architecture configuration: "groups" must be a non-empty array.' + ); } - // each group must have an include with at least on fully qualified fqcn or another qualified group - if (!array_filter($data, static fn(array $group) => array_key_exists(Group::INCLUDES_KEY, $group) && !empty($group[Group::INCLUDES_KEY]))) { - throw new InvalidArgumentException('Each group must have an "includes" property with at least one fully qualified fqcn or another qualified group.'); + // each group must have an include with at least one fully qualified fqcn or another qualified group + if (!array_filter( + $data, + static fn(array $group) => array_key_exists(Group::INCLUDES_KEY, $group) + && !empty($group[Group::INCLUDES_KEY]) + )) { + throw new InvalidArgumentException( + 'Each group must have an "includes" property with at least one fully qualified fqcn or ' + . 'another qualified group.' + ); } // at least one group with a depends_on property with at least one fqcn or another qualified group - if (!array_filter($data, static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) && !empty($group[Group::DEPENDS_ON_KEY]))) { - throw new InvalidArgumentException('At least one group must have a "dependsOn" property with at least one fqcn or another qualified group.'); + if (!array_filter( + $data, + static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) + && !empty($group[Group::DEPENDS_ON_KEY]) + )) { + throw new InvalidArgumentException( + 'At least one group must have a "dependsOn" property with at least one fqcn or ' + . 'another qualified group.' + ); } // groups with at least one include from a global namespace other than App\\, the depends_on properties must not be defined - if (array_filter($data, static fn(array $group) => !str_starts_with($group['includes'][0], 'App\\'))) { - if (array_filter($data, static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group))) { - throw new InvalidArgumentException('Groups with includes from a global namespace other than App\\ must not have a "dependsOn" property defined.'); + if (array_filter( + $data, + static fn(array $group) => !str_starts_with($group['includes'][0], 'App\\') + )) { + if (array_filter( + $data, + static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) + )) { + throw new InvalidArgumentException( + 'Groups with includes from a global namespace other than App\\ must not have a ' + . '"dependsOn" property defined.' + ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 7cbd2d6..70d86f4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -29,13 +29,13 @@ final class Group * @param array|null $implements */ private function __construct( - private string $name, - private SelectableCollection $includes, - private ?array $dependsOn = null, - private bool $final = false, - private ?Selectable $extends = null, - private ?array $implements = null, - private ?string $mustOnlyHaveOnePublicMethodNamed = null, + private readonly string $name, + private readonly SelectableCollection $includes, + private readonly ?array $dependsOn = null, + private readonly bool $final = false, + private readonly ?Selectable $extends = null, + private readonly ?array $implements = null, + private readonly ?string $mustOnlyHaveOnePublicMethodNamed = null, ) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index 6b4336d..7db96b1 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -11,9 +11,8 @@ { use RegexTrait; - public function __construct( - public string $interface, - ) { + public function __construct(public string $interface) + { } public function getPHPatSelector(): SelectorInterface diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index f9ff677..e7897cd 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -11,9 +11,8 @@ { use RegexTrait; - public function __construct( - public string $namespace, - ) { + public function __construct(public string $namespace) + { } public function getPHPatSelector(): SelectorInterface diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php index 9a7b864..7a4cdcd 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php @@ -5,7 +5,7 @@ trait RegexTrait { - public function makeRegex(string $path): string + public function makeRegex(string $path, bool $file = false): string { if (str_contains($path, '*')) { if (str_starts_with($path, '\\')) { @@ -17,6 +17,10 @@ public function makeRegex(string $path): string return '/' . str_replace('*', '.+', $path) . '/'; } + if ($file && !str_starts_with($path, '\\')) { + return "\\$path"; + } + return $path; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php index 2c4d322..6b8c856 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php @@ -13,9 +13,8 @@ final class SelectableCollection /** * @param array> $groups */ - public function __construct( - private array $groups = [], - ) { + public function __construct(private array $groups = []) + { } private static function getSelf(): self @@ -63,9 +62,9 @@ public static function generateSelectable(string $fqcn): Generator return match (true) { array_key_exists($fqcn, self::$singleton->groups) => self::$singleton->getSelectablesByGroup($fqcn), - str_starts_with($fqcn, '\\') => yield new ClassSelector($fqcn), + str_ends_with($fqcn, '\\') => yield new NamespaceSelector($fqcn), str_ends_with($fqcn, 'Interface') => yield new InterfaceClassSelector($fqcn), - default => yield new NamespaceSelector($fqcn), + default => yield new ClassSelector($fqcn), }; } } diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index d3ef2a5..6b1904b 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -161,6 +161,28 @@ Each group in your `architecture.yaml` configuration can have several properties - If a group includes from a global namespace other than `App\`, do not define `depends_on` for that group. - The configuration will fail with a clear error if these rules are violated. +### How Classes, Interfaces, and Namespaces Are Defined + +When specifying patterns or references in your `architecture.yaml` (for `includes`, `depends_on`, etc.), the sniffer interprets them as follows: + +- **Group Reference:** + - If the string matches a group name defined elsewhere in your configuration, it is treated as a reference to that group. All selectables from that group are included. + - Example: `"$services"` refers to the group named `$services`. + +- **Namespace:** + - If the string ends with a backslash (`\`), it is treated as a namespace. All classes within that namespace are matched. + - Example: `"App\\Service\\"` matches everything in the `App\Service` namespace. + +- **Interface:** + - If the string ends with `Interface`, it is treated as an interface. + - Example: `"App\\Service\\ServiceInterface"` matches the interface `ServiceInterface`. + +- **Class:** + - Any other string is treated as a fully qualified class name (FQCN). + - Example: `"App\\Controller\\MyController"` matches the class `MyController`. + +This logic applies to all properties that accept patterns or references, such as `includes`, `depends_on`, `extends`, and `implements`. + ## Advanced Features ### Variable Referencing From f8ce7caf9eaf4cda98c02d45883765a0f39349e3 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 11:41:18 +0200 Subject: [PATCH 04/35] fixing phpstan --- .../Configuration/Architecture.php | 3 +++ Kununu/ArchitectureSniffer/Configuration/Group.php | 10 ++++++---- .../Configuration/Rules/MustExtend.php | 12 ++++++++---- .../Configuration/Selector/ClassSelector.php | 2 ++ .../Selector/InterfaceClassSelector.php | 2 ++ .../Configuration/Selector/NamespaceSelector.php | 2 ++ .../Configuration/Selector/SelectableCollection.php | 7 +++++-- .../ArchitectureSniffer/Configuration/Selectors.php | 6 +++--- 8 files changed, 31 insertions(+), 13 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index 3fa82b8..21d8d94 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -15,6 +15,9 @@ private function __construct(private array $groups) { } + /** + * @param array> $data + */ public static function fromArray(array $data): self { if (empty($data)) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 70d86f4..f3422a7 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -6,7 +6,6 @@ use Generator; use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Rules\Rule; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; use Kununu\ArchitectureSniffer\Configuration\Selector\SelectableCollection; final class Group @@ -25,20 +24,23 @@ final class Group private array $rules = []; /** - * @param array $dependsOn - * @param array|null $implements + * @param array|null $dependsOn + * @param array|null $implements */ private function __construct( private readonly string $name, private readonly SelectableCollection $includes, private readonly ?array $dependsOn = null, private readonly bool $final = false, - private readonly ?Selectable $extends = null, + private readonly ?string $extends = null, private readonly ?array $implements = null, private readonly ?string $mustOnlyHaveOnePublicMethodNamed = null, ) { } + /** + * @param array $data + */ public static function fromArray(array $data): self { if (!array_key_exists(self::NAME_KEY, $data) || array_key_exists(self::INCLUDES_KEY, $data)) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 04cbff7..8f5f575 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -16,10 +16,14 @@ public function __construct( public Generator $extensions, public Generator $selectables, ) { - if ($this->extensions instanceof InterfaceClassSelector) { - throw new InvalidArgumentException( - 'Classes can not extend interfaces.' - ); + $extensions = clone $this->extensions; + + foreach ($extensions as $extension) { + if ($extension instanceof InterfaceClassSelector) { + throw new InvalidArgumentException( + 'Classes can not extend interfaces.' + ); + } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php index e0a49fd..093ddb2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -11,6 +11,8 @@ { use RegexTrait; + public const KEY = 'ClassSelector'; + public function __construct(public string $class) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index 7db96b1..a628b00 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -11,6 +11,8 @@ { use RegexTrait; + public const KEY = 'InterfaceSelector'; + public function __construct(public string $interface) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index e7897cd..7230288 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -11,6 +11,8 @@ { use RegexTrait; + public const KEY = 'NamespaceSelector'; + public function __construct(public string $namespace) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php index 6b8c856..bd9de31 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php @@ -43,6 +43,9 @@ public function getSelectablesByGroup(string $groupName): Generator return self::toSelectable($this->groups[$groupName]); } + /** + * @param string|array $fqcnListable + */ public static function toSelectable(string|array $fqcnListable): Generator { if (is_string($fqcnListable)) { @@ -63,8 +66,8 @@ public static function generateSelectable(string $fqcn): Generator return match (true) { array_key_exists($fqcn, self::$singleton->groups) => self::$singleton->getSelectablesByGroup($fqcn), str_ends_with($fqcn, '\\') => yield new NamespaceSelector($fqcn), - str_ends_with($fqcn, 'Interface') => yield new InterfaceClassSelector($fqcn), - default => yield new ClassSelector($fqcn), + str_ends_with($fqcn, 'Interface') => yield new InterfaceClassSelector($fqcn), + default => yield new ClassSelector($fqcn), }; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selectors.php b/Kununu/ArchitectureSniffer/Configuration/Selectors.php index 1d3b2e2..bfba736 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selectors.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selectors.php @@ -48,9 +48,9 @@ public static function findSelector(array $data, ?string $nameKey = null): Selec private static function createSelector(self $type, string $name, string $selection): Selectable { return match ($type) { - self::ClassSelector => new ClassSelector($name, $selection), - self::InterfaceSelector => new InterfaceClassSelector($name, $selection), - self::NamespaceSelector => new NamespaceSelector($name, $selection), + self::ClassSelector => new ClassSelector($selection), + self::InterfaceSelector => new InterfaceClassSelector($selection), + self::NamespaceSelector => new NamespaceSelector($selection), }; } } From cf44bec07addf160cb6b49a09fa0a32b3c22f32c Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 11:51:03 +0200 Subject: [PATCH 05/35] fix quodana --- .../Configuration/Selector/ClassSelector.php | 2 +- .../Configuration/Selector/InterfaceClassSelector.php | 2 +- .../Configuration/Selector/NamespaceSelector.php | 2 +- Kununu/ArchitectureSniffer/Configuration/Selectors.php | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php index 093ddb2..deba921 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -11,7 +11,7 @@ { use RegexTrait; - public const KEY = 'ClassSelector'; + public const string KEY = 'ClassSelector'; public function __construct(public string $class) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index a628b00..29cedb7 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -11,7 +11,7 @@ { use RegexTrait; - public const KEY = 'InterfaceSelector'; + public const string KEY = 'InterfaceSelector'; public function __construct(public string $interface) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index 7230288..528d5db 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -11,7 +11,7 @@ { use RegexTrait; - public const KEY = 'NamespaceSelector'; + public const string KEY = 'NamespaceSelector'; public function __construct(public string $namespace) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Selectors.php b/Kununu/ArchitectureSniffer/Configuration/Selectors.php index bfba736..e47314e 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selectors.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selectors.php @@ -37,7 +37,7 @@ public static function findSelector(array $data, ?string $nameKey = null): Selec { foreach (self::getValidTypes() as $type) { if (array_key_exists($type, $data)) { - return self::createSelector(self::from($type), $data[$nameKey ?? $type], $data[$type]); + return self::createSelector(self::from($type), $data[$type]); } } @@ -45,7 +45,7 @@ public static function findSelector(array $data, ?string $nameKey = null): Selec 'Missing selector in data ' . json_encode($data, JSON_THROW_ON_ERROR)); } - private static function createSelector(self $type, string $name, string $selection): Selectable + private static function createSelector(self $type, string $selection): Selectable { return match ($type) { self::ClassSelector => new ClassSelector($selection), From fa02e4e31813212f498e55de987a064e7bd0a6d4 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 13:04:22 +0200 Subject: [PATCH 06/35] fixing the sniffer --- .../ArchitectureSniffer.php | 4 +- .../Configuration/Architecture.php | 41 ++++++++++++------- .../Configuration/Group.php | 14 ++++--- .../Configuration/Rules/AbstractRule.php | 9 +++- .../Configuration/Rules/MustBeFinal.php | 4 +- .../Configuration/Rules/MustExtend.php | 10 ++--- .../Configuration/Rules/MustImplement.php | 4 +- .../Configuration/Rules/MustOnlyDependOn.php | 4 +- .../MustOnlyHaveOnePublicMethodNamed.php | 2 +- .../Configuration/Rules/Rule.php | 2 +- Kununu/ArchitectureSniffer/README.md | 4 ++ 11 files changed, 62 insertions(+), 36 deletions(-) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index ab8c495..a092a14 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -18,8 +18,8 @@ final class ArchitectureSniffer public function testArchitecture(): iterable { $architecture = $this->getArchitecture(); - foreach ($architecture->getGroups() as $group) { - foreach ($group->getRules() as $rule) { + foreach ($architecture->getRules() as $rules) { + foreach ($rules as $rule) { yield $rule; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index 21d8d94..dcce89a 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -8,6 +8,8 @@ final readonly class Architecture { + public const string ARCHITECTURE_KEY = 'architecture'; + /** * @param array $groups */ @@ -20,7 +22,15 @@ private function __construct(private array $groups) */ public static function fromArray(array $data): self { - if (empty($data)) { + if (!array_key_exists(self::ARCHITECTURE_KEY, $data)) { + throw new InvalidArgumentException( + 'Invalid architecture configuration: "architecture" key is missing.' + ); + } + + $architecture = $data['architecture']; + + if (empty($architecture)) { throw new InvalidArgumentException( 'Invalid architecture configuration: "groups" must be a non-empty array.' ); @@ -28,7 +38,7 @@ public static function fromArray(array $data): self // each group must have an include with at least one fully qualified fqcn or another qualified group if (!array_filter( - $data, + $architecture, static fn(array $group) => array_key_exists(Group::INCLUDES_KEY, $group) && !empty($group[Group::INCLUDES_KEY]) )) { @@ -39,7 +49,7 @@ public static function fromArray(array $data): self } // at least one group with a depends_on property with at least one fqcn or another qualified group if (!array_filter( - $data, + $architecture, static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) && !empty($group[Group::DEPENDS_ON_KEY]) )) { @@ -49,12 +59,17 @@ public static function fromArray(array $data): self ); } // groups with at least one include from a global namespace other than App\\, the depends_on properties must not be defined - if (array_filter( - $data, - static fn(array $group) => !str_starts_with($group['includes'][0], 'App\\') - )) { + $groupsWithIncludesFromGlobalNamespace = array_filter( + $architecture, + static fn(array $group) => !array_filter( + is_array($group[Group::INCLUDES_KEY] ?? null) ? $group[Group::INCLUDES_KEY] : [], + fn($include) => str_starts_with($include, 'App\\') + ) + ); + + if ($groupsWithIncludesFromGlobalNamespace) { if (array_filter( - $data, + $groupsWithIncludesFromGlobalNamespace, static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) )) { throw new InvalidArgumentException( @@ -66,20 +81,16 @@ public static function fromArray(array $data): self $groups = array_map( static fn(array $groupData) => Group::fromArray($groupData), - $data + $architecture ); - foreach ($groups as $group) { - $group->generateRules(); - } - return new self($groups); } - public function getGroups(): Generator + public function getRules(): Generator { foreach ($this->groups as $group) { - yield $group; + yield $group->generateRules()->getRules(); } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index f3422a7..92da052 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -43,7 +43,7 @@ private function __construct( */ public static function fromArray(array $data): self { - if (!array_key_exists(self::NAME_KEY, $data) || array_key_exists(self::INCLUDES_KEY, $data)) { + if (!array_key_exists(self::NAME_KEY, $data) || !array_key_exists(self::INCLUDES_KEY, $data)) { throw new InvalidArgumentException('Group configuration must contain "name" and "includes" keys.'); } @@ -58,7 +58,7 @@ public static function fromArray(array $data): self ); } - public function generateRules(): Generator + public function generateRules(): self { if ($this->extends) { $this->rules[] = new Rules\MustExtend( @@ -68,31 +68,33 @@ public function generateRules(): Generator } if ($this->implements) { - yield new Rules\MustImplement( + $this->rules[] = new Rules\MustImplement( selectables: $this->includes->getSelectablesByGroup($this->name), interfaces: SelectableCollection::toSelectable($this->implements), ); } if ($this->final) { - yield new Rules\MustBeFinal( + $this->rules[] = new Rules\MustBeFinal( selectables: $this->includes->getSelectablesByGroup($this->name), ); } if ($this->dependsOn) { - yield new Rules\MustOnlyDependOn( + $this->rules[] = new Rules\MustOnlyDependOn( selectables: $this->includes->getSelectablesByGroup($this->name), dependencies: SelectableCollection::toSelectable($this->dependsOn), ); } if ($this->mustOnlyHaveOnePublicMethodNamed) { - yield new Rules\MustOnlyHaveOnePublicMethodNamed( + $this->rules[] = new Rules\MustOnlyHaveOnePublicMethodNamed( selectables: $this->includes->getSelectablesByGroup($this->name), functionName: $this->mustOnlyHaveOnePublicMethodNamed, ); } + + return $this; } public function getRules(): Generator diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index 5e74bd1..5b5a9fb 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -4,12 +4,19 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use Generator; +use InvalidArgumentException; +use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; abstract readonly class AbstractRule implements Rule { - public function getPHPSelectors(Generator $selectors): Generator + public static function getPHPSelectors(Generator $selectors): Generator { foreach ($selectors as $selector) { + if (!$selector instanceof Selectable) { + throw new InvalidArgumentException( + 'Only Selectable instances can be used in rules.' + ); + } yield $selector->getPHPatSelector(); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 8a8beff..d1995d4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -21,12 +21,14 @@ public function __construct(public Generator $selectables) ); } } + + $this->selectables->rewind(); } public function getPHPatRule(string $groupName): PHPatRule { return PHPat::rule() - ->classes(...$this->getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldBeFinal() ->because("$groupName must be final."); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 8f5f575..ee64ca0 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -16,23 +16,23 @@ public function __construct( public Generator $extensions, public Generator $selectables, ) { - $extensions = clone $this->extensions; - - foreach ($extensions as $extension) { + foreach ($this->extensions as $extension) { if ($extension instanceof InterfaceClassSelector) { throw new InvalidArgumentException( 'Classes can not extend interfaces.' ); } } + + $this->extensions->rewind(); } public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...$this->getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->shouldExtend() - ->classes(...$this->getPHPSelectors($this->extensions)) + ->classes(...self::getPHPSelectors($this->extensions)) ->because("$groupName should extend class."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 4401950..98b39b4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -18,10 +18,10 @@ public function __construct( public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...$this->getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldImplement() - ->classes(...$this->getPHPSelectors($this->interfaces)) + ->classes(...self::getPHPSelectors($this->interfaces)) ->because("$groupName must implement interface."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 12f0ff1..2e80fda 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -18,11 +18,11 @@ public function __construct( public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...$this->getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->canOnlyDependOn() ->classes( Selector::classname('/^\\\\*[^\\\\]+$/', true), - ...$this->getPHPSelectors($this->dependencies) + ...self::getPHPSelectors($this->dependencies) ) ->because("$groupName has dependencies outside the allowed list."); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index a37e1c0..f4c54bb 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -19,7 +19,7 @@ public function __construct( public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...$this->getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->shouldHaveOnlyOnePublicMethodNamed($this->functionName) ->because("$groupName should only have one public method named $this->functionName."); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php index 0f701e2..fa20207 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php @@ -9,5 +9,5 @@ interface Rule { public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule; - public function getPHPSelectors(Generator $selectors): Generator; + public static function getPHPSelectors(Generator $selectors): Generator; } diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index 6b1904b..5e871c7 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -221,6 +221,10 @@ architecture: - Ensure `architecture.yaml` is in your project root. - Check for typos in group names and references. +- For a clean static analysis run, use: + ```sh + php vendor/bin/phpstan clear-result && php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 240M + ``` - For more help, see [PHPAT issues](https://github.com/carlosas/phpat/issues). ## Contributing From 32294c91d33c5a6fa2a8baae5b28679ea17922b1 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 17:34:21 +0200 Subject: [PATCH 07/35] refactor Singleton --- .../Configuration/Architecture.php | 20 ++- .../Configuration/Group.php | 64 +++---- .../Configuration/Selector/ClassSelector.php | 2 - .../Selector/InterfaceClassSelector.php | 2 - .../Selector/NamespaceSelector.php | 2 - .../Selector/SelectableCollection.php | 73 -------- .../Configuration/Selectors.php | 56 ------- .../Configuration/SelectorsLibrary.php | 84 ++++++++++ architecture.yaml | 158 ++++++++++++++++++ 9 files changed, 277 insertions(+), 184 deletions(-) delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Selectors.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php create mode 100644 architecture.yaml diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index dcce89a..2502bfb 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -13,8 +13,10 @@ /** * @param array $groups */ - private function __construct(private array $groups) - { + private function __construct( + private array $groups, + private SelectorsLibrary $selectorsLibrary, + ) { } /** @@ -79,18 +81,20 @@ public static function fromArray(array $data): self } } - $groups = array_map( - static fn(array $groupData) => Group::fromArray($groupData), - $architecture - ); + $groups = []; + foreach ($architecture as $groupName => $groupData) { + $groups[] = Group::fromArray($groupName, $groupData); + } + + $selectorsLibrary = new SelectorsLibrary($architecture); - return new self($groups); + return new self($groups, $selectorsLibrary); } public function getRules(): Generator { foreach ($this->groups as $group) { - yield $group->generateRules()->getRules(); + yield from $group->getRules($this->selectorsLibrary); } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 92da052..cd94384 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -5,10 +5,8 @@ use Generator; use InvalidArgumentException; -use Kununu\ArchitectureSniffer\Configuration\Rules\Rule; -use Kununu\ArchitectureSniffer\Configuration\Selector\SelectableCollection; -final class Group +final readonly class Group { private const string NAME_KEY = 'name'; public const string INCLUDES_KEY = 'includes'; @@ -18,38 +16,31 @@ final class Group private const string IMPLEMENTS_KEY = 'implements'; private const string MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY = 'must_only_have_one_public_method_named'; - /** - * @var array - */ - private array $rules = []; - /** * @param array|null $dependsOn * @param array|null $implements */ private function __construct( - private readonly string $name, - private readonly SelectableCollection $includes, - private readonly ?array $dependsOn = null, - private readonly bool $final = false, - private readonly ?string $extends = null, - private readonly ?array $implements = null, - private readonly ?string $mustOnlyHaveOnePublicMethodNamed = null, + private string $name, + private ?array $dependsOn = null, + private bool $final = false, + private ?string $extends = null, + private ?array $implements = null, + private ?string $mustOnlyHaveOnePublicMethodNamed = null, ) { } /** * @param array $data */ - public static function fromArray(array $data): self + public static function fromArray(string $name, array $data): self { if (!array_key_exists(self::NAME_KEY, $data) || !array_key_exists(self::INCLUDES_KEY, $data)) { throw new InvalidArgumentException('Group configuration must contain "name" and "includes" keys.'); } return new self( - name: $data[self::NAME_KEY], - includes: SelectableCollection::fromArray($data[self::INCLUDES_KEY], $data[self::NAME_KEY]), + name: $name, dependsOn: $data[self::DEPENDS_ON_KEY] ?? null, final: $data[self::FINAL_KEY] ?? false, extends: $data[self::EXTENDS_KEY] ?? null, @@ -58,49 +49,40 @@ public static function fromArray(array $data): self ); } - public function generateRules(): self + public function getRules(SelectorsLibrary $library): Generator { if ($this->extends) { - $this->rules[] = new Rules\MustExtend( - extensions: SelectableCollection::toSelectable($this->extends), - selectables: $this->includes->getSelectablesByGroup($this->name) + yield new Rules\MustExtend( + extensions: $library->getSelector($this->extends), + selectables: $library->getSelectorsFromGroup($this->name), ); } if ($this->implements) { - $this->rules[] = new Rules\MustImplement( - selectables: $this->includes->getSelectablesByGroup($this->name), - interfaces: SelectableCollection::toSelectable($this->implements), + yield new Rules\MustImplement( + selectables: $library->getSelectorsFromGroup($this->name), + interfaces: $library->getSelectors($this->implements), ); } if ($this->final) { - $this->rules[] = new Rules\MustBeFinal( - selectables: $this->includes->getSelectablesByGroup($this->name), + yield new Rules\MustBeFinal( + selectables: $library->getSelectorsFromGroup($this->name), ); } if ($this->dependsOn) { - $this->rules[] = new Rules\MustOnlyDependOn( - selectables: $this->includes->getSelectablesByGroup($this->name), - dependencies: SelectableCollection::toSelectable($this->dependsOn), + yield new Rules\MustOnlyDependOn( + selectables: $library->getSelectorsFromGroup($this->name), + dependencies: $library->getSelectors($this->dependsOn), ); } if ($this->mustOnlyHaveOnePublicMethodNamed) { - $this->rules[] = new Rules\MustOnlyHaveOnePublicMethodNamed( - selectables: $this->includes->getSelectablesByGroup($this->name), + yield new Rules\MustOnlyHaveOnePublicMethodNamed( + selectables: $library->getSelectorsFromGroup($this->name), functionName: $this->mustOnlyHaveOnePublicMethodNamed, ); } - - return $this; - } - - public function getRules(): Generator - { - foreach ($this->rules as $rule) { - yield $rule->getPHPatRule($this->name); - } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php index deba921..e0a49fd 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -11,8 +11,6 @@ { use RegexTrait; - public const string KEY = 'ClassSelector'; - public function __construct(public string $class) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index 29cedb7..7db96b1 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -11,8 +11,6 @@ { use RegexTrait; - public const string KEY = 'InterfaceSelector'; - public function __construct(public string $interface) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index 528d5db..e7897cd 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -11,8 +11,6 @@ { use RegexTrait; - public const string KEY = 'NamespaceSelector'; - public function __construct(public string $namespace) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php b/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php deleted file mode 100644 index bd9de31..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/SelectableCollection.php +++ /dev/null @@ -1,73 +0,0 @@ -> $groups - */ - public function __construct(private array $groups = []) - { - } - - private static function getSelf(): self - { - if (!isset(self::$singleton)) { - self::$singleton = new self(); - } - - return self::$singleton; - } - - /** - * @param string[] $data - */ - public static function fromArray(array $data, string $groupName): self - { - $collection = self::getSelf(); - - $collection->groups[$groupName] = $data; - - return $collection; - } - - public function getSelectablesByGroup(string $groupName): Generator - { - return self::toSelectable($this->groups[$groupName]); - } - - /** - * @param string|array $fqcnListable - */ - public static function toSelectable(string|array $fqcnListable): Generator - { - if (is_string($fqcnListable)) { - return self::generateSelectable($fqcnListable); - } - - foreach ($fqcnListable as $fqcn) { - yield self::toSelectable($fqcn); - } - } - - public static function generateSelectable(string $fqcn): Generator - { - if (self::$singleton === null) { - throw new RuntimeException('SelectableCollection is not initialized.'); - } - - return match (true) { - array_key_exists($fqcn, self::$singleton->groups) => self::$singleton->getSelectablesByGroup($fqcn), - str_ends_with($fqcn, '\\') => yield new NamespaceSelector($fqcn), - str_ends_with($fqcn, 'Interface') => yield new InterfaceClassSelector($fqcn), - default => yield new ClassSelector($fqcn), - }; - } -} diff --git a/Kununu/ArchitectureSniffer/Configuration/Selectors.php b/Kununu/ArchitectureSniffer/Configuration/Selectors.php deleted file mode 100644 index e47314e..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/Selectors.php +++ /dev/null @@ -1,56 +0,0 @@ -value, - self::InterfaceSelector->value, - self::NamespaceSelector->value, - ]; - } - - /** - * @param array $data - * - * @throws JsonException - */ - public static function findSelector(array $data, ?string $nameKey = null): Selectable - { - foreach (self::getValidTypes() as $type) { - if (array_key_exists($type, $data)) { - return self::createSelector(self::from($type), $data[$type]); - } - } - - throw new InvalidArgumentException($nameKey !== null ? "Missing selector for $nameKey" : - 'Missing selector in data ' . json_encode($data, JSON_THROW_ON_ERROR)); - } - - private static function createSelector(self $type, string $selection): Selectable - { - return match ($type) { - self::ClassSelector => new ClassSelector($selection), - self::InterfaceSelector => new InterfaceClassSelector($selection), - self::NamespaceSelector => new NamespaceSelector($selection), - }; - } -} diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php new file mode 100644 index 0000000..95fe6c8 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -0,0 +1,84 @@ + $includes) { + $this->passedGroups = [$groupName]; + $resolvedIncludes = []; + foreach ($includes as $include) { + foreach ($this->resolveGroup($include) as $selectable) { + $resolvedIncludes[] = $selectable; + } + } + $this->flattenedGroups[$groupName] = $resolvedIncludes; + } + } + + public function getSelector(string $fqcnOrGroup): Generator + { + if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { + foreach ($this->flattenedGroups[$fqcnOrGroup] as $fqcn) { + yield from $this->createSelectable($fqcn); + } + } + + return [$this->createSelectable($fqcnOrGroup)]; + } + + public function getSelectorsFromGroup(string $groupName): Generator + { + if (!array_key_exists($groupName, $this->flattenedGroups)) { + throw new InvalidArgumentException("Group '$groupName' does not exist."); + } + + foreach ($this->flattenedGroups[$groupName] as $fqcn) { + yield from $this->createSelectable($fqcn); + } + } + + public function getSelectors(array $values): Generator + { + foreach ($values as $fqcnOrGroup) { + yield from $this->getSelector($fqcnOrGroup); + } + } + + private function resolveGroup(string $fqcnOrGroupName): Generator + { + if (array_key_exists($fqcnOrGroupName, $this->groups)) { + if (in_array($fqcnOrGroupName, $this->passedGroups, true)) { + return; + } + + foreach ($this->groups[$fqcnOrGroupName] as $subFqcnOrGroupName) { + yield from $this->resolveGroup($subFqcnOrGroupName); + } + } + + yield $this->createSelectable($fqcnOrGroupName); + } + + private function createSelectable($fqcn): Selectable + { + return match (true) { + str_ends_with($fqcn, '\\') => new NamespaceSelector($fqcn), + str_ends_with($fqcn, 'Interface') => new InterfaceClassSelector($fqcn), + default => new ClassSelector($fqcn), + }; + } +} diff --git a/architecture.yaml b/architecture.yaml new file mode 100644 index 0000000..0c2b88a --- /dev/null +++ b/architecture.yaml @@ -0,0 +1,158 @@ +architecture: + # INTERFACE LAYER + $interface_controller: + includes: + - "App\\Interface\\Controller\\*" + depends_on: + - "interface_controller" + - "App\\Application\\Query\\*\\Query" + - "Symfony\\Component\\*" + - "Psr\\*" + - "App\\Infrastructure\\Api\\*" + extends: "App\\Infrastructure\\Api\\Controller\\ApiController" + final: false + + $interface_event_processor: + includes: + - "App\\Interface\\Event\\*\\Processor" + depends_on: + - "App\\Application\\*" + - "Kununu\\Hermes\\EventsProcessorBundle\\*" + - "Psr\\Log\\*" + - "Symfony\\Component\\Validator\\*" + extends: "Kununu\\Hermes\\EventsProcessorBundle\\AbstractProcessor" + final: false + + $interface_event_inbound: + includes: + - "App\\Interface\\Event\\*\\InboundEvent" + depends_on: + - "App\\Application\\*" + - "Psr\\Log\\*" + - "Symfony\\Component\\Validator\\*" + - "interface_event_outbound" + implements: + - "Kununu\\Hermes\\EventsProcessorBundle\\ReceivableEventInterface" + final: false + + $interface_event_outbound: + includes: + - "App\\Interface\\Event\\*\\OutboundEvent" + depends_on: + - "App\\Application\\*" + - "Psr\\Log\\*" + - "Symfony\\Component\\Validator\\*" + - "App\\Interface\\Event\\AbstractNamedEvent" + implements: + - "Kununu\\Hermes\\EventsProcessorBundle\\EventInterface" + extends: "App\\Interface\\Event\\AbstractNamedEvent" + final: false + + $interface_console: + includes: + - "App\\Interface\\Console\\*" + depends_on: + - "App\\Application\\*" + - "Symfony\\Component\\Console\\*" + - "Psr\\Log\\*" + - "Symfony\\Component\\Validator\\*" + extends: "Symfony\\Component\\Console\\Command\\Command" + final: true + must_only_have_one_public_method_named: "execute" + + # APPLICATION LAYER + $application_command: + includes: + - "App\\Application\\Command\\*\\Command" + depends_on: + - "Symfony\\Component\\Validator\\Constraints" + implements: + - "Kununu\\CQRS\\Command\\Command" + final: true + + $application_command_handler: + includes: + - "App\\Application\\Command\\*\\Handler" + depends_on: + - "App\\Application\\Command\\*" + - "App\\Application\\Helper\\*" + - "App\\Repository\\Doctrine\\*\\*RepositoryInterface" + - "App\\Model\\*" + - "App\\Interface\\*" + - "Kununu\\Hermes\\EventsProcessorBundle\\EventProducerInterface" + - "Psr\\*" + - "App\\Infrastructure\\Messaging\\Producer\\SynchronousEventProducerInterface" + - "App\\Infrastructure\\Messaging\\Events\\Outbound\\*" + implements: + - "Kununu\\CQRS\\Command\\CommandHandler" + final: true + must_only_have_one_public_method_named: "handle" + + $application_query: + includes: + - "App\\Application\\Query\\*\\Query" + depends_on: + - "Symfony\\Component\\Validator\\Constraints" + implements: + - "Kununu\\CQRS\\Query\\Query" + final: false + + $application_query_handler: + includes: + - "App\\Application\\Query\\*\\Handler" + depends_on: + - "App\\Repository\\Doctrine\\*\\*ReadRepositoryInterface" + - "Symfony\\Component\\Validator\\*" + implements: + - "Kununu\\CQRS\\Query\\QueryHandler" + final: false + + $application_helper: + includes: + - "App\\Application\\Helper\\*" + depends_on: + - "$model_entity" + - "App\\Model\\DataTransferObject\\*" + final: true + + # MODEL LAYER + $model_entity + includes: + - "App\\Model\\Entity\\*" + - "$model_dto" + depends_on: + - "Doctrine\\ORM\\Mapping\\*" + - "App\\Model\\Entity\\*" + - "Doctrine\\DBAL\\Types\\Types" + final: false + + - name: "model_dto" + includes: + - "App\\Model\\DataTransferObject\\*" + depends_on: + - "App\\Model\\DataTransferObject\\*" + - "App\\Infrastructure\\Common\\*" + - "Kununu\\Collection\\*" + final: false + + # REPOSITORY LAYER + - name: "repository_doctrine" + includes: + - "App\\Repository\\Doctrine\\*\\*Repository" + depends_on: + - "App\\Model\\*" + - "Doctrine\\*" + - "App\\Infrastructure\\Common\\*" + implements: + - "App\\Repository\\Doctrine\\*\\*RepositoryInterface" + final: true + + - name: "repository_doctrine_listener" + includes: + - "App\\Repository\\Doctrine\\*\\*\\Listener" + depends_on: + - "Kununu\\Hermes\\EventsProcessorBundle\\EventProducerInterface" + - "Doctrine\\*" + - "App\\Model\\*" + - "App\\Repository\\*" + final: true From 3c22c691e0d053ab571ac02934541df3a232f8e9 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 17:38:24 +0200 Subject: [PATCH 08/35] adjust documentation --- Kununu/ArchitectureSniffer/README.md | 50 ++++----- architecture.yaml | 158 --------------------------- 2 files changed, 20 insertions(+), 188 deletions(-) delete mode 100644 architecture.yaml diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index 5e871c7..4b4a091 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -29,15 +29,14 @@ Create an `architecture.yaml` in your `/services` directory: ```yaml architecture: - - name: "$controllers" + $controllers: includes: - - "App\Controller\*Controller" + - "App\\Controller\\*Controller" depends_on: - "$services" - - - name: "$services" + $services: includes: - - "App\Service\*Service" + - "App\\Service\\*Service" ``` ## Usage @@ -74,45 +73,38 @@ The `architecture.yaml` file defines architectural groups and their dependencies ```yaml architecture: - - name: "$controllers" + $controllers: final: true extends: "$baseControllers" implements: - - "App\Controller\ControllerInterface" + - "App\\Controller\\ControllerInterface" must_only_have_one_public_method_named: "handle" includes: - - "App\Controller\*Controller" + - "App\\Controller\\*Controller" depends_on: - "$services" - "$models" - - "External\Library\SomeClass" - - - name: "$baseControllers" + - "External\\Library\\SomeClass" + $baseControllers: includes: - - "App\Controller\Base\*BaseController" - - - name: "$services" + - "App\\Controller\\Base\\*BaseController" + $services: final: false implements: - - "App\Service\ServiceInterface" + - "App\\Service\\ServiceInterface" includes: - - "App\Service\*Service" + - "App\\Service\\*Service" - "$models" depends_on: - "$models" - - - name: "$models" + $models: includes: - - "App\Model\*Model" + - "App\\Model\\*Model" ``` ### Group Properties -Each group in your `architecture.yaml` configuration can have several properties. Only `name` and `includes` are required; all other properties are optional and trigger specific architectural rules: - -- **name** (required): - - Unique identifier for the group. Prefixing with `$` is recommended to avoid confusion with class names. - - Example: `name: "$controllers"` +Each group in your `architecture.yaml` configuration is now defined as a key under `architecture`. Only `includes` is required; all other properties are optional and trigger specific architectural rules: - **includes** (required): - List of patterns or group names that define which classes/interfaces belong to this group. @@ -123,7 +115,7 @@ Each group in your `architecture.yaml` configuration can have several properties - List of group names or patterns that this group is allowed to depend on. - Example: `depends_on: ["$services", "App\\Library\\*"]` - **Rule triggered:** Ensures that classes in this group only depend on allowed groups/classes. Violations are reported if dependencies are outside this list. - - **Important:** If a group includes from a global namespace other than `App\`, it must NOT have a `depends_on` property. This will cause a configuration error. + - **Important:** If a group includes from a global namespace other than `App\\`, it must NOT have a `depends_on` property. This will cause a configuration error. - **final** (optional): - Boolean (`true`/`false`). If `true`, all classes in this group must be declared as `final`. @@ -148,7 +140,6 @@ Each group in your `architecture.yaml` configuration can have several properties #### Summary Table | Property | Required | Type | Description | Rule Triggered | |----------------------------------|----------|-----------|-----------------------------------------------------------------------------|---------------------------------------------------------------------| -| name | Yes | string | Unique group name (recommended: `$` prefix) | Defines group | | includes | Yes | array | Patterns or group names for group membership | Group membership | | depends_on | No | array | Allowed dependencies (snake_case, not camelCase) | Dependency restriction | | final | No | boolean | Require classes to be `final` | Final class enforcement | @@ -158,7 +149,7 @@ Each group in your `architecture.yaml` configuration can have several properties **Note:** - Property names in YAML must use `snake_case` (e.g., `depends_on`), not camelCase. -- If a group includes from a global namespace other than `App\`, do not define `depends_on` for that group. +- If a group includes from a global namespace other than `App\\`, do not define `depends_on` for that group. - The configuration will fail with a clear error if these rules are violated. ### How Classes, Interfaces, and Namespaces Are Defined @@ -201,7 +192,7 @@ This logic applies to all properties that accept patterns or references, such as ```yaml architecture: - - name: "$repositories" + $repositories: final: true implements: - "App\Repository\RepositoryInterface" @@ -211,8 +202,7 @@ architecture: depends_on: - "$models" - "App\Model\*Model" - - - name: "$models" + $models: includes: - "App\Model\*Model" ``` diff --git a/architecture.yaml b/architecture.yaml deleted file mode 100644 index 0c2b88a..0000000 --- a/architecture.yaml +++ /dev/null @@ -1,158 +0,0 @@ -architecture: - # INTERFACE LAYER - $interface_controller: - includes: - - "App\\Interface\\Controller\\*" - depends_on: - - "interface_controller" - - "App\\Application\\Query\\*\\Query" - - "Symfony\\Component\\*" - - "Psr\\*" - - "App\\Infrastructure\\Api\\*" - extends: "App\\Infrastructure\\Api\\Controller\\ApiController" - final: false - - $interface_event_processor: - includes: - - "App\\Interface\\Event\\*\\Processor" - depends_on: - - "App\\Application\\*" - - "Kununu\\Hermes\\EventsProcessorBundle\\*" - - "Psr\\Log\\*" - - "Symfony\\Component\\Validator\\*" - extends: "Kununu\\Hermes\\EventsProcessorBundle\\AbstractProcessor" - final: false - - $interface_event_inbound: - includes: - - "App\\Interface\\Event\\*\\InboundEvent" - depends_on: - - "App\\Application\\*" - - "Psr\\Log\\*" - - "Symfony\\Component\\Validator\\*" - - "interface_event_outbound" - implements: - - "Kununu\\Hermes\\EventsProcessorBundle\\ReceivableEventInterface" - final: false - - $interface_event_outbound: - includes: - - "App\\Interface\\Event\\*\\OutboundEvent" - depends_on: - - "App\\Application\\*" - - "Psr\\Log\\*" - - "Symfony\\Component\\Validator\\*" - - "App\\Interface\\Event\\AbstractNamedEvent" - implements: - - "Kununu\\Hermes\\EventsProcessorBundle\\EventInterface" - extends: "App\\Interface\\Event\\AbstractNamedEvent" - final: false - - $interface_console: - includes: - - "App\\Interface\\Console\\*" - depends_on: - - "App\\Application\\*" - - "Symfony\\Component\\Console\\*" - - "Psr\\Log\\*" - - "Symfony\\Component\\Validator\\*" - extends: "Symfony\\Component\\Console\\Command\\Command" - final: true - must_only_have_one_public_method_named: "execute" - - # APPLICATION LAYER - $application_command: - includes: - - "App\\Application\\Command\\*\\Command" - depends_on: - - "Symfony\\Component\\Validator\\Constraints" - implements: - - "Kununu\\CQRS\\Command\\Command" - final: true - - $application_command_handler: - includes: - - "App\\Application\\Command\\*\\Handler" - depends_on: - - "App\\Application\\Command\\*" - - "App\\Application\\Helper\\*" - - "App\\Repository\\Doctrine\\*\\*RepositoryInterface" - - "App\\Model\\*" - - "App\\Interface\\*" - - "Kununu\\Hermes\\EventsProcessorBundle\\EventProducerInterface" - - "Psr\\*" - - "App\\Infrastructure\\Messaging\\Producer\\SynchronousEventProducerInterface" - - "App\\Infrastructure\\Messaging\\Events\\Outbound\\*" - implements: - - "Kununu\\CQRS\\Command\\CommandHandler" - final: true - must_only_have_one_public_method_named: "handle" - - $application_query: - includes: - - "App\\Application\\Query\\*\\Query" - depends_on: - - "Symfony\\Component\\Validator\\Constraints" - implements: - - "Kununu\\CQRS\\Query\\Query" - final: false - - $application_query_handler: - includes: - - "App\\Application\\Query\\*\\Handler" - depends_on: - - "App\\Repository\\Doctrine\\*\\*ReadRepositoryInterface" - - "Symfony\\Component\\Validator\\*" - implements: - - "Kununu\\CQRS\\Query\\QueryHandler" - final: false - - $application_helper: - includes: - - "App\\Application\\Helper\\*" - depends_on: - - "$model_entity" - - "App\\Model\\DataTransferObject\\*" - final: true - - # MODEL LAYER - $model_entity - includes: - - "App\\Model\\Entity\\*" - - "$model_dto" - depends_on: - - "Doctrine\\ORM\\Mapping\\*" - - "App\\Model\\Entity\\*" - - "Doctrine\\DBAL\\Types\\Types" - final: false - - - name: "model_dto" - includes: - - "App\\Model\\DataTransferObject\\*" - depends_on: - - "App\\Model\\DataTransferObject\\*" - - "App\\Infrastructure\\Common\\*" - - "Kununu\\Collection\\*" - final: false - - # REPOSITORY LAYER - - name: "repository_doctrine" - includes: - - "App\\Repository\\Doctrine\\*\\*Repository" - depends_on: - - "App\\Model\\*" - - "Doctrine\\*" - - "App\\Infrastructure\\Common\\*" - implements: - - "App\\Repository\\Doctrine\\*\\*RepositoryInterface" - final: true - - - name: "repository_doctrine_listener" - includes: - - "App\\Repository\\Doctrine\\*\\*\\Listener" - depends_on: - - "Kununu\\Hermes\\EventsProcessorBundle\\EventProducerInterface" - - "Doctrine\\*" - - "App\\Model\\*" - - "App\\Repository\\*" - final: true From c9b54733b2f9cf4cdcc196e996621a2042b8b617 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 17:46:56 +0200 Subject: [PATCH 09/35] adding it to the code-tools readme --- README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index db770b7..90c60a0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,33 @@ ### `bin/php-in-k8s` - This is a helper script that allows you to run PHP commands inside a local Kubernetes pod without having to connect to it via a terminal manually. +### `Architecture Sniffer & PHPAT` +- **Architecture Sniffer** enforces architectural and dependency rules in your PHP codebase, helping you maintain a clean and consistent architecture. +- It is powered by [PHPAT](https://github.com/carlosas/phpat), a static analysis tool for PHP architecture testing. +- Architecture Sniffer uses a YAML configuration file (`architecture.yaml`) where you define your architectural groups and their allowed dependencies. Each group is a key under the `architecture` root, e.g.: + + ```yaml + architecture: + $controllers: + includes: + - "App\\Controller\\*Controller" + depends_on: + - "$services" + $services: + includes: + - "App\\Service\\*Service" + ``` +- To use Architecture Sniffer with PHPStan, add the extension to your `phpstan.neon`: + ```neon + includes: + - vendor/carlosas/phpat/extension.neon + services: + - + class: PHPAT\PHPStan\PHPStanExtension + tags: [phpstan.extension] + ``` +- For more details and advanced configuration, see [Kununu/ArchitectureSniffer/README.md](Kununu/ArchitectureSniffer/README.md). + ## Install ### Add custom private repositories to composer.json @@ -52,4 +79,5 @@ composer require --dev kununu/code-tools --no-plugins - [PHP_CodeSniffer](docs/CodeSniffer/README.md) instructions. - [Rector](docs/Rector/README.md) instructions. - [bin/code-tools](docs/CodeTools/README.md) instructions. -- [bin/php-in-k8s](docs/PhpInK8s/README.md) instructions. \ No newline at end of file +- [bin/php-in-k8s](docs/PhpInK8s/README.md) instructions. +- [Architecture Sniffer & PHPAT](docs/ArchitectureSniffer/README.md) instructions. From e5069ec30e25d5558368f7582c61768e9594bf27 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 17:48:26 +0200 Subject: [PATCH 10/35] fixing name validation after restructuring --- Kununu/ArchitectureSniffer/Configuration/Group.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index cd94384..72724f8 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -35,8 +35,8 @@ private function __construct( */ public static function fromArray(string $name, array $data): self { - if (!array_key_exists(self::NAME_KEY, $data) || !array_key_exists(self::INCLUDES_KEY, $data)) { - throw new InvalidArgumentException('Group configuration must contain "name" and "includes" keys.'); + if (!array_key_exists(self::INCLUDES_KEY, $data)) { + throw new InvalidArgumentException('Group configuration must contain "includes" key.'); } return new self( From 1c0f9dd8c029afbcec557682ff7a3d386b7622ee Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 18:06:24 +0200 Subject: [PATCH 11/35] fixing Generator issues --- .../Configuration/Group.php | 11 +++-- .../Configuration/Rules/MustBeFinal.php | 7 ++- .../Configuration/Rules/MustExtend.php | 9 ++-- .../Configuration/Rules/MustImplement.php | 9 +++- .../Configuration/Rules/MustOnlyDependOn.php | 9 +++- .../MustOnlyHaveOnePublicMethodNamed.php | 7 ++- .../Configuration/SelectorsLibrary.php | 44 ++++++++++--------- 7 files changed, 60 insertions(+), 36 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 72724f8..7f1c39a 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -8,7 +8,6 @@ final readonly class Group { - private const string NAME_KEY = 'name'; public const string INCLUDES_KEY = 'includes'; public const string DEPENDS_ON_KEY = 'depends_on'; private const string FINAL_KEY = 'final'; @@ -52,34 +51,34 @@ public static function fromArray(string $name, array $data): self public function getRules(SelectorsLibrary $library): Generator { if ($this->extends) { - yield new Rules\MustExtend( + yield Rules\MustExtend::fromGenerators( extensions: $library->getSelector($this->extends), selectables: $library->getSelectorsFromGroup($this->name), ); } if ($this->implements) { - yield new Rules\MustImplement( + yield Rules\MustImplement::fromGenerators( selectables: $library->getSelectorsFromGroup($this->name), interfaces: $library->getSelectors($this->implements), ); } if ($this->final) { - yield new Rules\MustBeFinal( + yield Rules\MustBeFinal::fromGenerator( selectables: $library->getSelectorsFromGroup($this->name), ); } if ($this->dependsOn) { - yield new Rules\MustOnlyDependOn( + yield Rules\MustOnlyDependOn::fromGenerators( selectables: $library->getSelectorsFromGroup($this->name), dependencies: $library->getSelectors($this->dependsOn), ); } if ($this->mustOnlyHaveOnePublicMethodNamed) { - yield new Rules\MustOnlyHaveOnePublicMethodNamed( + yield Rules\MustOnlyHaveOnePublicMethodNamed::fromGenerator( selectables: $library->getSelectorsFromGroup($this->name), functionName: $this->mustOnlyHaveOnePublicMethodNamed, ); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index d1995d4..4da1558 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -12,7 +12,7 @@ final readonly class MustBeFinal extends AbstractRule { - public function __construct(public Generator $selectables) + public function __construct(public array $selectables) { foreach ($this->selectables as $selectable) { if (!$selectable instanceof ClassSelector) { @@ -21,8 +21,11 @@ public function __construct(public Generator $selectables) ); } } + } - $this->selectables->rewind(); + public static function fromGenerator(Generator $selectables): self + { + return new self(iterator_to_array($selectables)); } public function getPHPatRule(string $groupName): PHPatRule diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index ee64ca0..03d8a3a 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -13,8 +13,8 @@ public const string KEY = 'extends'; public function __construct( - public Generator $extensions, - public Generator $selectables, + public array $extensions, + public array $selectables, ) { foreach ($this->extensions as $extension) { if ($extension instanceof InterfaceClassSelector) { @@ -23,8 +23,11 @@ public function __construct( ); } } + } - $this->extensions->rewind(); + public static function fromGenerators(Generator $extensions, Generator $selectables): self + { + return new self(iterator_to_array($extensions), iterator_to_array($selectables)); } public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 98b39b4..58304c6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -10,11 +10,16 @@ final readonly class MustImplement extends AbstractRule { public function __construct( - public Generator $selectables, - public Generator $interfaces, + public array $selectables, + public array $interfaces, ) { } + public static function fromGenerators(Generator $selectables, Generator $interfaces): self + { + return new self(iterator_to_array($selectables), iterator_to_array($interfaces)); + } + public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 2e80fda..7c92204 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -10,11 +10,16 @@ final readonly class MustOnlyDependOn extends AbstractRule { public function __construct( - public Generator $selectables, - public Generator $dependencies, + public array $selectables, + public array $dependencies, ) { } + public static function fromGenerators(Generator $selectables, Generator $dependencies): self + { + return new self(iterator_to_array($selectables), iterator_to_array($dependencies)); + } + public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index f4c54bb..7fa59b2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -11,11 +11,16 @@ public const string KEY = 'only-one-public-method-named'; public function __construct( - public Generator $selectables, + public array $selectables, public string $functionName, ) { } + public static function fromGenerator(Generator $selectables, string $functionName): self + { + return new self(iterator_to_array($selectables), $functionName); + } + public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php index 95fe6c8..cc091f3 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -17,11 +17,11 @@ final class SelectorsLibrary public function __construct(private array $groups) { - foreach ($groups as $groupName => $includes) { + foreach ($groups as $groupName => $attributes) { $this->passedGroups = [$groupName]; $resolvedIncludes = []; - foreach ($includes as $include) { - foreach ($this->resolveGroup($include) as $selectable) { + foreach ($attributes[Group::INCLUDES_KEY] as $include) { + foreach ($this->resolveInclude($include) as $selectable) { $resolvedIncludes[] = $selectable; } } @@ -29,6 +29,25 @@ public function __construct(private array $groups) } } + private function resolveInclude(string $fqcnOrGroupName): Generator + { + if (array_key_exists($fqcnOrGroupName, $this->groups)) { + if (in_array($fqcnOrGroupName, $this->passedGroups, true)) { + return; + } + + $this->passedGroups[] = $fqcnOrGroupName; + + foreach ($this->groups[$fqcnOrGroupName] as $subFqcnOrGroupName) { + yield from $this->resolveInclude($subFqcnOrGroupName); + } + + return; + } + + yield $fqcnOrGroupName; + } + public function getSelector(string $fqcnOrGroup): Generator { if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { @@ -47,7 +66,7 @@ public function getSelectorsFromGroup(string $groupName): Generator } foreach ($this->flattenedGroups[$groupName] as $fqcn) { - yield from $this->createSelectable($fqcn); + yield $this->createSelectable($fqcn); } } @@ -58,22 +77,7 @@ public function getSelectors(array $values): Generator } } - private function resolveGroup(string $fqcnOrGroupName): Generator - { - if (array_key_exists($fqcnOrGroupName, $this->groups)) { - if (in_array($fqcnOrGroupName, $this->passedGroups, true)) { - return; - } - - foreach ($this->groups[$fqcnOrGroupName] as $subFqcnOrGroupName) { - yield from $this->resolveGroup($subFqcnOrGroupName); - } - } - - yield $this->createSelectable($fqcnOrGroupName); - } - - private function createSelectable($fqcn): Selectable + private function createSelectable(string $fqcn): Selectable { return match (true) { str_ends_with($fqcn, '\\') => new NamespaceSelector($fqcn), From 9530cc545a74e93170eecf462e8e950db5946673 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 14 Aug 2025 18:16:13 +0200 Subject: [PATCH 12/35] fixing rules --- .../ArchitectureSniffer/Configuration/Rules/AbstractRule.php | 2 +- .../ArchitectureSniffer/Configuration/Rules/MustBeFinal.php | 2 +- Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php | 4 ++-- .../ArchitectureSniffer/Configuration/Rules/MustImplement.php | 4 ++-- .../Configuration/Rules/MustOnlyDependOn.php | 4 ++-- .../Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php | 2 +- Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index 5b5a9fb..4b7c787 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -9,7 +9,7 @@ abstract readonly class AbstractRule implements Rule { - public static function getPHPSelectors(Generator $selectors): Generator + public static function getPHPSelectors(iterable $selectors): Generator { foreach ($selectors as $selector) { if (!$selector instanceof Selectable) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 4da1558..1901eec 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -31,7 +31,7 @@ public static function fromGenerator(Generator $selectables): self public function getPHPatRule(string $groupName): PHPatRule { return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) + ->classes(self::getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldBeFinal() ->because("$groupName must be final."); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 03d8a3a..6fc51a5 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -33,9 +33,9 @@ public static function fromGenerators(Generator $extensions, Generator $selectab public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) + ->classes(self::getPHPSelectors($this->selectables)) ->shouldExtend() - ->classes(...self::getPHPSelectors($this->extensions)) + ->classes(self::getPHPSelectors($this->extensions)) ->because("$groupName should extend class."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 58304c6..ec831a5 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -23,10 +23,10 @@ public static function fromGenerators(Generator $selectables, Generator $interfa public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) + ->classes(self::getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldImplement() - ->classes(...self::getPHPSelectors($this->interfaces)) + ->classes(self::getPHPSelectors($this->interfaces)) ->because("$groupName must implement interface."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 7c92204..d6048bb 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -23,11 +23,11 @@ public static function fromGenerators(Generator $selectables, Generator $depende public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) + ->classes(self::getPHPSelectors($this->selectables)) ->canOnlyDependOn() ->classes( Selector::classname('/^\\\\*[^\\\\]+$/', true), - ...self::getPHPSelectors($this->dependencies) + self::getPHPSelectors($this->dependencies) ) ->because("$groupName has dependencies outside the allowed list."); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index 7fa59b2..af2e089 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -24,7 +24,7 @@ public static function fromGenerator(Generator $selectables, string $functionNam public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) + ->classes(self::getPHPSelectors($this->selectables)) ->shouldHaveOnlyOnePublicMethodNamed($this->functionName) ->because("$groupName should only have one public method named $this->functionName."); } diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php index cc091f3..52323ef 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -52,7 +52,7 @@ public function getSelector(string $fqcnOrGroup): Generator { if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { foreach ($this->flattenedGroups[$fqcnOrGroup] as $fqcn) { - yield from $this->createSelectable($fqcn); + yield $this->createSelectable($fqcn); } } From ff3802e95f9f8dbaa8837fd9e503f3e933bf49db Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 18 Aug 2025 10:06:22 +0200 Subject: [PATCH 13/35] fixing rules --- Kununu/ArchitectureSniffer/ArchitectureSniffer.php | 6 +----- Kununu/ArchitectureSniffer/Configuration/Group.php | 10 +++++----- .../Configuration/Rules/AbstractRule.php | 8 +++++--- .../Configuration/Rules/MustBeFinal.php | 2 +- .../Configuration/Rules/MustExtend.php | 6 ++---- .../Configuration/Rules/MustImplement.php | 4 ++-- .../Configuration/Rules/MustOnlyDependOn.php | 12 +++++++----- .../Rules/MustOnlyHaveOnePublicMethodNamed.php | 4 +--- 8 files changed, 24 insertions(+), 28 deletions(-) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index a092a14..792bd75 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -18,11 +18,7 @@ final class ArchitectureSniffer public function testArchitecture(): iterable { $architecture = $this->getArchitecture(); - foreach ($architecture->getRules() as $rules) { - foreach ($rules as $rule) { - yield $rule; - } - } + yield from $architecture->getRules(); } private function getArchitecture(): Architecture diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 7f1c39a..f55d8da 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -54,34 +54,34 @@ public function getRules(SelectorsLibrary $library): Generator yield Rules\MustExtend::fromGenerators( extensions: $library->getSelector($this->extends), selectables: $library->getSelectorsFromGroup($this->name), - ); + )->getPHPatRule($this->name); } if ($this->implements) { yield Rules\MustImplement::fromGenerators( selectables: $library->getSelectorsFromGroup($this->name), interfaces: $library->getSelectors($this->implements), - ); + )->getPHPatRule($this->name); } if ($this->final) { yield Rules\MustBeFinal::fromGenerator( selectables: $library->getSelectorsFromGroup($this->name), - ); + )->getPHPatRule($this->name); } if ($this->dependsOn) { yield Rules\MustOnlyDependOn::fromGenerators( selectables: $library->getSelectorsFromGroup($this->name), dependencies: $library->getSelectors($this->dependsOn), - ); + )->getPHPatRule($this->name); } if ($this->mustOnlyHaveOnePublicMethodNamed) { yield Rules\MustOnlyHaveOnePublicMethodNamed::fromGenerator( selectables: $library->getSelectorsFromGroup($this->name), functionName: $this->mustOnlyHaveOnePublicMethodNamed, - ); + )->getPHPatRule($this->name); } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index 4b7c787..75815a4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -3,21 +3,23 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Generator; use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; abstract readonly class AbstractRule implements Rule { - public static function getPHPSelectors(iterable $selectors): Generator + public static function getPHPSelectors(iterable $selectors): array { + $result = []; foreach ($selectors as $selector) { if (!$selector instanceof Selectable) { throw new InvalidArgumentException( 'Only Selectable instances can be used in rules.' ); } - yield $selector->getPHPatSelector(); + $result[] = $selector->getPHPatSelector(); } + + return $result; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 1901eec..4da1558 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -31,7 +31,7 @@ public static function fromGenerator(Generator $selectables): self public function getPHPatRule(string $groupName): PHPatRule { return PHPat::rule() - ->classes(self::getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldBeFinal() ->because("$groupName must be final."); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 6fc51a5..b51b245 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -10,8 +10,6 @@ final readonly class MustExtend extends AbstractRule { - public const string KEY = 'extends'; - public function __construct( public array $extensions, public array $selectables, @@ -33,9 +31,9 @@ public static function fromGenerators(Generator $extensions, Generator $selectab public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(self::getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->shouldExtend() - ->classes(self::getPHPSelectors($this->extensions)) + ->classes(...self::getPHPSelectors($this->extensions)) ->because("$groupName should extend class."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index ec831a5..58304c6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -23,10 +23,10 @@ public static function fromGenerators(Generator $selectables, Generator $interfa public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(self::getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->excluding(Selector::isInterface()) ->shouldImplement() - ->classes(self::getPHPSelectors($this->interfaces)) + ->classes(...self::getPHPSelectors($this->interfaces)) ->because("$groupName must implement interface."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index d6048bb..f4835eb 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -22,13 +22,15 @@ public static function fromGenerators(Generator $selectables, Generator $depende public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { + $allowed = array_merge( + [Selector::classname('/^\\\\*[^\\\\]+$/', true)], + self::getPHPSelectors($this->dependencies) + ); + return PHPat::rule() - ->classes(self::getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->canOnlyDependOn() - ->classes( - Selector::classname('/^\\\\*[^\\\\]+$/', true), - self::getPHPSelectors($this->dependencies) - ) + ->classes(...$allowed) ->because("$groupName has dependencies outside the allowed list."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index af2e089..00b387e 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -8,8 +8,6 @@ final readonly class MustOnlyHaveOnePublicMethodNamed extends AbstractRule { - public const string KEY = 'only-one-public-method-named'; - public function __construct( public array $selectables, public string $functionName, @@ -24,7 +22,7 @@ public static function fromGenerator(Generator $selectables, string $functionNam public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule { return PHPat::rule() - ->classes(self::getPHPSelectors($this->selectables)) + ->classes(...self::getPHPSelectors($this->selectables)) ->shouldHaveOnlyOnePublicMethodNamed($this->functionName) ->because("$groupName should only have one public method named $this->functionName."); } From 411bb9ee18c03a9215ec349e66c25792fb3b2fd3 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 18 Aug 2025 10:18:31 +0200 Subject: [PATCH 14/35] change Generator to array in interface --- Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php index fa20207..b6a3e7d 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php @@ -3,11 +3,9 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Generator; - interface Rule { public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule; - public static function getPHPSelectors(Generator $selectors): Generator; + public static function getPHPSelectors(array $selectors): array; } From 6224fe63f04bf9a024d2ea9f1a747e393535d5e3 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 18 Aug 2025 11:24:56 +0200 Subject: [PATCH 15/35] extend depends on including implements and extends, plus adding excludes support --- .../Configuration/Group.php | 6 +++++ .../Configuration/Rules/MustOnlyDependOn.php | 25 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index f55d8da..56f36e2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -9,6 +9,7 @@ final readonly class Group { public const string INCLUDES_KEY = 'includes'; + public const string EXCLUDES_KEY = 'excludes'; public const string DEPENDS_ON_KEY = 'depends_on'; private const string FINAL_KEY = 'final'; private const string EXTENDS_KEY = 'extends'; @@ -21,6 +22,7 @@ */ private function __construct( private string $name, + private ?array $excludes = null, private ?array $dependsOn = null, private bool $final = false, private ?string $extends = null, @@ -40,6 +42,7 @@ public static function fromArray(string $name, array $data): self return new self( name: $name, + excludes: $data[self::EXCLUDES_KEY] ?? null, dependsOn: $data[self::DEPENDS_ON_KEY] ?? null, final: $data[self::FINAL_KEY] ?? false, extends: $data[self::EXTENDS_KEY] ?? null, @@ -74,6 +77,9 @@ interfaces: $library->getSelectors($this->implements), yield Rules\MustOnlyDependOn::fromGenerators( selectables: $library->getSelectorsFromGroup($this->name), dependencies: $library->getSelectors($this->dependsOn), + extends: $this->extends ? $library->getSelector($this->extends) : null, + implements: $this->implements ? $library->getSelectors($this->implements) : null, + excludes: $this->excludes ? $library->getSelectors($this->excludes) : null, )->getPHPatRule($this->name); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index f4835eb..d91ceb2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -12,12 +12,30 @@ public function __construct( public array $selectables, public array $dependencies, + public ?array $excludes = null, ) { } - public static function fromGenerators(Generator $selectables, Generator $dependencies): self - { - return new self(iterator_to_array($selectables), iterator_to_array($dependencies)); + public static function fromGenerators( + Generator $selectables, + Generator $dependencies, + ?Generator $extends = null, + ?Generator $implements = null, + ?Generator $excludes = null, + ): self { + $iteratedSelectables = iterator_to_array($selectables); + $unitedDependencies = array_merge( + iterator_to_array($dependencies), + $iteratedSelectables, + $extends ? iterator_to_array($extends) : [], + $implements ? iterator_to_array($implements) : [] + ); + + return new self( + $iteratedSelectables, + $unitedDependencies, + $excludes ? iterator_to_array($excludes) : null + ); } public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule @@ -29,6 +47,7 @@ public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule return PHPat::rule() ->classes(...self::getPHPSelectors($this->selectables)) + ->excluding(...$this->excludes) ->canOnlyDependOn() ->classes(...$allowed) ->because("$groupName has dependencies outside the allowed list."); From 4afcdccda2edf71ee1a43602d8cf2dc8bb7e5abb Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 19 Aug 2025 17:33:31 +0200 Subject: [PATCH 16/35] refactor all again --- .../ArchitectureSniffer.php | 8 +- .../Configuration/Architecture.php | 26 +--- .../Configuration/Group.php | 107 +++++--------- .../Configuration/Rules/AbstractRule.php | 2 +- .../Configuration/Rules/MustBeFinal.php | 35 +++-- .../Configuration/Rules/MustExtend.php | 47 +++--- .../Configuration/Rules/MustImplement.php | 40 +++--- .../Configuration/Rules/MustNotDependOn.php | 36 +++++ .../Configuration/Rules/MustOnlyDependOn.php | 61 +++----- .../MustOnlyHaveOnePublicMethodNamed.php | 34 ++--- .../Configuration/Rules/Rule.php | 11 -- .../Configuration/SelectorsLibrary.php | 136 ++++++++++++++++-- Kununu/ArchitectureSniffer/README.md | 15 +- 13 files changed, 313 insertions(+), 245 deletions(-) create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 792bd75..c7e52e7 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -17,13 +17,7 @@ final class ArchitectureSniffer */ public function testArchitecture(): iterable { - $architecture = $this->getArchitecture(); - yield from $architecture->getRules(); - } - - private function getArchitecture(): Architecture - { - return Architecture::fromArray( + yield from Architecture::fromArray( Yaml::parseFile(ProjectPathResolver::resolve(self::ARCHITECTURE_FILENAME)) ); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index 2502bfb..ed69fd4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -3,26 +3,16 @@ namespace Kununu\ArchitectureSniffer\Configuration; -use Generator; use InvalidArgumentException; final readonly class Architecture { public const string ARCHITECTURE_KEY = 'architecture'; - /** - * @param array $groups - */ - private function __construct( - private array $groups, - private SelectorsLibrary $selectorsLibrary, - ) { - } - /** * @param array> $data */ - public static function fromArray(array $data): self + public static function fromArray(array $data): iterable { if (!array_key_exists(self::ARCHITECTURE_KEY, $data)) { throw new InvalidArgumentException( @@ -81,20 +71,8 @@ public static function fromArray(array $data): self } } - $groups = []; foreach ($architecture as $groupName => $groupData) { - $groups[] = Group::fromArray($groupName, $groupData); - } - - $selectorsLibrary = new SelectorsLibrary($architecture); - - return new self($groups, $selectorsLibrary); - } - - public function getRules(): Generator - { - foreach ($this->groups as $group) { - yield from $group->getRules($this->selectorsLibrary); + yield from Group::getRules($groupName, $groupData); } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 56f36e2..51ef765 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -3,91 +3,56 @@ namespace Kununu\ArchitectureSniffer\Configuration; -use Generator; -use InvalidArgumentException; - final readonly class Group { public const string INCLUDES_KEY = 'includes'; public const string EXCLUDES_KEY = 'excludes'; public const string DEPENDS_ON_KEY = 'depends_on'; - private const string FINAL_KEY = 'final'; - private const string EXTENDS_KEY = 'extends'; - private const string IMPLEMENTS_KEY = 'implements'; - private const string MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY = 'must_only_have_one_public_method_named'; - - /** - * @param array|null $dependsOn - * @param array|null $implements - */ - private function __construct( - private string $name, - private ?array $excludes = null, - private ?array $dependsOn = null, - private bool $final = false, - private ?string $extends = null, - private ?array $implements = null, - private ?string $mustOnlyHaveOnePublicMethodNamed = null, - ) { - } + public const string FINAL_KEY = 'final'; + public const string EXTENDS_KEY = 'extends'; + public const string IMPLEMENTS_KEY = 'implements'; + public const string MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY = 'must_only_have_one_public_method_named'; + public const string MUST_NOT_DEPEND_ON_KEY = 'must_not_depend_on'; - /** - * @param array $data - */ - public static function fromArray(string $name, array $data): self + public static function getRules(string $groupName, SelectorsLibrary $library): iterable { - if (!array_key_exists(self::INCLUDES_KEY, $data)) { - throw new InvalidArgumentException('Group configuration must contain "includes" key.'); + if ($library->groupHasKey($groupName, self::DEPENDS_ON_KEY)) { + yield Rules\MustExtend::createRule( + $groupName, + $library + ); } - return new self( - name: $name, - excludes: $data[self::EXCLUDES_KEY] ?? null, - dependsOn: $data[self::DEPENDS_ON_KEY] ?? null, - final: $data[self::FINAL_KEY] ?? false, - extends: $data[self::EXTENDS_KEY] ?? null, - implements: $data[self::IMPLEMENTS_KEY] ?? null, - mustOnlyHaveOnePublicMethodNamed: $data[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null, - ); - } - - public function getRules(SelectorsLibrary $library): Generator - { - if ($this->extends) { - yield Rules\MustExtend::fromGenerators( - extensions: $library->getSelector($this->extends), - selectables: $library->getSelectorsFromGroup($this->name), - )->getPHPatRule($this->name); + if ($library->groupHasKey($groupName, self::IMPLEMENTS_KEY)) { + yield Rules\MustImplement::createRule( + $groupName, + $library + ); } - if ($this->implements) { - yield Rules\MustImplement::fromGenerators( - selectables: $library->getSelectorsFromGroup($this->name), - interfaces: $library->getSelectors($this->implements), - )->getPHPatRule($this->name); + if ($library->groupHasKey($groupName, self::FINAL_KEY)) { + yield Rules\MustBeFinal::createRule( + $groupName, + $library + ); } - - if ($this->final) { - yield Rules\MustBeFinal::fromGenerator( - selectables: $library->getSelectorsFromGroup($this->name), - )->getPHPatRule($this->name); + if ($library->groupHasKey($groupName, self::DEPENDS_ON_KEY)) { + yield Rules\MustOnlyDependOn::createRule( + $groupName, + $library + ); } - - if ($this->dependsOn) { - yield Rules\MustOnlyDependOn::fromGenerators( - selectables: $library->getSelectorsFromGroup($this->name), - dependencies: $library->getSelectors($this->dependsOn), - extends: $this->extends ? $library->getSelector($this->extends) : null, - implements: $this->implements ? $library->getSelectors($this->implements) : null, - excludes: $this->excludes ? $library->getSelectors($this->excludes) : null, - )->getPHPatRule($this->name); + if ($library->groupHasKey($groupName, self::MUST_NOT_DEPEND_ON_KEY)) { + yield Rules\MustNotDependOn::createRule( + $groupName, + $library + ); } - - if ($this->mustOnlyHaveOnePublicMethodNamed) { - yield Rules\MustOnlyHaveOnePublicMethodNamed::fromGenerator( - selectables: $library->getSelectorsFromGroup($this->name), - functionName: $this->mustOnlyHaveOnePublicMethodNamed, - )->getPHPatRule($this->name); + if ($library->groupHasKey($groupName, self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY)) { + yield Rules\MustOnlyHaveOnePublicMethodNamed::createRule( + $groupName, + $library + ); } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index 75815a4..c2be269 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -6,7 +6,7 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -abstract readonly class AbstractRule implements Rule +abstract readonly class AbstractRule { public static function getPHPSelectors(iterable $selectors): array { diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 4da1558..ecd0c63 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -3,37 +3,34 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Generator; use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; +use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; final readonly class MustBeFinal extends AbstractRule { - public function __construct(public array $selectables) - { - foreach ($this->selectables as $selectable) { + public static function createRule( + string $groupName, + SelectorsLibrary $library, + ): PHPatRule { + $includes = $library->getIncludesByGroup($groupName); + $excludes = $library->getExcludesByGroup($groupName); + + foreach ($includes as $selectable) { if (!$selectable instanceof ClassSelector) { - throw new InvalidArgumentException( - 'Only classes can be final.' - ); + throw new InvalidArgumentException('Only classes can be final.'); } } - } - public static function fromGenerator(Generator $selectables): self - { - return new self(iterator_to_array($selectables)); - } + $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); + + $excludes = $excludes ? self::getPHPSelectors($excludes) : []; + $excludes[] = Selector::isInterface(); + $rule = $rule->excluding(...$excludes); - public function getPHPatRule(string $groupName): PHPatRule - { - return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) - ->excluding(Selector::isInterface()) - ->shouldBeFinal() - ->because("$groupName must be final."); + return $rule->shouldBeFinal()->because("$groupName must be final."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index b51b245..5816319 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -3,37 +3,42 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Generator; use InvalidArgumentException; +use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; +use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; final readonly class MustExtend extends AbstractRule { - public function __construct( - public array $extensions, - public array $selectables, - ) { - foreach ($this->extensions as $extension) { + public static function createRule( + string $groupName, + SelectorsLibrary $selectorsLibrary, + ): PHPatRule { + $includes = $selectorsLibrary->getIncludesByGroup($groupName); + $excludes = $selectorsLibrary->getExcludesByGroup($groupName); + $extensions = $selectorsLibrary->getTargetByGroup($groupName, Group::EXTENDS_KEY); + $extensionExcludes = $selectorsLibrary->getTargetExcludesByGroup($groupName, Group::EXTENDS_KEY); + + foreach ($extensions as $extension) { if ($extension instanceof InterfaceClassSelector) { - throw new InvalidArgumentException( - 'Classes can not extend interfaces.' - ); + throw new InvalidArgumentException('Classes can not extend interfaces.'); } } - } - public static function fromGenerators(Generator $extensions, Generator $selectables): self - { - return new self(iterator_to_array($extensions), iterator_to_array($selectables)); - } + $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); + + if ($excludes !== null) { + $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + } + + $rule = $rule->shouldExtend()->classes(...self::getPHPSelectors($extensions)); + + if ($extensionExcludes !== null) { + $rule = $rule->excluding(...self::getPHPSelectors($extensionExcludes)); + } - public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule - { - return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) - ->shouldExtend() - ->classes(...self::getPHPSelectors($this->extensions)) - ->because("$groupName should extend class."); + return $rule->because("$groupName should extend class."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 58304c6..556ff4f 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -3,30 +3,34 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Generator; +use Kununu\ArchitectureSniffer\Configuration\Group; +use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; +use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; final readonly class MustImplement extends AbstractRule { - public function __construct( - public array $selectables, - public array $interfaces, - ) { - } + public static function createRule( + string $groupName, + SelectorsLibrary $library, + ): PHPatRule { + $includes = $library->getIncludesByGroup($groupName); + $excludes = $library->getExcludesByGroup($groupName); + $interfaces = $library->getTargetByGroup($groupName, Group::INCLUDES_KEY); + $interfacesExcludes = $library->getTargetExcludesByGroup($groupName, Group::INCLUDES_KEY); - public static function fromGenerators(Generator $selectables, Generator $interfaces): self - { - return new self(iterator_to_array($selectables), iterator_to_array($interfaces)); - } + $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); + + $excludeSelectors = $excludes ? self::getPHPSelectors($excludes) : []; + $excludeSelectors[] = Selector::isInterface(); + $rule = $rule->excluding(...$excludeSelectors); + + $rule = $rule->shouldImplement()->classes(...self::getPHPSelectors($interfaces)); + if ($interfacesExcludes !== null) { + $rule = $rule->excluding(...self::getPHPSelectors($interfacesExcludes)); + } - public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule - { - return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) - ->excluding(Selector::isInterface()) - ->shouldImplement() - ->classes(...self::getPHPSelectors($this->interfaces)) - ->because("$groupName must implement interface."); + return $rule->because("$groupName must implement interface."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php new file mode 100644 index 0000000..5380e55 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php @@ -0,0 +1,36 @@ +getIncludesByGroup($groupName); + $excludes = $library->getExcludesByGroup($groupName); + $mostNotDependOn = $library->getTargetByGroup($groupName, Group::MUST_NOT_DEPEND_ON_KEY); + $mostNotDependOnExcludes = $library->getTargetExcludesByGroup($groupName, Group::MUST_NOT_DEPEND_ON_KEY); + + $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); + + if ($excludes !== null) { + $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + } + + $rule = $rule->shouldNotDependOn()->classes(...self::getPHPSelectors($mostNotDependOn)); + + if ($mostNotDependOnExcludes !== null) { + $rule = $rule->excluding(...self::getPHPSelectors($mostNotDependOnExcludes)); + } + + return $rule->because("$groupName must not depend on forbidden dependencies."); + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index d91ceb2..7b9b218 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -3,53 +3,32 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Generator; -use PHPat\Selector\Selector; +use Kununu\ArchitectureSniffer\Configuration\Group; +use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; final readonly class MustOnlyDependOn extends AbstractRule { - public function __construct( - public array $selectables, - public array $dependencies, - public ?array $excludes = null, - ) { - } - - public static function fromGenerators( - Generator $selectables, - Generator $dependencies, - ?Generator $extends = null, - ?Generator $implements = null, - ?Generator $excludes = null, - ): self { - $iteratedSelectables = iterator_to_array($selectables); - $unitedDependencies = array_merge( - iterator_to_array($dependencies), - $iteratedSelectables, - $extends ? iterator_to_array($extends) : [], - $implements ? iterator_to_array($implements) : [] - ); + public static function createRule( + string $groupName, + SelectorsLibrary $library, + ): PHPatRule { + $includes = $library->getIncludesByGroup($groupName); + $excludes = $library->getExcludesByGroup($groupName); + $onlyDependOn = $library->getTargetByGroup($groupName, Group::DEPENDS_ON_KEY); + $onlyDependOnExcludes = $library->getTargetExcludesByGroup($groupName, Group::DEPENDS_ON_KEY); - return new self( - $iteratedSelectables, - $unitedDependencies, - $excludes ? iterator_to_array($excludes) : null - ); - } + $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); + if ($excludes !== null) { + $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + } - public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule - { - $allowed = array_merge( - [Selector::classname('/^\\\\*[^\\\\]+$/', true)], - self::getPHPSelectors($this->dependencies) - ); + $rule = $rule->canOnlyDependOn()->classes(...self::getPHPSelectors($onlyDependOn)); + if ($onlyDependOnExcludes !== null) { + $rule = $rule->excluding(...$onlyDependOnExcludes); + } - return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) - ->excluding(...$this->excludes) - ->canOnlyDependOn() - ->classes(...$allowed) - ->because("$groupName has dependencies outside the allowed list."); + return $rule->because("$groupName must only depend on allowed dependencies."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index 00b387e..e7e30f6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -3,27 +3,29 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Generator; +use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; final readonly class MustOnlyHaveOnePublicMethodNamed extends AbstractRule { - public function __construct( - public array $selectables, - public string $functionName, - ) { - } + public static function createRule( + string $groupName, + SelectorsLibrary $library, + ): PHPatRule { + $includes = $library->getIncludesByGroup($groupName); + $excludes = $library->getExcludesByGroup($groupName); + $functionName = $library->getOnlyPublicFunctionByGroup($groupName); - public static function fromGenerator(Generator $selectables, string $functionName): self - { - return new self(iterator_to_array($selectables), $functionName); - } + $rule = PHPat::rule() + ->classes(...self::getPHPSelectors($includes)); + + if ($excludes !== null) { + $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + } - public function getPHPatRule(string $groupName): \PHPat\Test\Builder\Rule - { - return PHPat::rule() - ->classes(...self::getPHPSelectors($this->selectables)) - ->shouldHaveOnlyOnePublicMethodNamed($this->functionName) - ->because("$groupName should only have one public method named $this->functionName."); + return $rule + ->shouldHaveOnlyOnePublicMethodNamed($functionName) + ->because("$groupName should only have one public method named $functionName"); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php deleted file mode 100644 index b6a3e7d..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php +++ /dev/null @@ -1,11 +0,0 @@ -passedGroups = [$groupName]; $resolvedIncludes = []; foreach ($attributes[Group::INCLUDES_KEY] as $include) { - foreach ($this->resolveInclude($include) as $selectable) { + foreach ($this->resolveGroup($include, Group::INCLUDES_KEY) as $selectable) { $resolvedIncludes[] = $selectable; } } - $this->flattenedGroups[$groupName] = $resolvedIncludes; + $this->passedGroups = [$groupName]; + $resolvedExcludes = []; + foreach ($attributes[Group::EXCLUDES_KEY] as $include) { + foreach ($this->resolveGroup($include, Group::EXCLUDES_KEY) as $selectable) { + $resolvedIncludes[] = $selectable; + } + } + $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; + $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); + $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY]; + $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY] ?? []; + $this->flattenedGroups[$groupName][Group::FINAL_KEY] = $attributes[Group::FINAL_KEY] ?? false; + $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] = is_string($attributes[Group::EXTENDS_KEY]) ? [$attributes[Group::EXTENDS_KEY]] : null; + $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] = $attributes[Group::IMPLEMENTS_KEY] ?? null; + $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] = $attributes[Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null; } } - private function resolveInclude(string $fqcnOrGroupName): Generator + private function resolveGroup(string $fqcnOrGroupName, string $key): Generator { if (array_key_exists($fqcnOrGroupName, $this->groups)) { if (in_array($fqcnOrGroupName, $this->passedGroups, true)) { @@ -38,8 +52,8 @@ private function resolveInclude(string $fqcnOrGroupName): Generator $this->passedGroups[] = $fqcnOrGroupName; - foreach ($this->groups[$fqcnOrGroupName] as $subFqcnOrGroupName) { - yield from $this->resolveInclude($subFqcnOrGroupName); + foreach ($this->groups[$fqcnOrGroupName][$key] as $subFqcnOrGroupName) { + yield from $this->resolveGroup($subFqcnOrGroupName, $key); } return; @@ -48,33 +62,125 @@ private function resolveInclude(string $fqcnOrGroupName): Generator yield $fqcnOrGroupName; } - public function getSelector(string $fqcnOrGroup): Generator + public function getOnlyPublicFunctionByGroup(string $groupName): string { - if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { - foreach ($this->flattenedGroups[$fqcnOrGroup] as $fqcn) { - yield $this->createSelectable($fqcn); - } + if (!array_key_exists($groupName, $this->flattenedGroups)) { + throw new InvalidArgumentException("Group '$groupName' does not exist."); + } + + return $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; + } + + public function getIncludesByGroup(string $groupName): ?Generator + { + if (!array_key_exists($groupName, $this->flattenedGroups)) { + throw new InvalidArgumentException("Group '$groupName' does not exist."); + } + + if (empty($this->flattenedGroups[$groupName][Group::INCLUDES_KEY])) { + return null; } - return [$this->createSelectable($fqcnOrGroup)]; + foreach ($this->flattenedGroups[$groupName][Group::INCLUDES_KEY] as $fqcn) { + yield $this->createSelectable($fqcn); + } } - public function getSelectorsFromGroup(string $groupName): Generator + public function getExcludesByGroup(string $groupName): ?Generator { if (!array_key_exists($groupName, $this->flattenedGroups)) { throw new InvalidArgumentException("Group '$groupName' does not exist."); } - foreach ($this->flattenedGroups[$groupName] as $fqcn) { + if (empty($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + return null; + } + + foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] as $fqcn) { yield $this->createSelectable($fqcn); } } - public function getSelectors(array $values): Generator + public function getTargetByGroup(string $groupName, string $key): ?Generator + { + if (!array_key_exists($groupName, $this->flattenedGroups)) { + throw new InvalidArgumentException("Group '$groupName' does not exist."); + } + + if (empty($this->flattenedGroups[$groupName][$key])) { + return null; + } + + yield from $this->getSelectors($this->flattenedGroups[$groupName][$key]); + } + + public function groupHasKey(string $groupName, string $key): bool + { + if (!array_key_exists($groupName, $this->flattenedGroups)) { + throw new InvalidArgumentException("Group '$groupName' does not exist."); + } + + return array_key_exists($key, $this->flattenedGroups[$groupName]); + } + + public function getTargetExcludesByGroup(string $groupName, string $key): ?Generator + { + if (!array_key_exists($groupName, $this->flattenedGroups)) { + throw new InvalidArgumentException("Group '$groupName' does not exist."); + } + + if (empty($this->flattenedGroups[$groupName][$key])) { + return null; + } + + yield from array_diff( + $this->getPotentialExcludesBy($this->flattenedGroups[$groupName][$key]), + $this->getTargetByGroup($this->flattenedGroups[$groupName][$key], $key) + ); + } + + private function getSelector(string $fqcnOrGroup, string $key): ?Generator + { + if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { + if (!array_key_exists($key, $this->flattenedGroups[$fqcnOrGroup])) { + return null; + } + + foreach ($this->flattenedGroups[$fqcnOrGroup][$key] as $fqcn) { + yield $this->createSelectable($fqcn); + } + + return; + } + + yield $this->createSelectable($fqcnOrGroup); + } + + private function getSelectors(array $values): ?Generator { foreach ($values as $fqcnOrGroup) { - yield from $this->getSelector($fqcnOrGroup); + $generator = $this->getSelector($fqcnOrGroup, Group::INCLUDES_KEY); + if ($generator === null) { + return null; + } + yield from $generator; + } + } + + private function getPotentialExcludesBy(array $groups): array + { + $result = []; + foreach ($groups as $groupName) { + if (is_string($groupName) && array_key_exists($groupName, $this->flattenedGroups)) { + if (!empty($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] as $exclude) { + $result[] = $exclude; + } + } + } } + + return array_unique($result); } private function createSelectable(string $fqcn): Selectable diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index 4b4a091..ebd08cd 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -111,12 +111,23 @@ Each group in your `architecture.yaml` configuration is now defined as a key und - Example: `includes: ["App\\Controller\\*Controller"]` - **Rule triggered:** Classes matching these patterns are considered part of the group. +- **excludes** (optional): + - List of patterns or group names to be excluded from all rule assertions for this group. + - Example: `excludes: ["App\\Controller\\Abstract*", "App\\Service\\Legacy*"]` + - This property is used for all rules (extends, implements, depends_on, must_not_depend_on, etc.). + - **Note:** To blacklist dependencies, use `must_not_depend_on`. + - **depends_on** (optional): - List of group names or patterns that this group is allowed to depend on. - Example: `depends_on: ["$services", "App\\Library\\*"]` - **Rule triggered:** Ensures that classes in this group only depend on allowed groups/classes. Violations are reported if dependencies are outside this list. - **Important:** If a group includes from a global namespace other than `App\\`, it must NOT have a `depends_on` property. This will cause a configuration error. +- **must_not_depend_on** (optional): + - List of group names or patterns that this group is forbidden to depend on. + - Example: `must_not_depend_on: ["$forbidden", "App\\Forbidden\\*"]` + - **Rule triggered:** Reports any class in the group that depends on forbidden groups/classes. + - **final** (optional): - Boolean (`true`/`false`). If `true`, all classes in this group must be declared as `final`. - Example: `final: true` @@ -141,7 +152,9 @@ Each group in your `architecture.yaml` configuration is now defined as a key und | Property | Required | Type | Description | Rule Triggered | |----------------------------------|----------|-----------|-----------------------------------------------------------------------------|---------------------------------------------------------------------| | includes | Yes | array | Patterns or group names for group membership | Group membership | -| depends_on | No | array | Allowed dependencies (snake_case, not camelCase) | Dependency restriction | +| excludes | No | array | Excludes for all rules in this group | Exclusion from all rule assertions | +| depends_on | No | array | Allowed dependencies | Dependency restriction | +| must_not_depend_on | No | array | Forbidden dependencies | Forbidden dependency restriction | | final | No | boolean | Require classes to be `final` | Final class enforcement | | extends | No | string | Required base class/group | Inheritance enforcement | | implements | No | array | Required interfaces | Interface implementation enforcement | From 61ff2a50d58bd13302ca01a1c6fae9036b7ccd52 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 10:37:51 +0200 Subject: [PATCH 17/35] fixing rules --- .../Configuration/Architecture.php | 4 +++- .../Configuration/Rules/MustBeFinal.php | 18 ++++++++++------- .../Configuration/Rules/MustExtend.php | 20 ++++++++++++------- .../Configuration/Rules/MustOnlyDependOn.php | 7 ++++--- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index ed69fd4..0068a8e 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -71,8 +71,10 @@ public static function fromArray(array $data): iterable } } + $library = new SelectorsLibrary($architecture); + foreach ($architecture as $groupName => $groupData) { - yield from Group::getRules($groupName, $groupData); + yield from Group::getRules($groupName, $library); } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index ecd0c63..d5066bf 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -16,15 +16,9 @@ public static function createRule( string $groupName, SelectorsLibrary $library, ): PHPatRule { - $includes = $library->getIncludesByGroup($groupName); + $includes = self::checkIfClassSelectors($library->getIncludesByGroup($groupName)); $excludes = $library->getExcludesByGroup($groupName); - foreach ($includes as $selectable) { - if (!$selectable instanceof ClassSelector) { - throw new InvalidArgumentException('Only classes can be final.'); - } - } - $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); $excludes = $excludes ? self::getPHPSelectors($excludes) : []; @@ -33,4 +27,14 @@ public static function createRule( return $rule->shouldBeFinal()->because("$groupName must be final."); } + + private static function checkIfClassSelectors(iterable $selectors): iterable + { + foreach ($selectors as $selector) { + if (!$selector instanceof ClassSelector) { + throw new InvalidArgumentException('Only ClassSelector instances can be used in this rule.'); + } + yield $selector; + } + } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 5816319..8f8c4ae 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -18,15 +18,11 @@ public static function createRule( ): PHPatRule { $includes = $selectorsLibrary->getIncludesByGroup($groupName); $excludes = $selectorsLibrary->getExcludesByGroup($groupName); - $extensions = $selectorsLibrary->getTargetByGroup($groupName, Group::EXTENDS_KEY); + $extensions = self::checkIfInterfaceSelectors( + $selectorsLibrary->getTargetByGroup($groupName, Group::EXTENDS_KEY) + ); $extensionExcludes = $selectorsLibrary->getTargetExcludesByGroup($groupName, Group::EXTENDS_KEY); - foreach ($extensions as $extension) { - if ($extension instanceof InterfaceClassSelector) { - throw new InvalidArgumentException('Classes can not extend interfaces.'); - } - } - $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); if ($excludes !== null) { @@ -41,4 +37,14 @@ public static function createRule( return $rule->because("$groupName should extend class."); } + + private static function checkIfInterfaceSelectors(iterable $selectors): iterable + { + foreach ($selectors as $selector) { + if (!$selector instanceof InterfaceClassSelector) { + throw new InvalidArgumentException('Only InterfaceClassSelector instances can be used in this rule.'); + } + yield $selector; + } + } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 7b9b218..77ad395 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use iterable; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\Rule as PHPatRule; @@ -20,13 +21,13 @@ public static function createRule( $onlyDependOnExcludes = $library->getTargetExcludesByGroup($groupName, Group::DEPENDS_ON_KEY); $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - if ($excludes !== null) { + if ($excludes instanceof iterable) { $rule = $rule->excluding(...self::getPHPSelectors($excludes)); } $rule = $rule->canOnlyDependOn()->classes(...self::getPHPSelectors($onlyDependOn)); - if ($onlyDependOnExcludes !== null) { - $rule = $rule->excluding(...$onlyDependOnExcludes); + if ($onlyDependOnExcludes instanceof iterable) { + $rule = $rule->excluding(...self::getPHPSelectors($onlyDependOnExcludes)); } return $rule->because("$groupName must only depend on allowed dependencies."); From 16bdce23b26b008a44aeebff63ad0634f315451f Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 11:55:31 +0200 Subject: [PATCH 18/35] fixing validation --- .../Configuration/Rules/MustExtend.php | 9 ++++----- .../Configuration/Rules/MustImplement.php | 13 ++++++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 8f8c4ae..3a0ed67 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -5,7 +5,6 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Group; -use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -18,7 +17,7 @@ public static function createRule( ): PHPatRule { $includes = $selectorsLibrary->getIncludesByGroup($groupName); $excludes = $selectorsLibrary->getExcludesByGroup($groupName); - $extensions = self::checkIfInterfaceSelectors( + $extensions = self::checkIfNotInterfaceSelectors( $selectorsLibrary->getTargetByGroup($groupName, Group::EXTENDS_KEY) ); $extensionExcludes = $selectorsLibrary->getTargetExcludesByGroup($groupName, Group::EXTENDS_KEY); @@ -38,11 +37,11 @@ public static function createRule( return $rule->because("$groupName should extend class."); } - private static function checkIfInterfaceSelectors(iterable $selectors): iterable + private static function checkIfNotInterfaceSelectors(iterable $selectors): iterable { foreach ($selectors as $selector) { - if (!$selector instanceof InterfaceClassSelector) { - throw new InvalidArgumentException('Only InterfaceClassSelector instances can be used in this rule.'); + if (str_ends_with($selector, 'Interface')) { + throw new InvalidArgumentException("$selector cannot be used in the MustExtend rule, as it is an interface."); } yield $selector; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 556ff4f..1696639 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; @@ -17,7 +18,7 @@ public static function createRule( ): PHPatRule { $includes = $library->getIncludesByGroup($groupName); $excludes = $library->getExcludesByGroup($groupName); - $interfaces = $library->getTargetByGroup($groupName, Group::INCLUDES_KEY); + $interfaces = self::checkIfInterfaceSelectors($library->getTargetByGroup($groupName, Group::INCLUDES_KEY)); $interfacesExcludes = $library->getTargetExcludesByGroup($groupName, Group::INCLUDES_KEY); $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); @@ -33,4 +34,14 @@ public static function createRule( return $rule->because("$groupName must implement interface."); } + + private static function checkIfInterfaceSelectors(iterable $selectors): iterable + { + foreach ($selectors as $selector) { + if (!str_ends_with($selector, 'Interface')) { + throw new InvalidArgumentException("$selector cannot be used in the MustImplement rule, as it is not an interface."); + } + yield $selector; + } + } } From 35cf7cf76c47d7cb4230824d9556711b47e47a48 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 12:04:36 +0200 Subject: [PATCH 19/35] fix check --- .../ArchitectureSniffer/Configuration/Rules/MustExtend.php | 5 +++-- .../Configuration/Rules/MustImplement.php | 7 +++++-- .../Configuration/Selector/ClassSelector.php | 5 ----- .../Configuration/Selector/InterfaceClassSelector.php | 5 ----- .../Configuration/Selector/NamespaceSelector.php | 5 ----- .../Configuration/Selector/Selectable.php | 2 -- 6 files changed, 8 insertions(+), 21 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 3a0ed67..391c432 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Group; +use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -40,8 +41,8 @@ public static function createRule( private static function checkIfNotInterfaceSelectors(iterable $selectors): iterable { foreach ($selectors as $selector) { - if (str_ends_with($selector, 'Interface')) { - throw new InvalidArgumentException("$selector cannot be used in the MustExtend rule, as it is an interface."); + if ($selector instanceof InterfaceClassSelector) { + throw new InvalidArgumentException("$selector->interface cannot be used in the MustExtend rule, as it is an interface."); } yield $selector; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 1696639..90bdef2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -5,6 +5,8 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Group; +use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; +use Kununu\ArchitectureSniffer\Configuration\Selector\NamespaceSelector; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; @@ -38,8 +40,9 @@ public static function createRule( private static function checkIfInterfaceSelectors(iterable $selectors): iterable { foreach ($selectors as $selector) { - if (!str_ends_with($selector, 'Interface')) { - throw new InvalidArgumentException("$selector cannot be used in the MustImplement rule, as it is not an interface."); + if ($selector instanceof ClassSelector || $selector instanceof NamespaceSelector) { + $name = $selector->namespace ?? $selector->class; + throw new InvalidArgumentException("$name cannot be used in the MustImplement rule, as it is not an interface."); } yield $selector; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php index e0a49fd..3171e20 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -25,9 +25,4 @@ public function getPHPatSelector(): SelectorInterface return Selector::classname($class, $class !== $this->class); } - - public function getDefinition(): string - { - return $this->class; - } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index 7db96b1..ead2092 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -28,9 +28,4 @@ public function getPHPatSelector(): SelectorInterface Selector::isInterface(), ); } - - public function getDefinition(): string - { - return $this->interface; - } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index e7897cd..6be22a9 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -25,9 +25,4 @@ public function getPHPatSelector(): SelectorInterface return Selector::inNamespace($namespace, $namespace !== $this->namespace); } - - public function getDefinition(): string - { - return $this->namespace; - } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php b/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php index ef9ed18..eaf44e9 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php @@ -8,6 +8,4 @@ interface Selectable { public function getPHPatSelector(): SelectorInterface; - - public function getDefinition(): string; } From ced2a338e8a06112014124ac3a6d5d4a69028527 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 12:59:10 +0200 Subject: [PATCH 20/35] fixing Generator checks --- .../Configuration/Group.php | 4 +- .../Configuration/Rules/MustBeFinal.php | 7 ++- .../Configuration/Rules/MustExtend.php | 10 +-- .../Configuration/Rules/MustImplement.php | 9 +-- .../Configuration/Rules/MustNotDependOn.php | 10 +-- .../Configuration/Rules/MustOnlyDependOn.php | 12 ++-- .../MustOnlyHaveOnePublicMethodNamed.php | 5 +- .../Configuration/SelectorsLibrary.php | 63 +++++++------------ 8 files changed, 54 insertions(+), 66 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 51ef765..e8d8b3a 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -16,7 +16,7 @@ public static function getRules(string $groupName, SelectorsLibrary $library): iterable { - if ($library->groupHasKey($groupName, self::DEPENDS_ON_KEY)) { + if ($library->groupHasKey($groupName, self::EXTENDS_KEY)) { yield Rules\MustExtend::createRule( $groupName, $library @@ -48,7 +48,7 @@ public static function getRules(string $groupName, SelectorsLibrary $library): i $library ); } - if ($library->groupHasKey($groupName, self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY)) { + if ($library->getOnlyPublicFunctionByGroup($groupName)) { yield Rules\MustOnlyHaveOnePublicMethodNamed::createRule( $groupName, $library diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index d5066bf..02dbd78 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -4,7 +4,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use InvalidArgumentException; -use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; +use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; @@ -31,8 +31,9 @@ public static function createRule( private static function checkIfClassSelectors(iterable $selectors): iterable { foreach ($selectors as $selector) { - if (!$selector instanceof ClassSelector) { - throw new InvalidArgumentException('Only ClassSelector instances can be used in this rule.'); + if ($selector instanceof InterfaceClassSelector) { + $name = $selector->interface; + throw new InvalidArgumentException("$name must be a class selector for rule MustBeFinal."); } yield $selector; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 391c432..46919a5 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -25,14 +25,16 @@ public static function createRule( $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - if ($excludes !== null) { - $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + $excludes = self::getPHPSelectors($excludes); + if ($excludes !== []) { + $rule = $rule->excluding(...$excludes); } $rule = $rule->shouldExtend()->classes(...self::getPHPSelectors($extensions)); - if ($extensionExcludes !== null) { - $rule = $rule->excluding(...self::getPHPSelectors($extensionExcludes)); + $extensionExcludes = self::getPHPSelectors($extensionExcludes); + if ($extensionExcludes !== []) { + $rule = $rule->excluding(...$extensionExcludes); } return $rule->because("$groupName should extend class."); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 90bdef2..24e7afa 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -20,8 +20,8 @@ public static function createRule( ): PHPatRule { $includes = $library->getIncludesByGroup($groupName); $excludes = $library->getExcludesByGroup($groupName); - $interfaces = self::checkIfInterfaceSelectors($library->getTargetByGroup($groupName, Group::INCLUDES_KEY)); - $interfacesExcludes = $library->getTargetExcludesByGroup($groupName, Group::INCLUDES_KEY); + $interfaces = self::checkIfInterfaceSelectors($library->getTargetByGroup($groupName, Group::IMPLEMENTS_KEY)); + $interfacesExcludes = $library->getTargetExcludesByGroup($groupName, Group::IMPLEMENTS_KEY); $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); @@ -30,8 +30,9 @@ public static function createRule( $rule = $rule->excluding(...$excludeSelectors); $rule = $rule->shouldImplement()->classes(...self::getPHPSelectors($interfaces)); - if ($interfacesExcludes !== null) { - $rule = $rule->excluding(...self::getPHPSelectors($interfacesExcludes)); + $interfacesExcludes = self::getPHPSelectors($interfacesExcludes); + if ($interfacesExcludes !== []) { + $rule = $rule->excluding(...$interfacesExcludes); } return $rule->because("$groupName must implement interface."); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php index 5380e55..b3ce453 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php @@ -21,14 +21,16 @@ public static function createRule( $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - if ($excludes !== null) { - $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + $excludes = self::getPHPSelectors($excludes); + if ($excludes !== []) { + $rule = $rule->excluding(...$excludes); } $rule = $rule->shouldNotDependOn()->classes(...self::getPHPSelectors($mostNotDependOn)); - if ($mostNotDependOnExcludes !== null) { - $rule = $rule->excluding(...self::getPHPSelectors($mostNotDependOnExcludes)); + $mostNotDependOnExcludes = self::getPHPSelectors($mostNotDependOnExcludes); + if ($mostNotDependOnExcludes !== []) { + $rule = $rule->excluding(...$mostNotDependOnExcludes); } return $rule->because("$groupName must not depend on forbidden dependencies."); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 77ad395..0c02fff 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -3,7 +3,6 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use iterable; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\Rule as PHPatRule; @@ -21,13 +20,16 @@ public static function createRule( $onlyDependOnExcludes = $library->getTargetExcludesByGroup($groupName, Group::DEPENDS_ON_KEY); $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - if ($excludes instanceof iterable) { - $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + $excludes = self::getPHPSelectors($excludes); + if ($excludes !== []) { + $rule = $rule->excluding(...$excludes); } $rule = $rule->canOnlyDependOn()->classes(...self::getPHPSelectors($onlyDependOn)); - if ($onlyDependOnExcludes instanceof iterable) { - $rule = $rule->excluding(...self::getPHPSelectors($onlyDependOnExcludes)); + + $onlyDependOnExcludes = self::getPHPSelectors($onlyDependOnExcludes); + if ($onlyDependOnExcludes !== []) { + $rule = $rule->excluding(...$onlyDependOnExcludes); } return $rule->because("$groupName must only depend on allowed dependencies."); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index e7e30f6..283cc6b 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -20,8 +20,9 @@ public static function createRule( $rule = PHPat::rule() ->classes(...self::getPHPSelectors($includes)); - if ($excludes !== null) { - $rule = $rule->excluding(...self::getPHPSelectors($excludes)); + $excludes = self::getPHPSelectors($excludes); + if ($excludes !== []) { + $rule = $rule->excluding(...$excludes); } return $rule diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php index 9243c9b..4ca9589 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -34,9 +34,9 @@ public function __construct(private array $groups) } $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); - $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY]; - $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY] ?? []; - $this->flattenedGroups[$groupName][Group::FINAL_KEY] = $attributes[Group::FINAL_KEY] ?? false; + $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY] ?? null; + $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY] ?? null; + $this->flattenedGroups[$groupName][Group::FINAL_KEY] = $attributes[Group::FINAL_KEY] ?? null; $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] = is_string($attributes[Group::EXTENDS_KEY]) ? [$attributes[Group::EXTENDS_KEY]] : null; $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] = $attributes[Group::IMPLEMENTS_KEY] ?? null; $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] = $attributes[Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null; @@ -62,7 +62,7 @@ private function resolveGroup(string $fqcnOrGroupName, string $key): Generator yield $fqcnOrGroupName; } - public function getOnlyPublicFunctionByGroup(string $groupName): string + public function getOnlyPublicFunctionByGroup(string $groupName): ?string { if (!array_key_exists($groupName, $this->flattenedGroups)) { throw new InvalidArgumentException("Group '$groupName' does not exist."); @@ -71,46 +71,34 @@ public function getOnlyPublicFunctionByGroup(string $groupName): string return $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; } - public function getIncludesByGroup(string $groupName): ?Generator + public function getIncludesByGroup(string $groupName): Generator { if (!array_key_exists($groupName, $this->flattenedGroups)) { throw new InvalidArgumentException("Group '$groupName' does not exist."); } - if (empty($this->flattenedGroups[$groupName][Group::INCLUDES_KEY])) { - return null; - } - foreach ($this->flattenedGroups[$groupName][Group::INCLUDES_KEY] as $fqcn) { yield $this->createSelectable($fqcn); } } - public function getExcludesByGroup(string $groupName): ?Generator + public function getExcludesByGroup(string $groupName): Generator { if (!array_key_exists($groupName, $this->flattenedGroups)) { throw new InvalidArgumentException("Group '$groupName' does not exist."); } - if (empty($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { - return null; - } - foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] as $fqcn) { yield $this->createSelectable($fqcn); } } - public function getTargetByGroup(string $groupName, string $key): ?Generator + public function getTargetByGroup(string $groupName, string $key): Generator { if (!array_key_exists($groupName, $this->flattenedGroups)) { throw new InvalidArgumentException("Group '$groupName' does not exist."); } - if (empty($this->flattenedGroups[$groupName][$key])) { - return null; - } - yield from $this->getSelectors($this->flattenedGroups[$groupName][$key]); } @@ -120,32 +108,27 @@ public function groupHasKey(string $groupName, string $key): bool throw new InvalidArgumentException("Group '$groupName' does not exist."); } - return array_key_exists($key, $this->flattenedGroups[$groupName]); + return array_key_exists($key, $this->flattenedGroups[$groupName]) && $this->flattenedGroups[$groupName][$key] !== null; } - public function getTargetExcludesByGroup(string $groupName, string $key): ?Generator + public function getTargetExcludesByGroup(string $groupName, string $key): Generator { if (!array_key_exists($groupName, $this->flattenedGroups)) { throw new InvalidArgumentException("Group '$groupName' does not exist."); } - if (empty($this->flattenedGroups[$groupName][$key])) { - return null; + $includes = iterator_to_array($this->getTargetByGroup($groupName, $key)); + foreach ($this->getPotentialExcludesBy($this->flattenedGroups[$groupName][$key]) as $exclude) { + if (in_array($exclude, $includes, true)) { + continue; + } + yield $exclude; } - - yield from array_diff( - $this->getPotentialExcludesBy($this->flattenedGroups[$groupName][$key]), - $this->getTargetByGroup($this->flattenedGroups[$groupName][$key], $key) - ); } - private function getSelector(string $fqcnOrGroup, string $key): ?Generator + private function getSelector(string $fqcnOrGroup, string $key): Generator { if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { - if (!array_key_exists($key, $this->flattenedGroups[$fqcnOrGroup])) { - return null; - } - foreach ($this->flattenedGroups[$fqcnOrGroup][$key] as $fqcn) { yield $this->createSelectable($fqcn); } @@ -156,14 +139,10 @@ private function getSelector(string $fqcnOrGroup, string $key): ?Generator yield $this->createSelectable($fqcnOrGroup); } - private function getSelectors(array $values): ?Generator + private function getSelectors(array $values): Generator { foreach ($values as $fqcnOrGroup) { - $generator = $this->getSelector($fqcnOrGroup, Group::INCLUDES_KEY); - if ($generator === null) { - return null; - } - yield from $generator; + yield from $this->getSelector($fqcnOrGroup, Group::INCLUDES_KEY); } } @@ -186,9 +165,9 @@ private function getPotentialExcludesBy(array $groups): array private function createSelectable(string $fqcn): Selectable { return match (true) { - str_ends_with($fqcn, '\\') => new NamespaceSelector($fqcn), - str_ends_with($fqcn, 'Interface') => new InterfaceClassSelector($fqcn), - default => new ClassSelector($fqcn), + interface_exists($fqcn) || str_ends_with($fqcn, 'Interface') => new InterfaceClassSelector($fqcn), + str_ends_with($fqcn, '\\') => new NamespaceSelector($fqcn), + default => new ClassSelector($fqcn), }; } } From fe3f410c7cb76c108bae462e499bb113160c9052 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 14:18:04 +0200 Subject: [PATCH 21/35] fixing last bits --- .../ArchitectureSniffer.php | 6 +++-- .../Configuration/Architecture.php | 3 +++ .../Configuration/Group.php | 5 ++++ .../Configuration/Rules/MustOnlyDependOn.php | 6 ++++- .../Configuration/SelectorsLibrary.php | 26 ++++++++++++++----- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index c7e52e7..cd75957 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -17,8 +17,10 @@ final class ArchitectureSniffer */ public function testArchitecture(): iterable { - yield from Architecture::fromArray( + foreach (Architecture::fromArray( Yaml::parseFile(ProjectPathResolver::resolve(self::ARCHITECTURE_FILENAME)) - ); + ) as $rule) { + yield $rule; + } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index 0068a8e..a03f29a 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -4,6 +4,7 @@ namespace Kununu\ArchitectureSniffer\Configuration; use InvalidArgumentException; +use PHPat\Test\Builder\Rule as PHPatRule; final readonly class Architecture { @@ -11,6 +12,8 @@ /** * @param array> $data + * + * @return iterable */ public static function fromArray(array $data): iterable { diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index e8d8b3a..1626c8b 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -3,6 +3,8 @@ namespace Kununu\ArchitectureSniffer\Configuration; +use PHPat\Test\Builder\Rule as PHPatRule; + final readonly class Group { public const string INCLUDES_KEY = 'includes'; @@ -14,6 +16,9 @@ public const string MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY = 'must_only_have_one_public_method_named'; public const string MUST_NOT_DEPEND_ON_KEY = 'must_not_depend_on'; + /** + * @return iterable + **/ public static function getRules(string $groupName, SelectorsLibrary $library): iterable { if ($library->groupHasKey($groupName, self::EXTENDS_KEY)) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 0c02fff..0dd25d5 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -5,6 +5,7 @@ use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -20,12 +21,15 @@ public static function createRule( $onlyDependOnExcludes = $library->getTargetExcludesByGroup($groupName, Group::DEPENDS_ON_KEY); $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); + $excludes = self::getPHPSelectors($excludes); if ($excludes !== []) { $rule = $rule->excluding(...$excludes); } - $rule = $rule->canOnlyDependOn()->classes(...self::getPHPSelectors($onlyDependOn)); + $onlyDependOn = self::getPHPSelectors($onlyDependOn); + $onlyDependOn[] = Selector::classname('/^\\\\*[^\\\\]+$/', true); + $rule = $rule->canOnlyDependOn()->classes(...$onlyDependOn); $onlyDependOnExcludes = self::getPHPSelectors($onlyDependOnExcludes); if ($onlyDependOnExcludes !== []) { diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php index 4ca9589..d6245c8 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -77,9 +77,7 @@ public function getIncludesByGroup(string $groupName): Generator throw new InvalidArgumentException("Group '$groupName' does not exist."); } - foreach ($this->flattenedGroups[$groupName][Group::INCLUDES_KEY] as $fqcn) { - yield $this->createSelectable($fqcn); - } + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::INCLUDES_KEY]); } public function getExcludesByGroup(string $groupName): Generator @@ -88,9 +86,7 @@ public function getExcludesByGroup(string $groupName): Generator throw new InvalidArgumentException("Group '$groupName' does not exist."); } - foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] as $fqcn) { - yield $this->createSelectable($fqcn); - } + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY]); } public function getTargetByGroup(string $groupName, string $key): Generator @@ -99,6 +95,24 @@ public function getTargetByGroup(string $groupName, string $key): Generator throw new InvalidArgumentException("Group '$groupName' does not exist."); } + if ($key === Group::DEPENDS_ON_KEY) { + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::INCLUDES_KEY]); + + if ( + array_key_exists(Group::EXTENDS_KEY, $this->flattenedGroups[$groupName]) + && $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] !== null + ) { + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXTENDS_KEY]); + } + + if ( + array_key_exists(Group::IMPLEMENTS_KEY, $this->flattenedGroups[$groupName]) + && $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] !== null + ) { + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY]); + } + } + yield from $this->getSelectors($this->flattenedGroups[$groupName][$key]); } From 43081545e4c192ffa25828bc7a5e2e67ad94fe56 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 14:59:18 +0200 Subject: [PATCH 22/35] fix quodana errors --- .../Configuration/Rules/AbstractRule.php | 46 +++++++++++++++++++ .../Configuration/Rules/MustBeFinal.php | 2 +- .../Configuration/Rules/MustImplement.php | 2 +- .../Configuration/Rules/MustNotDependOn.php | 32 +++++-------- .../Configuration/Rules/MustOnlyDependOn.php | 35 +++++--------- .../Configuration/SelectorsLibrary.php | 8 ++-- Kununu/ArchitectureSniffer/README.md | 26 +++++++++-- 7 files changed, 98 insertions(+), 53 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index c2be269..a477998 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -5,6 +5,11 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; +use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use PHPat\Test\Builder\AssertionStep; +use PHPat\Test\Builder\Rule as PHPatRule; +use PHPat\Test\Builder\TargetStep; +use PHPat\Test\PHPat; abstract readonly class AbstractRule { @@ -22,4 +27,45 @@ public static function getPHPSelectors(iterable $selectors): array return $result; } + + /** + * @param callable(AssertionStep): TargetStep $assertionStep + */ + protected static function buildDependencyRule( + string $groupName, + SelectorsLibrary $library, + callable $assertionStep, + string $because = '', + ?string $targetKey = null, + array $extraSelectors = [], + ): PHPatRule { + $includes = $library->getIncludesByGroup($groupName); + $excludes = $library->getExcludesByGroup($groupName); + $target = $targetKey ? $library->getTargetByGroup($groupName, $targetKey) : []; + $targetExcludes = $targetKey ? $library->getTargetExcludesByGroup($groupName, $targetKey) : []; + + $includes = self::getPHPSelectors($includes); + $excludes = self::getPHPSelectors($excludes); + $target = self::getPHPSelectors($target); + $targetExcludes = self::getPHPSelectors($targetExcludes); + + $rule = PHPat::rule()->classes(...$includes); + if ($excludes !== []) { + $rule = $rule->excluding(...$excludes); + } + $rule = $assertionStep($rule); + + if ($extraSelectors !== []) { + $target = array_merge($target, $extraSelectors); + } + $rule = $rule->classes(...$target); + if ($targetExcludes !== []) { + $rule = $rule->excluding(...$targetExcludes); + } + if ($because) { + $rule = $rule->because($because); + } + + return $rule; + } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 02dbd78..2737fc4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -21,7 +21,7 @@ public static function createRule( $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - $excludes = $excludes ? self::getPHPSelectors($excludes) : []; + $excludes = self::getPHPSelectors($excludes); $excludes[] = Selector::isInterface(); $rule = $rule->excluding(...$excludes); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 24e7afa..a770db0 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -25,7 +25,7 @@ public static function createRule( $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - $excludeSelectors = $excludes ? self::getPHPSelectors($excludes) : []; + $excludeSelectors = self::getPHPSelectors($excludes); $excludeSelectors[] = Selector::isInterface(); $rule = $rule->excluding(...$excludeSelectors); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php index b3ce453..aa092e2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php @@ -5,8 +5,9 @@ use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use PHPat\Test\Builder\AssertionStep; use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\PHPat; +use PHPat\Test\Builder\TargetStep; final readonly class MustNotDependOn extends AbstractRule { @@ -14,25 +15,14 @@ public static function createRule( string $groupName, SelectorsLibrary $library, ): PHPatRule { - $includes = $library->getIncludesByGroup($groupName); - $excludes = $library->getExcludesByGroup($groupName); - $mostNotDependOn = $library->getTargetByGroup($groupName, Group::MUST_NOT_DEPEND_ON_KEY); - $mostNotDependOnExcludes = $library->getTargetExcludesByGroup($groupName, Group::MUST_NOT_DEPEND_ON_KEY); - - $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - - $excludes = self::getPHPSelectors($excludes); - if ($excludes !== []) { - $rule = $rule->excluding(...$excludes); - } - - $rule = $rule->shouldNotDependOn()->classes(...self::getPHPSelectors($mostNotDependOn)); - - $mostNotDependOnExcludes = self::getPHPSelectors($mostNotDependOnExcludes); - if ($mostNotDependOnExcludes !== []) { - $rule = $rule->excluding(...$mostNotDependOnExcludes); - } - - return $rule->because("$groupName must not depend on forbidden dependencies."); + return self::buildDependencyRule( + $groupName, + $library, + static function(AssertionStep $rule): TargetStep { + return $rule->shouldNotDependOn(); + }, + "$groupName must not depend on forbidden dependencies.", + Group::MUST_NOT_DEPEND_ON_KEY, + ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 0dd25d5..7d5467c 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -6,8 +6,9 @@ use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; +use PHPat\Test\Builder\AssertionStep; use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\PHPat; +use PHPat\Test\Builder\TargetStep; final readonly class MustOnlyDependOn extends AbstractRule { @@ -15,27 +16,15 @@ public static function createRule( string $groupName, SelectorsLibrary $library, ): PHPatRule { - $includes = $library->getIncludesByGroup($groupName); - $excludes = $library->getExcludesByGroup($groupName); - $onlyDependOn = $library->getTargetByGroup($groupName, Group::DEPENDS_ON_KEY); - $onlyDependOnExcludes = $library->getTargetExcludesByGroup($groupName, Group::DEPENDS_ON_KEY); - - $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - - $excludes = self::getPHPSelectors($excludes); - if ($excludes !== []) { - $rule = $rule->excluding(...$excludes); - } - - $onlyDependOn = self::getPHPSelectors($onlyDependOn); - $onlyDependOn[] = Selector::classname('/^\\\\*[^\\\\]+$/', true); - $rule = $rule->canOnlyDependOn()->classes(...$onlyDependOn); - - $onlyDependOnExcludes = self::getPHPSelectors($onlyDependOnExcludes); - if ($onlyDependOnExcludes !== []) { - $rule = $rule->excluding(...$onlyDependOnExcludes); - } - - return $rule->because("$groupName must only depend on allowed dependencies."); + return self::buildDependencyRule( + $groupName, + $library, + static function(AssertionStep $rule): TargetStep { + return $rule->canOnlyDependOn(); + }, + "$groupName must only depend on allowed dependencies.", + Group::DEPENDS_ON_KEY, + [Selector::classname('/^\\*[^\\]+$/', true)], + ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php index d6245c8..5a6e3bf 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -15,7 +15,7 @@ final class SelectorsLibrary private array $flattenedGroups = []; private array $passedGroups = []; - public function __construct(private array $groups) + public function __construct(private readonly array $groups) { foreach ($groups as $groupName => $attributes) { $this->passedGroups = [$groupName]; @@ -140,10 +140,10 @@ public function getTargetExcludesByGroup(string $groupName, string $key): Genera } } - private function getSelector(string $fqcnOrGroup, string $key): Generator + private function getSelectorFromIncludes(string $fqcnOrGroup): Generator { if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { - foreach ($this->flattenedGroups[$fqcnOrGroup][$key] as $fqcn) { + foreach ($this->flattenedGroups[$fqcnOrGroup][Group::INCLUDES_KEY] as $fqcn) { yield $this->createSelectable($fqcn); } @@ -156,7 +156,7 @@ private function getSelector(string $fqcnOrGroup, string $key): Generator private function getSelectors(array $values): Generator { foreach ($values as $fqcnOrGroup) { - yield from $this->getSelector($fqcnOrGroup, Group::INCLUDES_KEY); + yield from $this->getSelectorFromIncludes($fqcnOrGroup); } } diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index ebd08cd..4194cc8 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -104,7 +104,7 @@ architecture: ### Group Properties -Each group in your `architecture.yaml` configuration is now defined as a key under `architecture`. Only `includes` is required; all other properties are optional and trigger specific architectural rules: +Each group in your `architecture.yaml` configuration is defined as a key under `architecture`. Only `includes` is required; all other properties are optional and trigger specific architectural rules: - **includes** (required): - List of patterns or group names that define which classes/interfaces belong to this group. @@ -119,7 +119,9 @@ Each group in your `architecture.yaml` configuration is now defined as a key und - **depends_on** (optional): - List of group names or patterns that this group is allowed to depend on. - - Example: `depends_on: ["$services", "App\\Library\\*"]` + - To prevent redundant dependencies, the rule will also consider all dependencies from "includes", "extends" and "implements". + - Classes from the root namespace are also always included (e.g., `\DateTime`). + - Example: `depends_on: ["services", "App\\Library\\*"]` - **Rule triggered:** Ensures that classes in this group only depend on allowed groups/classes. Violations are reported if dependencies are outside this list. - **Important:** If a group includes from a global namespace other than `App\\`, it must NOT have a `depends_on` property. This will cause a configuration error. @@ -178,7 +180,7 @@ When specifying patterns or references in your `architecture.yaml` (for `include - Example: `"App\\Service\\"` matches everything in the `App\Service` namespace. - **Interface:** - - If the string ends with `Interface`, it is treated as an interface. + - If the fqcn is a Interface or the regex ends with `Interface`, it is treated as an interface. - Example: `"App\\Service\\ServiceInterface"` matches the interface `ServiceInterface`. - **Class:** @@ -194,6 +196,22 @@ This logic applies to all properties that accept patterns or references, such as - Groups are referenced by their name. - The `$` prefix is recommended but not required. - The reference must match the group name exactly. +- When referencing a group, all includes and excludes from that group are considered. +- Important: Includes overrule excludes, meaning if a exact namespace is listed in both include and exclude, it will only be part of the includes. +- Example: + ```yaml + architecture: + $command_handler: + includes: + - "App\\Application\\Command\\*\\*Handler" + depends_on: + - "$write_repository" + $write_repository: + includes: + - "App\\Repository\\*\\*RepositoryInterface" + excludes: + - "App\\Repository\\*\\*ReadOnlyRepositoryInterface" + ``` ### Pattern Matching @@ -242,3 +260,5 @@ See [LICENSE](../LICENSE). - [PHPAT Documentation](https://github.com/carlosas/phpat) - [Architecture Sniffer (Spryker)](https://github.com/spryker/architecture-sniffer) + + From c13dfa445cab737cde3c200fe76c79c632ee6924 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 15:03:06 +0200 Subject: [PATCH 23/35] fixing cs fixer --- .../Configuration/Rules/MustExtend.php | 4 +++- .../Configuration/Rules/MustImplement.php | 4 +++- .../Configuration/SelectorsLibrary.php | 12 ++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 46919a5..6f52171 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -44,7 +44,9 @@ private static function checkIfNotInterfaceSelectors(iterable $selectors): itera { foreach ($selectors as $selector) { if ($selector instanceof InterfaceClassSelector) { - throw new InvalidArgumentException("$selector->interface cannot be used in the MustExtend rule, as it is an interface."); + throw new InvalidArgumentException( + "$selector->interface cannot be used in the MustExtend rule, as it is an interface." + ); } yield $selector; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index a770db0..cf0a023 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -43,7 +43,9 @@ private static function checkIfInterfaceSelectors(iterable $selectors): iterable foreach ($selectors as $selector) { if ($selector instanceof ClassSelector || $selector instanceof NamespaceSelector) { $name = $selector->namespace ?? $selector->class; - throw new InvalidArgumentException("$name cannot be used in the MustImplement rule, as it is not an interface."); + throw new InvalidArgumentException( + "$name cannot be used in the MustImplement rule, as it is not an interface." + ); } yield $selector; } diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php index 5a6e3bf..631bbe1 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -35,11 +35,14 @@ public function __construct(private readonly array $groups) $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY] ?? null; - $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY] ?? null; + $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] + = $attributes[Group::MUST_NOT_DEPEND_ON_KEY] ?? null; $this->flattenedGroups[$groupName][Group::FINAL_KEY] = $attributes[Group::FINAL_KEY] ?? null; - $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] = is_string($attributes[Group::EXTENDS_KEY]) ? [$attributes[Group::EXTENDS_KEY]] : null; + $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] + = is_string($attributes[Group::EXTENDS_KEY]) ? [$attributes[Group::EXTENDS_KEY]] : null; $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] = $attributes[Group::IMPLEMENTS_KEY] ?? null; - $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] = $attributes[Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null; + $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] + = $attributes[Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null; } } @@ -122,7 +125,8 @@ public function groupHasKey(string $groupName, string $key): bool throw new InvalidArgumentException("Group '$groupName' does not exist."); } - return array_key_exists($key, $this->flattenedGroups[$groupName]) && $this->flattenedGroups[$groupName][$key] !== null; + return array_key_exists($key, $this->flattenedGroups[$groupName]) + && $this->flattenedGroups[$groupName][$key] !== null; } public function getTargetExcludesByGroup(string $groupName, string $key): Generator From 62e2c2a6b7e9bef13704a3ac4721e99d297d47f7 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 16:06:35 +0200 Subject: [PATCH 24/35] fixing phpstan --- .../Configuration/Architecture.php | 4 +- .../Configuration/Rules/AbstractRule.php | 15 +-- .../Configuration/Rules/MustBeFinal.php | 6 ++ .../Configuration/Rules/MustExtend.php | 6 ++ .../Configuration/Rules/MustImplement.php | 12 ++- .../Configuration/Rules/MustNotDependOn.php | 2 +- .../Configuration/Rules/MustOnlyDependOn.php | 2 +- .../MustOnlyHaveOnePublicMethodNamed.php | 7 ++ .../Configuration/SelectorsLibrary.php | 100 +++++++++++++++--- 9 files changed, 130 insertions(+), 24 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index a03f29a..60fcfc7 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -11,7 +11,7 @@ public const string ARCHITECTURE_KEY = 'architecture'; /** - * @param array> $data + * @param array>> $data * * @return iterable */ @@ -58,7 +58,7 @@ public static function fromArray(array $data): iterable $architecture, static fn(array $group) => !array_filter( is_array($group[Group::INCLUDES_KEY] ?? null) ? $group[Group::INCLUDES_KEY] : [], - fn($include) => str_starts_with($include, 'App\\') + static fn($include) => str_starts_with($include, 'App\\') ) ); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index a477998..ac5c9d2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -3,9 +3,9 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use PHPat\Selector\SelectorInterface; use PHPat\Test\Builder\AssertionStep; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\Builder\TargetStep; @@ -13,15 +13,15 @@ abstract readonly class AbstractRule { + /** + * @param iterable $selectors + * + * @return array + */ public static function getPHPSelectors(iterable $selectors): array { $result = []; foreach ($selectors as $selector) { - if (!$selector instanceof Selectable) { - throw new InvalidArgumentException( - 'Only Selectable instances can be used in rules.' - ); - } $result[] = $selector->getPHPatSelector(); } @@ -30,13 +30,14 @@ public static function getPHPSelectors(iterable $selectors): array /** * @param callable(AssertionStep): TargetStep $assertionStep + * @param array $extraSelectors */ protected static function buildDependencyRule( string $groupName, SelectorsLibrary $library, callable $assertionStep, + string $targetKey, string $because = '', - ?string $targetKey = null, array $extraSelectors = [], ): PHPatRule { $includes = $library->getIncludesByGroup($groupName); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 2737fc4..1ae3f30 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; +use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; @@ -28,6 +29,11 @@ public static function createRule( return $rule->shouldBeFinal()->because("$groupName must be final."); } + /** + * @param iterable $selectors + * + * @return iterable + */ private static function checkIfClassSelectors(iterable $selectors): iterable { foreach ($selectors as $selector) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 6f52171..4b87e7d 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; +use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -40,6 +41,11 @@ public static function createRule( return $rule->because("$groupName should extend class."); } + /** + * @param iterable $selectors + * + * @return iterable + */ private static function checkIfNotInterfaceSelectors(iterable $selectors): iterable { foreach ($selectors as $selector) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index cf0a023..f88fa84 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -7,6 +7,7 @@ use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; use Kununu\ArchitectureSniffer\Configuration\Selector\NamespaceSelector; +use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; @@ -38,11 +39,20 @@ public static function createRule( return $rule->because("$groupName must implement interface."); } + /** + * @param iterable $selectors + * + * @return iterable + */ private static function checkIfInterfaceSelectors(iterable $selectors): iterable { foreach ($selectors as $selector) { if ($selector instanceof ClassSelector || $selector instanceof NamespaceSelector) { - $name = $selector->namespace ?? $selector->class; + if ($selector instanceof NamespaceSelector) { + $name = $selector->namespace; + } else { + $name = $selector->class; + } throw new InvalidArgumentException( "$name cannot be used in the MustImplement rule, as it is not an interface." ); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php index aa092e2..a0658ce 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php @@ -21,8 +21,8 @@ public static function createRule( static function(AssertionStep $rule): TargetStep { return $rule->shouldNotDependOn(); }, - "$groupName must not depend on forbidden dependencies.", Group::MUST_NOT_DEPEND_ON_KEY, + "$groupName must not depend on forbidden dependencies.", ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 7d5467c..1182825 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -22,8 +22,8 @@ public static function createRule( static function(AssertionStep $rule): TargetStep { return $rule->canOnlyDependOn(); }, - "$groupName must only depend on allowed dependencies.", Group::DEPENDS_ON_KEY, + "$groupName must only depend on allowed dependencies.", [Selector::classname('/^\\*[^\\]+$/', true)], ); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index 283cc6b..7e70b3b 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -17,6 +18,12 @@ public static function createRule( $excludes = $library->getExcludesByGroup($groupName); $functionName = $library->getOnlyPublicFunctionByGroup($groupName); + if ($functionName === null) { + throw new InvalidArgumentException( + "Group $groupName does not have a public function defined." + ); + } + $rule = PHPat::rule() ->classes(...self::getPHPSelectors($includes)); diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php index 631bbe1..32d8ed5 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php @@ -12,14 +12,25 @@ final class SelectorsLibrary { + /** @var array> */ private array $flattenedGroups = []; + /** @var array */ private array $passedGroups = []; + /** + * @param array> $groups + */ public function __construct(private readonly array $groups) { foreach ($groups as $groupName => $attributes) { $this->passedGroups = [$groupName]; $resolvedIncludes = []; + if (!array_key_exists(Group::INCLUDES_KEY, $attributes)) { + throw new InvalidArgumentException("Group '$groupName' must have an 'includes' key."); + } + if (!is_array($attributes[Group::INCLUDES_KEY])) { + throw new InvalidArgumentException("Group '$groupName' 'includes' key must be an array."); + } foreach ($attributes[Group::INCLUDES_KEY] as $include) { foreach ($this->resolveGroup($include, Group::INCLUDES_KEY) as $selectable) { $resolvedIncludes[] = $selectable; @@ -27,13 +38,19 @@ public function __construct(private readonly array $groups) } $this->passedGroups = [$groupName]; $resolvedExcludes = []; - foreach ($attributes[Group::EXCLUDES_KEY] as $include) { - foreach ($this->resolveGroup($include, Group::EXCLUDES_KEY) as $selectable) { + if (!array_key_exists(Group::EXCLUDES_KEY, $attributes)) { + $attributes[Group::EXCLUDES_KEY] = []; + } elseif (!is_array($attributes[Group::EXCLUDES_KEY])) { + throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); + } + foreach ($attributes[Group::EXCLUDES_KEY] as $excludes) { + foreach ($this->resolveGroup($excludes, Group::EXCLUDES_KEY) as $selectable) { $resolvedIncludes[] = $selectable; } } $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; - $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); + $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] + = empty($attributes[Group::EXCLUDES_KEY]) ? null : array_diff($resolvedExcludes, $resolvedIncludes); $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY] ?? null; $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY] ?? null; @@ -55,6 +72,11 @@ private function resolveGroup(string $fqcnOrGroupName, string $key): Generator $this->passedGroups[] = $fqcnOrGroupName; + if (!is_array($this->groups[$fqcnOrGroupName][$key])) { + throw new InvalidArgumentException( + "Group '$fqcnOrGroupName' must have a non-empty '$key' key." + ); + } foreach ($this->groups[$fqcnOrGroupName][$key] as $subFqcnOrGroupName) { yield from $this->resolveGroup($subFqcnOrGroupName, $key); } @@ -71,7 +93,16 @@ public function getOnlyPublicFunctionByGroup(string $groupName): ?string throw new InvalidArgumentException("Group '$groupName' does not exist."); } - return $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; + $funtionName = $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; + + if (!is_string($funtionName) && $funtionName !== null) { + $key = Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY; + throw new InvalidArgumentException( + "Group '$groupName' must have a string value for '$key' key." + ); + } + + return $funtionName; } public function getIncludesByGroup(string $groupName): Generator @@ -80,6 +111,10 @@ public function getIncludesByGroup(string $groupName): Generator throw new InvalidArgumentException("Group '$groupName' does not exist."); } + if (!is_array($this->flattenedGroups[$groupName][Group::INCLUDES_KEY])) { + throw new InvalidArgumentException("Group '$groupName' 'includes' key must be an array."); + } + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::INCLUDES_KEY]); } @@ -89,6 +124,10 @@ public function getExcludesByGroup(string $groupName): Generator throw new InvalidArgumentException("Group '$groupName' does not exist."); } + if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); + } + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY]); } @@ -99,23 +138,33 @@ public function getTargetByGroup(string $groupName, string $key): Generator } if ($key === Group::DEPENDS_ON_KEY) { + if (!is_array($this->flattenedGroups[$groupName][Group::INCLUDES_KEY])) { + throw new InvalidArgumentException("Group '$groupName' 'includes' key must be an array."); + } + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::INCLUDES_KEY]); if ( array_key_exists(Group::EXTENDS_KEY, $this->flattenedGroups[$groupName]) - && $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] !== null + && is_array($this->flattenedGroups[$groupName][Group::EXTENDS_KEY]) ) { yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXTENDS_KEY]); } if ( array_key_exists(Group::IMPLEMENTS_KEY, $this->flattenedGroups[$groupName]) - && $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] !== null + && is_array($this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY]) ) { yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY]); } } + if (!is_array($this->flattenedGroups[$groupName][$key])) { + throw new InvalidArgumentException( + "Property '$key' of group '$groupName' must be an array." + ); + } + yield from $this->getSelectors($this->flattenedGroups[$groupName][$key]); } @@ -136,7 +185,15 @@ public function getTargetExcludesByGroup(string $groupName, string $key): Genera } $includes = iterator_to_array($this->getTargetByGroup($groupName, $key)); - foreach ($this->getPotentialExcludesBy($this->flattenedGroups[$groupName][$key]) as $exclude) { + $target = $this->flattenedGroups[$groupName][$key]; + + if (!is_array($target)) { + throw new InvalidArgumentException( + "Property '$key' of group '$groupName' must be an array." + ); + } + + foreach ($this->getPotentialExcludesBy($target) as $exclude) { if (in_array($exclude, $includes, true)) { continue; } @@ -147,6 +204,12 @@ public function getTargetExcludesByGroup(string $groupName, string $key): Genera private function getSelectorFromIncludes(string $fqcnOrGroup): Generator { if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { + if (!is_array($this->flattenedGroups[$fqcnOrGroup][Group::INCLUDES_KEY])) { + throw new InvalidArgumentException( + "Group '$fqcnOrGroup' 'includes' key must be an array." + ); + } + foreach ($this->flattenedGroups[$fqcnOrGroup][Group::INCLUDES_KEY] as $fqcn) { yield $this->createSelectable($fqcn); } @@ -157,6 +220,11 @@ private function getSelectorFromIncludes(string $fqcnOrGroup): Generator yield $this->createSelectable($fqcnOrGroup); } + /** + * @param string[] $values + * + * @return Generator + */ private function getSelectors(array $values): Generator { foreach ($values as $fqcnOrGroup) { @@ -164,15 +232,23 @@ private function getSelectors(array $values): Generator } } + /** + * @param string[] $groups + * + * @return string[] + */ private function getPotentialExcludesBy(array $groups): array { $result = []; foreach ($groups as $groupName) { - if (is_string($groupName) && array_key_exists($groupName, $this->flattenedGroups)) { - if (!empty($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { - foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] as $exclude) { - $result[] = $exclude; - } + if (array_key_exists($groupName, $this->flattenedGroups)) { + if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + throw new InvalidArgumentException( + "Group '$groupName' 'excludes' key must be an array." + ); + } + foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] as $exclude) { + $result[] = $exclude; } } } From 8e1f6d5f0be6572c874f1e828414f8a6559a2e99 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 16:42:50 +0200 Subject: [PATCH 25/35] fix some stuff --- .../ArchitectureSniffer.php | 72 +++++++++++++++++-- .../Configuration/Architecture.php | 65 ----------------- ...orsLibrary.php => ArchitectureLibrary.php} | 35 +++++---- .../Configuration/Group.php | 2 +- .../Configuration/Rules/AbstractRule.php | 4 +- .../Configuration/Rules/MustBeFinal.php | 4 +- .../Configuration/Rules/MustExtend.php | 4 +- .../Configuration/Rules/MustImplement.php | 4 +- .../Configuration/Rules/MustNotDependOn.php | 4 +- .../Configuration/Rules/MustOnlyDependOn.php | 4 +- .../MustOnlyHaveOnePublicMethodNamed.php | 4 +- 11 files changed, 105 insertions(+), 97 deletions(-) rename Kununu/ArchitectureSniffer/Configuration/{SelectorsLibrary.php => ArchitectureLibrary.php} (85%) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index cd75957..6eb842b 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -3,7 +3,9 @@ namespace Kununu\ArchitectureSniffer; -use Kununu\ArchitectureSniffer\Configuration\Architecture; +use InvalidArgumentException; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; +use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Helper\ProjectPathResolver; use PHPat\Test\Builder\Rule as PHPatRule; use Symfony\Component\Yaml\Yaml; @@ -11,16 +13,76 @@ final class ArchitectureSniffer { private const string ARCHITECTURE_FILENAME = 'architecture.yaml'; + public const string ARCHITECTURE_KEY = 'architecture'; /** * @return iterable */ public function testArchitecture(): iterable { - foreach (Architecture::fromArray( - Yaml::parseFile(ProjectPathResolver::resolve(self::ARCHITECTURE_FILENAME)) - ) as $rule) { - yield $rule; + $data = Yaml::parseFile(ProjectPathResolver::resolve(self::ARCHITECTURE_FILENAME)); + + if (!array_key_exists(self::ARCHITECTURE_KEY, $data)) { + throw new InvalidArgumentException( + 'Invalid architecture configuration: "architecture" key is missing.' + ); + } + + $architecture = $data['architecture']; + + if (empty($architecture)) { + throw new InvalidArgumentException( + 'Invalid architecture configuration: "groups" must be a non-empty array.' + ); + } + + // each group must have an include with at least one fully qualified fqcn or another qualified group + if (!array_filter( + $architecture, + static fn(array $group) => array_key_exists(Group::INCLUDES_KEY, $group) + && !empty($group[Group::INCLUDES_KEY]) + )) { + throw new InvalidArgumentException( + 'Each group must have an "includes" property with at least one fully qualified fqcn or ' + . 'another qualified group.' + ); + } + // at least one group with a depends_on property with at least one fqcn or another qualified group + if (!array_filter( + $architecture, + static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) + && !empty($group[Group::DEPENDS_ON_KEY]) + )) { + throw new InvalidArgumentException( + 'At least one group must have a "dependsOn" property with at least one fqcn or ' + . 'another qualified group.' + ); + } + // groups with at least one include from a global namespace other than App\\, the depends_on properties must not be defined + $groupsWithIncludesFromGlobalNamespace = array_filter( + $architecture, + static fn(array $group) => !array_filter( + is_array($group[Group::INCLUDES_KEY] ?? null) ? $group[Group::INCLUDES_KEY] : [], + static fn($include) => str_starts_with($include, 'App\\') + ) + ); + + if ($groupsWithIncludesFromGlobalNamespace) { + if (array_filter( + $groupsWithIncludesFromGlobalNamespace, + static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) + )) { + throw new InvalidArgumentException( + 'Groups with includes from a global namespace other than App\\ must not have a ' + . '"dependsOn" property defined.' + ); + } + } + + $library = new ArchitectureLibrary($architecture); + + foreach ($architecture as $groupName => $groupData) { + yield from Group::getRules($groupName, $library); } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php index 60fcfc7..17a6748 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ b/Kununu/ArchitectureSniffer/Configuration/Architecture.php @@ -3,13 +3,10 @@ namespace Kununu\ArchitectureSniffer\Configuration; -use InvalidArgumentException; use PHPat\Test\Builder\Rule as PHPatRule; final readonly class Architecture { - public const string ARCHITECTURE_KEY = 'architecture'; - /** * @param array>> $data * @@ -17,67 +14,5 @@ */ public static function fromArray(array $data): iterable { - if (!array_key_exists(self::ARCHITECTURE_KEY, $data)) { - throw new InvalidArgumentException( - 'Invalid architecture configuration: "architecture" key is missing.' - ); - } - - $architecture = $data['architecture']; - - if (empty($architecture)) { - throw new InvalidArgumentException( - 'Invalid architecture configuration: "groups" must be a non-empty array.' - ); - } - - // each group must have an include with at least one fully qualified fqcn or another qualified group - if (!array_filter( - $architecture, - static fn(array $group) => array_key_exists(Group::INCLUDES_KEY, $group) - && !empty($group[Group::INCLUDES_KEY]) - )) { - throw new InvalidArgumentException( - 'Each group must have an "includes" property with at least one fully qualified fqcn or ' - . 'another qualified group.' - ); - } - // at least one group with a depends_on property with at least one fqcn or another qualified group - if (!array_filter( - $architecture, - static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) - && !empty($group[Group::DEPENDS_ON_KEY]) - )) { - throw new InvalidArgumentException( - 'At least one group must have a "dependsOn" property with at least one fqcn or ' - . 'another qualified group.' - ); - } - // groups with at least one include from a global namespace other than App\\, the depends_on properties must not be defined - $groupsWithIncludesFromGlobalNamespace = array_filter( - $architecture, - static fn(array $group) => !array_filter( - is_array($group[Group::INCLUDES_KEY] ?? null) ? $group[Group::INCLUDES_KEY] : [], - static fn($include) => str_starts_with($include, 'App\\') - ) - ); - - if ($groupsWithIncludesFromGlobalNamespace) { - if (array_filter( - $groupsWithIncludesFromGlobalNamespace, - static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) - )) { - throw new InvalidArgumentException( - 'Groups with includes from a global namespace other than App\\ must not have a ' - . '"dependsOn" property defined.' - ); - } - } - - $library = new SelectorsLibrary($architecture); - - foreach ($architecture as $groupName => $groupData) { - yield from Group::getRules($groupName, $library); - } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php similarity index 85% rename from Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php rename to Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index 32d8ed5..b87783d 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SelectorsLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -10,7 +10,7 @@ use Kununu\ArchitectureSniffer\Configuration\Selector\NamespaceSelector; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -final class SelectorsLibrary +final class ArchitectureLibrary { /** @var array> */ private array $flattenedGroups = []; @@ -49,17 +49,28 @@ public function __construct(private readonly array $groups) } } $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; - $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] - = empty($attributes[Group::EXCLUDES_KEY]) ? null : array_diff($resolvedExcludes, $resolvedIncludes); - $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY] ?? null; - $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] - = $attributes[Group::MUST_NOT_DEPEND_ON_KEY] ?? null; - $this->flattenedGroups[$groupName][Group::FINAL_KEY] = $attributes[Group::FINAL_KEY] ?? null; - $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] - = is_string($attributes[Group::EXTENDS_KEY]) ? [$attributes[Group::EXTENDS_KEY]] : null; - $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] = $attributes[Group::IMPLEMENTS_KEY] ?? null; - $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] - = $attributes[Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null; + if (!empty($attributes[Group::EXCLUDES_KEY])) { + $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); + } + if (!array_key_exists( Group::DEPENDS_ON_KEY, $attributes)) { + $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY]; + } + if (!array_key_exists(Group::MUST_NOT_DEPEND_ON_KEY, $attributes)) { + $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY]; + } + if (!array_key_exists( GROUP::FINAL_KEY, $attributes) && $attributes[Group::FINAL_KEY] === true) { + $this->flattenedGroups[$groupName][Group::FINAL_KEY] = true; + } + if (!array_key_exists(Group::EXCLUDES_KEY, $attributes) && is_string($attributes[Group::EXCLUDES_KEY])) { + $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] = [$attributes[Group::EXTENDS_KEY]]; + } + if (!array_key_exists(Group::IMPLEMENTS_KEY, $attributes)) { + $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] = $attributes[Group::IMPLEMENTS_KEY]; + } + if (!array_key_exists(Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY, $attributes)) { + $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] + = $attributes[Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; + } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 1626c8b..9872cf4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -19,7 +19,7 @@ /** * @return iterable **/ - public static function getRules(string $groupName, SelectorsLibrary $library): iterable + public static function getRules(string $groupName, ArchitectureLibrary $library): iterable { if ($library->groupHasKey($groupName, self::EXTENDS_KEY)) { yield Rules\MustExtend::createRule( diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index ac5c9d2..20f1935 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -3,8 +3,8 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\SelectorInterface; use PHPat\Test\Builder\AssertionStep; use PHPat\Test\Builder\Rule as PHPatRule; @@ -34,7 +34,7 @@ public static function getPHPSelectors(iterable $selectors): array */ protected static function buildDependencyRule( string $groupName, - SelectorsLibrary $library, + ArchitectureLibrary $library, callable $assertionStep, string $targetKey, string $because = '', diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 1ae3f30..f7da940 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -4,9 +4,9 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use InvalidArgumentException; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -15,7 +15,7 @@ { public static function createRule( string $groupName, - SelectorsLibrary $library, + ArchitectureLibrary $library, ): PHPatRule { $includes = self::checkIfClassSelectors($library->getIncludesByGroup($groupName)); $excludes = $library->getExcludesByGroup($groupName); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 4b87e7d..bb9e0aa 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -4,10 +4,10 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use InvalidArgumentException; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -15,7 +15,7 @@ { public static function createRule( string $groupName, - SelectorsLibrary $selectorsLibrary, + ArchitectureLibrary $selectorsLibrary, ): PHPatRule { $includes = $selectorsLibrary->getIncludesByGroup($groupName); $excludes = $selectorsLibrary->getExcludesByGroup($groupName); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index f88fa84..89626a3 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -4,11 +4,11 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use InvalidArgumentException; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; use Kununu\ArchitectureSniffer\Configuration\Selector\NamespaceSelector; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -17,7 +17,7 @@ { public static function createRule( string $groupName, - SelectorsLibrary $library, + ArchitectureLibrary $library, ): PHPatRule { $includes = $library->getIncludesByGroup($groupName); $excludes = $library->getExcludesByGroup($groupName); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php index a0658ce..79d8b27 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php @@ -3,8 +3,8 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; -use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Test\Builder\AssertionStep; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\Builder\TargetStep; @@ -13,7 +13,7 @@ { public static function createRule( string $groupName, - SelectorsLibrary $library, + ArchitectureLibrary $library, ): PHPatRule { return self::buildDependencyRule( $groupName, diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 1182825..29a7341 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -3,8 +3,8 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; -use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; use PHPat\Selector\Selector; use PHPat\Test\Builder\AssertionStep; use PHPat\Test\Builder\Rule as PHPatRule; @@ -14,7 +14,7 @@ { public static function createRule( string $groupName, - SelectorsLibrary $library, + ArchitectureLibrary $library, ): PHPatRule { return self::buildDependencyRule( $groupName, diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index 7e70b3b..56f0bc5 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -4,7 +4,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use InvalidArgumentException; -use Kununu\ArchitectureSniffer\Configuration\SelectorsLibrary; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; @@ -12,7 +12,7 @@ { public static function createRule( string $groupName, - SelectorsLibrary $library, + ArchitectureLibrary $library, ): PHPatRule { $includes = $library->getIncludesByGroup($groupName); $excludes = $library->getExcludesByGroup($groupName); From 9f034cb91548852247aff15aa466c0fe936a12db Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 16:44:47 +0200 Subject: [PATCH 26/35] fix --- .../Configuration/Architecture.php | 18 ------------------ .../Configuration/ArchitectureLibrary.php | 7 +++---- 2 files changed, 3 insertions(+), 22 deletions(-) delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Architecture.php diff --git a/Kununu/ArchitectureSniffer/Configuration/Architecture.php b/Kununu/ArchitectureSniffer/Configuration/Architecture.php deleted file mode 100644 index 17a6748..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/Architecture.php +++ /dev/null @@ -1,18 +0,0 @@ ->> $data - * - * @return iterable - */ - public static function fromArray(array $data): iterable - { - } -} diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index b87783d..4cea43c 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -52,13 +52,13 @@ public function __construct(private readonly array $groups) if (!empty($attributes[Group::EXCLUDES_KEY])) { $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); } - if (!array_key_exists( Group::DEPENDS_ON_KEY, $attributes)) { + if (!array_key_exists(Group::DEPENDS_ON_KEY, $attributes)) { $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY]; } if (!array_key_exists(Group::MUST_NOT_DEPEND_ON_KEY, $attributes)) { $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY]; } - if (!array_key_exists( GROUP::FINAL_KEY, $attributes) && $attributes[Group::FINAL_KEY] === true) { + if (!array_key_exists(GROUP::FINAL_KEY, $attributes) && $attributes[Group::FINAL_KEY] === true) { $this->flattenedGroups[$groupName][Group::FINAL_KEY] = true; } if (!array_key_exists(Group::EXCLUDES_KEY, $attributes) && is_string($attributes[Group::EXCLUDES_KEY])) { @@ -185,8 +185,7 @@ public function groupHasKey(string $groupName, string $key): bool throw new InvalidArgumentException("Group '$groupName' does not exist."); } - return array_key_exists($key, $this->flattenedGroups[$groupName]) - && $this->flattenedGroups[$groupName][$key] !== null; + return array_key_exists($key, $this->flattenedGroups[$groupName]); } public function getTargetExcludesByGroup(string $groupName, string $key): Generator From 5308e506a0dbf07631780e617b2cf091910a8879 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 16:49:09 +0200 Subject: [PATCH 27/35] fix --- .../Configuration/ArchitectureLibrary.php | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index 4cea43c..bb8539c 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -23,6 +23,8 @@ final class ArchitectureLibrary public function __construct(private readonly array $groups) { foreach ($groups as $groupName => $attributes) { + $this->flattenedGroups[$groupName] = $attributes; + $this->passedGroups = [$groupName]; $resolvedIncludes = []; if (!array_key_exists(Group::INCLUDES_KEY, $attributes)) { @@ -36,6 +38,7 @@ public function __construct(private readonly array $groups) $resolvedIncludes[] = $selectable; } } + $this->passedGroups = [$groupName]; $resolvedExcludes = []; if (!array_key_exists(Group::EXCLUDES_KEY, $attributes)) { @@ -49,28 +52,9 @@ public function __construct(private readonly array $groups) } } $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; - if (!empty($attributes[Group::EXCLUDES_KEY])) { + if (!empty($resolvedExcludes)) { $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); } - if (!array_key_exists(Group::DEPENDS_ON_KEY, $attributes)) { - $this->flattenedGroups[$groupName][Group::DEPENDS_ON_KEY] = $attributes[Group::DEPENDS_ON_KEY]; - } - if (!array_key_exists(Group::MUST_NOT_DEPEND_ON_KEY, $attributes)) { - $this->flattenedGroups[$groupName][Group::MUST_NOT_DEPEND_ON_KEY] = $attributes[Group::MUST_NOT_DEPEND_ON_KEY]; - } - if (!array_key_exists(GROUP::FINAL_KEY, $attributes) && $attributes[Group::FINAL_KEY] === true) { - $this->flattenedGroups[$groupName][Group::FINAL_KEY] = true; - } - if (!array_key_exists(Group::EXCLUDES_KEY, $attributes) && is_string($attributes[Group::EXCLUDES_KEY])) { - $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] = [$attributes[Group::EXTENDS_KEY]]; - } - if (!array_key_exists(Group::IMPLEMENTS_KEY, $attributes)) { - $this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY] = $attributes[Group::IMPLEMENTS_KEY]; - } - if (!array_key_exists(Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY, $attributes)) { - $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] - = $attributes[Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; - } } } From 6d84f47aa4a4fb4a3ff820e7efb2f866b5db3bb8 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 16:52:40 +0200 Subject: [PATCH 28/35] fix --- .../Configuration/ArchitectureLibrary.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index bb8539c..03ba6a6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -119,11 +119,12 @@ public function getExcludesByGroup(string $groupName): Generator throw new InvalidArgumentException("Group '$groupName' does not exist."); } - if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + if (array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName]) + && !is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); } - yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY]); + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] ?? []); } public function getTargetByGroup(string $groupName, string $key): Generator @@ -236,12 +237,13 @@ private function getPotentialExcludesBy(array $groups): array $result = []; foreach ($groups as $groupName) { if (array_key_exists($groupName, $this->flattenedGroups)) { - if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + if (array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName]) + && !is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { throw new InvalidArgumentException( "Group '$groupName' 'excludes' key must be an array." ); } - foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] as $exclude) { + foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] ?? [] as $exclude) { $result[] = $exclude; } } From 4389230ec337ac31f3299c2dce797366f607f9af Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 16:54:14 +0200 Subject: [PATCH 29/35] fix --- .../ArchitectureSniffer/Configuration/ArchitectureLibrary.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index 03ba6a6..6eb13cc 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -53,7 +53,8 @@ public function __construct(private readonly array $groups) } $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; if (!empty($resolvedExcludes)) { - $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); + $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] + = array_diff($resolvedExcludes, $resolvedIncludes); } } } From 477453e517db368bfb9f7a2c9cc97d16a5fdc6cd Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 17:23:21 +0200 Subject: [PATCH 30/35] fix --- .../ArchitectureSniffer.php | 4 ++- .../Configuration/ArchitectureLibrary.php | 30 +++++++++++-------- .../Configuration/Rules/AbstractRule.php | 9 ++---- .../Configuration/Rules/MustOnlyDependOn.php | 2 +- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 6eb842b..789c673 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -82,7 +82,9 @@ public function testArchitecture(): iterable $library = new ArchitectureLibrary($architecture); foreach ($architecture as $groupName => $groupData) { - yield from Group::getRules($groupName, $library); + foreach (Group::getRules($groupName, $library) as $rule) { + yield $rule; + } } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index 6eb13cc..476dfca 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -25,6 +25,14 @@ public function __construct(private readonly array $groups) foreach ($groups as $groupName => $attributes) { $this->flattenedGroups[$groupName] = $attributes; + if (array_key_exists(Group::EXTENDS_KEY, $attributes)) { + if (!is_string($attributes[Group::EXTENDS_KEY])) { + throw new InvalidArgumentException("Group '$groupName' 'extends' key must be a string."); + } + + $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] = [$attributes[Group::EXTENDS_KEY]]; + } + $this->passedGroups = [$groupName]; $resolvedIncludes = []; if (!array_key_exists(Group::INCLUDES_KEY, $attributes)) { @@ -38,21 +46,19 @@ public function __construct(private readonly array $groups) $resolvedIncludes[] = $selectable; } } + $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; $this->passedGroups = [$groupName]; $resolvedExcludes = []; - if (!array_key_exists(Group::EXCLUDES_KEY, $attributes)) { - $attributes[Group::EXCLUDES_KEY] = []; - } elseif (!is_array($attributes[Group::EXCLUDES_KEY])) { - throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); - } - foreach ($attributes[Group::EXCLUDES_KEY] as $excludes) { - foreach ($this->resolveGroup($excludes, Group::EXCLUDES_KEY) as $selectable) { - $resolvedIncludes[] = $selectable; + if (array_key_exists(Group::EXCLUDES_KEY, $attributes)) { + if (!is_array($attributes[Group::EXCLUDES_KEY])) { + throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); + } + foreach ($attributes[Group::EXCLUDES_KEY] as $excludes) { + foreach ($this->resolveGroup($excludes, Group::EXCLUDES_KEY) as $selectable) { + $resolvedIncludes[] = $selectable; + } } - } - $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; - if (!empty($resolvedExcludes)) { $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] = array_diff($resolvedExcludes, $resolvedIncludes); } @@ -181,7 +187,7 @@ public function getTargetExcludesByGroup(string $groupName, string $key): Genera } $includes = iterator_to_array($this->getTargetByGroup($groupName, $key)); - $target = $this->flattenedGroups[$groupName][$key]; + $target = $this->flattenedGroups[$groupName][$key] ?? []; if (!is_array($target)) { throw new InvalidArgumentException( diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index 20f1935..7eeb391 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -42,8 +42,8 @@ protected static function buildDependencyRule( ): PHPatRule { $includes = $library->getIncludesByGroup($groupName); $excludes = $library->getExcludesByGroup($groupName); - $target = $targetKey ? $library->getTargetByGroup($groupName, $targetKey) : []; - $targetExcludes = $targetKey ? $library->getTargetExcludesByGroup($groupName, $targetKey) : []; + $target = $library->getTargetByGroup($groupName, $targetKey); + $targetExcludes = $library->getTargetExcludesByGroup($groupName, $targetKey); $includes = self::getPHPSelectors($includes); $excludes = self::getPHPSelectors($excludes); @@ -56,10 +56,7 @@ protected static function buildDependencyRule( } $rule = $assertionStep($rule); - if ($extraSelectors !== []) { - $target = array_merge($target, $extraSelectors); - } - $rule = $rule->classes(...$target); + $rule = $rule->classes(...array_merge($target, $extraSelectors)); if ($targetExcludes !== []) { $rule = $rule->excluding(...$targetExcludes); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 29a7341..e15a8f6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -24,7 +24,7 @@ static function(AssertionStep $rule): TargetStep { }, Group::DEPENDS_ON_KEY, "$groupName must only depend on allowed dependencies.", - [Selector::classname('/^\\*[^\\]+$/', true)], + [Selector::classname('/^\\\\*[^\\\\]+$/', true)], ); } } From f7c098d2cb0e9aba4088621a80e57a50edbba222 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Wed, 20 Aug 2025 17:29:35 +0200 Subject: [PATCH 31/35] fix phpstan --- .../Configuration/ArchitectureLibrary.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index 476dfca..2b6fea0 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -126,12 +126,15 @@ public function getExcludesByGroup(string $groupName): Generator throw new InvalidArgumentException("Group '$groupName' does not exist."); } - if (array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName]) - && !is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + if (!array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName])) { + return; + } + + if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); } - yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] ?? []); + yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY]); } public function getTargetByGroup(string $groupName, string $key): Generator @@ -244,8 +247,10 @@ private function getPotentialExcludesBy(array $groups): array $result = []; foreach ($groups as $groupName) { if (array_key_exists($groupName, $this->flattenedGroups)) { - if (array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName]) - && !is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { + if (!array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName])) { + continue; + } + if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { throw new InvalidArgumentException( "Group '$groupName' 'excludes' key must be an array." ); From e70c890f63b83a73d78c633969a2d8ca8f4e4d0b Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 21 Aug 2025 16:02:56 +0200 Subject: [PATCH 32/35] fixing phpstan and refactoring --- .../ArchitectureSniffer.php | 16 +- .../Configuration/ArchitectureLibrary.php | 286 ++++-------------- .../Configuration/Group.php | 122 +++++--- .../Configuration/Rules/AbstractRule.php | 91 ++++-- .../Configuration/Rules/MustBeFinal.php | 39 ++- .../Configuration/Rules/MustExtend.php | 52 ++-- .../Configuration/Rules/MustImplement.php | 56 ++-- .../Configuration/Rules/MustNotDependOn.php | 27 +- .../Configuration/Rules/MustOnlyDependOn.php | 29 +- .../MustOnlyHaveOnePublicMethodNamed.php | 37 +-- .../Helper/GroupFlattener.php | 90 ++++++ .../Helper/RuleBuilder.php | 60 ++++ .../Helper/SelectorBuilder.php | 21 ++ .../Helper/TypeChecker.php | 55 ++++ 14 files changed, 548 insertions(+), 433 deletions(-) create mode 100644 Kununu/ArchitectureSniffer/Helper/GroupFlattener.php create mode 100644 Kununu/ArchitectureSniffer/Helper/RuleBuilder.php create mode 100644 Kununu/ArchitectureSniffer/Helper/SelectorBuilder.php create mode 100644 Kununu/ArchitectureSniffer/Helper/TypeChecker.php diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 789c673..a027dfa 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -7,6 +7,8 @@ use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Helper\ProjectPathResolver; +use Kununu\ArchitectureSniffer\Helper\RuleBuilder; +use Kununu\ArchitectureSniffer\Helper\TypeChecker; use PHPat\Test\Builder\Rule as PHPatRule; use Symfony\Component\Yaml\Yaml; @@ -20,6 +22,7 @@ final class ArchitectureSniffer */ public function testArchitecture(): iterable { + /** @var array $data */ $data = Yaml::parseFile(ProjectPathResolver::resolve(self::ARCHITECTURE_FILENAME)); if (!array_key_exists(self::ARCHITECTURE_KEY, $data)) { @@ -30,12 +33,18 @@ public function testArchitecture(): iterable $architecture = $data['architecture']; - if (empty($architecture)) { + if (TypeChecker::isArrayKeysOfStrings($architecture)) { throw new InvalidArgumentException( 'Invalid architecture configuration: "groups" must be a non-empty array.' ); } + if (!is_array($architecture)) { + throw new InvalidArgumentException( + 'Invalid architecture configuration: "groups" must be an array.' + ); + } + // each group must have an include with at least one fully qualified fqcn or another qualified group if (!array_filter( $architecture, @@ -47,6 +56,7 @@ public function testArchitecture(): iterable . 'another qualified group.' ); } + // at least one group with a depends_on property with at least one fqcn or another qualified group if (!array_filter( $architecture, @@ -81,8 +91,8 @@ public function testArchitecture(): iterable $library = new ArchitectureLibrary($architecture); - foreach ($architecture as $groupName => $groupData) { - foreach (Group::getRules($groupName, $library) as $rule) { + foreach (array_keys($architecture) as $groupName) { + foreach (RuleBuilder::getRules($library->getGroupBy($groupName), $library) as $rule) { yield $rule; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index 2b6fea0..39cc43b 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -3,273 +3,119 @@ namespace Kununu\ArchitectureSniffer\Configuration; -use Generator; use InvalidArgumentException; -use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\NamespaceSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; +use Kununu\ArchitectureSniffer\Helper\GroupFlattener; +use Kununu\ArchitectureSniffer\Helper\TypeChecker; final class ArchitectureLibrary { - /** @var array> */ - private array $flattenedGroups = []; - /** @var array */ - private array $passedGroups = []; + /** @var array */ + private array $groups = []; /** - * @param array> $groups + * @param array $groups */ - public function __construct(private readonly array $groups) + public function __construct(array $groups) { - foreach ($groups as $groupName => $attributes) { - $this->flattenedGroups[$groupName] = $attributes; - - if (array_key_exists(Group::EXTENDS_KEY, $attributes)) { - if (!is_string($attributes[Group::EXTENDS_KEY])) { - throw new InvalidArgumentException("Group '$groupName' 'extends' key must be a string."); - } + GroupFlattener::$groups = $groups; - $this->flattenedGroups[$groupName][Group::EXTENDS_KEY] = [$attributes[Group::EXTENDS_KEY]]; - } - - $this->passedGroups = [$groupName]; - $resolvedIncludes = []; - if (!array_key_exists(Group::INCLUDES_KEY, $attributes)) { - throw new InvalidArgumentException("Group '$groupName' must have an 'includes' key."); - } - if (!is_array($attributes[Group::INCLUDES_KEY])) { - throw new InvalidArgumentException("Group '$groupName' 'includes' key must be an array."); - } - foreach ($attributes[Group::INCLUDES_KEY] as $include) { - foreach ($this->resolveGroup($include, Group::INCLUDES_KEY) as $selectable) { - $resolvedIncludes[] = $selectable; - } - } - $this->flattenedGroups[$groupName][Group::INCLUDES_KEY] = $resolvedIncludes; - - $this->passedGroups = [$groupName]; - $resolvedExcludes = []; - if (array_key_exists(Group::EXCLUDES_KEY, $attributes)) { - if (!is_array($attributes[Group::EXCLUDES_KEY])) { - throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); - } - foreach ($attributes[Group::EXCLUDES_KEY] as $excludes) { - foreach ($this->resolveGroup($excludes, Group::EXCLUDES_KEY) as $selectable) { - $resolvedIncludes[] = $selectable; - } - } - $this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] - = array_diff($resolvedExcludes, $resolvedIncludes); - } - } - } - - private function resolveGroup(string $fqcnOrGroupName, string $key): Generator - { - if (array_key_exists($fqcnOrGroupName, $this->groups)) { - if (in_array($fqcnOrGroupName, $this->passedGroups, true)) { - return; - } - - $this->passedGroups[] = $fqcnOrGroupName; - - if (!is_array($this->groups[$fqcnOrGroupName][$key])) { + foreach ($groups as $groupName => $attributes) { + if (!TypeChecker::isArrayOfStrings($attributes[Group::INCLUDES_KEY])) { throw new InvalidArgumentException( - "Group '$fqcnOrGroupName' must have a non-empty '$key' key." + "Group '$groupName' includes must be an array of strings." ); } - foreach ($this->groups[$fqcnOrGroupName][$key] as $subFqcnOrGroupName) { - yield from $this->resolveGroup($subFqcnOrGroupName, $key); - } - return; - } - - yield $fqcnOrGroupName; - } - - public function getOnlyPublicFunctionByGroup(string $groupName): ?string - { - if (!array_key_exists($groupName, $this->flattenedGroups)) { - throw new InvalidArgumentException("Group '$groupName' does not exist."); - } - - $funtionName = $this->flattenedGroups[$groupName][Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; - - if (!is_string($funtionName) && $funtionName !== null) { - $key = Group::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY; - throw new InvalidArgumentException( - "Group '$groupName' must have a string value for '$key' key." + $flattenedIncludes = GroupFlattener::flattenIncludes($groupName, $attributes[Group::INCLUDES_KEY]); + $flattenedExcludes = GroupFlattener::flattenExcludes( + groupName: $groupName, + excludes: $attributes[Group::EXCLUDES_KEY] + && TypeChecker::isArrayOfStrings($attributes[Group::EXCLUDES_KEY]) ? + $attributes[Group::EXCLUDES_KEY] : [], + flattenedIncludes: $flattenedIncludes ); - } - - return $funtionName; - } - - public function getIncludesByGroup(string $groupName): Generator - { - if (!array_key_exists($groupName, $this->flattenedGroups)) { - throw new InvalidArgumentException("Group '$groupName' does not exist."); - } - if (!is_array($this->flattenedGroups[$groupName][Group::INCLUDES_KEY])) { - throw new InvalidArgumentException("Group '$groupName' 'includes' key must be an array."); + $this->groups[$groupName] = Group::buildFrom( + groupName: $groupName, + flattenedIncludes: $flattenedIncludes, + targetAttributes: $attributes, + flattenedExcludes: $flattenedExcludes, + ); } - - yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::INCLUDES_KEY]); } - public function getExcludesByGroup(string $groupName): Generator + public function getGroupBy(string $groupName): Group { - if (!array_key_exists($groupName, $this->flattenedGroups)) { + if (!array_key_exists($groupName, $this->groups)) { throw new InvalidArgumentException("Group '$groupName' does not exist."); } - if (!array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName])) { - return; - } - - if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { - throw new InvalidArgumentException("Group '$groupName' 'excludes' key must be an array."); - } - - yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY]); + return $this->groups[$groupName]; } - public function getTargetByGroup(string $groupName, string $key): Generator + /** + * @param string[] $potentialGroups + * + * @return string[] + */ + private function resolvePotentialGroups(array $potentialGroups): array { - if (!array_key_exists($groupName, $this->flattenedGroups)) { - throw new InvalidArgumentException("Group '$groupName' does not exist."); - } - - if ($key === Group::DEPENDS_ON_KEY) { - if (!is_array($this->flattenedGroups[$groupName][Group::INCLUDES_KEY])) { - throw new InvalidArgumentException("Group '$groupName' 'includes' key must be an array."); - } - - yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::INCLUDES_KEY]); - - if ( - array_key_exists(Group::EXTENDS_KEY, $this->flattenedGroups[$groupName]) - && is_array($this->flattenedGroups[$groupName][Group::EXTENDS_KEY]) - ) { - yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::EXTENDS_KEY]); - } - - if ( - array_key_exists(Group::IMPLEMENTS_KEY, $this->flattenedGroups[$groupName]) - && is_array($this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY]) - ) { - yield from $this->getSelectors($this->flattenedGroups[$groupName][Group::IMPLEMENTS_KEY]); + $groupsIncludes = []; + foreach ($potentialGroups as $potentialGroup) { + if (array_key_exists($potentialGroup, $this->groups)) { + foreach ($this->getGroupBy($potentialGroup)->flattenedIncludes as $fqcn) { + $groupsIncludes[] = $fqcn; + } + } else { + $groupsIncludes[] = $potentialGroup; } } - if (!is_array($this->flattenedGroups[$groupName][$key])) { - throw new InvalidArgumentException( - "Property '$key' of group '$groupName' must be an array." - ); - } - - yield from $this->getSelectors($this->flattenedGroups[$groupName][$key]); - } - - public function groupHasKey(string $groupName, string $key): bool - { - if (!array_key_exists($groupName, $this->flattenedGroups)) { - throw new InvalidArgumentException("Group '$groupName' does not exist."); - } - - return array_key_exists($key, $this->flattenedGroups[$groupName]); + return $groupsIncludes; } - public function getTargetExcludesByGroup(string $groupName, string $key): Generator + /** + * @param string[] $targets + * + * @return string[] + */ + public function resolveTargets(Group $group, array $targets, bool $dependsOnRule = false): array { - if (!array_key_exists($groupName, $this->flattenedGroups)) { - throw new InvalidArgumentException("Group '$groupName' does not exist."); - } - - $includes = iterator_to_array($this->getTargetByGroup($groupName, $key)); - $target = $this->flattenedGroups[$groupName][$key] ?? []; + $resolvedTargets = []; + if ($dependsOnRule) { + $resolvedTargets = $this->resolvePotentialGroups($group->flattenedIncludes); - if (!is_array($target)) { - throw new InvalidArgumentException( - "Property '$key' of group '$groupName' must be an array." - ); - } - - foreach ($this->getPotentialExcludesBy($target) as $exclude) { - if (in_array($exclude, $includes, true)) { - continue; - } - yield $exclude; - } - } - - private function getSelectorFromIncludes(string $fqcnOrGroup): Generator - { - if (array_key_exists($fqcnOrGroup, $this->flattenedGroups)) { - if (!is_array($this->flattenedGroups[$fqcnOrGroup][Group::INCLUDES_KEY])) { - throw new InvalidArgumentException( - "Group '$fqcnOrGroup' 'includes' key must be an array." - ); + if ($group->extends !== null) { + $resolvedTargets = array_merge($resolvedTargets, $this->resolvePotentialGroups([$group->extends])); } - foreach ($this->flattenedGroups[$fqcnOrGroup][Group::INCLUDES_KEY] as $fqcn) { - yield $this->createSelectable($fqcn); + if ($group->implements !== null) { + $resolvedTargets = array_merge($resolvedTargets, $this->resolvePotentialGroups($group->implements)); } - - return; } - yield $this->createSelectable($fqcnOrGroup); - } - - /** - * @param string[] $values - * - * @return Generator - */ - private function getSelectors(array $values): Generator - { - foreach ($values as $fqcnOrGroup) { - yield from $this->getSelectorFromIncludes($fqcnOrGroup); - } + return array_unique(array_merge($this->resolvePotentialGroups($targets), $resolvedTargets)); } /** - * @param string[] $groups + * @param string[] $unresolvedTargets + * @param string[] $targets * * @return string[] */ - private function getPotentialExcludesBy(array $groups): array + public function findTargetExcludes(array $unresolvedTargets, array $targets): array { - $result = []; - foreach ($groups as $groupName) { - if (array_key_exists($groupName, $this->flattenedGroups)) { - if (!array_key_exists(Group::EXCLUDES_KEY, $this->flattenedGroups[$groupName])) { - continue; - } - if (!is_array($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY])) { - throw new InvalidArgumentException( - "Group '$groupName' 'excludes' key must be an array." - ); - } - foreach ($this->flattenedGroups[$groupName][Group::EXCLUDES_KEY] ?? [] as $exclude) { - $result[] = $exclude; + $targetExcludes = []; + foreach ($unresolvedTargets as $potentialGroup) { + if (array_key_exists($potentialGroup, $this->groups)) { + $group = $this->getGroupBy($potentialGroup); + + foreach ($group->flattenedExcludes ?? [] as $exclude) { + $targetExcludes[] = $exclude; } } } - return array_unique($result); - } - - private function createSelectable(string $fqcn): Selectable - { - return match (true) { - interface_exists($fqcn) || str_ends_with($fqcn, 'Interface') => new InterfaceClassSelector($fqcn), - str_ends_with($fqcn, '\\') => new NamespaceSelector($fqcn), - default => new ClassSelector($fqcn), - }; + return array_unique(array_diff($targetExcludes, $targets)); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 9872cf4..4b0f7c7 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -3,7 +3,7 @@ namespace Kununu\ArchitectureSniffer\Configuration; -use PHPat\Test\Builder\Rule as PHPatRule; +use Kununu\ArchitectureSniffer\Helper\TypeChecker; final readonly class Group { @@ -17,47 +17,85 @@ public const string MUST_NOT_DEPEND_ON_KEY = 'must_not_depend_on'; /** - * @return iterable - **/ - public static function getRules(string $groupName, ArchitectureLibrary $library): iterable + * @param string[] $flattenedIncludes + * @param string[]|null $flattenedExcludes + * @param string[]|null $implements + * @param string[]|null $mustNotDependOn + * @param string[]|null $dependsOn + */ + public function __construct( + public string $name, + public array $flattenedIncludes, + public ?array $flattenedExcludes, + public ?array $dependsOn, + public ?array $mustNotDependOn, + public ?string $extends, + public ?array $implements, + public bool $isFinal, + public ?string $mustOnlyHaveOnePublicMethodName, + ) { + } + + /** + * @param string[] $flattenedIncludes + * @param array $targetAttributes + * @param string[]|null $flattenedExcludes + */ + public static function buildFrom( + string $groupName, + array $flattenedIncludes, + array $targetAttributes, + ?array $flattenedExcludes, + ): self { + return new self( + name: $groupName, + flattenedIncludes: $flattenedIncludes, + flattenedExcludes: $flattenedExcludes, + dependsOn: $targetAttributes[self::DEPENDS_ON_KEY] ? + TypeChecker::castArrayOfStrings($targetAttributes[self::DEPENDS_ON_KEY]) : null, + mustNotDependOn: $targetAttributes[self::MUST_NOT_DEPEND_ON_KEY] ? + TypeChecker::castArrayOfStrings($targetAttributes[self::MUST_NOT_DEPEND_ON_KEY]) : null, + extends: is_string($targetAttributes[self::EXTENDS_KEY]) ? $targetAttributes[self::EXTENDS_KEY] : null, + implements: $targetAttributes[self::IMPLEMENTS_KEY] ? + TypeChecker::castArrayOfStrings($targetAttributes[self::IMPLEMENTS_KEY]) : null, + isFinal: $targetAttributes[self::FINAL_KEY] === true, + mustOnlyHaveOnePublicMethodName: is_string($targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]) ? + $targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] : null, + ); + } + + public function hasExcludes(): bool + { + return $this->flattenedExcludes !== null && count($this->flattenedExcludes) > 0; + } + + public function shouldBeFinal(): bool + { + return $this->isFinal; + } + + public function shouldExtend(): bool + { + return $this->extends !== null; + } + + public function shouldNotDependOn(): bool + { + return $this->mustNotDependOn !== null && count($this->mustNotDependOn) > 0; + } + + public function shouldDependOn(): bool + { + return $this->dependsOn !== null && count($this->dependsOn) > 0; + } + + public function shouldImplement(): bool + { + return $this->implements !== null && count($this->implements) > 0; + } + + public function shouldOnlyHaveOnePublicMethodNamed(): bool { - if ($library->groupHasKey($groupName, self::EXTENDS_KEY)) { - yield Rules\MustExtend::createRule( - $groupName, - $library - ); - } - - if ($library->groupHasKey($groupName, self::IMPLEMENTS_KEY)) { - yield Rules\MustImplement::createRule( - $groupName, - $library - ); - } - - if ($library->groupHasKey($groupName, self::FINAL_KEY)) { - yield Rules\MustBeFinal::createRule( - $groupName, - $library - ); - } - if ($library->groupHasKey($groupName, self::DEPENDS_ON_KEY)) { - yield Rules\MustOnlyDependOn::createRule( - $groupName, - $library - ); - } - if ($library->groupHasKey($groupName, self::MUST_NOT_DEPEND_ON_KEY)) { - yield Rules\MustNotDependOn::createRule( - $groupName, - $library - ); - } - if ($library->getOnlyPublicFunctionByGroup($groupName)) { - yield Rules\MustOnlyHaveOnePublicMethodNamed::createRule( - $groupName, - $library - ); - } + return $this->mustOnlyHaveOnePublicMethodName !== null && $this->mustOnlyHaveOnePublicMethodName !== ''; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index 7eeb391..4df6ca6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -4,66 +4,91 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; +use Kununu\ArchitectureSniffer\Configuration\Group; +use Kununu\ArchitectureSniffer\Helper\SelectorBuilder; +use LogicException; +use PHPat\Rule\Assertion\Declaration\DeclarationAssertion; +use PHPat\Rule\Assertion\Relation\RelationAssertion; use PHPat\Selector\SelectorInterface; -use PHPat\Test\Builder\AssertionStep; -use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\Builder\TargetStep; -use PHPat\Test\PHPat; +use PHPat\Test\Builder\BuildStep; +use PHPat\Test\Builder\Rule; +use PHPat\Test\RelationRule; abstract readonly class AbstractRule { + abstract public static function createRule( + Group $group, + ArchitectureLibrary $library, + ): Rule; + /** - * @param iterable $selectors + * @param string[] $selectors * * @return array */ - public static function getPHPSelectors(iterable $selectors): array + public static function getPHPSelectors(array $selectors): array { $result = []; foreach ($selectors as $selector) { - $result[] = $selector->getPHPatSelector(); + $result[] = SelectorBuilder::createSelectable($selector)->getPHPatSelector(); } return $result; } /** - * @param callable(AssertionStep): TargetStep $assertionStep - * @param array $extraSelectors + * @param class-string|class-string $specificRule + * @param array|null $ruleParams + * @param string[]|null $targets + * @param string[]|null $targetExcludes + * @param array $extraTargetSelectors + * @param array $extraExcludeSelectors */ protected static function buildDependencyRule( - string $groupName, - ArchitectureLibrary $library, - callable $assertionStep, - string $targetKey, + Group $group, + string $specificRule, string $because = '', - array $extraSelectors = [], - ): PHPatRule { - $includes = $library->getIncludesByGroup($groupName); - $excludes = $library->getExcludesByGroup($groupName); - $target = $library->getTargetByGroup($groupName, $targetKey); - $targetExcludes = $library->getTargetExcludesByGroup($groupName, $targetKey); + ?array $ruleParams = [], + ?array $targets = null, + ?array $targetExcludes = null, + array $extraTargetSelectors = [], + array $extraExcludeSelectors = [], + ): Rule { + $rule = new RelationRule(); - $includes = self::getPHPSelectors($includes); - $excludes = self::getPHPSelectors($excludes); - $target = self::getPHPSelectors($target); - $targetExcludes = self::getPHPSelectors($targetExcludes); + $rule->subjects = self::getPHPSelectors($group->flattenedIncludes); + if ($group->flattenedExcludes !== null) { + $rule->subjectExcludes = array_merge( + self::getPHPSelectors($group->flattenedExcludes), + $extraExcludeSelectors + ); + } - $rule = PHPat::rule()->classes(...$includes); - if ($excludes !== []) { - $rule = $rule->excluding(...$excludes); + $rule->assertion = $specificRule; + if ($ruleParams !== null) { + $rule->params = $ruleParams; } - $rule = $assertionStep($rule); - $rule = $rule->classes(...array_merge($target, $extraSelectors)); - if ($targetExcludes !== []) { - $rule = $rule->excluding(...$targetExcludes); + if ($targets !== null) { + $targetSelectors = self::getPHPSelectors($targets); + $rule->targets = array_merge($targetSelectors, $extraTargetSelectors); + + if ($targetExcludes !== null) { + $rule->targetExcludes = self::getPHPSelectors($targetExcludes); + } } + if ($because) { - $rule = $rule->because($because); + $rule->tips = [$because]; } - return $rule; + return new BuildStep($rule); + } + + public static function getInvalidCallException(string $rule, string $groupName, string $key): LogicException + { + return new LogicException( + "$rule should only be called if there are $key defined in $groupName." + ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index f7da940..63cf69e 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -5,43 +5,38 @@ use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; +use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; +use Kununu\ArchitectureSniffer\Helper\SelectorBuilder; +use PHPat\Rule\Assertion\Declaration\ShouldBeFinal\ShouldBeFinal; use PHPat\Selector\Selector; -use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\PHPat; +use PHPat\Test\Builder\Rule; final readonly class MustBeFinal extends AbstractRule { public static function createRule( - string $groupName, + Group $group, ArchitectureLibrary $library, - ): PHPatRule { - $includes = self::checkIfClassSelectors($library->getIncludesByGroup($groupName)); - $excludes = $library->getExcludesByGroup($groupName); + ): Rule { + self::checkIfClassSelectors($group->flattenedIncludes); - $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - - $excludes = self::getPHPSelectors($excludes); - $excludes[] = Selector::isInterface(); - $rule = $rule->excluding(...$excludes); - - return $rule->shouldBeFinal()->because("$groupName must be final."); + return self::buildDependencyRule( + group: $group, + specificRule: ShouldBeFinal::class, + because: "$group->name must be final.", + extraExcludeSelectors: [Selector::isInterface()] + ); } /** - * @param iterable $selectors - * - * @return iterable + * @param string[] $selectors */ - private static function checkIfClassSelectors(iterable $selectors): iterable + private static function checkIfClassSelectors(array $selectors): void { foreach ($selectors as $selector) { - if ($selector instanceof InterfaceClassSelector) { - $name = $selector->interface; - throw new InvalidArgumentException("$name must be a class selector for rule MustBeFinal."); + if (SelectorBuilder::createSelectable($selector) instanceof InterfaceClassSelector) { + throw new InvalidArgumentException("$selector must be a class selector for rule MustBeFinal."); } - yield $selector; } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index bb9e0aa..bacdde8 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -7,54 +7,44 @@ use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\InterfaceClassSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\PHPat; +use Kununu\ArchitectureSniffer\Helper\SelectorBuilder; +use PHPat\Rule\Assertion\Relation\ShouldExtend\ShouldExtend; +use PHPat\Test\Builder\Rule; final readonly class MustExtend extends AbstractRule { public static function createRule( - string $groupName, - ArchitectureLibrary $selectorsLibrary, - ): PHPatRule { - $includes = $selectorsLibrary->getIncludesByGroup($groupName); - $excludes = $selectorsLibrary->getExcludesByGroup($groupName); - $extensions = self::checkIfNotInterfaceSelectors( - $selectorsLibrary->getTargetByGroup($groupName, Group::EXTENDS_KEY) - ); - $extensionExcludes = $selectorsLibrary->getTargetExcludesByGroup($groupName, Group::EXTENDS_KEY); - - $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - - $excludes = self::getPHPSelectors($excludes); - if ($excludes !== []) { - $rule = $rule->excluding(...$excludes); + Group $group, + ArchitectureLibrary $library, + ): Rule { + if ($group->extends === null) { + throw self::getInvalidCallException(self::class, $group->name, 'extends'); } - $rule = $rule->shouldExtend()->classes(...self::getPHPSelectors($extensions)); + self::checkIfNotInterfaceSelectors($group->flattenedIncludes); - $extensionExcludes = self::getPHPSelectors($extensionExcludes); - if ($extensionExcludes !== []) { - $rule = $rule->excluding(...$extensionExcludes); - } + $targets = $library->resolveTargets($group, [$group->extends]); - return $rule->because("$groupName should extend class."); + return self::buildDependencyRule( + group: $group, + specificRule: ShouldExtend::class, + because: "$group->name should extend class.", + targets: $targets, + targetExcludes: $library->findTargetExcludes([$group->extends], $targets), + ); } /** - * @param iterable $selectors - * - * @return iterable + * @param string[] $selectors */ - private static function checkIfNotInterfaceSelectors(iterable $selectors): iterable + private static function checkIfNotInterfaceSelectors(array $selectors): void { foreach ($selectors as $selector) { - if ($selector instanceof InterfaceClassSelector) { + if (SelectorBuilder::createSelectable($selector) instanceof InterfaceClassSelector) { throw new InvalidArgumentException( - "$selector->interface cannot be used in the MustExtend rule, as it is an interface." + "$selector cannot be used in the MustExtend rule, as it is an interface." ); } - yield $selector; } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 89626a3..97366c6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -8,56 +8,46 @@ use Kununu\ArchitectureSniffer\Configuration\Group; use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; use Kununu\ArchitectureSniffer\Configuration\Selector\NamespaceSelector; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; +use Kununu\ArchitectureSniffer\Helper\SelectorBuilder; +use PHPat\Rule\Assertion\Relation\ShouldImplement\ShouldImplement; use PHPat\Selector\Selector; -use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\PHPat; +use PHPat\Test\Builder\Rule; final readonly class MustImplement extends AbstractRule { public static function createRule( - string $groupName, + Group $group, ArchitectureLibrary $library, - ): PHPatRule { - $includes = $library->getIncludesByGroup($groupName); - $excludes = $library->getExcludesByGroup($groupName); - $interfaces = self::checkIfInterfaceSelectors($library->getTargetByGroup($groupName, Group::IMPLEMENTS_KEY)); - $interfacesExcludes = $library->getTargetExcludesByGroup($groupName, Group::IMPLEMENTS_KEY); - - $rule = PHPat::rule()->classes(...self::getPHPSelectors($includes)); - - $excludeSelectors = self::getPHPSelectors($excludes); - $excludeSelectors[] = Selector::isInterface(); - $rule = $rule->excluding(...$excludeSelectors); - - $rule = $rule->shouldImplement()->classes(...self::getPHPSelectors($interfaces)); - $interfacesExcludes = self::getPHPSelectors($interfacesExcludes); - if ($interfacesExcludes !== []) { - $rule = $rule->excluding(...$interfacesExcludes); + ): Rule { + if ($group->implements === null) { + throw self::getInvalidCallException(self::class, $group->name, 'implements'); } - return $rule->because("$groupName must implement interface."); + $targets = $library->resolveTargets($group, $group->implements); + self::checkIfInterfaceSelectors($targets); + + return self::buildDependencyRule( + group: $group, + specificRule: ShouldImplement::class, + because: "$group->name must implement interface.", + targets: $targets, + targetExcludes: $library->findTargetExcludes($group->implements, $targets), + extraExcludeSelectors: [Selector::isInterface()], + ); } /** - * @param iterable $selectors - * - * @return iterable + * @param string[] $selectors */ - private static function checkIfInterfaceSelectors(iterable $selectors): iterable + private static function checkIfInterfaceSelectors(iterable $selectors): void { foreach ($selectors as $selector) { - if ($selector instanceof ClassSelector || $selector instanceof NamespaceSelector) { - if ($selector instanceof NamespaceSelector) { - $name = $selector->namespace; - } else { - $name = $selector->class; - } + if (SelectorBuilder::createSelectable($selector) instanceof ClassSelector + || SelectorBuilder::createSelectable($selector) instanceof NamespaceSelector) { throw new InvalidArgumentException( - "$name cannot be used in the MustImplement rule, as it is not an interface." + "$selector cannot be used in the MustImplement rule, as it is not an interface." ); } - yield $selector; } } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php index 79d8b27..827b3bf 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php @@ -5,24 +5,27 @@ use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; -use PHPat\Test\Builder\AssertionStep; -use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\Builder\TargetStep; +use PHPat\Rule\Assertion\Relation\ShouldNotDepend\ShouldNotDepend; +use PHPat\Test\Builder\Rule; final readonly class MustNotDependOn extends AbstractRule { public static function createRule( - string $groupName, + Group $group, ArchitectureLibrary $library, - ): PHPatRule { + ): Rule { + if ($group->mustNotDependOn === null) { + throw self::getInvalidCallException(self::class, $group->name, 'mustNotDependOn'); + } + + $targets = $library->resolveTargets($group, $group->mustNotDependOn); + return self::buildDependencyRule( - $groupName, - $library, - static function(AssertionStep $rule): TargetStep { - return $rule->shouldNotDependOn(); - }, - Group::MUST_NOT_DEPEND_ON_KEY, - "$groupName must not depend on forbidden dependencies.", + group: $group, + specificRule: ShouldNotDepend::class, + because: "$group->name must not depend on forbidden dependencies.", + targets: $targets, + targetExcludes: $library->findTargetExcludes($group->mustNotDependOn, $targets), ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index e15a8f6..86d2e79 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -5,26 +5,29 @@ use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; use Kununu\ArchitectureSniffer\Configuration\Group; +use PHPat\Rule\Assertion\Relation\CanOnlyDepend\CanOnlyDepend; use PHPat\Selector\Selector; -use PHPat\Test\Builder\AssertionStep; -use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\Builder\TargetStep; +use PHPat\Test\Builder\Rule; final readonly class MustOnlyDependOn extends AbstractRule { public static function createRule( - string $groupName, + Group $group, ArchitectureLibrary $library, - ): PHPatRule { + ): Rule { + if ($group->dependsOn === null) { + throw self::getInvalidCallException(self::class, $group->name, 'dependsOn'); + } + + $targets = $library->resolveTargets($group, $group->dependsOn); + return self::buildDependencyRule( - $groupName, - $library, - static function(AssertionStep $rule): TargetStep { - return $rule->canOnlyDependOn(); - }, - Group::DEPENDS_ON_KEY, - "$groupName must only depend on allowed dependencies.", - [Selector::classname('/^\\\\*[^\\\\]+$/', true)], + group: $group, + specificRule: CanOnlyDepend::class, + because: "$group->name must only depend on allowed dependencies.", + targets: $targets, + targetExcludes: $library->findTargetExcludes($group->dependsOn, $targets), + extraTargetSelectors: [Selector::classname('/^\\\\*[^\\\\]+$/', true)], ); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index 56f0bc5..a7be694 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -3,37 +3,26 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use InvalidArgumentException; use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; -use PHPat\Test\Builder\Rule as PHPatRule; -use PHPat\Test\PHPat; +use Kununu\ArchitectureSniffer\Configuration\Group; +use PHPat\Rule\Assertion\Declaration\ShouldHaveOnlyOnePublicMethodNamed\ShouldHaveOnlyOnePublicMethodNamed; +use PHPat\Test\Builder\Rule; final readonly class MustOnlyHaveOnePublicMethodNamed extends AbstractRule { public static function createRule( - string $groupName, + Group $group, ArchitectureLibrary $library, - ): PHPatRule { - $includes = $library->getIncludesByGroup($groupName); - $excludes = $library->getExcludesByGroup($groupName); - $functionName = $library->getOnlyPublicFunctionByGroup($groupName); - - if ($functionName === null) { - throw new InvalidArgumentException( - "Group $groupName does not have a public function defined." - ); - } - - $rule = PHPat::rule() - ->classes(...self::getPHPSelectors($includes)); - - $excludes = self::getPHPSelectors($excludes); - if ($excludes !== []) { - $rule = $rule->excluding(...$excludes); + ): Rule { + if ($group->mustOnlyHaveOnePublicMethodName === null) { + throw self::getInvalidCallException(self::class, $group->name, 'mustOnlyHaveOnePublicMethodName'); } - return $rule - ->shouldHaveOnlyOnePublicMethodNamed($functionName) - ->because("$groupName should only have one public method named $functionName"); + return self::buildDependencyRule( + group: $group, + specificRule: ShouldHaveOnlyOnePublicMethodNamed::class, + because: "$group->name should only have one public method named $group->mustOnlyHaveOnePublicMethodName.", + ruleParams: ['name' => $group->mustOnlyHaveOnePublicMethodName, 'isRegex' => false], + ); } } diff --git a/Kununu/ArchitectureSniffer/Helper/GroupFlattener.php b/Kununu/ArchitectureSniffer/Helper/GroupFlattener.php new file mode 100644 index 0000000..7669afd --- /dev/null +++ b/Kununu/ArchitectureSniffer/Helper/GroupFlattener.php @@ -0,0 +1,90 @@ +> + */ + public static array $groups; + + /** + * @var string[] + */ + public static array $passedGroups = []; + + /** + * @param string[] $includes + * + * @return string[] + */ + public static function flattenIncludes(string $groupName, array $includes): array + { + self::$passedGroups = [$groupName]; + $flattenedIncludes = []; + + foreach ($includes as $include) { + foreach (self::resolveGroup($include, Group::INCLUDES_KEY) as $selectable) { + $flattenedIncludes[] = $selectable; + } + } + + return $flattenedIncludes; + } + + /** + * @param string[] $excludes + * @param string[] $flattenedIncludes + * + * @return string[]|null + */ + public static function flattenExcludes(string $groupName, array $excludes, array $flattenedIncludes): ?array + { + self::$passedGroups = [$groupName]; + + $flattenedExcludes = []; + foreach ($excludes as $exclude) { + foreach (self::resolveGroup($exclude, Group::EXCLUDES_KEY) as $selectable) { + $flattenedExcludes[] = $selectable; + } + } + + $flattenedExcludes = array_diff($flattenedExcludes, $flattenedIncludes); + + return $flattenedExcludes !== [] ? $flattenedExcludes : null; + } + + /** + * @return Generator + */ + private static function resolveGroup(string $fqcnOrGroupName, string $key): Generator + { + if (array_key_exists($fqcnOrGroupName, self::$groups)) { + if (in_array($fqcnOrGroupName, self::$passedGroups, true)) { + return; + } + + self::$passedGroups[] = $fqcnOrGroupName; + + if (!is_array(self::$groups[$fqcnOrGroupName][$key])) { + throw new InvalidArgumentException( + "Group '$fqcnOrGroupName' must have a non-empty '$key' key." + ); + } + + foreach (self::$groups[$fqcnOrGroupName][$key] as $subFqcnOrGroupName) { + yield from self::resolveGroup($subFqcnOrGroupName, $key); + } + + return; + } + + yield $fqcnOrGroupName; + } +} diff --git a/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php b/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php new file mode 100644 index 0000000..34b2348 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php @@ -0,0 +1,60 @@ + + **/ + public static function getRules(Group $group, ArchitectureLibrary $library): iterable + { + if ($group->shouldExtend()) { + yield Rules\MustExtend::createRule( + $group, + $library + ); + } + + if ($group->shouldImplement()) { + yield Rules\MustImplement::createRule( + $group, + $library + ); + } + + if ($group->shouldBeFinal()) { + yield Rules\MustBeFinal::createRule( + $group, + $library + ); + } + + if ($group->shouldDependOn()) { + yield Rules\MustOnlyDependOn::createRule( + $group, + $library + ); + } + + if ($group->shouldNotDependOn()) { + yield Rules\MustNotDependOn::createRule( + $group, + $library + ); + } + + if ($group->shouldOnlyHaveOnePublicMethodNamed()) { + yield Rules\MustOnlyHaveOnePublicMethodNamed::createRule( + $group, + $library + ); + } + } +} diff --git a/Kununu/ArchitectureSniffer/Helper/SelectorBuilder.php b/Kununu/ArchitectureSniffer/Helper/SelectorBuilder.php new file mode 100644 index 0000000..3092a88 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Helper/SelectorBuilder.php @@ -0,0 +1,21 @@ + new InterfaceClassSelector($fqcn), + str_ends_with($fqcn, '\\') => new NamespaceSelector($fqcn), + default => new ClassSelector($fqcn), + }; + } +} diff --git a/Kununu/ArchitectureSniffer/Helper/TypeChecker.php b/Kununu/ArchitectureSniffer/Helper/TypeChecker.php new file mode 100644 index 0000000..d78d6d6 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Helper/TypeChecker.php @@ -0,0 +1,55 @@ + Date: Thu, 21 Aug 2025 16:03:38 +0200 Subject: [PATCH 33/35] remove unused method --- Kununu/ArchitectureSniffer/Configuration/Group.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 4b0f7c7..63a2873 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -64,11 +64,6 @@ public static function buildFrom( ); } - public function hasExcludes(): bool - { - return $this->flattenedExcludes !== null && count($this->flattenedExcludes) > 0; - } - public function shouldBeFinal(): bool { return $this->isFinal; From 4708179cc9246ff4c935ecca5486e0b7dc979206 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 21 Aug 2025 16:22:23 +0200 Subject: [PATCH 34/35] fixing last bits --- Kununu/ArchitectureSniffer/ArchitectureSniffer.php | 2 +- .../ArchitectureSniffer/Configuration/Rules/AbstractRule.php | 5 ++++- .../Configuration/Rules/MustOnlyDependOn.php | 2 +- Kununu/ArchitectureSniffer/Helper/TypeChecker.php | 4 ---- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index a027dfa..abe8d24 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -33,7 +33,7 @@ public function testArchitecture(): iterable $architecture = $data['architecture']; - if (TypeChecker::isArrayKeysOfStrings($architecture)) { + if (!TypeChecker::isArrayKeysOfStrings($architecture)) { throw new InvalidArgumentException( 'Invalid architecture configuration: "groups" must be a non-empty array.' ); diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php index 4df6ca6..8ea2da4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -57,12 +57,15 @@ protected static function buildDependencyRule( $rule = new RelationRule(); $rule->subjects = self::getPHPSelectors($group->flattenedIncludes); + + $excludes = $extraExcludeSelectors; if ($group->flattenedExcludes !== null) { - $rule->subjectExcludes = array_merge( + $excludes = array_merge( self::getPHPSelectors($group->flattenedExcludes), $extraExcludeSelectors ); } + $rule->subjectExcludes = $excludes; $rule->assertion = $specificRule; if ($ruleParams !== null) { diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php index 86d2e79..4504160 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -19,7 +19,7 @@ public static function createRule( throw self::getInvalidCallException(self::class, $group->name, 'dependsOn'); } - $targets = $library->resolveTargets($group, $group->dependsOn); + $targets = $library->resolveTargets($group, $group->dependsOn, true); return self::buildDependencyRule( group: $group, diff --git a/Kununu/ArchitectureSniffer/Helper/TypeChecker.php b/Kununu/ArchitectureSniffer/Helper/TypeChecker.php index d78d6d6..6f5956e 100644 --- a/Kununu/ArchitectureSniffer/Helper/TypeChecker.php +++ b/Kununu/ArchitectureSniffer/Helper/TypeChecker.php @@ -38,10 +38,6 @@ public static function isArrayOfStrings(mixed $arr): bool } /** - * @param mixed $arrayOfStrings - * - * @throws InvalidArgumentException if any element is not a string - * * @return string[] */ public static function castArrayOfStrings(mixed $arrayOfStrings): array From 6d5ea89ab194ebcdf580000d271a65273940d30a Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 21 Aug 2025 16:24:47 +0200 Subject: [PATCH 35/35] cs fix --- Kununu/ArchitectureSniffer/Configuration/Group.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 63a2873..25a3783 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -47,6 +47,8 @@ public static function buildFrom( array $targetAttributes, ?array $flattenedExcludes, ): self { + $mustOnlyHaveOnePublicMethodName = $targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; + return new self( name: $groupName, flattenedIncludes: $flattenedIncludes, @@ -59,8 +61,8 @@ public static function buildFrom( implements: $targetAttributes[self::IMPLEMENTS_KEY] ? TypeChecker::castArrayOfStrings($targetAttributes[self::IMPLEMENTS_KEY]) : null, isFinal: $targetAttributes[self::FINAL_KEY] === true, - mustOnlyHaveOnePublicMethodName: is_string($targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]) ? - $targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] : null, + mustOnlyHaveOnePublicMethodName: is_string($mustOnlyHaveOnePublicMethodName) ? + $mustOnlyHaveOnePublicMethodName : null, ); }