diff --git a/Kununu/ArchitectureSniffer/ArchitectureSniffer.php b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php new file mode 100644 index 0000000..abe8d24 --- /dev/null +++ b/Kununu/ArchitectureSniffer/ArchitectureSniffer.php @@ -0,0 +1,100 @@ + + */ + public function testArchitecture(): iterable + { + /** @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.' + ); + } + + $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.' + ); + } + + // at least one group with a depends_on property with at least one fqcn or another qualified group + if (!array_filter( + $architecture, + static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) + && !empty($group[Group::DEPENDS_ON_KEY]) + )) { + throw new InvalidArgumentException( + 'At least one group must have a "dependsOn" property with at least one fqcn or ' + . 'another qualified group.' + ); + } + // groups with at least one include from a global namespace other than App\\, the depends_on properties must not be defined + $groupsWithIncludesFromGlobalNamespace = array_filter( + $architecture, + static fn(array $group) => !array_filter( + is_array($group[Group::INCLUDES_KEY] ?? null) ? $group[Group::INCLUDES_KEY] : [], + static fn($include) => str_starts_with($include, 'App\\') + ) + ); + + if ($groupsWithIncludesFromGlobalNamespace) { + if (array_filter( + $groupsWithIncludesFromGlobalNamespace, + static fn(array $group) => array_key_exists(Group::DEPENDS_ON_KEY, $group) + )) { + throw new InvalidArgumentException( + 'Groups with includes from a global namespace other than App\\ must not have a ' + . '"dependsOn" property defined.' + ); + } + } + + $library = new ArchitectureLibrary($architecture); + + foreach (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..8d2bd5b --- /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: isset($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..68e2fbe --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Group.php @@ -0,0 +1,107 @@ + $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] ?? null; + + return new self( + name: $groupName, + flattenedIncludes: $flattenedIncludes, + flattenedExcludes: $flattenedExcludes, + dependsOn: isset($targetAttributes[self::DEPENDS_ON_KEY]) ? + 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, + 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, + ); + } + + public function shouldBeFinal(): bool + { + return $this->isFinal; + } + + public function shouldBeReadonly(): bool + { + return $this->isReadonly; + } + + 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/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 new file mode 100644 index 0000000..0462e78 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustBeFinal.php @@ -0,0 +1,25 @@ +name must be final.", + extraExcludeSelectors: [Selector::isInterface()] + ); + } +} 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/Configuration/Rules/MustExtend.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php new file mode 100644 index 0000000..bacdde8 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustExtend.php @@ -0,0 +1,50 @@ +extends === null) { + throw self::getInvalidCallException(self::class, $group->name, 'extends'); + } + + self::checkIfNotInterfaceSelectors($group->flattenedIncludes); + + $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 string[] $selectors + */ + private static function checkIfNotInterfaceSelectors(array $selectors): void + { + 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." + ); + } + } + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php new file mode 100644 index 0000000..97366c6 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustImplement.php @@ -0,0 +1,53 @@ +implements === null) { + throw self::getInvalidCallException(self::class, $group->name, 'implements'); + } + + $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 string[] $selectors + */ + private static function checkIfInterfaceSelectors(iterable $selectors): void + { + foreach ($selectors as $selector) { + if (SelectorBuilder::createSelectable($selector) instanceof ClassSelector + || SelectorBuilder::createSelectable($selector) instanceof NamespaceSelector) { + throw new InvalidArgumentException( + "$selector cannot be used in the MustImplement rule, as it is not an interface." + ); + } + } + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustNotDependOn.php 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/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/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php new file mode 100644 index 0000000..a7be694 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Rules/MustOnlyHaveOnePublicMethodNamed.php @@ -0,0 +1,28 @@ +mustOnlyHaveOnePublicMethodName === null) { + throw self::getInvalidCallException(self::class, $group->name, 'mustOnlyHaveOnePublicMethodName'); + } + + 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/Selector/ClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php new file mode 100644 index 0000000..3171e20 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/ClassSelector.php @@ -0,0 +1,28 @@ +makeRegex($this->class); + + if (empty($class)) { + throw new InvalidArgumentException('Class definition should not be an empty string.'); + } + + return Selector::classname($class, $class !== $this->class); + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php new file mode 100644 index 0000000..ead2092 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/InterfaceClassSelector.php @@ -0,0 +1,31 @@ +makeRegex($this->interface); + + if (empty($interface)) { + throw new InvalidArgumentException('Interface definition should not be an empty string.'); + } + + return Selector::AllOf( + Selector::classname($interface, $interface !== $this->interface), + Selector::isInterface(), + ); + } +} diff --git a/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php new file mode 100644 index 0000000..6be22a9 --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/NamespaceSelector.php @@ -0,0 +1,28 @@ +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/Selector/RegexTrait.php b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php new file mode 100644 index 0000000..7a4cdcd --- /dev/null +++ b/Kununu/ArchitectureSniffer/Configuration/Selector/RegexTrait.php @@ -0,0 +1,26 @@ +> + */ + 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->shouldBeReadonly()) { + yield Rules\MustBeReadonly::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 + ); + yield Rules\MustOnlyHaveOnePublicMethod::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 kununu/code-tools +``` + +### 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 + +services: + - class: Kununu\ArchitectureSniffer\ArchitectureSniffer + tags: + - phpat.test +``` + +Run analysis: + +```bash +vendor/bin/phpstan analyse +``` + +### Standalone Usage + +Refer to [PHPAT documentation](https://github.com/carlosas/phpat) for standalone usage. + +## 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 + +```yaml +architecture: + $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" +``` + +### 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/`. + +## 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) + + 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. diff --git a/composer.json b/composer.json index e44a044..50a5565 100644 --- a/composer.json +++ b/composer.json @@ -10,14 +10,18 @@ } ], "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" + "squizlabs/php_codesniffer": "^3.10", + "symfony/yaml": "^6.4" }, "require-dev": { + "kununu/scripts": "^5.1", "phpunit/phpunit": "^11.5" }, "autoload": { @@ -36,7 +40,10 @@ ], "config": { "sort-packages": true, - "process-timeout": 900 + "process-timeout": 900, + "allow-plugins": { + "kununu/scripts": true + } }, "extra": { "class": "Kununu\\CsFixer\\CsFixerPlugin"