Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
570a2b2
refactor architecture sniffer
CarlosGRodriguezL Aug 13, 2025
0f1ca74
adding some advanced documentation
CarlosGRodriguezL Aug 13, 2025
758f8b4
fixing quodana
CarlosGRodriguezL Aug 14, 2025
f8ce7ca
fixing phpstan
CarlosGRodriguezL Aug 14, 2025
cf44bec
fix quodana
CarlosGRodriguezL Aug 14, 2025
fa02e4e
fixing the sniffer
CarlosGRodriguezL Aug 14, 2025
32294c9
refactor Singleton
CarlosGRodriguezL Aug 14, 2025
3c22c69
adjust documentation
CarlosGRodriguezL Aug 14, 2025
c9b5473
adding it to the code-tools readme
CarlosGRodriguezL Aug 14, 2025
e5069ec
fixing name validation after restructuring
CarlosGRodriguezL Aug 14, 2025
1c0f9dd
fixing Generator issues
CarlosGRodriguezL Aug 14, 2025
9530cc5
fixing rules
CarlosGRodriguezL Aug 14, 2025
ff3802e
fixing rules
CarlosGRodriguezL Aug 18, 2025
411bb9e
change Generator to array in interface
CarlosGRodriguezL Aug 18, 2025
6224fe6
extend depends on including implements and extends, plus adding exclu…
CarlosGRodriguezL Aug 18, 2025
4afcdcc
refactor all again
CarlosGRodriguezL Aug 19, 2025
61ff2a5
fixing rules
CarlosGRodriguezL Aug 20, 2025
16bdce2
fixing validation
CarlosGRodriguezL Aug 20, 2025
35cf7cf
fix check
CarlosGRodriguezL Aug 20, 2025
ced2a33
fixing Generator checks
CarlosGRodriguezL Aug 20, 2025
fe3f410
fixing last bits
CarlosGRodriguezL Aug 20, 2025
4308154
fix quodana errors
CarlosGRodriguezL Aug 20, 2025
c13dfa4
fixing cs fixer
CarlosGRodriguezL Aug 20, 2025
62e2c2a
fixing phpstan
CarlosGRodriguezL Aug 20, 2025
8e1f6d5
fix some stuff
CarlosGRodriguezL Aug 20, 2025
9f034cb
fix
CarlosGRodriguezL Aug 20, 2025
5308e50
fix
CarlosGRodriguezL Aug 20, 2025
6d84f47
fix
CarlosGRodriguezL Aug 20, 2025
4389230
fix
CarlosGRodriguezL Aug 20, 2025
477453e
fix
CarlosGRodriguezL Aug 20, 2025
f7c098d
fix phpstan
CarlosGRodriguezL Aug 20, 2025
e70c890
fixing phpstan and refactoring
CarlosGRodriguezL Aug 21, 2025
ef551b9
remove unused method
CarlosGRodriguezL Aug 21, 2025
4708179
fixing last bits
CarlosGRodriguezL Aug 21, 2025
6d5ea89
cs fix
CarlosGRodriguezL Aug 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 77 additions & 28 deletions Kununu/ArchitectureSniffer/ArchitectureSniffer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<PHPatRule>
*/
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<string, mixed> $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<string, mixed> $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;
}
}
}
}
121 changes: 121 additions & 0 deletions Kununu/ArchitectureSniffer/Configuration/ArchitectureLibrary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);

namespace Kununu\ArchitectureSniffer\Configuration;

use InvalidArgumentException;
use Kununu\ArchitectureSniffer\Helper\GroupFlattener;
use Kununu\ArchitectureSniffer\Helper\TypeChecker;

final class ArchitectureLibrary
{
/** @var array<string, Group> */
private array $groups = [];

/**
* @param array<string, mixed> $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));
}
}
98 changes: 98 additions & 0 deletions Kununu/ArchitectureSniffer/Configuration/Group.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);

namespace Kununu\ArchitectureSniffer\Configuration;

use Kununu\ArchitectureSniffer\Helper\TypeChecker;

final readonly class Group
{
public const string INCLUDES_KEY = 'includes';
public const string EXCLUDES_KEY = 'excludes';
public const string DEPENDS_ON_KEY = 'depends_on';
public const string FINAL_KEY = 'final';
public const string EXTENDS_KEY = 'extends';
public const string IMPLEMENTS_KEY = 'implements';
public const string MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY = 'must_only_have_one_public_method_named';
public const string MUST_NOT_DEPEND_ON_KEY = 'must_not_depend_on';

/**
* @param string[] $flattenedIncludes
* @param string[]|null $flattenedExcludes
* @param string[]|null $implements
* @param string[]|null $mustNotDependOn
* @param string[]|null $dependsOn
*/
public function __construct(
public string $name,
public array $flattenedIncludes,
public ?array $flattenedExcludes,
public ?array $dependsOn,
public ?array $mustNotDependOn,
public ?string $extends,
public ?array $implements,
public bool $isFinal,
public ?string $mustOnlyHaveOnePublicMethodName,
) {
}

/**
* @param string[] $flattenedIncludes
* @param array<string, string|bool|string[]> $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 !== '';
}
}
49 changes: 0 additions & 49 deletions Kununu/ArchitectureSniffer/Configuration/Layer.php

This file was deleted.

Loading
Loading