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 db770b7..90c60a0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,33 @@ ### `bin/php-in-k8s` - This is a helper script that allows you to run PHP commands inside a local Kubernetes pod without having to connect to it via a terminal manually. +### `Architecture Sniffer & PHPAT` +- **Architecture Sniffer** enforces architectural and dependency rules in your PHP codebase, helping you maintain a clean and consistent architecture. +- It is powered by [PHPAT](https://github.com/carlosas/phpat), a static analysis tool for PHP architecture testing. +- Architecture Sniffer uses a YAML configuration file (`architecture.yaml`) where you define your architectural groups and their allowed dependencies. Each group is a key under the `architecture` root, e.g.: + + ```yaml + architecture: + $controllers: + includes: + - "App\\Controller\\*Controller" + depends_on: + - "$services" + $services: + includes: + - "App\\Service\\*Service" + ``` +- To use Architecture Sniffer with PHPStan, add the extension to your `phpstan.neon`: + ```neon + includes: + - vendor/carlosas/phpat/extension.neon + services: + - + class: PHPAT\PHPStan\PHPStanExtension + tags: [phpstan.extension] + ``` +- For more details and advanced configuration, see [Kununu/ArchitectureSniffer/README.md](Kununu/ArchitectureSniffer/README.md). + ## Install ### Add custom private repositories to composer.json @@ -52,4 +79,5 @@ composer require --dev kununu/code-tools --no-plugins - [PHP_CodeSniffer](docs/CodeSniffer/README.md) instructions. - [Rector](docs/Rector/README.md) instructions. - [bin/code-tools](docs/CodeTools/README.md) instructions. -- [bin/php-in-k8s](docs/PhpInK8s/README.md) instructions. \ No newline at end of file +- [bin/php-in-k8s](docs/PhpInK8s/README.md) instructions. +- [Architecture Sniffer & PHPAT](docs/ArchitectureSniffer/README.md) instructions.