Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf45d0b
adding architectur test
CarlosGRodriguezL May 23, 2025
0368ad2
move phpat to require
CarlosGRodriguezL May 23, 2025
53c352d
fixing the FollowsFolderStrucure Rule
CarlosGRodriguezL May 23, 2025
f7f5893
improve by introducing directory finder and organizing files
CarlosGRodriguezL May 23, 2025
2412e50
register bin command
CarlosGRodriguezL May 23, 2025
09f94ca
remove unused yaml read
CarlosGRodriguezL May 23, 2025
7ca7105
remove unused yaml read
CarlosGRodriguezL May 23, 2025
6eb417c
fixing doc generator
CarlosGRodriguezL May 23, 2025
677b3d6
supporting deprecated
CarlosGRodriguezL May 23, 2025
4df6310
fixing quodana errors
CarlosGRodriguezL Aug 12, 2025
f2025d3
removing the doc generator and fixing quodana errors
CarlosGRodriguezL Aug 12, 2025
1b1eec8
fixing phpcbf
CarlosGRodriguezL Aug 12, 2025
37980f2
rename ArchitectureTest to *Sniffer
CarlosGRodriguezL Aug 12, 2025
b02a999
fixing phpstan errors
CarlosGRodriguezL Aug 12, 2025
9239723
fixing phpcbf
CarlosGRodriguezL Aug 12, 2025
fce637c
fixing qodana error
CarlosGRodriguezL Aug 12, 2025
7de02ea
Architecture Sniffer V2 (#16)
CarlosGRodriguezL Aug 21, 2025
a3db5b0
empty-commit
CarlosGRodriguezL Aug 21, 2025
45a240e
allow kununu scripts
CarlosGRodriguezL Aug 21, 2025
0bde168
fixing json
CarlosGRodriguezL Aug 21, 2025
14dfd89
removing some smaller part from the readme
CarlosGRodriguezL Aug 21, 2025
ef266ee
adding architecture dist
CarlosGRodriguezL Sep 8, 2025
54e0f3c
add to the services folder
CarlosGRodriguezL Sep 8, 2025
125f218
fixing warnings and adjusting setup
CarlosGRodriguezL Sep 8, 2025
fd99ad8
fix cs sniffer
CarlosGRodriguezL Sep 8, 2025
e608cc4
adding must be readonly rule
CarlosGRodriguezL Sep 8, 2025
2ae2edb
delete dist for now
CarlosGRodriguezL Sep 8, 2025
79cc4db
remove the dist also from the code-tool list
CarlosGRodriguezL Sep 8, 2025
eec74ed
fixing readme
CarlosGRodriguezL Sep 9, 2025
4da89b4
fixing only one public method named rule, by adding the only one publ…
CarlosGRodriguezL Sep 9, 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
100 changes: 100 additions & 0 deletions Kununu/ArchitectureSniffer/ArchitectureSniffer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);

namespace Kununu\ArchitectureSniffer;

use InvalidArgumentException;
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';

/**
* @return iterable<PHPatRule>
*/
public function testArchitecture(): iterable
{
/** @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.'
);
}

$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;
}
}
}
}
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: 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));
}
}
107 changes: 107 additions & 0 deletions Kununu/ArchitectureSniffer/Configuration/Group.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?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 READONLY_KEY = 'readonly';
public const string EXTENDS_KEY = 'extends';
public const string IMPLEMENTS_KEY = 'implements';
public const string MUST_ONLY_HAVE_ONE_PUBLIC_METHOD_NAMED_KEY = 'must_only_have_one_public_method_named';
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 bool $isReadonly,
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] ?? 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 !== '';
}
}
Loading
Loading