From cf45d0b18b5d2ffff0f7f799dd9a812760c5e2f6 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 16:08:15 +0200 Subject: [PATCH 01/30] adding architectur test --- .../ConfigurableArchitectureTest.php | 74 ++++++++++++ .../Configuration/ClassSelector.php | 32 ++++++ .../Configuration/InterfaceClassSelector.php | 35 ++++++ .../ArchitectureTest/Configuration/Layer.php | 37 ++++++ .../Configuration/NamespaceSelector.php | 32 ++++++ .../Configuration/RegexTrait.php | 18 +++ .../Configuration/Rules/MustBeFinal.php | 40 +++++++ .../Configuration/Rules/MustExtend.php | 43 +++++++ .../Configuration/Rules/MustImplement.php | 60 ++++++++++ .../Rules/MustOnlyDependOnWhitelist.php | 52 +++++++++ .../MustOnlyHaveOnePublicMethodNamed.php | 30 +++++ .../Configuration/Rules/Rule.php | 9 ++ .../Configuration/Selectable.php | 13 +++ .../Configuration/Selectors.php | 41 +++++++ .../Configuration/SubLayer.php | 58 ++++++++++ Kununu/ArchitectureTest/DocGenerator.php | 88 +++++++++++++++ .../FollowsFolderStructureRule.php | 63 +++++++++++ Kununu/ArchitectureTest/README.md | 106 ++++++++++++++++++ composer.json | 4 +- 19 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 Kununu/ArchitectureTest/ConfigurableArchitectureTest.php create mode 100644 Kununu/ArchitectureTest/Configuration/ClassSelector.php create mode 100644 Kununu/ArchitectureTest/Configuration/InterfaceClassSelector.php create mode 100644 Kununu/ArchitectureTest/Configuration/Layer.php create mode 100644 Kununu/ArchitectureTest/Configuration/NamespaceSelector.php create mode 100644 Kununu/ArchitectureTest/Configuration/RegexTrait.php create mode 100644 Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php create mode 100644 Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php create mode 100644 Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php create mode 100644 Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php create mode 100644 Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php create mode 100644 Kununu/ArchitectureTest/Configuration/Rules/Rule.php create mode 100644 Kununu/ArchitectureTest/Configuration/Selectable.php create mode 100644 Kununu/ArchitectureTest/Configuration/Selectors.php create mode 100644 Kununu/ArchitectureTest/Configuration/SubLayer.php create mode 100644 Kununu/ArchitectureTest/DocGenerator.php create mode 100644 Kununu/ArchitectureTest/FollowsFolderStructureRule.php create mode 100644 Kununu/ArchitectureTest/README.md diff --git a/Kununu/ArchitectureTest/ConfigurableArchitectureTest.php b/Kununu/ArchitectureTest/ConfigurableArchitectureTest.php new file mode 100644 index 0000000..7430d12 --- /dev/null +++ b/Kununu/ArchitectureTest/ConfigurableArchitectureTest.php @@ -0,0 +1,74 @@ + + */ + public function testArchitecture(): iterable + { + $archDefinition = self::getArchitectureDefinition(); + $layers = $this->validateArchitectureDefinition($archDefinition); + /** @var Layer $layer */ + foreach ($layers as $layer) { + /** @var SubLayer $subLayer */ + foreach ($layer->subLayers as $subLayer) { + /** @var Rule $rule */ + foreach ($subLayer->rules as $rule) { + yield $rule->getPHPatRule(); + } + } + } + } + + private function validateArchitectureDefinition(array $architectureDefinition): array + { + 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; + } + + 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; + } + + private static function getArchitectureDefinition(): array + { + $filePath = self::getArchitectureDefinitionFile(); + + if (!file_exists($filePath)) { + throw new InvalidArgumentException( + 'ArchitectureTest definition file not found, please create it at ' . $filePath + ); + } + + return Yaml::parseFile($filePath); + } +} diff --git a/Kununu/ArchitectureTest/Configuration/ClassSelector.php b/Kununu/ArchitectureTest/Configuration/ClassSelector.php new file mode 100644 index 0000000..ff237f5 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/ClassSelector.php @@ -0,0 +1,32 @@ +makeRegex($this->namespace); + + return Selector::classname($namespace, $namespace !== $this->namespace); + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/Kununu/ArchitectureTest/Configuration/InterfaceClassSelector.php b/Kununu/ArchitectureTest/Configuration/InterfaceClassSelector.php new file mode 100644 index 0000000..3c528f3 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/InterfaceClassSelector.php @@ -0,0 +1,35 @@ +makeRegex($this->namespace); + + return Selector::AllOf( + Selector::classname($namespace, $namespace !== $this->namespace), + Selector::isInterface(), + ); + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/Kununu/ArchitectureTest/Configuration/Layer.php b/Kununu/ArchitectureTest/Configuration/Layer.php new file mode 100644 index 0000000..9be224d --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/Layer.php @@ -0,0 +1,37 @@ + SubLayer::fromArray($subLayer), + $data[SubLayer::KEY], + ) : [], + ); + } +} diff --git a/Kununu/ArchitectureTest/Configuration/NamespaceSelector.php b/Kununu/ArchitectureTest/Configuration/NamespaceSelector.php new file mode 100644 index 0000000..c524de8 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/NamespaceSelector.php @@ -0,0 +1,32 @@ +makeRegex($this->namespace); + + return Selector::inNamespace($namespace, $namespace !== $this->namespace); + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/Kununu/ArchitectureTest/Configuration/RegexTrait.php b/Kununu/ArchitectureTest/Configuration/RegexTrait.php new file mode 100644 index 0000000..4a14643 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/RegexTrait.php @@ -0,0 +1,18 @@ +classes($this->selector->getPHPatSelector()) + ->excluding(Selector::isInterface()) + ->shouldBeFinal() + ->because("{$this->selector->getName()} must be final."); + } +} diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php new file mode 100644 index 0000000..77982b4 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php @@ -0,0 +1,43 @@ +classes($this->selector->getPHPatSelector()) + ->shouldExtend() + ->classes( + $this->parent->getPHPatSelector() + ) + ->because("{$this->selector->getName()} should extend {$this->parent->getName()}."); + } +} diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php new file mode 100644 index 0000000..6f5889d --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php @@ -0,0 +1,60 @@ +getName()} must be declared as interface." + ); + } + $interfaces[] = $interfaceSelector; + } + + return new self($selector, $interfaces); + } + + public function getPHPatRule(): \PHPat\Test\Builder\Rule + { + $interfacesString = implode(', ', array_map( + static fn (Selectable $interface): string => $interface->getName(), + $this->interfaces + )); + + return PHPat::rule() + ->classes( + $this->selector->getPHPatSelector(), + ) + ->excluding(Selector::isInterface()) + ->shouldImplement() + ->classes( + ...array_map( + static fn (Selectable $interface): SelectorInterface => $interface->getPHPatSelector(), + $this->interfaces + ) + ) + ->because("{$this->selector->getName()} must implement $interfacesString."); + } +} diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php new file mode 100644 index 0000000..c16acc3 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php @@ -0,0 +1,52 @@ + $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/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php new file mode 100644 index 0000000..4aa765c --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -0,0 +1,30 @@ +classes($this->selector->getPHPatSelector()) + ->shouldHaveOnlyOnePublicMethodNamed($this->functionName) + ->because("{$this->selector->getName()} should only have one public method named $this->functionName."); + } +} diff --git a/Kununu/ArchitectureTest/Configuration/Rules/Rule.php b/Kununu/ArchitectureTest/Configuration/Rules/Rule.php new file mode 100644 index 0000000..1f8fcb5 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/Rules/Rule.php @@ -0,0 +1,9 @@ +value, + self::InterfaceSelector->value, + self::NamespaceSelector->value, + ]; + } + + public static function findSelector(array $data, ?string $nameKey = null): Selectable + { + foreach (self::getValidTypes() as $type) { + if (array_key_exists($type, $data)) { + return self::createSelector($type, $data[$nameKey ?? $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(string $type, string $name, string $selection): Selectable + { + return match ($type) { + self::ClassSelector->value => new ClassSelector($name, $selection), + self::InterfaceSelector->value => new InterfaceClassSelector($name, $selection), + self::NamespaceSelector->value => new NamespaceSelector($name, $selection), + }; + } +} diff --git a/Kununu/ArchitectureTest/Configuration/SubLayer.php b/Kununu/ArchitectureTest/Configuration/SubLayer.php new file mode 100644 index 0000000..c9fd1d7 --- /dev/null +++ b/Kununu/ArchitectureTest/Configuration/SubLayer.php @@ -0,0 +1,58 @@ + $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), + MustOnlyDependOnWhitelist::KEY => + $rules[] = MustOnlyDependOnWhitelist::fromArray($selector, $item), + MustOnlyHaveOnePublicMethodNamed::KEY => + $rules[] = MustOnlyHaveOnePublicMethodNamed::fromArray($selector, $item), + default => throw new \Exception("Unknown key: $key"), + }; + } + + if (!isset($name) || empty($name)) { + throw new \InvalidArgumentException('Missing name for sub layer'); + } + + return new self( + name: $name, + selector: $selector, + rules: $rules, + ); + } +} diff --git a/Kununu/ArchitectureTest/DocGenerator.php b/Kununu/ArchitectureTest/DocGenerator.php new file mode 100644 index 0000000..eeb76ab --- /dev/null +++ b/Kununu/ArchitectureTest/DocGenerator.php @@ -0,0 +1,88 @@ + %s\n", + $subLayer['layer'], + $whitelist['class'] + ); + } elseif (isset($whitelist['namespace'])) { + $content .= sprintf( + " %s --> %s\n", + $subLayer['layer'], + $whitelist['namespace'] + ); + } + } + } + + // Add extends information as a comment or metadata (not as part of block) + if (isset($subLayer['extends'])) { + $content .= sprintf( + " %% Extends: %s %%\n", + $subLayer['extends']['class'] + ); + } + + // Add implements information as a comment or metadata + if (isset($subLayer['implements'])) { + foreach ($subLayer['implements'] as $implement) { + $content .= sprintf( + " %% Implements: %s %%\n", + $implement['class'] + ); + } + } + + $content .= " end\n"; // End sub-layer block + } + } + + $content .= " end\n\n"; // End the main layer block + } +} + +addLayerToMmd($architectureDefinition['architecture'], $mmdContent); + +file_put_contents($outputFile, $mmdContent); + +echo 'Mermaid file generated at: ' . $outputFile . PHP_EOL; diff --git a/Kununu/ArchitectureTest/FollowsFolderStructureRule.php b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php new file mode 100644 index 0000000..16d7010 --- /dev/null +++ b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php @@ -0,0 +1,63 @@ +architectureLayers[] = $layer['layer']; + } + + foreach ($archDefinition['deprecated'] as $layer) { + $this->deprecatedLayers[] = $layer['layer']; + } + } + + public function getNodeType(): string + { + return Namespace_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $directories = array_merge($this->architectureLayers, $this->deprecatedLayers); + $basePath = __DIR__ . '/../../src'; + + $actualDirectories = array_filter(glob($basePath . '/*'), '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/ArchitectureTest/README.md b/Kununu/ArchitectureTest/README.md new file mode 100644 index 0000000..7e93769 --- /dev/null +++ b/Kununu/ArchitectureTest/README.md @@ -0,0 +1,106 @@ +# 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 +```bash +composer require --dev phpunit/phpunit phpat/phpat +``` +2. Configure phpstan.neon +```neon +includes: + - vendor/phpat/phpat/extension.neon + +parameters: + ... + phpat: + ignore_built_in_classes: false + show_rule_names: true + ... + +services: + - class: Kununu\ArchitectureTest\FollowsFolderStructureRule + tags: + - phpstan.rules.rule + + - class: Kununu\ArchitectureTest\ConfigurableArchitectureTest + tags: + - phpat.test +``` + +3. Define your architecture rules by creating an `arch_definition.yaml` in the root of your project + +### How to define your architecture +#### Requirements for the FollowsFolderStructureRule +```yaml +architecture: + - layer: FirstLayer + - layer: SecondLayer + +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. + +#### 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" +``` +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. +```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" +``` + +## 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 +``` +This will clear the cache and run the tests. + +You can run the tests in your directory or in the container. diff --git a/composer.json b/composer.json index e44a044..003842f 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,9 @@ "squizlabs/php_codesniffer": "^3.10" }, "require-dev": { - "phpunit/phpunit": "^11.5" + "kununu/scripts": "^5.1", + "phpunit/phpunit": "^11.5", + "phpat/phpat": "^0.11.4" }, "autoload": { "psr-4": { From 0368ad29b3314a5b697ed81ac1a51c6a0c117062 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 16:56:46 +0200 Subject: [PATCH 02/30] move phpat to require --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 003842f..47ec10b 100644 --- a/composer.json +++ b/composer.json @@ -15,12 +15,12 @@ "composer/composer": "^2.8", "friendsofphp/php-cs-fixer": "^3.75", "rector/rector": "^2.0", - "squizlabs/php_codesniffer": "^3.10" + "squizlabs/php_codesniffer": "^3.10", + "phpat/phpat": "^0.11.4" }, "require-dev": { "kununu/scripts": "^5.1", - "phpunit/phpunit": "^11.5", - "phpat/phpat": "^0.11.4" + "phpunit/phpunit": "^11.5" }, "autoload": { "psr-4": { From 53c352d2b331a13540259ba7e0f5d9ea99b0d315 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 17:10:28 +0200 Subject: [PATCH 03/30] fixing the FollowsFolderStrucure Rule --- Kununu/ArchitectureTest/FollowsFolderStructureRule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kununu/ArchitectureTest/FollowsFolderStructureRule.php b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php index 16d7010..924a499 100644 --- a/Kununu/ArchitectureTest/FollowsFolderStructureRule.php +++ b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php @@ -16,7 +16,7 @@ public function __construct( private array $architectureLayers = [], private array $deprecatedLayers = [], ) { - $archDefinition = Yaml::parseFile(__DIR__ . '/../../arch_definition.yaml'); + $archDefinition = Yaml::parseFile(ConfigurableArchitectureTest::getArchitectureDefinitionFile()); foreach ($archDefinition['architecture'] as $layer) { $this->architectureLayers[] = $layer['layer']; From f7f5893266979551c47294ef4c536288b2277cb5 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 17:32:09 +0200 Subject: [PATCH 04/30] improve by introducing directory finder and organizing files --- ...hitectureTest.php => ArchitectureTest.php} | 31 +-------- .../ArchitectureTest/Configuration/Layer.php | 1 + .../Configuration/Rules/MustBeFinal.php | 4 +- .../Configuration/Rules/MustExtend.php | 4 +- .../Configuration/Rules/MustImplement.php | 4 +- .../Rules/MustOnlyDependOnWhitelist.php | 3 +- .../MustOnlyHaveOnePublicMethodNamed.php | 2 +- .../{ => Selector}/ClassSelector.php | 2 +- .../{ => Selector}/InterfaceClassSelector.php | 2 +- .../{ => Selector}/NamespaceSelector.php | 2 +- .../{ => Selector}/RegexTrait.php | 2 +- .../{ => Selector}/Selectable.php | 2 +- .../Configuration/Selectors.php | 5 ++ .../Configuration/SubLayer.php | 1 + Kununu/ArchitectureTest/DirectoryFinder.php | 36 +++++++++++ Kununu/ArchitectureTest/DocGenerator.php | 64 ++----------------- .../FollowsFolderStructureRule.php | 6 +- Kununu/ArchitectureTest/README.md | 2 +- 18 files changed, 69 insertions(+), 104 deletions(-) rename Kununu/ArchitectureTest/{ConfigurableArchitectureTest.php => ArchitectureTest.php} (58%) rename Kununu/ArchitectureTest/Configuration/{ => Selector}/ClassSelector.php (91%) rename Kununu/ArchitectureTest/Configuration/{ => Selector}/InterfaceClassSelector.php (92%) rename Kununu/ArchitectureTest/Configuration/{ => Selector}/NamespaceSelector.php (91%) rename Kununu/ArchitectureTest/Configuration/{ => Selector}/RegexTrait.php (84%) rename Kununu/ArchitectureTest/Configuration/{ => Selector}/Selectable.php (77%) create mode 100644 Kununu/ArchitectureTest/DirectoryFinder.php diff --git a/Kununu/ArchitectureTest/ConfigurableArchitectureTest.php b/Kununu/ArchitectureTest/ArchitectureTest.php similarity index 58% rename from Kununu/ArchitectureTest/ConfigurableArchitectureTest.php rename to Kununu/ArchitectureTest/ArchitectureTest.php index 7430d12..322d2d4 100644 --- a/Kununu/ArchitectureTest/ConfigurableArchitectureTest.php +++ b/Kununu/ArchitectureTest/ArchitectureTest.php @@ -8,18 +8,15 @@ use Kununu\ArchitectureTest\Configuration\SubLayer; use InvalidArgumentException; use PHPat\Test\Builder\Rule as PHPatRule; -use Symfony\Component\Yaml\Yaml; -final class ConfigurableArchitectureTest +final class ArchitectureTest { - private const string ARCHITECTURE_DEFINITION_FILE = '/arch_definition.yaml'; - /** * @return iterable */ public function testArchitecture(): iterable { - $archDefinition = self::getArchitectureDefinition(); + $archDefinition = DirectoryFinder::getArchitectureDefinition(); $layers = $this->validateArchitectureDefinition($archDefinition); /** @var Layer $layer */ foreach ($layers as $layer) { @@ -47,28 +44,4 @@ private function validateArchitectureDefinition(array $architectureDefinition): return $layers; } - 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; - } - - private static function getArchitectureDefinition(): array - { - $filePath = self::getArchitectureDefinitionFile(); - - if (!file_exists($filePath)) { - throw new InvalidArgumentException( - 'ArchitectureTest definition file not found, please create it at ' . $filePath - ); - } - - return Yaml::parseFile($filePath); - } } diff --git a/Kununu/ArchitectureTest/Configuration/Layer.php b/Kununu/ArchitectureTest/Configuration/Layer.php index 9be224d..532278c 100644 --- a/Kununu/ArchitectureTest/Configuration/Layer.php +++ b/Kununu/ArchitectureTest/Configuration/Layer.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureTest\Configuration; +use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use Symfony\Component\Validator\Constraints as Assert; final readonly class Layer diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php index 13a4444..61e1cec 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php @@ -3,8 +3,8 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; -use Kununu\ArchitectureTest\Configuration\InterfaceClassSelector; -use Kununu\ArchitectureTest\Configuration\Selectable; +use Kununu\ArchitectureTest\Configuration\Selector\InterfaceClassSelector; +use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule as PHPatRule; use PHPat\Test\PHPat; diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php index 77982b4..222fb01 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php @@ -3,8 +3,8 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; -use Kununu\ArchitectureTest\Configuration\InterfaceClassSelector; -use Kununu\ArchitectureTest\Configuration\Selectable; +use Kununu\ArchitectureTest\Configuration\Selector\InterfaceClassSelector; +use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use Kununu\ArchitectureTest\Configuration\Selectors; use PHPat\Test\PHPat; diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php index 6f5889d..7fc2e86 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php @@ -3,8 +3,8 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; -use Kununu\ArchitectureTest\Configuration\InterfaceClassSelector; -use Kununu\ArchitectureTest\Configuration\Selectable; +use Kununu\ArchitectureTest\Configuration\Selector\InterfaceClassSelector; +use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use Kununu\ArchitectureTest\Configuration\Selectors; use PHPat\Selector\Selector; use PHPat\Selector\SelectorInterface; diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php index c16acc3..09de77f 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php @@ -3,8 +3,7 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; -use Kununu\ArchitectureTest\Configuration\InterfaceClassSelector; -use Kununu\ArchitectureTest\Configuration\Selectable; +use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use Kununu\ArchitectureTest\Configuration\Selectors; use PHPat\Selector\Selector; use PHPat\Test\PHPat; diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index 4aa765c..a7c91a7 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -3,7 +3,7 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; -use Kununu\ArchitectureTest\Configuration\Selectable; +use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use PHPat\Test\PHPat; final readonly class MustOnlyHaveOnePublicMethodNamed implements Rule diff --git a/Kununu/ArchitectureTest/Configuration/ClassSelector.php b/Kununu/ArchitectureTest/Configuration/Selector/ClassSelector.php similarity index 91% rename from Kununu/ArchitectureTest/Configuration/ClassSelector.php rename to Kununu/ArchitectureTest/Configuration/Selector/ClassSelector.php index ff237f5..32c4094 100644 --- a/Kununu/ArchitectureTest/Configuration/ClassSelector.php +++ b/Kununu/ArchitectureTest/Configuration/Selector/ClassSelector.php @@ -1,7 +1,7 @@ %s\n", - $subLayer['layer'], - $whitelist['class'] - ); - } elseif (isset($whitelist['namespace'])) { - $content .= sprintf( - " %s --> %s\n", - $subLayer['layer'], - $whitelist['namespace'] - ); - } - } - } - - // Add extends information as a comment or metadata (not as part of block) - if (isset($subLayer['extends'])) { - $content .= sprintf( - " %% Extends: %s %%\n", - $subLayer['extends']['class'] - ); - } - - // Add implements information as a comment or metadata - if (isset($subLayer['implements'])) { - foreach ($subLayer['implements'] as $implement) { - $content .= sprintf( - " %% Implements: %s %%\n", - $implement['class'] - ); - } - } - - $content .= " end\n"; // End sub-layer block - } - } - - $content .= " end\n\n"; // End the main layer block + $content .= " }\n\n"; // End the main layer block } } diff --git a/Kununu/ArchitectureTest/FollowsFolderStructureRule.php b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php index 924a499..749ef6e 100644 --- a/Kununu/ArchitectureTest/FollowsFolderStructureRule.php +++ b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php @@ -8,15 +8,15 @@ use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use Symfony\Component\Yaml\Yaml; final class FollowsFolderStructureRule implements Rule { public function __construct( private array $architectureLayers = [], private array $deprecatedLayers = [], + ) { - $archDefinition = Yaml::parseFile(ConfigurableArchitectureTest::getArchitectureDefinitionFile()); + $archDefinition = DirectoryFinder::getArchitectureDefinition(); foreach ($archDefinition['architecture'] as $layer) { $this->architectureLayers[] = $layer['layer']; @@ -35,7 +35,7 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { $directories = array_merge($this->architectureLayers, $this->deprecatedLayers); - $basePath = __DIR__ . '/../../src'; + $basePath = DirectoryFinder::getProjectDirectory() . '/src'; $actualDirectories = array_filter(glob($basePath . '/*'), 'is_dir'); $actualNames = array_map('basename', $actualDirectories); diff --git a/Kununu/ArchitectureTest/README.md b/Kununu/ArchitectureTest/README.md index 7e93769..b6eac93 100644 --- a/Kununu/ArchitectureTest/README.md +++ b/Kununu/ArchitectureTest/README.md @@ -31,7 +31,7 @@ services: tags: - phpstan.rules.rule - - class: Kununu\ArchitectureTest\ConfigurableArchitectureTest + - class: Kununu\ArchitectureTest\ArchitectureTest tags: - phpat.test ``` From 2412e50b523cde72360343d1451610e684bea2e3 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 17:43:53 +0200 Subject: [PATCH 05/30] register bin command --- bin/generate-arch-mmd | 9 +++++++++ composer.json | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 bin/generate-arch-mmd diff --git a/bin/generate-arch-mmd b/bin/generate-arch-mmd new file mode 100644 index 0000000..88bf0c2 --- /dev/null +++ b/bin/generate-arch-mmd @@ -0,0 +1,9 @@ +#!/bin/bash + +# run php script +# script location: ./Kununu/ArchitectureTest/DocGenerator.php +php -f vendor/kununu/code-tools/Kununu/ArchitectureTest/DocGenerator.php -- "$@" + for file in "${CONFIG_FILES[@]}"; do + copy_config_file "$file" + done +fi diff --git a/composer.json b/composer.json index 47ec10b..4649da0 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ }, "bin": [ "bin/code-tools", - "bin/php-in-k8s" + "bin/php-in-k8s", + "bin/generate-arch-mmd" ], "config": { "sort-packages": true, From 09f94ca46e525cdcc9e0f925df65e741007016b8 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 17:50:40 +0200 Subject: [PATCH 06/30] remove unused yaml read --- Kununu/ArchitectureTest/DocGenerator.php | 1 - bin/generate-arch-mmd | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Kununu/ArchitectureTest/DocGenerator.php b/Kununu/ArchitectureTest/DocGenerator.php index b64f374..84dbb16 100644 --- a/Kununu/ArchitectureTest/DocGenerator.php +++ b/Kununu/ArchitectureTest/DocGenerator.php @@ -18,7 +18,6 @@ } $yamlFile = DirectoryFinder::getArchitectureDefinitionFile(); -$architectureDefinition = Yaml::parseFile($yamlFile); $mmdContent = "C4Context\n"; function addLayerToMmd(array $layers, &$content): void diff --git a/bin/generate-arch-mmd b/bin/generate-arch-mmd index 88bf0c2..dbbb7dd 100644 --- a/bin/generate-arch-mmd +++ b/bin/generate-arch-mmd @@ -2,8 +2,4 @@ # run php script # script location: ./Kununu/ArchitectureTest/DocGenerator.php -php -f vendor/kununu/code-tools/Kununu/ArchitectureTest/DocGenerator.php -- "$@" - for file in "${CONFIG_FILES[@]}"; do - copy_config_file "$file" - done -fi +php -f vendor/kununu/code-tools/Kununu/ArchitectureTest/DocGenerator.php From 7ca7105441041b495b91bb6d51eb6097bbd6917a Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 17:52:27 +0200 Subject: [PATCH 07/30] remove unused yaml read --- Kununu/ArchitectureTest/DocGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kununu/ArchitectureTest/DocGenerator.php b/Kununu/ArchitectureTest/DocGenerator.php index 84dbb16..b72148d 100644 --- a/Kununu/ArchitectureTest/DocGenerator.php +++ b/Kununu/ArchitectureTest/DocGenerator.php @@ -17,7 +17,7 @@ } } -$yamlFile = DirectoryFinder::getArchitectureDefinitionFile(); +$architectureDefinition = DirectoryFinder::getArchitectureDefinitionFile(); $mmdContent = "C4Context\n"; function addLayerToMmd(array $layers, &$content): void From 6eb417c0babf1f887b9dec9d9ca43d9751165ffa Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 21:43:36 +0200 Subject: [PATCH 08/30] fixing doc generator --- Kununu/ArchitectureTest/DocGenerator.php | 172 +++++++++++++++++++++-- 1 file changed, 161 insertions(+), 11 deletions(-) diff --git a/Kununu/ArchitectureTest/DocGenerator.php b/Kununu/ArchitectureTest/DocGenerator.php index b72148d..c0f4bcb 100644 --- a/Kununu/ArchitectureTest/DocGenerator.php +++ b/Kununu/ArchitectureTest/DocGenerator.php @@ -7,9 +7,10 @@ require $root . '/vendor/autoload.php'; use Kununu\ArchitectureTest\DirectoryFinder; +use Kununu\ArchitectureTest\Configuration\Layer; $outputDir = DirectoryFinder::getProjectDirectory() . '/doc/architecture'; -$outputFile = $outputDir . '/architecture.mmd'; +$outputFile = $outputDir . '/architecture-diagram.mmd'; if (!is_dir($outputDir)) { if (!mkdir($outputDir, 0755, true) && !is_dir($outputDir)) { @@ -17,21 +18,170 @@ } } -$architectureDefinition = DirectoryFinder::getArchitectureDefinitionFile(); +$architectureDefinition = DirectoryFinder::getArchitectureDefinition(); -$mmdContent = "C4Context\n"; -function addLayerToMmd(array $layers, &$content): void -{ - foreach ($layers as $layer) { - $content .= " title Architecture Diagram\n"; - $content .= " Enterprise_Boundary(b0, \"{$layer['layer']}\"){\n"; +$content = "C4Context\n"; +$content .= "title Architecture Diagram\n"; +$layersTrack = []; +$componentsTrack = []; +$innerDependenciesTrack = []; +$dependenciesTrack = []; +$externalDependenciesTrack = []; +$trackedRelations = []; +$relations = ""; +$i = 0; +$j = 0; +$k = 0; +foreach ($architectureDefinition['architecture'] as $layerData) { + $layer = Layer::fromArray($layerData); + $layersTrack[$layer->selector->getName()] = $i; + $i++; +} + +foreach ($architectureDefinition['architecture'] as $layerData) { + $layer = Layer::fromArray($layerData); + $outerLayerNr = $layersTrack[$layer->selector->getName()]; + $content .= "Container_Boundary(b$outerLayerNr, \"{$layer->name}\"){\n"; + + /** @var \Kununu\ArchitectureTest\Configuration\SubLayer $subLayer */ + foreach ($layer->subLayers as $subLayer) { + $componentsTrack[$subLayer->selector->getName()] = $j; + $trackedRelations[$j] = []; + $innerDependenciesTrack[$j] = []; + $finalText = ""; + foreach($subLayer->rules as $rule) { + if ($rule instanceof \Kununu\ArchitectureTest\Configuration\Rules\MustBeFinal) { + $finalText = "🔒 "; + } + + if ($rule instanceof \Kununu\ArchitectureTest\Configuration\Rules\MustOnlyDependOnWhitelist) { + /** @var \Kununu\ArchitectureTest\Configuration\Selector\Selectable $dependency */ + foreach ($rule->dependencyWhitelist as $dependency) { + $name = $dependency->getName(); + if (str_starts_with($name, "\\")) { + $name = substr($dependency->getName(), 1); + } + if (str_ends_with($name, "\\")) { + $name = substr($name, 0, -1); + } + if (str_starts_with($name, '*')) { + continue; + } + + if (str_starts_with($name, 'App')) { + $outerLayerOfDependency = "App\\" . explode('\\', $name)[1]; + $currentOuterLayer = $layer->selector->getName(); + if ($outerLayerOfDependency === $currentOuterLayer) { + continue; + } + if (in_array($outerLayerOfDependency, array_keys($layersTrack), true)) { + $innerDependenciesTrack[$j][$subLayer->selector->getName()] = $name; + continue; + } + $dependenciesTrack[$name] = $k; + continue; + } + if (!array_key_exists($name, $dependenciesTrack)) { + $dependenciesTrack[$name] = $k; + $trackedRelations[$j][] = $k; + $k++; + } + $number = $dependenciesTrack[$name]; + $trackedRelations[$j][] = $number; + } + } + } + $content .= " Component(\"c$j\", \"$finalText{$subLayer->name}\", \"{$subLayer->selector->getName()}\")\n"; + $j++; + } + $content .= "}\n\n"; // End the main layer block +} + +if (array_key_exists('deprecated', $architectureDefinition)) { + foreach ($architectureDefinition['deprecated'] as $layerData) { + $layer = Layer::fromArray($layerData); + $layersCount[$layer->selector->getName()] = $i; + $content .= "Container_Boundary(b$i, \"{$layerData['layer']}\"){\n"; + foreach($dependenciesTrack as $name => $number) { + if (str_starts_with($name, $layer->selector->getName())) { + $nameSpaces = explode('\\', $name); + $className = $nameSpaces[count($nameSpaces) - 1]; + $content .= " Component(\"e$number\", \"Deprecated $className\", \"$name\")\n"; + } else { + if (str_starts_with($name, 'App')) { + continue; + } + $externalContainer = explode('\\', $name)[0]; + if (!array_key_exists($externalContainer, $externalDependenciesTrack)) { + $externalDependenciesTrack[$externalContainer] = [ + $name => $number + ]; + continue; + } + $externalDependenciesTrack[$externalContainer][$name] = $number; + } + } + $content .= "}\n\n"; // End the main layer block + $i++; + } +} else { + $externalDependenciesTrack = $dependenciesTrack; +} + +$externalComponentsTrack = []; +foreach ($externalDependenciesTrack as $container => $external) { + $content .= "Container_Boundary(b$i, \"{$container}\"){\n"; + foreach ($external as $name => $number) { + $nameSpaces = explode('\\', $name); + $className = $nameSpaces[count($nameSpaces) - 1]; + $content .= " Component(\"e$number\", \"$className\", \"$name\")\n"; + $externalComponentsTrack[$name] = $number; + } + $content .= "}\n\n"; // End the main layer block + $i++; +} + +$trackedInnerRelations = []; +foreach ($innerDependenciesTrack as $componentNumber => $connections) { + $trackedInnerRelations[$componentNumber] = []; + foreach($connections as $sourceComponent => $dependency) { + $nameSpaces = explode('\\', $dependency); + foreach ($componentsTrack as $component => $externalNumber) { + if (count($nameSpaces) < 3) { + if ($component === $dependency) { + $trackedInnerRelations[$componentNumber][] = $externalNumber; + } + continue; + } + $shortedName = $nameSpaces[0] . '/' . $nameSpaces[1] . '/' . $nameSpaces[2]; + if (str_starts_with($component, $shortedName)) { + $nameSpaces = explode('\\', $name); + $className = $nameSpaces[count($nameSpaces) - 1]; + $trackedInnerRelations[$componentNumber][] = $externalNumber; + } + } + } +} + +foreach($trackedRelations as $number => $externalNumbers) { + foreach(array_unique($externalNumbers) as $externalNumber) { + if (!in_array($externalNumber, $externalComponentsTrack, true)) { + var_dump("Missing external Component nr $externalNumber"); + continue; + } + $relations .= "Rel(c$number, e$externalNumber, \"Can depend on\", \"DI\")\n"; + $relations .= "UpdateRelStyle(c$number, e$externalNumber, \$textColor=\"red\", \$offsetY=\"-40\")\n"; + } +} - $content .= " }\n\n"; // End the main layer block +foreach ($trackedInnerRelations as $number => $innerNumbers) { + foreach(array_unique($innerNumbers) as $innerNumber) { + $relations .= "Rel(c$number, c$innerNumber, \"Can depend on\", \"DI\")\n"; } } -addLayerToMmd($architectureDefinition['architecture'], $mmdContent); +$content .= $relations; -file_put_contents($outputFile, $mmdContent); +file_put_contents($outputFile, $content); echo 'Mermaid file generated at: ' . $outputFile . PHP_EOL; From 677b3d69e18c343e45e4fbc577f6491ffa0e7838 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Fri, 23 May 2025 22:20:13 +0200 Subject: [PATCH 09/30] supporting deprecated --- Kununu/ArchitectureTest/DocGenerator.php | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Kununu/ArchitectureTest/DocGenerator.php b/Kununu/ArchitectureTest/DocGenerator.php index c0f4bcb..620cdbe 100644 --- a/Kununu/ArchitectureTest/DocGenerator.php +++ b/Kununu/ArchitectureTest/DocGenerator.php @@ -75,10 +75,12 @@ continue; } if (in_array($outerLayerOfDependency, array_keys($layersTrack), true)) { - $innerDependenciesTrack[$j][$subLayer->selector->getName()] = $name; + $innerDependenciesTrack[$j][$subLayer->selector->getName()] = $dependency->getName(); continue; } $dependenciesTrack[$name] = $k; + $trackedRelations[$j][] = $k; + $k++; continue; } if (!array_key_exists($name, $dependenciesTrack)) { @@ -97,6 +99,7 @@ $content .= "}\n\n"; // End the main layer block } +$depricatedComponentIds = []; if (array_key_exists('deprecated', $architectureDefinition)) { foreach ($architectureDefinition['deprecated'] as $layerData) { $layer = Layer::fromArray($layerData); @@ -107,6 +110,7 @@ $nameSpaces = explode('\\', $name); $className = $nameSpaces[count($nameSpaces) - 1]; $content .= " Component(\"e$number\", \"Deprecated $className\", \"$name\")\n"; + $depricatedComponentIds[] = $number; } else { if (str_starts_with($name, 'App')) { continue; @@ -147,8 +151,9 @@ foreach($connections as $sourceComponent => $dependency) { $nameSpaces = explode('\\', $dependency); foreach ($componentsTrack as $component => $externalNumber) { - if (count($nameSpaces) < 3) { - if ($component === $dependency) { + if (count($nameSpaces) < 3 || count_chars($nameSpaces[2]) < 2) { + $componentOuterLayer = 'App\\' . explode('\\', $component)[1]; + if ($componentOuterLayer === $dependency) { $trackedInnerRelations[$componentNumber][] = $externalNumber; } continue; @@ -165,12 +170,15 @@ foreach($trackedRelations as $number => $externalNumbers) { foreach(array_unique($externalNumbers) as $externalNumber) { - if (!in_array($externalNumber, $externalComponentsTrack, true)) { + if (!in_array($externalNumber, $externalComponentsTrack, true) && + !in_array($externalNumber, $depricatedComponentIds, true)) { var_dump("Missing external Component nr $externalNumber"); continue; } $relations .= "Rel(c$number, e$externalNumber, \"Can depend on\", \"DI\")\n"; - $relations .= "UpdateRelStyle(c$number, e$externalNumber, \$textColor=\"red\", \$offsetY=\"-40\")\n"; + if (in_array($externalNumber, $depricatedComponentIds, true)) { + $relations .= "UpdateRelStyle(c$number, e$externalNumber, \$textColor=\"red\", \$offsetY=\"-40\")\n"; + } } } From 4df63102670efab752f6c969c265048a922a95f1 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 12 Aug 2025 09:44:31 +0200 Subject: [PATCH 10/30] fixing quodana errors --- .../ArchitectureTest/Configuration/Layer.php | 12 +- .../Configuration/Rules/MustBeFinal.php | 5 +- .../Configuration/Rules/MustExtend.php | 8 +- .../Configuration/Rules/MustImplement.php | 11 +- .../Rules/MustOnlyDependOnWhitelist.php | 10 +- .../Configuration/Selectors.php | 9 +- .../Configuration/SubLayer.php | 24 +- Kununu/ArchitectureTest/DirectoryFinder.php | 3 +- Kununu/ArchitectureTest/DocGenerator.php | 347 +++++++++--------- .../FollowsFolderStructureRule.php | 5 +- composer.json | 7 +- 11 files changed, 245 insertions(+), 196 deletions(-) diff --git a/Kununu/ArchitectureTest/Configuration/Layer.php b/Kununu/ArchitectureTest/Configuration/Layer.php index 532278c..de9abcd 100644 --- a/Kununu/ArchitectureTest/Configuration/Layer.php +++ b/Kununu/ArchitectureTest/Configuration/Layer.php @@ -3,8 +3,10 @@ namespace Kununu\ArchitectureTest\Configuration; +use Exception; +use InvalidArgumentException; +use JsonException; use Kununu\ArchitectureTest\Configuration\Selector\Selectable; -use Symfony\Component\Validator\Constraints as Assert; final readonly class Layer { @@ -17,12 +19,16 @@ public function __construct( ) { } + /** + * @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.'); + throw new InvalidArgumentException('Layer name is missing.'); } return new self( @@ -30,7 +36,7 @@ public static function fromArray(array $data): self selector: $selector, subLayers: array_key_exists(SubLayer::KEY, $data) ? array_map( - fn (array $subLayer) => SubLayer::fromArray($subLayer), + static fn(array $subLayer) => SubLayer::fromArray($subLayer), $data[SubLayer::KEY], ) : [], ); diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php index 61e1cec..371e807 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; +use InvalidArgumentException; use Kununu\ArchitectureTest\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use PHPat\Selector\Selector; @@ -14,14 +15,14 @@ public const string KEY = 'final'; public function __construct( - public ?Selectable $selector + public ?Selectable $selector, ) { } public static function fromArray(Selectable $selector): self { if ($selector instanceof InterfaceClassSelector) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'The class must not be an interface.' ); } diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php index 222fb01..0ca280a 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustExtend.php @@ -3,6 +3,8 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; +use InvalidArgumentException; +use JsonException; use Kununu\ArchitectureTest\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use Kununu\ArchitectureTest\Configuration\Selectors; @@ -11,18 +13,22 @@ final readonly class MustExtend implements Rule { public const string KEY = 'extends'; + public function __construct( public Selectable $selector, public Selectable $parent, ) { } + /** + * @throws JsonException + */ public static function fromArray(Selectable $selector, array $data): self { $parent = Selectors::findSelector($data); if ($parent instanceof InterfaceClassSelector) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'The parent class must not be an interface.' ); } diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php index 7fc2e86..6ef0060 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustImplement.php @@ -3,6 +3,8 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; +use InvalidArgumentException; +use JsonException; use Kununu\ArchitectureTest\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use Kununu\ArchitectureTest\Configuration\Selectors; @@ -20,13 +22,16 @@ public function __construct( ) { } + /** + * @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( + throw new InvalidArgumentException( "The {$interfaceSelector->getName()} must be declared as interface." ); } @@ -39,7 +44,7 @@ public static function fromArray(Selectable $selector, array $data): self public function getPHPatRule(): \PHPat\Test\Builder\Rule { $interfacesString = implode(', ', array_map( - static fn (Selectable $interface): string => $interface->getName(), + static fn(Selectable $interface): string => $interface->getName(), $this->interfaces )); @@ -51,7 +56,7 @@ public function getPHPatRule(): \PHPat\Test\Builder\Rule ->shouldImplement() ->classes( ...array_map( - static fn (Selectable $interface): SelectorInterface => $interface->getPHPatSelector(), + static fn(Selectable $interface): SelectorInterface => $interface->getPHPatSelector(), $this->interfaces ) ) diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php index 09de77f..6bfd754 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyDependOnWhitelist.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureTest\Configuration\Rules; +use JsonException; use Kununu\ArchitectureTest\Configuration\Selector\Selectable; use Kununu\ArchitectureTest\Configuration\Selectors; use PHPat\Selector\Selector; @@ -14,10 +15,13 @@ public function __construct( public Selectable $selector, - public array $dependencyWhitelist, + public array $dependencyWhitelist, ) { } + /** + * @throws JsonException + */ public static function fromArray(Selectable $selector, array $data): self { $dependencies = []; @@ -32,12 +36,12 @@ public static function fromArray(Selectable $selector, array $data): self public function getPHPatRule(): \PHPat\Test\Builder\Rule { $dependentsString = implode(', ', array_map( - static fn (Selectable $dependency): string => $dependency->getName(), + static fn(Selectable $dependency): string => $dependency->getName(), $this->dependencyWhitelist )); $selectors = array_map( - static fn (Selectable $dependency) => $dependency->getPHPatSelector(), + static fn(Selectable $dependency) => $dependency->getPHPatSelector(), $this->dependencyWhitelist ); $selectors[] = Selector::classname('/^\\\\*[^\\\\]+$/', true); diff --git a/Kununu/ArchitectureTest/Configuration/Selectors.php b/Kununu/ArchitectureTest/Configuration/Selectors.php index d7a17d9..bfa02a9 100644 --- a/Kununu/ArchitectureTest/Configuration/Selectors.php +++ b/Kununu/ArchitectureTest/Configuration/Selectors.php @@ -3,6 +3,8 @@ namespace Kununu\ArchitectureTest\Configuration; +use InvalidArgumentException; +use JsonException; use Kununu\ArchitectureTest\Configuration\Selector\ClassSelector; use Kununu\ArchitectureTest\Configuration\Selector\InterfaceClassSelector; use Kununu\ArchitectureTest\Configuration\Selector\NamespaceSelector; @@ -23,6 +25,9 @@ public static function getValidTypes(): array ]; } + /** + * @throws JsonException + */ public static function findSelector(array $data, ?string $nameKey = null): Selectable { foreach (self::getValidTypes() as $type) { @@ -31,14 +36,14 @@ public static function findSelector(array $data, ?string $nameKey = null): Selec } } - throw new \InvalidArgumentException($nameKey !== null ? "Missing selector for $nameKey" : + throw new InvalidArgumentException($nameKey !== null ? "Missing selector for $nameKey" : 'Missing selector in data ' . json_encode($data, JSON_THROW_ON_ERROR)); } private static function createSelector(string $type, string $name, string $selection): Selectable { return match ($type) { - self::ClassSelector->value => new ClassSelector($name, $selection), + self::ClassSelector->value => new ClassSelector($name, $selection), self::InterfaceSelector->value => new InterfaceClassSelector($name, $selection), self::NamespaceSelector->value => new NamespaceSelector($name, $selection), }; diff --git a/Kununu/ArchitectureTest/Configuration/SubLayer.php b/Kununu/ArchitectureTest/Configuration/SubLayer.php index c98fa88..2757eef 100644 --- a/Kununu/ArchitectureTest/Configuration/SubLayer.php +++ b/Kununu/ArchitectureTest/Configuration/SubLayer.php @@ -3,22 +3,24 @@ namespace Kununu\ArchitectureTest\Configuration; +use Exception; +use InvalidArgumentException; use Kununu\ArchitectureTest\Configuration\Rules\MustBeFinal; use Kununu\ArchitectureTest\Configuration\Rules\MustExtend; use Kununu\ArchitectureTest\Configuration\Rules\MustImplement; use Kununu\ArchitectureTest\Configuration\Rules\MustOnlyDependOnWhitelist; use Kununu\ArchitectureTest\Configuration\Rules\MustOnlyHaveOnePublicMethodNamed; use Kununu\ArchitectureTest\Configuration\Selector\Selectable; -use Symfony\Component\Validator\Constraints as Assert; final readonly class SubLayer { public const string KEY = 'sub-layers'; public const string NAME_KEY = 'name'; + public function __construct( public string $name, public Selectable $selector, - public array $rules = [] + public array $rules = [], ) { } @@ -31,23 +33,19 @@ public static function fromArray(array $subLayer) continue; } match ($key) { - self::NAME_KEY => $name = $item, + 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), - MustOnlyDependOnWhitelist::KEY => - $rules[] = MustOnlyDependOnWhitelist::fromArray($selector, $item), - MustOnlyHaveOnePublicMethodNamed::KEY => - $rules[] = MustOnlyHaveOnePublicMethodNamed::fromArray($selector, $item), - default => throw new \Exception("Unknown key: $key"), + MustExtend::KEY => $rules[] = MustExtend::fromArray($selector, $item), + MustImplement::KEY => $rules[] = MustImplement::fromArray($selector, $item), + MustOnlyDependOnWhitelist::KEY => $rules[] = MustOnlyDependOnWhitelist::fromArray($selector, $item), + MustOnlyHaveOnePublicMethodNamed::KEY => $rules[] = MustOnlyHaveOnePublicMethodNamed::fromArray($selector, $item), + default => throw new Exception("Unknown key: $key"), }; } if (!isset($name) || empty($name)) { - throw new \InvalidArgumentException('Missing name for sub layer'); + throw new InvalidArgumentException('Missing name for sub layer'); } return new self( diff --git a/Kununu/ArchitectureTest/DirectoryFinder.php b/Kununu/ArchitectureTest/DirectoryFinder.php index b1fa7fc..ade6d70 100644 --- a/Kununu/ArchitectureTest/DirectoryFinder.php +++ b/Kununu/ArchitectureTest/DirectoryFinder.php @@ -3,7 +3,8 @@ namespace Kununu\ArchitectureTest; -use Symfony\Component\Yaml\Yaml; +use InvalidArgumentException; +use RectorPrefix202507\Symfony\Component\Yaml\Yaml; final readonly class DirectoryFinder { diff --git a/Kununu/ArchitectureTest/DocGenerator.php b/Kununu/ArchitectureTest/DocGenerator.php index 620cdbe..5e2bf4f 100644 --- a/Kununu/ArchitectureTest/DocGenerator.php +++ b/Kununu/ArchitectureTest/DocGenerator.php @@ -1,195 +1,214 @@ selector->getName()] = $i; - $i++; + file_put_contents($outputFile, $content); + echo 'Mermaid file generated at: ' . $outputFile . PHP_EOL; } -foreach ($architectureDefinition['architecture'] as $layerData) { - $layer = Layer::fromArray($layerData); - $outerLayerNr = $layersTrack[$layer->selector->getName()]; - $content .= "Container_Boundary(b$outerLayerNr, \"{$layer->name}\"){\n"; +/** + * Gets the root directory for the project. + */ +function getProjectRoot(): string +{ + $directory = dirname(__DIR__); - /** @var \Kununu\ArchitectureTest\Configuration\SubLayer $subLayer */ - foreach ($layer->subLayers as $subLayer) { - $componentsTrack[$subLayer->selector->getName()] = $j; - $trackedRelations[$j] = []; - $innerDependenciesTrack[$j] = []; - $finalText = ""; - foreach($subLayer->rules as $rule) { - if ($rule instanceof \Kununu\ArchitectureTest\Configuration\Rules\MustBeFinal) { - $finalText = "🔒 "; - } + return explode('/services', $directory)[0] . '/services'; +} - if ($rule instanceof \Kununu\ArchitectureTest\Configuration\Rules\MustOnlyDependOnWhitelist) { - /** @var \Kununu\ArchitectureTest\Configuration\Selector\Selectable $dependency */ - foreach ($rule->dependencyWhitelist as $dependency) { - $name = $dependency->getName(); - if (str_starts_with($name, "\\")) { - $name = substr($dependency->getName(), 1); - } - if (str_ends_with($name, "\\")) { - $name = substr($name, 0, -1); - } - if (str_starts_with($name, '*')) { - continue; - } - - if (str_starts_with($name, 'App')) { - $outerLayerOfDependency = "App\\" . explode('\\', $name)[1]; - $currentOuterLayer = $layer->selector->getName(); - if ($outerLayerOfDependency === $currentOuterLayer) { - continue; - } - if (in_array($outerLayerOfDependency, array_keys($layersTrack), true)) { - $innerDependenciesTrack[$j][$subLayer->selector->getName()] = $dependency->getName(); - continue; - } - $dependenciesTrack[$name] = $k; - $trackedRelations[$j][] = $k; - $k++; - continue; - } - if (!array_key_exists($name, $dependenciesTrack)) { - $dependenciesTrack[$name] = $k; - $trackedRelations[$j][] = $k; - $k++; - } - $number = $dependenciesTrack[$name]; - $trackedRelations[$j][] = $number; - } - } - } - $content .= " Component(\"c$j\", \"$finalText{$subLayer->name}\", \"{$subLayer->selector->getName()}\")\n"; - $j++; +/** + * Creates a directory if it doesn't exist. + */ +function ensureDirectoryExists(string $dir): void +{ + if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $dir)); } - $content .= "}\n\n"; // End the main layer block } -$depricatedComponentIds = []; -if (array_key_exists('deprecated', $architectureDefinition)) { - foreach ($architectureDefinition['deprecated'] as $layerData) { +/** + * Builds the full Mermaid architecture diagram content. + */ +function buildArchitectureDiagram(array $definition): string +{ + $layersTrack = []; + $componentsTrack = []; + $innerDependenciesTrack = []; + $dependenciesTrack = []; + $externalDependenciesTrack = []; + $trackedRelations = []; + + $content = "C4Context\n"; + $content .= "title Architecture Diagram\n"; + + // Track layer indexes + $layerIndex = 0; + foreach ($definition['architecture'] as $layerData) { $layer = Layer::fromArray($layerData); - $layersCount[$layer->selector->getName()] = $i; - $content .= "Container_Boundary(b$i, \"{$layerData['layer']}\"){\n"; - foreach($dependenciesTrack as $name => $number) { - if (str_starts_with($name, $layer->selector->getName())) { - $nameSpaces = explode('\\', $name); - $className = $nameSpaces[count($nameSpaces) - 1]; - $content .= " Component(\"e$number\", \"Deprecated $className\", \"$name\")\n"; - $depricatedComponentIds[] = $number; - } else { - if (str_starts_with($name, 'App')) { - continue; - } - $externalContainer = explode('\\', $name)[0]; - if (!array_key_exists($externalContainer, $externalDependenciesTrack)) { - $externalDependenciesTrack[$externalContainer] = [ - $name => $number - ]; - continue; - } - $externalDependenciesTrack[$externalContainer][$name] = $number; - } - } - $content .= "}\n\n"; // End the main layer block - $i++; + $layersTrack[$layer->selector->getName()] = $layerIndex++; } -} else { - $externalDependenciesTrack = $dependenciesTrack; -} -$externalComponentsTrack = []; -foreach ($externalDependenciesTrack as $container => $external) { - $content .= "Container_Boundary(b$i, \"{$container}\"){\n"; - foreach ($external as $name => $number) { - $nameSpaces = explode('\\', $name); - $className = $nameSpaces[count($nameSpaces) - 1]; - $content .= " Component(\"e$number\", \"$className\", \"$name\")\n"; - $externalComponentsTrack[$name] = $number; + // Process main architecture layers + $componentIndex = 0; + $externalIndex = 0; + + foreach ($definition['architecture'] as $layerData) { + $layer = Layer::fromArray($layerData); + $content .= buildLayerContent( + $layer, + $layersTrack, + $componentsTrack, + $innerDependenciesTrack, + $dependenciesTrack, + $trackedRelations, + $componentIndex, + $externalIndex + ); } - $content .= "}\n\n"; // End the main layer block - $i++; -} -$trackedInnerRelations = []; -foreach ($innerDependenciesTrack as $componentNumber => $connections) { - $trackedInnerRelations[$componentNumber] = []; - foreach($connections as $sourceComponent => $dependency) { - $nameSpaces = explode('\\', $dependency); - foreach ($componentsTrack as $component => $externalNumber) { - if (count($nameSpaces) < 3 || count_chars($nameSpaces[2]) < 2) { - $componentOuterLayer = 'App\\' . explode('\\', $component)[1]; - if ($componentOuterLayer === $dependency) { - $trackedInnerRelations[$componentNumber][] = $externalNumber; - } - continue; - } - $shortedName = $nameSpaces[0] . '/' . $nameSpaces[1] . '/' . $nameSpaces[2]; - if (str_starts_with($component, $shortedName)) { - $nameSpaces = explode('\\', $name); - $className = $nameSpaces[count($nameSpaces) - 1]; - $trackedInnerRelations[$componentNumber][] = $externalNumber; - } - } + // Process deprecated layers + $deprecatedComponentIds = []; + if (!empty($definition['deprecated'])) { + $content .= buildDeprecatedLayers( + $definition['deprecated'], + $dependenciesTrack, + $externalDependenciesTrack, + $deprecatedComponentIds, + $layerIndex + ); + } else { + $externalDependenciesTrack = $dependenciesTrack; } + + // Process external dependencies + $externalComponentsTrack = []; + foreach ($externalDependenciesTrack as $container => $external) { + $content .= buildExternalContainer($container, $external, $externalComponentsTrack, $layerIndex); + } + + // Build relations + $trackedInnerRelations = buildInnerRelations($innerDependenciesTrack, $componentsTrack); + $relations = buildRelations($trackedRelations, $externalComponentsTrack, $deprecatedComponentIds); + $relations .= buildInnerRelationsContent($trackedInnerRelations); + + return $content . $relations; } -foreach($trackedRelations as $number => $externalNumbers) { - foreach(array_unique($externalNumbers) as $externalNumber) { - if (!in_array($externalNumber, $externalComponentsTrack, true) && - !in_array($externalNumber, $depricatedComponentIds, true)) { - var_dump("Missing external Component nr $externalNumber"); - continue; - } - $relations .= "Rel(c$number, e$externalNumber, \"Can depend on\", \"DI\")\n"; - if (in_array($externalNumber, $depricatedComponentIds, true)) { - $relations .= "UpdateRelStyle(c$number, e$externalNumber, \$textColor=\"red\", \$offsetY=\"-40\")\n"; - } +/** + * Builds Mermaid content for a single layer. + */ +function buildLayerContent( + Layer $layer, + array &$layersTrack, + array &$componentsTrack, + array &$innerDependenciesTrack, + array &$dependenciesTrack, + array &$trackedRelations, + int &$componentIndex, + int &$externalIndex, +): string { + $content = "Container_Boundary(b{$layersTrack[$layer->selector->getName()]}, \"{$layer->name}\"){\n"; + + foreach ($layer->subLayers as $subLayer) { + $componentsTrack[$subLayer->selector->getName()] = $componentIndex; + $trackedRelations[$componentIndex] = []; + $innerDependenciesTrack[$componentIndex] = []; + + $finalText = hasFinalRule($subLayer) ? '🔒 ' : ''; + processWhitelistRules( + $subLayer, + $layer, + $layersTrack, + $innerDependenciesTrack, + $dependenciesTrack, + $trackedRelations, + $componentIndex, + $externalIndex + ); + + $content .= " Component(\"c{$componentIndex}\", \"{$finalText}{$subLayer->name}\", \"{$subLayer->selector->getName()}\")\n"; + ++$componentIndex; } + + return $content . "}\n\n"; } -foreach ($trackedInnerRelations as $number => $innerNumbers) { - foreach(array_unique($innerNumbers) as $innerNumber) { - $relations .= "Rel(c$number, c$innerNumber, \"Can depend on\", \"DI\")\n"; +/** + * Checks if a sub-layer has a "MustBeFinal" rule. + */ +function hasFinalRule($subLayer): bool +{ + foreach ($subLayer->rules as $rule) { + if ($rule instanceof Kununu\ArchitectureTest\Configuration\Rules\MustBeFinal) { + return true; + } } + + return false; } -$content .= $relations; +/** + * Processes dependency whitelist rules for a sub-layer. + */ +function processWhitelistRules( + $subLayer, + Layer $layer, + array $layersTrack, + array &$innerDependenciesTrack, + array &$dependenciesTrack, + array &$trackedRelations, + int $componentIndex, + int &$externalIndex, +): void { + foreach ($subLayer->rules as $rule) { + if (!$rule instanceof Kununu\ArchitectureTest\Configuration\Rules\MustOnlyDependOnWhitelist) { + continue; + } -file_put_contents($outputFile, $content); + foreach ($rule->dependencyWhitelist as $dependency) { + $name = trim($dependency->getName(), '\\'); + if (str_starts_with($name, '*')) { + continue; + } -echo 'Mermaid file generated at: ' . $outputFile . PHP_EOL; + if (str_starts_with($name, 'App')) { + $outerLayerOfDependency = 'App\\' . explode('\\', $name)[1]; + if ($outerLayerOfDependency === $layer->selector->getName()) { + continue; + } + if (isset($layersTrack[$outerLayerOfDependency])) { + $innerDependenciesTrack[$componentIndex][$subLayer->selector->getName()] = $dependency->getName(); + continue; + } + if (!isset($dependenciesTrack[$name])) { + $dependenciesTrack[$name] = $externalIndex++; + } + $trackedRelations[$componentIndex][] = $dependenciesTrack[$name]; + continue; + } + + if (!isset($dependenciesTrack[$name])) { + $dependenciesTrack[$name] = $externalIndex++; + } + $trackedRelations[$componentIndex][] = $dependenciesTrack[$name]; + } + } +} diff --git a/Kununu/ArchitectureTest/FollowsFolderStructureRule.php b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php index 749ef6e..b5c7b39 100644 --- a/Kununu/ArchitectureTest/FollowsFolderStructureRule.php +++ b/Kununu/ArchitectureTest/FollowsFolderStructureRule.php @@ -8,13 +8,13 @@ use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\ShouldNotHappenException; final class FollowsFolderStructureRule implements Rule { public function __construct( private array $architectureLayers = [], private array $deprecatedLayers = [], - ) { $archDefinition = DirectoryFinder::getArchitectureDefinition(); @@ -32,6 +32,9 @@ public function getNodeType(): string return Namespace_::class; } + /** + * @throws ShouldNotHappenException + */ public function processNode(Node $node, Scope $scope): array { $directories = array_merge($this->architectureLayers, $this->deprecatedLayers); diff --git a/composer.json b/composer.json index 4649da0..355e52e 100644 --- a/composer.json +++ b/composer.json @@ -10,13 +10,14 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.3", + "phpat/phpat": "^0.11.4", + "phpstan/phpstan": "^2.1", "composer-plugin-api": "^2.0", "composer/composer": "^2.8", "friendsofphp/php-cs-fixer": "^3.75", "rector/rector": "^2.0", - "squizlabs/php_codesniffer": "^3.10", - "phpat/phpat": "^0.11.4" + "squizlabs/php_codesniffer": "^3.10" }, "require-dev": { "kununu/scripts": "^5.1", From f2025d33ed339e53261a83a7e0a0910a19aa901b Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 12 Aug 2025 09:58:38 +0200 Subject: [PATCH 11/30] removing the doc generator and fixing quodana errors --- .../Configuration/SubLayer.php | 9 +- Kununu/ArchitectureTest/DocGenerator.php | 214 ------------------ bin/generate-arch-mmd | 5 - composer.json | 3 +- 4 files changed, 8 insertions(+), 223 deletions(-) delete mode 100644 Kununu/ArchitectureTest/DocGenerator.php delete mode 100644 bin/generate-arch-mmd diff --git a/Kununu/ArchitectureTest/Configuration/SubLayer.php b/Kununu/ArchitectureTest/Configuration/SubLayer.php index 2757eef..c302cc2 100644 --- a/Kununu/ArchitectureTest/Configuration/SubLayer.php +++ b/Kununu/ArchitectureTest/Configuration/SubLayer.php @@ -5,6 +5,7 @@ use Exception; use InvalidArgumentException; +use JsonException; use Kununu\ArchitectureTest\Configuration\Rules\MustBeFinal; use Kununu\ArchitectureTest\Configuration\Rules\MustExtend; use Kununu\ArchitectureTest\Configuration\Rules\MustImplement; @@ -24,7 +25,11 @@ public function __construct( ) { } - public static function fromArray(array $subLayer) + /** + * @throws JsonException + * @throws Exception + */ + public static function fromArray(array $subLayer): SubLayer { $rules = []; $selector = Selectors::findSelector($subLayer); @@ -44,7 +49,7 @@ public static function fromArray(array $subLayer) }; } - if (!isset($name) || empty($name)) { + if (empty($name)) { throw new InvalidArgumentException('Missing name for sub layer'); } diff --git a/Kununu/ArchitectureTest/DocGenerator.php b/Kununu/ArchitectureTest/DocGenerator.php deleted file mode 100644 index 5e2bf4f..0000000 --- a/Kununu/ArchitectureTest/DocGenerator.php +++ /dev/null @@ -1,214 +0,0 @@ -selector->getName()] = $layerIndex++; - } - - // Process main architecture layers - $componentIndex = 0; - $externalIndex = 0; - - foreach ($definition['architecture'] as $layerData) { - $layer = Layer::fromArray($layerData); - $content .= buildLayerContent( - $layer, - $layersTrack, - $componentsTrack, - $innerDependenciesTrack, - $dependenciesTrack, - $trackedRelations, - $componentIndex, - $externalIndex - ); - } - - // Process deprecated layers - $deprecatedComponentIds = []; - if (!empty($definition['deprecated'])) { - $content .= buildDeprecatedLayers( - $definition['deprecated'], - $dependenciesTrack, - $externalDependenciesTrack, - $deprecatedComponentIds, - $layerIndex - ); - } else { - $externalDependenciesTrack = $dependenciesTrack; - } - - // Process external dependencies - $externalComponentsTrack = []; - foreach ($externalDependenciesTrack as $container => $external) { - $content .= buildExternalContainer($container, $external, $externalComponentsTrack, $layerIndex); - } - - // Build relations - $trackedInnerRelations = buildInnerRelations($innerDependenciesTrack, $componentsTrack); - $relations = buildRelations($trackedRelations, $externalComponentsTrack, $deprecatedComponentIds); - $relations .= buildInnerRelationsContent($trackedInnerRelations); - - return $content . $relations; -} - -/** - * Builds Mermaid content for a single layer. - */ -function buildLayerContent( - Layer $layer, - array &$layersTrack, - array &$componentsTrack, - array &$innerDependenciesTrack, - array &$dependenciesTrack, - array &$trackedRelations, - int &$componentIndex, - int &$externalIndex, -): string { - $content = "Container_Boundary(b{$layersTrack[$layer->selector->getName()]}, \"{$layer->name}\"){\n"; - - foreach ($layer->subLayers as $subLayer) { - $componentsTrack[$subLayer->selector->getName()] = $componentIndex; - $trackedRelations[$componentIndex] = []; - $innerDependenciesTrack[$componentIndex] = []; - - $finalText = hasFinalRule($subLayer) ? '🔒 ' : ''; - processWhitelistRules( - $subLayer, - $layer, - $layersTrack, - $innerDependenciesTrack, - $dependenciesTrack, - $trackedRelations, - $componentIndex, - $externalIndex - ); - - $content .= " Component(\"c{$componentIndex}\", \"{$finalText}{$subLayer->name}\", \"{$subLayer->selector->getName()}\")\n"; - ++$componentIndex; - } - - return $content . "}\n\n"; -} - -/** - * Checks if a sub-layer has a "MustBeFinal" rule. - */ -function hasFinalRule($subLayer): bool -{ - foreach ($subLayer->rules as $rule) { - if ($rule instanceof Kununu\ArchitectureTest\Configuration\Rules\MustBeFinal) { - return true; - } - } - - return false; -} - -/** - * Processes dependency whitelist rules for a sub-layer. - */ -function processWhitelistRules( - $subLayer, - Layer $layer, - array $layersTrack, - array &$innerDependenciesTrack, - array &$dependenciesTrack, - array &$trackedRelations, - int $componentIndex, - int &$externalIndex, -): void { - foreach ($subLayer->rules as $rule) { - if (!$rule instanceof Kununu\ArchitectureTest\Configuration\Rules\MustOnlyDependOnWhitelist) { - continue; - } - - foreach ($rule->dependencyWhitelist as $dependency) { - $name = trim($dependency->getName(), '\\'); - if (str_starts_with($name, '*')) { - continue; - } - - if (str_starts_with($name, 'App')) { - $outerLayerOfDependency = 'App\\' . explode('\\', $name)[1]; - if ($outerLayerOfDependency === $layer->selector->getName()) { - continue; - } - if (isset($layersTrack[$outerLayerOfDependency])) { - $innerDependenciesTrack[$componentIndex][$subLayer->selector->getName()] = $dependency->getName(); - continue; - } - if (!isset($dependenciesTrack[$name])) { - $dependenciesTrack[$name] = $externalIndex++; - } - $trackedRelations[$componentIndex][] = $dependenciesTrack[$name]; - continue; - } - - if (!isset($dependenciesTrack[$name])) { - $dependenciesTrack[$name] = $externalIndex++; - } - $trackedRelations[$componentIndex][] = $dependenciesTrack[$name]; - } - } -} diff --git a/bin/generate-arch-mmd b/bin/generate-arch-mmd deleted file mode 100644 index dbbb7dd..0000000 --- a/bin/generate-arch-mmd +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# run php script -# script location: ./Kununu/ArchitectureTest/DocGenerator.php -php -f vendor/kununu/code-tools/Kununu/ArchitectureTest/DocGenerator.php diff --git a/composer.json b/composer.json index 355e52e..c7968ab 100644 --- a/composer.json +++ b/composer.json @@ -35,8 +35,7 @@ }, "bin": [ "bin/code-tools", - "bin/php-in-k8s", - "bin/generate-arch-mmd" + "bin/php-in-k8s" ], "config": { "sort-packages": true, From 1b1eec84beda4521dcd952edd725a719265f280e Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 12 Aug 2025 10:05:11 +0200 Subject: [PATCH 12/30] fixing phpcbf --- Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php | 5 ++--- .../Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php index 371e807..e931128 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustBeFinal.php @@ -14,9 +14,8 @@ { public const string KEY = 'final'; - public function __construct( - public ?Selectable $selector, - ) { + public function __construct(public ?Selectable $selector) + { } public static function fromArray(Selectable $selector): self diff --git a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php index a7c91a7..c499310 100644 --- a/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureTest/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -9,6 +9,7 @@ final readonly class MustOnlyHaveOnePublicMethodNamed implements Rule { public const string KEY = 'only-one-public-method-named'; + public function __construct( public Selectable $selector, public string $functionName, From 37980f2ebf910718e4bdda83a36f00520a5db874 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 12 Aug 2025 10:16:09 +0200 Subject: [PATCH 13/30] rename ArchitectureTest to *Sniffer --- .../ArchitectureSniffer.php} | 13 ++++++---- .../Configuration/Layer.php | 4 ++-- .../Configuration/Rules/MustBeFinal.php | 6 ++--- .../Configuration/Rules/MustExtend.php | 8 +++---- .../Configuration/Rules/MustImplement.php | 8 +++---- .../Rules/MustOnlyDependOnWhitelist.php | 6 ++--- .../MustOnlyHaveOnePublicMethodNamed.php | 4 ++-- .../Configuration/Rules/Rule.php | 2 +- .../Configuration/Selector/ClassSelector.php | 2 +- .../Selector/InterfaceClassSelector.php | 2 +- .../Selector/NamespaceSelector.php | 2 +- .../Configuration/Selector/RegexTrait.php | 2 +- .../Configuration/Selector/Selectable.php | 2 +- .../Configuration/Selectors.php | 10 ++++---- .../Configuration/SubLayer.php | 24 ++++++++++++------- .../DirectoryFinder.php | 4 ++-- .../FollowsFolderStructureRule.php | 2 +- .../README.md | 4 ++-- 18 files changed, 57 insertions(+), 48 deletions(-) rename Kununu/{ArchitectureTest/ArchitectureTest.php => ArchitectureSniffer/ArchitectureSniffer.php} (81%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Layer.php (89%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Rules/MustBeFinal.php (82%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Rules/MustExtend.php (81%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Rules/MustImplement.php (87%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Rules/MustOnlyDependOnWhitelist.php (89%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php (86%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Rules/Rule.php (66%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Selector/ClassSelector.php (91%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Selector/InterfaceClassSelector.php (92%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Selector/NamespaceSelector.php (91%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Selector/RegexTrait.php (83%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Selector/Selectable.php (76%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/Selectors.php (81%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/Configuration/SubLayer.php (69%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/DirectoryFinder.php (86%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/FollowsFolderStructureRule.php (98%) rename Kununu/{ArchitectureTest => ArchitectureSniffer}/README.md (96%) diff --git a/Kununu/ArchitectureTest/ArchitectureTest.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php similarity index 81% rename from Kununu/ArchitectureTest/ArchitectureTest.php rename to Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 322d2d4..0fd40c3 100644 --- a/Kununu/ArchitectureTest/ArchitectureTest.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -1,15 +1,15 @@ @@ -30,6 +30,9 @@ public function testArchitecture(): iterable } } + /** + * @throws \JsonException + */ private function validateArchitectureDefinition(array $architectureDefinition): array { if (!array_key_exists('architecture', $architectureDefinition)) { diff --git a/Kununu/ArchitectureTest/Configuration/Layer.php b/Kununu/ArchitectureSniffer/Configuration/Layer.php similarity index 89% rename from Kununu/ArchitectureTest/Configuration/Layer.php rename to Kununu/ArchitectureSniffer/Configuration/Layer.php index de9abcd..a3af614 100644 --- a/Kununu/ArchitectureTest/Configuration/Layer.php +++ b/Kununu/ArchitectureSniffer/Configuration/Layer.php @@ -1,12 +1,12 @@ $rules[] = MustExtend::fromArray($selector, $item), MustImplement::KEY => $rules[] = MustImplement::fromArray($selector, $item), - MustOnlyDependOnWhitelist::KEY => $rules[] = MustOnlyDependOnWhitelist::fromArray($selector, $item), - MustOnlyHaveOnePublicMethodNamed::KEY => $rules[] = MustOnlyHaveOnePublicMethodNamed::fromArray($selector, $item), + MustOnlyDependOnWhitelist::KEY => $rules[] = MustOnlyDependOnWhitelist::fromArray( + $selector, + $item + ), + MustOnlyHaveOnePublicMethodNamed::KEY => $rules[] = MustOnlyHaveOnePublicMethodNamed::fromArray( + $selector, + $item + ), default => throw new Exception("Unknown key: $key"), }; } diff --git a/Kununu/ArchitectureTest/DirectoryFinder.php b/Kununu/ArchitectureSniffer/DirectoryFinder.php similarity index 86% rename from Kununu/ArchitectureTest/DirectoryFinder.php rename to Kununu/ArchitectureSniffer/DirectoryFinder.php index ade6d70..19a5e97 100644 --- a/Kununu/ArchitectureTest/DirectoryFinder.php +++ b/Kununu/ArchitectureSniffer/DirectoryFinder.php @@ -1,7 +1,7 @@ Date: Tue, 12 Aug 2025 11:18:15 +0200 Subject: [PATCH 14/30] fixing phpstan errors --- .../ArchitectureSniffer.php | 10 +++++++--- .../Configuration/Layer.php | 5 +++++ .../Configuration/Rules/MustBeFinal.php | 2 +- .../Configuration/Rules/MustExtend.php | 2 ++ .../Configuration/Rules/MustImplement.php | 5 +++++ .../Rules/MustOnlyDependOnWhitelist.php | 5 +++++ .../Configuration/Selector/ClassSelector.php | 11 +++++++--- .../Selector/InterfaceClassSelector.php | 11 +++++++--- .../Selector/NamespaceSelector.php | 5 +++++ .../Configuration/Selectors.php | 15 +++++++++----- .../Configuration/SubLayer.php | 6 ++++++ .../ArchitectureSniffer/DirectoryFinder.php | 5 ++++- .../FollowsFolderStructureRule.php | 20 ++++++++++++++++++- composer.json | 3 ++- 14 files changed, 87 insertions(+), 18 deletions(-) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 0fd40c3..3478201 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -3,10 +3,11 @@ namespace Kununu\ArchitectureSniffer; +use InvalidArgumentException; +use JsonException; use Kununu\ArchitectureSniffer\Configuration\Layer; use Kununu\ArchitectureSniffer\Configuration\Rules\Rule; use Kununu\ArchitectureSniffer\Configuration\SubLayer; -use InvalidArgumentException; use PHPat\Test\Builder\Rule as PHPatRule; final class ArchitectureSniffer @@ -31,7 +32,11 @@ public function testArchitecture(): iterable } /** - * @throws \JsonException + * @param array $architectureDefinition + * + * @throws JsonException + * + * @return Layer[] */ private function validateArchitectureDefinition(array $architectureDefinition): array { @@ -46,5 +51,4 @@ private function validateArchitectureDefinition(array $architectureDefinition): return $layers; } - } diff --git a/Kununu/ArchitectureSniffer/Configuration/Layer.php b/Kununu/ArchitectureSniffer/Configuration/Layer.php index a3af614..fda7a06 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Layer.php +++ b/Kununu/ArchitectureSniffer/Configuration/Layer.php @@ -12,6 +12,9 @@ { public const string KEY = 'layer'; + /** + * @param SubLayer[] $subLayers + */ public function __construct( public string $name, public Selectable $selector, @@ -20,6 +23,8 @@ public function __construct( } /** + * @param array $data + * * @throws JsonException * @throws Exception */ diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 7824a69..690bc02 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -14,7 +14,7 @@ { public const string KEY = 'final'; - public function __construct(public ?Selectable $selector) + public function __construct(public Selectable $selector) { } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index aed113d..29e2437 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -21,6 +21,8 @@ public function __construct( } /** + * @param array $data + * * @throws JsonException */ public static function fromArray(Selectable $selector, array $data): self diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index 5132a98..db76f58 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -16,6 +16,9 @@ { public const string KEY = 'implements'; + /** + * @param InterfaceClassSelector[] $interfaces + */ public function __construct( public Selectable $selector, public array $interfaces, @@ -23,6 +26,8 @@ public function __construct( } /** + * @param array $data + * * @throws JsonException */ public static function fromArray(Selectable $selector, array $data): self diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php index eaffd70..082c237 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php @@ -13,6 +13,9 @@ { public const string KEY = 'dependency-whitelist'; + /** + * @param Selectable[] $dependencyWhitelist + */ public function __construct( public Selectable $selector, public array $dependencyWhitelist, @@ -20,6 +23,8 @@ public function __construct( } /** + * @param array $data + * * @throws JsonException */ public static function fromArray(Selectable $selector, array $data): self diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php index 88db318..3fbac59 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Selector; +use InvalidArgumentException; use PHPat\Selector\Selector; use PHPat\Selector\SelectorInterface; @@ -14,15 +15,19 @@ public function __construct( public string $name, - public string $namespace, + public string $class, ) { } public function getPHPatSelector(): SelectorInterface { - $namespace = $this->makeRegex($this->namespace); + $class = $this->makeRegex($this->class); - return Selector::classname($namespace, $namespace !== $this->namespace); + if (empty($class)) { + throw new InvalidArgumentException('Class definition should not be an empty string.'); + } + + return Selector::classname($class, $class !== $this->class); } public function getName(): string diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index 3802496..f06d0b4 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Selector; +use InvalidArgumentException; use PHPat\Selector\Selector; use PHPat\Selector\SelectorInterface; @@ -14,16 +15,20 @@ public function __construct( public string $name, - public string $namespace, + public string $interface, ) { } public function getPHPatSelector(): SelectorInterface { - $namespace = $this->makeRegex($this->namespace); + $interface = $this->makeRegex($this->interface); + + if (empty($interface)) { + throw new InvalidArgumentException('Interface definition should not be an empty string.'); + } return Selector::AllOf( - Selector::classname($namespace, $namespace !== $this->namespace), + Selector::classname($interface, $interface !== $this->interface), Selector::isInterface(), ); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index e08f10b..8e7e3f3 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -3,6 +3,7 @@ namespace Kununu\ArchitectureSniffer\Configuration\Selector; +use InvalidArgumentException; use PHPat\Selector\Selector; use PHPat\Selector\SelectorInterface; @@ -22,6 +23,10 @@ public function getPHPatSelector(): SelectorInterface { $namespace = $this->makeRegex($this->namespace); + if (empty($namespace)) { + throw new InvalidArgumentException('Namespace definition should not be an empty string.'); + } + return Selector::inNamespace($namespace, $namespace !== $this->namespace); } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selectors.php b/Kununu/ArchitectureSniffer/Configuration/Selectors.php index d66a043..1d3b2e2 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selectors.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selectors.php @@ -16,6 +16,9 @@ enum Selectors: string case InterfaceSelector = InterfaceClassSelector::KEY; case NamespaceSelector = NamespaceSelector::KEY; + /** + * @return string[] + */ public static function getValidTypes(): array { return [ @@ -26,13 +29,15 @@ public static function getValidTypes(): array } /** + * @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($type, $data[$nameKey ?? $type], $data[$type]); + return self::createSelector(self::from($type), $data[$nameKey ?? $type], $data[$type]); } } @@ -40,12 +45,12 @@ 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(string $type, string $name, string $selection): Selectable + private static function createSelector(self $type, string $name, string $selection): Selectable { return match ($type) { - self::ClassSelector->value => new ClassSelector($name, $selection), - self::InterfaceSelector->value => new InterfaceClassSelector($name, $selection), - self::NamespaceSelector->value => new NamespaceSelector($name, $selection), + self::ClassSelector => new ClassSelector($name, $selection), + self::InterfaceSelector => new InterfaceClassSelector($name, $selection), + self::NamespaceSelector => new NamespaceSelector($name, $selection), }; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/SubLayer.php b/Kununu/ArchitectureSniffer/Configuration/SubLayer.php index ec425ad..015b22b 100644 --- a/Kununu/ArchitectureSniffer/Configuration/SubLayer.php +++ b/Kununu/ArchitectureSniffer/Configuration/SubLayer.php @@ -11,6 +11,7 @@ use Kununu\ArchitectureSniffer\Configuration\Rules\MustImplement; use Kununu\ArchitectureSniffer\Configuration\Rules\MustOnlyDependOnWhitelist; use Kununu\ArchitectureSniffer\Configuration\Rules\MustOnlyHaveOnePublicMethodNamed; +use Kununu\ArchitectureSniffer\Configuration\Rules\Rule; use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; final readonly class SubLayer @@ -18,6 +19,9 @@ public const string KEY = 'sub-layers'; public const string NAME_KEY = 'name'; + /** + * @param Rule[] $rules + */ public function __construct( public string $name, public Selectable $selector, @@ -26,6 +30,8 @@ public function __construct( } /** + * @param array $subLayer + * * @throws JsonException * @throws Exception */ diff --git a/Kununu/ArchitectureSniffer/DirectoryFinder.php b/Kununu/ArchitectureSniffer/DirectoryFinder.php index 19a5e97..85aeef2 100644 --- a/Kununu/ArchitectureSniffer/DirectoryFinder.php +++ b/Kununu/ArchitectureSniffer/DirectoryFinder.php @@ -4,12 +4,15 @@ namespace Kununu\ArchitectureSniffer; use InvalidArgumentException; -use RectorPrefix202507\Symfony\Component\Yaml\Yaml; +use Symfony\Component\Yaml\Yaml; final readonly class DirectoryFinder { private const string ARCHITECTURE_DEFINITION_FILE = '/arch_definition.yaml'; + /** + * @return array + */ public static function getArchitectureDefinition(): array { $filePath = self::getArchitectureDefinitionFile(); diff --git a/Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php b/Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php index 20eb333..55d2574 100644 --- a/Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php +++ b/Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php @@ -7,11 +7,19 @@ use PhpParser\Node\Stmt\Namespace_; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleError; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; +/** + * @implements Rule + */ final class FollowsFolderStructureRule implements Rule { + /** + * @param array $architectureLayers + * @param array $deprecatedLayers + */ public function __construct( private array $architectureLayers = [], private array $deprecatedLayers = [], @@ -34,13 +42,23 @@ public function getNodeType(): string /** * @throws ShouldNotHappenException + * + * @return RuleError[] */ public function processNode(Node $node, Scope $scope): array { $directories = array_merge($this->architectureLayers, $this->deprecatedLayers); $basePath = DirectoryFinder::getProjectDirectory() . '/src'; - $actualDirectories = array_filter(glob($basePath . '/*'), 'is_dir'); + $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 diff --git a/composer.json b/composer.json index c7968ab..b32df62 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "composer/composer": "^2.8", "friendsofphp/php-cs-fixer": "^3.75", "rector/rector": "^2.0", - "squizlabs/php_codesniffer": "^3.10" + "squizlabs/php_codesniffer": "^3.10", + "symfony/yaml": "^6.4" }, "require-dev": { "kununu/scripts": "^5.1", From 9239723bdf067d243dcdfd56bbb4c228e6e92016 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 12 Aug 2025 11:20:09 +0200 Subject: [PATCH 15/30] fixing phpcbf --- .../Configuration/Selector/ClassSelector.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php index 3fbac59..d03c1b9 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -13,10 +13,8 @@ public const string KEY = 'class'; - public function __construct( - public string $name, - public string $class, - ) { + public function __construct(public string $name, public string $class) + { } public function getPHPatSelector(): SelectorInterface From fce637cd7ea9cc3e6fd61b67d2a649f1e2adccb9 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 12 Aug 2025 11:51:35 +0200 Subject: [PATCH 16/30] fixing qodana error --- Kununu/ArchitectureSniffer/ArchitectureSniffer.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php index 3478201..1b197b6 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -6,24 +6,21 @@ use InvalidArgumentException; use JsonException; use Kununu\ArchitectureSniffer\Configuration\Layer; -use Kununu\ArchitectureSniffer\Configuration\Rules\Rule; -use Kununu\ArchitectureSniffer\Configuration\SubLayer; use PHPat\Test\Builder\Rule as PHPatRule; final class ArchitectureSniffer { /** + * @throws JsonException + * * @return iterable */ public function testArchitecture(): iterable { $archDefinition = DirectoryFinder::getArchitectureDefinition(); $layers = $this->validateArchitectureDefinition($archDefinition); - /** @var Layer $layer */ foreach ($layers as $layer) { - /** @var SubLayer $subLayer */ foreach ($layer->subLayers as $subLayer) { - /** @var Rule $rule */ foreach ($subLayer->rules as $rule) { yield $rule->getPHPatRule(); } From 7de02eadba6aa7db228f4798cf3d92ef73890db0 Mon Sep 17 00:00:00 2001 From: Carlos Rodriguez Date: Thu, 21 Aug 2025 16:28:25 +0200 Subject: [PATCH 17/30] Architecture Sniffer V2 (#16) --- .../ArchitectureSniffer.php | 105 ++++-- .../Configuration/ArchitectureLibrary.php | 121 +++++++ .../Configuration/Group.php | 98 ++++++ .../Configuration/Layer.php | 49 --- .../Configuration/Rules/AbstractRule.php | 97 +++++ .../Configuration/Rules/MustBeFinal.php | 48 +-- .../Configuration/Rules/MustExtend.php | 65 ++-- .../Configuration/Rules/MustImplement.php | 81 ++--- .../Configuration/Rules/MustNotDependOn.php | 31 ++ .../Configuration/Rules/MustOnlyDependOn.php | 33 ++ .../Rules/MustOnlyDependOnWhitelist.php | 60 ---- .../MustOnlyHaveOnePublicMethodNamed.php | 39 +-- .../Configuration/Rules/Rule.php | 9 - .../Configuration/Selector/ClassSelector.php | 9 +- .../Selector/InterfaceClassSelector.php | 13 +- .../Selector/NamespaceSelector.php | 13 +- .../Configuration/Selector/RegexTrait.php | 10 +- .../Configuration/Selector/Selectable.php | 2 - .../Configuration/Selectors.php | 56 --- .../Configuration/SubLayer.php | 74 ---- .../ArchitectureSniffer/DirectoryFinder.php | 40 --- .../FollowsFolderStructureRule.php | 84 ----- .../Helper/GroupFlattener.php | 90 +++++ .../Helper/ProjectPathResolver.php | 19 + .../Helper/RuleBuilder.php | 60 ++++ .../Helper/SelectorBuilder.php | 21 ++ .../Helper/TypeChecker.php | 51 +++ Kununu/ArchitectureSniffer/README.md | 330 +++++++++++++----- README.md | 28 ++ 29 files changed, 1091 insertions(+), 645 deletions(-) create mode 100644 Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Group.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Layer.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOnWhitelist.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/Selectors.php delete mode 100644 Kununu/ArchitectureSniffer/Configuration/SubLayer.php delete mode 100644 Kununu/ArchitectureSniffer/DirectoryFinder.php delete mode 100644 Kununu/ArchitectureSniffer/FollowsFolderStructureRule.php create mode 100644 Kununu/ArchitectureSniffer/Helper/GroupFlattener.php create mode 100644 Kununu/ArchitectureSniffer/Helper/ProjectPathResolver.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 1b197b6..abe8d24 100644 --- a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -4,48 +4,97 @@ namespace Kununu\ArchitectureSniffer; use InvalidArgumentException; -use JsonException; -use Kununu\ArchitectureSniffer\Configuration\Layer; +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; final class ArchitectureSniffer { + private const string ARCHITECTURE_FILENAME = 'architecture.yaml'; + public const string ARCHITECTURE_KEY = 'architecture'; + /** - * @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(); - } - } + /** @var array $data */ + $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.' + ); } - } - /** - * @param array $architectureDefinition - * - * @throws JsonException - * - * @return Layer[] - */ - private function validateArchitectureDefinition(array $architectureDefinition): array - { - if (!array_key_exists('architecture', $architectureDefinition)) { - throw new InvalidArgumentException('Invalid architecture definition, missing architecture key'); + $architecture = $data['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, + 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.' + ); } - $layers = []; - foreach ($architectureDefinition['architecture'] as $layer) { - $layers[] = Layer::fromArray($layer); + // 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\\') + ) + ); - return $layers; + 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 (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 new file mode 100644 index 0000000..39cc43b --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -0,0 +1,121 @@ + */ + private array $groups = []; + + /** + * @param array $groups + */ + public function __construct(array $groups) + { + GroupFlattener::$groups = $groups; + + foreach ($groups as $groupName => $attributes) { + if (!TypeChecker::isArrayOfStrings($attributes[Group::INCLUDES_KEY])) { + throw new InvalidArgumentException( + "Group '$groupName' includes must be an array of strings." + ); + } + + $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 + ); + + $this->groups[$groupName] = Group::buildFrom( + groupName: $groupName, + flattenedIncludes: $flattenedIncludes, + targetAttributes: $attributes, + flattenedExcludes: $flattenedExcludes, + ); + } + } + + public function getGroupBy(string $groupName): Group + { + if (!array_key_exists($groupName, $this->groups)) { + throw new InvalidArgumentException("Group '$groupName' does not exist."); + } + + return $this->groups[$groupName]; + } + + /** + * @param string[] $potentialGroups + * + * @return string[] + */ + private function resolvePotentialGroups(array $potentialGroups): array + { + $groupsIncludes = []; + foreach ($potentialGroups as $potentialGroup) { + if (array_key_exists($potentialGroup, $this->groups)) { + foreach ($this->getGroupBy($potentialGroup)->flattenedIncludes as $fqcn) { + $groupsIncludes[] = $fqcn; + } + } else { + $groupsIncludes[] = $potentialGroup; + } + } + + return $groupsIncludes; + } + + /** + * @param string[] $targets + * + * @return string[] + */ + public function resolveTargets(Group $group, array $targets, bool $dependsOnRule = false): array + { + $resolvedTargets = []; + if ($dependsOnRule) { + $resolvedTargets = $this->resolvePotentialGroups($group->flattenedIncludes); + + if ($group->extends !== null) { + $resolvedTargets = array_merge($resolvedTargets, $this->resolvePotentialGroups([$group->extends])); + } + + if ($group->implements !== null) { + $resolvedTargets = array_merge($resolvedTargets, $this->resolvePotentialGroups($group->implements)); + } + } + + return array_unique(array_merge($this->resolvePotentialGroups($targets), $resolvedTargets)); + } + + /** + * @param string[] $unresolvedTargets + * @param string[] $targets + * + * @return string[] + */ + public function findTargetExcludes(array $unresolvedTargets, array $targets): array + { + $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(array_diff($targetExcludes, $targets)); + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php new file mode 100644 index 0000000..25a3783 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -0,0 +1,98 @@ + $targetAttributes + * @param string[]|null $flattenedExcludes + */ + public static function buildFrom( + string $groupName, + array $flattenedIncludes, + array $targetAttributes, + ?array $flattenedExcludes, + ): self { + $mustOnlyHaveOnePublicMethodName = $targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; + + 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($mustOnlyHaveOnePublicMethodName) ? + $mustOnlyHaveOnePublicMethodName : null, + ); + } + + 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 + { + return $this->mustOnlyHaveOnePublicMethodName !== null && $this->mustOnlyHaveOnePublicMethodName !== ''; + } +} 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/Rules/AbstractRule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php new file mode 100644 index 0000000..8ea2da4 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/AbstractRule.php @@ -0,0 +1,97 @@ + + */ + public static function getPHPSelectors(array $selectors): array + { + $result = []; + foreach ($selectors as $selector) { + $result[] = SelectorBuilder::createSelectable($selector)->getPHPatSelector(); + } + + return $result; + } + + /** + * @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( + Group $group, + string $specificRule, + string $because = '', + ?array $ruleParams = [], + ?array $targets = null, + ?array $targetExcludes = null, + array $extraTargetSelectors = [], + array $extraExcludeSelectors = [], + ): Rule { + $rule = new RelationRule(); + + $rule->subjects = self::getPHPSelectors($group->flattenedIncludes); + + $excludes = $extraExcludeSelectors; + if ($group->flattenedExcludes !== null) { + $excludes = array_merge( + self::getPHPSelectors($group->flattenedExcludes), + $extraExcludeSelectors + ); + } + $rule->subjectExcludes = $excludes; + + $rule->assertion = $specificRule; + if ($ruleParams !== null) { + $rule->params = $ruleParams; + } + + if ($targets !== null) { + $targetSelectors = self::getPHPSelectors($targets); + $rule->targets = array_merge($targetSelectors, $extraTargetSelectors); + + if ($targetExcludes !== null) { + $rule->targetExcludes = self::getPHPSelectors($targetExcludes); + } + } + + if ($because) { + $rule->tips = [$because]; + } + + 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 690bc02..63cf69e 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -4,37 +4,39 @@ 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\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 implements Rule +final readonly class MustBeFinal extends AbstractRule { - public const string KEY = 'final'; + public static function createRule( + Group $group, + ArchitectureLibrary $library, + ): Rule { + self::checkIfClassSelectors($group->flattenedIncludes); - public function __construct(public Selectable $selector) - { + return self::buildDependencyRule( + group: $group, + specificRule: ShouldBeFinal::class, + because: "$group->name must be final.", + extraExcludeSelectors: [Selector::isInterface()] + ); } - public static function fromArray(Selectable $selector): self + /** + * @param string[] $selectors + */ + private static function checkIfClassSelectors(array $selectors): void { - if ($selector instanceof InterfaceClassSelector) { - throw new InvalidArgumentException( - 'The class must not be an interface.' - ); + foreach ($selectors as $selector) { + if (SelectorBuilder::createSelectable($selector) instanceof InterfaceClassSelector) { + throw new InvalidArgumentException("$selector must be a class selector for rule MustBeFinal."); + } } - - return new self($selector); - } - - public function getPHPatRule(): PHPatRule - { - return PHPat::rule() - ->classes($this->selector->getPHPatSelector()) - ->excluding(Selector::isInterface()) - ->shouldBeFinal() - ->because("{$this->selector->getName()} must be final."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php index 29e2437..bacdde8 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -4,48 +4,47 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; use InvalidArgumentException; -use JsonException; +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\Selectors; -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 implements Rule +final readonly class MustExtend extends AbstractRule { - public const string KEY = 'extends'; + public static function createRule( + Group $group, + ArchitectureLibrary $library, + ): Rule { + if ($group->extends === null) { + throw self::getInvalidCallException(self::class, $group->name, 'extends'); + } + + self::checkIfNotInterfaceSelectors($group->flattenedIncludes); - public function __construct( - public Selectable $selector, - public Selectable $parent, - ) { + $targets = $library->resolveTargets($group, [$group->extends]); + + return self::buildDependencyRule( + group: $group, + specificRule: ShouldExtend::class, + because: "$group->name should extend class.", + targets: $targets, + targetExcludes: $library->findTargetExcludes([$group->extends], $targets), + ); } /** - * @param array $data - * - * @throws JsonException + * @param string[] $selectors */ - public static function fromArray(Selectable $selector, array $data): self + private static function checkIfNotInterfaceSelectors(array $selectors): void { - $parent = Selectors::findSelector($data); - - if ($parent instanceof InterfaceClassSelector) { - throw new InvalidArgumentException( - 'The parent class must not be an interface.' - ); + foreach ($selectors as $selector) { + if (SelectorBuilder::createSelectable($selector) instanceof InterfaceClassSelector) { + throw new InvalidArgumentException( + "$selector cannot be used in the MustExtend rule, as it is an interface." + ); + } } - - return new self($selector, $parent); - } - - public function getPHPatRule(): \PHPat\Test\Builder\Rule - { - return PHPat::rule() - ->classes($this->selector->getPHPatSelector()) - ->shouldExtend() - ->classes( - $this->parent->getPHPatSelector() - ) - ->because("{$this->selector->getName()} should extend {$this->parent->getName()}."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php index db76f58..97366c6 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -4,67 +4,50 @@ 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 Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; +use Kununu\ArchitectureSniffer\Configuration\Group; +use Kununu\ArchitectureSniffer\Configuration\Selector\ClassSelector; +use Kununu\ArchitectureSniffer\Configuration\Selector\NamespaceSelector; +use Kununu\ArchitectureSniffer\Helper\SelectorBuilder; +use PHPat\Rule\Assertion\Relation\ShouldImplement\ShouldImplement; use PHPat\Selector\Selector; -use PHPat\Selector\SelectorInterface; -use PHPat\Test\PHPat; +use PHPat\Test\Builder\Rule; -final readonly class MustImplement implements Rule +final readonly class MustImplement extends AbstractRule { - public const string KEY = 'implements'; + public static function createRule( + Group $group, + ArchitectureLibrary $library, + ): Rule { + if ($group->implements === null) { + throw self::getInvalidCallException(self::class, $group->name, 'implements'); + } - /** - * @param InterfaceClassSelector[] $interfaces - */ - public function __construct( - public Selectable $selector, - public array $interfaces, - ) { + $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 array $data - * - * @throws JsonException + * @param string[] $selectors */ - public static function fromArray(Selectable $selector, array $data): self + private static function checkIfInterfaceSelectors(iterable $selectors): void { - $interfaces = []; - foreach ($data as $interface) { - $interfaceSelector = Selectors::findSelector($interface); - if (!$interfaceSelector instanceof InterfaceClassSelector) { + foreach ($selectors as $selector) { + if (SelectorBuilder::createSelectable($selector) instanceof ClassSelector + || SelectorBuilder::createSelectable($selector) instanceof NamespaceSelector) { throw new InvalidArgumentException( - "The {$interfaceSelector->getName()} must be declared as interface." + "$selector cannot be used in the MustImplement rule, as it is not an interface." ); } - $interfaces[] = $interfaceSelector; } - - return new self($selector, $interfaces); - } - - public function getPHPatRule(): \PHPat\Test\Builder\Rule - { - $interfacesString = implode(', ', array_map( - static fn(Selectable $interface): string => $interface->getName(), - $this->interfaces - )); - - return PHPat::rule() - ->classes( - $this->selector->getPHPatSelector(), - ) - ->excluding(Selector::isInterface()) - ->shouldImplement() - ->classes( - ...array_map( - static fn(Selectable $interface): SelectorInterface => $interface->getPHPatSelector(), - $this->interfaces - ) - ) - ->because("{$this->selector->getName()} must implement $interfacesString."); } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php new file mode 100644 index 0000000..827b3bf --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php @@ -0,0 +1,31 @@ +mustNotDependOn === null) { + throw self::getInvalidCallException(self::class, $group->name, 'mustNotDependOn'); + } + + $targets = $library->resolveTargets($group, $group->mustNotDependOn); + + return self::buildDependencyRule( + 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 new file mode 100644 index 0000000..4504160 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyDependOn.php @@ -0,0 +1,33 @@ +dependsOn === null) { + throw self::getInvalidCallException(self::class, $group->name, 'dependsOn'); + } + + $targets = $library->resolveTargets($group, $group->dependsOn, true); + + return self::buildDependencyRule( + 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/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..a7be694 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -3,29 +3,26 @@ namespace Kununu\ArchitectureSniffer\Configuration\Rules; -use Kununu\ArchitectureSniffer\Configuration\Selector\Selectable; -use PHPat\Test\PHPat; +use Kununu\ArchitectureSniffer\Configuration\ArchitectureLibrary; +use Kununu\ArchitectureSniffer\Configuration\Group; +use PHPat\Rule\Assertion\Declaration\ShouldHaveOnlyOnePublicMethodNamed\ShouldHaveOnlyOnePublicMethodNamed; +use PHPat\Test\Builder\Rule; -final readonly class MustOnlyHaveOnePublicMethodNamed implements Rule +final readonly class MustOnlyHaveOnePublicMethodNamed extends AbstractRule { - public const string KEY = 'only-one-public-method-named'; + public static function createRule( + Group $group, + ArchitectureLibrary $library, + ): Rule { + if ($group->mustOnlyHaveOnePublicMethodName === null) { + throw self::getInvalidCallException(self::class, $group->name, 'mustOnlyHaveOnePublicMethodName'); + } - public function __construct( - public Selectable $selector, - public string $functionName, - ) { - } - - public static function fromArray(Selectable $base, string $functionName): self - { - return new self($base, $functionName); - } - - public function getPHPatRule(): \PHPat\Test\Builder\Rule - { - return PHPat::rule() - ->classes($this->selector->getPHPatSelector()) - ->shouldHaveOnlyOnePublicMethodNamed($this->functionName) - ->because("{$this->selector->getName()} should only have one public method named $this->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/Configuration/Rules/Rule.php b/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php deleted file mode 100644 index 994b15e..0000000 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/Rule.php +++ /dev/null @@ -1,9 +0,0 @@ -class); } - - public function getName(): string - { - return $this->name; - } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php index f06d0b4..ead2092 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -11,12 +11,8 @@ { use RegexTrait; - public const string KEY = 'interface'; - - public function __construct( - public string $name, - public string $interface, - ) { + public function __construct(public string $interface) + { } public function getPHPatSelector(): SelectorInterface @@ -32,9 +28,4 @@ public function getPHPatSelector(): SelectorInterface Selector::isInterface(), ); } - - public function getName(): string - { - return $this->name; - } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php index 8e7e3f3..6be22a9 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -11,12 +11,8 @@ { use RegexTrait; - public const string KEY = 'namespace'; - - public function __construct( - public string $name, - public string $namespace, - ) { + public function __construct(public string $namespace) + { } public function getPHPatSelector(): SelectorInterface @@ -29,9 +25,4 @@ public function getPHPatSelector(): SelectorInterface return Selector::inNamespace($namespace, $namespace !== $this->namespace); } - - public function getName(): string - { - return $this->name; - } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php index 0e2f5bb..7a4cdcd 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php @@ -5,14 +5,22 @@ 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, '\\')) { + $path = substr($path, 1); + } + $path = str_replace('\\', '\\\\', $path); return '/' . str_replace('*', '.+', $path) . '/'; } + if ($file && !str_starts_with($path, '\\')) { + return "\\$path"; + } + return $path; } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php b/Kununu/ArchitectureSniffer/Configuration/Selector/Selectable.php index bbf6db0..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 getName(): string; } diff --git a/Kununu/ArchitectureSniffer/Configuration/Selectors.php b/Kununu/ArchitectureSniffer/Configuration/Selectors.php deleted file mode 100644 index 1d3b2e2..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[$nameKey ?? $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 $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), - }; - } -} diff --git a/Kununu/ArchitectureSniffer/Configuration/SubLayer.php b/Kununu/ArchitectureSniffer/Configuration/SubLayer.php deleted file mode 100644 index 015b22b..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), - MustOnlyDependOnWhitelist::KEY => $rules[] = MustOnlyDependOnWhitelist::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/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/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/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 @@ + + **/ + 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..6f5956e --- /dev/null +++ b/Kununu/ArchitectureSniffer/Helper/TypeChecker.php @@ -0,0 +1,51 @@ += 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 ``` -2. Configure phpstan.neon + +### Minimal Configuration + +Create an `architecture.yaml` in your `/services` directory: + +```yaml +architecture: + $controllers: + includes: + - "App\\Controller\\*Controller" + depends_on: + - "$services" + $services: + includes: + - "App\\Service\\*Service" +``` + +## 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] +``` - - class: Kununu\ArchitectureSniffer\ArchitectureSniffer - tags: - - phpat.test +Run analysis: + +```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" + $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" + $baseControllers: + includes: + - "App\\Controller\\Base\\*BaseController" + $services: + final: false + implements: + - "App\\Service\\ServiceInterface" + includes: + - "App\\Service\\*Service" + - "$models" + depends_on: + - "$models" + $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 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. + - 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. + - 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. + +- **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` + - **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 | +|----------------------------------|----------|-----------|-----------------------------------------------------------------------------|---------------------------------------------------------------------| +| includes | Yes | array | Patterns or group names for group membership | Group membership | +| 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 | +| 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. + +### 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 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:** + - 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 + +- 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 + +- 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" + $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" + $models: + includes: + - "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 -``` -This will clear the cache and run the tests. +## Troubleshooting & FAQ + +- 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 + +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) + -You can run the tests in your directory or in the container. diff --git a/README.md b/README.md index c19bdc8..421565d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,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 @@ -62,3 +89,4 @@ composer require --dev kununu/code-tools --no-plugins - [Rector](docs/Rector/README.md) instructions. - [bin/code-tools](docs/CodeTools/README.md) instructions. - [bin/php-in-k8s](docs/PhpInK8s/README.md) instructions. +- [Architecture Sniffer & PHPAT](docs/ArchitectureSniffer/README.md) instructions. From a3db5b063422c9f09dd8a5c2427995785c96d358 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 21 Aug 2025 16:44:05 +0200 Subject: [PATCH 18/30] empty-commit From 45a240ed815064849b52f477bbf01bc83f3cc1bd Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 21 Aug 2025 16:46:31 +0200 Subject: [PATCH 19/30] allow kununu scripts --- composer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b32df62..e51d26f 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,9 @@ ], "config": { "sort-packages": true, - "process-timeout": 900 + "process-timeout": 900, + "allow-plugins": { + "kununu/scripts": true }, "extra": { "class": "Kununu\\CsFixer\\CsFixerPlugin" From 0bde1686e059497ea2ed31464f46fafec6a48a49 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 21 Aug 2025 16:47:42 +0200 Subject: [PATCH 20/30] fixing json --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index e51d26f..50a5565 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "process-timeout": 900, "allow-plugins": { "kununu/scripts": true + } }, "extra": { "class": "Kununu\\CsFixer\\CsFixerPlugin" From 14dfd893b8f9e7aadf5095efc60f998834e6936c Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Thu, 21 Aug 2025 16:55:07 +0200 Subject: [PATCH 21/30] removing some smaller part from the readme --- Kununu/ArchitectureSniffer/README.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index 4194cc8..42040dc 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -219,25 +219,6 @@ This logic applies to all properties that accept patterns or references, such as - Internally, `*` is converted to `.+` for regex matching. - Example: `App\Controller\*Controller` becomes `/App\\Controller\\.+Controller/`. -### Avoiding Accidental Matches - -```yaml -architecture: - $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" - $models: - includes: - - "App\Model\*Model" -``` - ## Troubleshooting & FAQ - Ensure `architecture.yaml` is in your project root. From ef266ee507672299e3bef5f50b55e6a3b616d51f Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 8 Sep 2025 14:44:28 +0200 Subject: [PATCH 22/30] adding architecture dist --- bin/code-tools | 4 ++++ dist/architecture.yaml.dist | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 dist/architecture.yaml.dist diff --git a/bin/code-tools b/bin/code-tools index 178cf37..5c0b259 100755 --- a/bin/code-tools +++ b/bin/code-tools @@ -33,6 +33,7 @@ Valid tool names: cs-fixer => copies dist/php-cs-fixer.php.dist code-sniffer => copies dist/phpcs.xml.dist rector => copies dist/rector.php.dist + arch-sniffer => copies dist/architecture.yaml.dist editorconfig => copies dist/.editorconfig.dist EOF } @@ -85,6 +86,9 @@ case "$tool_name" in editorconfig) copy_config_file "dist/.editorconfig.dist" ;; + arch-sniffer) + copy_config_file "dist/architecture.yaml.dist" + ;; *) echo "❌ Invalid tool name: '$tool_name'" show_help diff --git a/dist/architecture.yaml.dist b/dist/architecture.yaml.dist new file mode 100644 index 0000000..5b1f4ff --- /dev/null +++ b/dist/architecture.yaml.dist @@ -0,0 +1,46 @@ +architecture: + $controller: + includes: + - "*Controller" + dependsOn: + - "$cqrs" + - "Psr\\" + - "Symfony\\Component\\" + + $cqrs: + includes: + - "\\League\\Tactician\\CommandBus" + - "\\Kununu\\CQRS\\Bundle\\Bus\\CommandBusInterface" + - "$application_query" + - "$application_command" + + $cqrs_command: + includes: + - "App\\UseCase\\Command\\*\\*Handler" + dependsOn: + - "" + - "App\\Application\\*" + - "App\\Domain\\*" + implements: + - "Kununu\\CQRS\\Command\\CommandHandler" + mustOnlyHaveOnePublicMethodName: "handle" + + $cqrs_query: + includes: + - "App\\UseCase\\Query\\*\\*Handler" + dependsOn: + - "$repository" + - "App\\Application\\*" + - "App\\Domain\\*" + implements: + - "Kununu\\CQRS\\Query\\QueryHandler" + mustOnlyHaveOnePublicMethodName: "handle" + + $repository: + includes: + - "*RepositoryInterface" + dependsOn: + - "App\\Domain\\*" + - "Doctrine\\" + - "OpenSearch\\" + - "Elasticsearch\\" From 54e0f3ccd0ff379fd156d514f3dfd19ffdd974a4 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 8 Sep 2025 14:51:15 +0200 Subject: [PATCH 23/30] add to the services folder --- bin/code-tools | 2 +- dist/{ => services}/architecture.yaml.dist | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename dist/{ => services}/architecture.yaml.dist (100%) diff --git a/bin/code-tools b/bin/code-tools index 5c0b259..f21c1d3 100755 --- a/bin/code-tools +++ b/bin/code-tools @@ -87,7 +87,7 @@ case "$tool_name" in copy_config_file "dist/.editorconfig.dist" ;; arch-sniffer) - copy_config_file "dist/architecture.yaml.dist" + copy_config_file "dist/services/architecture.yaml.dist" ;; *) echo "❌ Invalid tool name: '$tool_name'" diff --git a/dist/architecture.yaml.dist b/dist/services/architecture.yaml.dist similarity index 100% rename from dist/architecture.yaml.dist rename to dist/services/architecture.yaml.dist From 125f2185e29a936a4cadba22b949e07fdd971180 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 8 Sep 2025 15:46:41 +0200 Subject: [PATCH 24/30] fixing warnings and adjusting setup --- .../Configuration/ArchitectureLibrary.php | 2 +- .../Configuration/Group.php | 12 ++-- Kununu/ArchitectureSniffer/README.md | 13 ++-- dist/services/architecture.yaml.dist | 63 ++++++++++++++----- 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php index 39cc43b..8d2bd5b 100644 --- a/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php +++ b/Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php @@ -29,7 +29,7 @@ public function __construct(array $groups) $flattenedIncludes = GroupFlattener::flattenIncludes($groupName, $attributes[Group::INCLUDES_KEY]); $flattenedExcludes = GroupFlattener::flattenExcludes( groupName: $groupName, - excludes: $attributes[Group::EXCLUDES_KEY] + excludes: isset($attributes[Group::EXCLUDES_KEY]) && TypeChecker::isArrayOfStrings($attributes[Group::EXCLUDES_KEY]) ? $attributes[Group::EXCLUDES_KEY] : [], flattenedIncludes: $flattenedIncludes diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 25a3783..c6c217a 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -47,20 +47,20 @@ public static function buildFrom( array $targetAttributes, ?array $flattenedExcludes, ): self { - $mustOnlyHaveOnePublicMethodName = $targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY]; + $mustOnlyHaveOnePublicMethodName = $targetAttributes[self::MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY] ?? null; return new self( name: $groupName, flattenedIncludes: $flattenedIncludes, flattenedExcludes: $flattenedExcludes, - dependsOn: $targetAttributes[self::DEPENDS_ON_KEY] ? + dependsOn: isset($targetAttributes[self::DEPENDS_ON_KEY]) ? TypeChecker::castArrayOfStrings($targetAttributes[self::DEPENDS_ON_KEY]) : null, - mustNotDependOn: $targetAttributes[self::MUST_NOT_DEPEND_ON_KEY] ? + mustNotDependOn: isset($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] ? + extends: isset($targetAttributes[self::EXTENDS_KEY]) && is_string($targetAttributes[self::EXTENDS_KEY]) ? $targetAttributes[self::EXTENDS_KEY] : null, + implements: isset($targetAttributes[self::IMPLEMENTS_KEY]) ? TypeChecker::castArrayOfStrings($targetAttributes[self::IMPLEMENTS_KEY]) : null, - isFinal: $targetAttributes[self::FINAL_KEY] === true, + isFinal: isset($targetAttributes[self::FINAL_KEY]) && $targetAttributes[self::FINAL_KEY] === true, mustOnlyHaveOnePublicMethodName: is_string($mustOnlyHaveOnePublicMethodName) ? $mustOnlyHaveOnePublicMethodName : null, ); diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index 42040dc..f5d7b19 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -47,12 +47,17 @@ Add to your `phpstan.neon`: ```neon includes: - - vendor/carlosas/phpat/extension.neon + - vendor/phpat/phpat/extension.neon + +parameters: + phpat: + - ignore_built_in_classes: false + - show_rule_names: true services: - - - class: PHPAT\PHPStan\PHPStanExtension - tags: [phpstan.extension] + - class: Kununu\ArchitectureSniffer\ArchitectureSniffer + tags: + - phpat.testnsion ``` Run analysis: diff --git a/dist/services/architecture.yaml.dist b/dist/services/architecture.yaml.dist index 5b1f4ff..ac1161b 100644 --- a/dist/services/architecture.yaml.dist +++ b/dist/services/architecture.yaml.dist @@ -1,26 +1,33 @@ architecture: $controller: includes: - - "*Controller" - dependsOn: + - "App\\Controller\\*\\*Controller" + depends_on: - "$cqrs" - - "Psr\\" + - "$common" + - "App\\Domain\\" + - "App\\Request\\" - "Symfony\\Component\\" $cqrs: includes: - - "\\League\\Tactician\\CommandBus" - - "\\Kununu\\CQRS\\Bundle\\Bus\\CommandBusInterface" - - "$application_query" - - "$application_command" + - "League\\Tactician\\CommandBus" + - "Kununu\\CQRS\\Bundle\\Bus\\CommandBusInterface" + - "Kununu\\CQRS\\Bundle\\Controller\\" + - "App\\UseCase\\Query\\" + - "App\\UseCase\\Command\\" $cqrs_command: includes: - "App\\UseCase\\Command\\*\\*Handler" - dependsOn: - - "" - - "App\\Application\\*" + depends_on: + - "$repository_command" + - "$common" + - "Kununu\\CQRS\\Command\\" + - "Kununu\\CQRS\\Event\\" + - "App\\UseCase\\*" - "App\\Domain\\*" + - "App\\Event\\*" implements: - "Kununu\\CQRS\\Command\\CommandHandler" mustOnlyHaveOnePublicMethodName: "handle" @@ -28,19 +35,43 @@ architecture: $cqrs_query: includes: - "App\\UseCase\\Query\\*\\*Handler" - dependsOn: - - "$repository" - - "App\\Application\\*" + depends_on: + - "$repository_query" + - "$common" + - "Kununu\\CQRS\\Query\\" + - "App\\UseCase\\*" - "App\\Domain\\*" implements: - "Kununu\\CQRS\\Query\\QueryHandler" mustOnlyHaveOnePublicMethodName: "handle" - $repository: + $repository_command: + includes: + - "App\\UseCase\\Command\\*\\RepositoryInterface" + depends_on: + - "App\\UseCase\\Command\\" + - "App\\Domain\\*" + - "$common" + - "Doctrine\\" + - "OpenSearch\\" + - "Elasticsearch\\" + + $repository_query: includes: - - "*RepositoryInterface" - dependsOn: + - "App\\UseCase\\Query\\*\\RepositoryInterface" + depends_on: + - "App\\UseCase\\Query\\" - "App\\Domain\\*" + - "$common" - "Doctrine\\" - "OpenSearch\\" - "Elasticsearch\\" + + $common: + includes: + - "Psr\\" + - "Ramsey\\Uuid\\" + - "Kununu\\Utilities\\" + - "Kununu\\OpenApi\\" + - "Kununu\\Monitoring\\" + - "Kununu\\ResponseSerializationBundle\\" From fd99ad8ef1cddba831da292ff0c570e6cfe59518 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 8 Sep 2025 15:50:06 +0200 Subject: [PATCH 25/30] fix cs sniffer --- Kununu/ArchitectureSniffer/Configuration/Group.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index c6c217a..1767ce7 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -57,7 +57,8 @@ public static function buildFrom( TypeChecker::castArrayOfStrings($targetAttributes[self::DEPENDS_ON_KEY]) : null, mustNotDependOn: isset($targetAttributes[self::MUST_NOT_DEPEND_ON_KEY]) ? TypeChecker::castArrayOfStrings($targetAttributes[self::MUST_NOT_DEPEND_ON_KEY]) : null, - extends: isset($targetAttributes[self::EXTENDS_KEY]) && is_string($targetAttributes[self::EXTENDS_KEY]) ? $targetAttributes[self::EXTENDS_KEY] : null, + extends: isset($targetAttributes[self::EXTENDS_KEY]) && is_string($targetAttributes[self::EXTENDS_KEY]) ? + $targetAttributes[self::EXTENDS_KEY] : null, implements: isset($targetAttributes[self::IMPLEMENTS_KEY]) ? TypeChecker::castArrayOfStrings($targetAttributes[self::IMPLEMENTS_KEY]) : null, isFinal: isset($targetAttributes[self::FINAL_KEY]) && $targetAttributes[self::FINAL_KEY] === true, From e608cc45791b156df5c82a12dff62dcbeb032546 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 8 Sep 2025 15:57:16 +0200 Subject: [PATCH 26/30] adding must be readonly rule --- .../Configuration/Group.php | 8 ++++++ .../Configuration/Rules/MustBeFinal.php | 17 ------------- .../Configuration/Rules/MustBeReadonly.php | 25 +++++++++++++++++++ .../Helper/RuleBuilder.php | 7 ++++++ 4 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustBeReadonly.php diff --git a/Kununu/ArchitectureSniffer/Configuration/Group.php b/Kununu/ArchitectureSniffer/Configuration/Group.php index 1767ce7..68e2fbe 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Group.php +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -11,6 +11,7 @@ public const string EXCLUDES_KEY = 'excludes'; public const string DEPENDS_ON_KEY = 'depends_on'; public const string FINAL_KEY = 'final'; + public const string READONLY_KEY = 'readonly'; 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'; @@ -32,6 +33,7 @@ public function __construct( public ?string $extends, public ?array $implements, public bool $isFinal, + public bool $isReadonly, public ?string $mustOnlyHaveOnePublicMethodName, ) { } @@ -62,6 +64,7 @@ public static function buildFrom( implements: isset($targetAttributes[self::IMPLEMENTS_KEY]) ? TypeChecker::castArrayOfStrings($targetAttributes[self::IMPLEMENTS_KEY]) : null, isFinal: isset($targetAttributes[self::FINAL_KEY]) && $targetAttributes[self::FINAL_KEY] === true, + isReadonly: isset($targetAttributes[self::READONLY_KEY]) && $targetAttributes[self::READONLY_KEY] === true, mustOnlyHaveOnePublicMethodName: is_string($mustOnlyHaveOnePublicMethodName) ? $mustOnlyHaveOnePublicMethodName : null, ); @@ -72,6 +75,11 @@ public function shouldBeFinal(): bool return $this->isFinal; } + public function shouldBeReadonly(): bool + { + return $this->isReadonly; + } + public function shouldExtend(): bool { return $this->extends !== null; diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php index 63cf69e..0462e78 100644 --- a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -3,11 +3,8 @@ 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\Helper\SelectorBuilder; use PHPat\Rule\Assertion\Declaration\ShouldBeFinal\ShouldBeFinal; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule; @@ -18,8 +15,6 @@ public static function createRule( Group $group, ArchitectureLibrary $library, ): Rule { - self::checkIfClassSelectors($group->flattenedIncludes); - return self::buildDependencyRule( group: $group, specificRule: ShouldBeFinal::class, @@ -27,16 +22,4 @@ public static function createRule( extraExcludeSelectors: [Selector::isInterface()] ); } - - /** - * @param string[] $selectors - */ - private static function checkIfClassSelectors(array $selectors): void - { - foreach ($selectors as $selector) { - if (SelectorBuilder::createSelectable($selector) instanceof InterfaceClassSelector) { - throw new InvalidArgumentException("$selector must be a class selector for rule MustBeFinal."); - } - } - } } diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeReadonly.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeReadonly.php new file mode 100644 index 0000000..de35155 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeReadonly.php @@ -0,0 +1,25 @@ +name must be read only.", + extraExcludeSelectors: [Selector::isInterface()] + ); + } +} diff --git a/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php b/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php index 34b2348..980b79e 100644 --- a/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php +++ b/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php @@ -36,6 +36,13 @@ public static function getRules(Group $group, ArchitectureLibrary $library): ite ); } + if ($group->shouldBeReadonly()) { + yield Rules\MustBeReadonly::createRule( + $group, + $library + ); + } + if ($group->shouldDependOn()) { yield Rules\MustOnlyDependOn::createRule( $group, From 2ae2edb49c762396f3a845e0c88dd7af048d3d61 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 8 Sep 2025 16:11:09 +0200 Subject: [PATCH 27/30] delete dist for now --- bin/code-tools | 3 -- dist/services/architecture.yaml.dist | 77 ---------------------------- 2 files changed, 80 deletions(-) delete mode 100644 dist/services/architecture.yaml.dist diff --git a/bin/code-tools b/bin/code-tools index f21c1d3..f9d4532 100755 --- a/bin/code-tools +++ b/bin/code-tools @@ -86,9 +86,6 @@ case "$tool_name" in editorconfig) copy_config_file "dist/.editorconfig.dist" ;; - arch-sniffer) - copy_config_file "dist/services/architecture.yaml.dist" - ;; *) echo "❌ Invalid tool name: '$tool_name'" show_help diff --git a/dist/services/architecture.yaml.dist b/dist/services/architecture.yaml.dist deleted file mode 100644 index ac1161b..0000000 --- a/dist/services/architecture.yaml.dist +++ /dev/null @@ -1,77 +0,0 @@ -architecture: - $controller: - includes: - - "App\\Controller\\*\\*Controller" - depends_on: - - "$cqrs" - - "$common" - - "App\\Domain\\" - - "App\\Request\\" - - "Symfony\\Component\\" - - $cqrs: - includes: - - "League\\Tactician\\CommandBus" - - "Kununu\\CQRS\\Bundle\\Bus\\CommandBusInterface" - - "Kununu\\CQRS\\Bundle\\Controller\\" - - "App\\UseCase\\Query\\" - - "App\\UseCase\\Command\\" - - $cqrs_command: - includes: - - "App\\UseCase\\Command\\*\\*Handler" - depends_on: - - "$repository_command" - - "$common" - - "Kununu\\CQRS\\Command\\" - - "Kununu\\CQRS\\Event\\" - - "App\\UseCase\\*" - - "App\\Domain\\*" - - "App\\Event\\*" - implements: - - "Kununu\\CQRS\\Command\\CommandHandler" - mustOnlyHaveOnePublicMethodName: "handle" - - $cqrs_query: - includes: - - "App\\UseCase\\Query\\*\\*Handler" - depends_on: - - "$repository_query" - - "$common" - - "Kununu\\CQRS\\Query\\" - - "App\\UseCase\\*" - - "App\\Domain\\*" - implements: - - "Kununu\\CQRS\\Query\\QueryHandler" - mustOnlyHaveOnePublicMethodName: "handle" - - $repository_command: - includes: - - "App\\UseCase\\Command\\*\\RepositoryInterface" - depends_on: - - "App\\UseCase\\Command\\" - - "App\\Domain\\*" - - "$common" - - "Doctrine\\" - - "OpenSearch\\" - - "Elasticsearch\\" - - $repository_query: - includes: - - "App\\UseCase\\Query\\*\\RepositoryInterface" - depends_on: - - "App\\UseCase\\Query\\" - - "App\\Domain\\*" - - "$common" - - "Doctrine\\" - - "OpenSearch\\" - - "Elasticsearch\\" - - $common: - includes: - - "Psr\\" - - "Ramsey\\Uuid\\" - - "Kununu\\Utilities\\" - - "Kununu\\OpenApi\\" - - "Kununu\\Monitoring\\" - - "Kununu\\ResponseSerializationBundle\\" From 79cc4dbcd72ad84e4ec7f4774b16a5b710833ab6 Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Mon, 8 Sep 2025 16:16:58 +0200 Subject: [PATCH 28/30] remove the dist also from the code-tool list --- bin/code-tools | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/code-tools b/bin/code-tools index f9d4532..178cf37 100755 --- a/bin/code-tools +++ b/bin/code-tools @@ -33,7 +33,6 @@ Valid tool names: cs-fixer => copies dist/php-cs-fixer.php.dist code-sniffer => copies dist/phpcs.xml.dist rector => copies dist/rector.php.dist - arch-sniffer => copies dist/architecture.yaml.dist editorconfig => copies dist/.editorconfig.dist EOF } From eec74edc0e9c45d778eec1717d2f67f43bc83fef Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 9 Sep 2025 12:55:18 +0200 Subject: [PATCH 29/30] fixing readme --- Kununu/ArchitectureSniffer/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index f5d7b19..1a9d179 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -51,13 +51,13 @@ includes: parameters: phpat: - - ignore_built_in_classes: false - - show_rule_names: true + ignore_built_in_classes: false + show_rule_names: true services: - class: Kununu\ArchitectureSniffer\ArchitectureSniffer tags: - - phpat.testnsion + - phpat.test ``` Run analysis: From 4da89b4e8d40ab82cc2aa24c5d665444518d5f0c Mon Sep 17 00:00:00 2001 From: CarlosGRodriguezL Date: Tue, 9 Sep 2025 14:48:58 +0200 Subject: [PATCH 30/30] fixing only one public method named rule, by adding the only one public method to the test set --- .../Rules/MustOnlyHaveOnePublicMethod.php | 23 +++++++++++++++++++ .../Helper/RuleBuilder.php | 4 ++++ Kununu/ArchitectureSniffer/README.md | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethod.php diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethod.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethod.php new file mode 100644 index 0000000..8d5461a --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethod.php @@ -0,0 +1,23 @@ +name should only have one public method named $group->mustOnlyHaveOnePublicMethodName.", + ); + } +} diff --git a/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php b/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php index 980b79e..61ff661 100644 --- a/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php +++ b/Kununu/ArchitectureSniffer/Helper/RuleBuilder.php @@ -62,6 +62,10 @@ public static function getRules(Group $group, ArchitectureLibrary $library): ite $group, $library ); + yield Rules\MustOnlyHaveOnePublicMethod::createRule( + $group, + $library + ); } } } diff --git a/Kununu/ArchitectureSniffer/README.md b/Kununu/ArchitectureSniffer/README.md index 1a9d179..13a90d1 100644 --- a/Kununu/ArchitectureSniffer/README.md +++ b/Kununu/ArchitectureSniffer/README.md @@ -230,7 +230,7 @@ This logic applies to all properties that accept patterns or references, such as - 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 + `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).