Skip to content

Commit 51ba7c5

Browse files
committed
Add exact namespace matching rules (#406)
This commit adds two new expression rules to support exact namespace matching, addressing the feature request in issue #406: - ResideInOneOfTheseNamespacesExactly: Validates that classes reside in one of the specified namespaces exactly, without matching child namespaces - NotResideInOneOfTheseNamespacesExactly: The inverse rule that validates classes do NOT reside in the specified namespaces exactly The existing rules (ResideInOneOfTheseNamespaces and NotResideInTheseNamespaces) match recursively, including all child namespaces. The new rules provide an exact match option for stricter architectural constraints. Implementation: - Added namespaceMatchesExactly() method to ClassDescription - Created ResideInOneOfTheseNamespacesExactly expression class - Created NotResideInOneOfTheseNamespacesExactly expression class - Added comprehensive test coverage for both new rules - All existing tests continue to pass
1 parent 730d429 commit 51ba7c5

File tree

5 files changed

+296
-0
lines changed

5 files changed

+296
-0
lines changed

src/Analyzer/ClassDescription.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ public function namespaceMatches(string $pattern): bool
103103
return $this->FQCN->matches($pattern);
104104
}
105105

106+
public function namespaceMatchesExactly(string $namespace): bool
107+
{
108+
return $this->FQCN->namespace() === $namespace;
109+
}
110+
106111
public function namespaceMatchesOneOfTheseNamespaces(array $classesToBeExcluded): bool
107112
{
108113
foreach ($classesToBeExcluded as $classToBeExcluded) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arkitect\Expression\ForClasses;
6+
7+
use Arkitect\Analyzer\ClassDescription;
8+
use Arkitect\Expression\Description;
9+
use Arkitect\Expression\Expression;
10+
use Arkitect\Rules\Violation;
11+
use Arkitect\Rules\ViolationMessage;
12+
use Arkitect\Rules\Violations;
13+
14+
class NotResideInOneOfTheseNamespacesExactly implements Expression
15+
{
16+
/** @var array<string> */
17+
private $namespaces;
18+
19+
public function __construct(string ...$namespaces)
20+
{
21+
$this->namespaces = $namespaces;
22+
}
23+
24+
public function describe(ClassDescription $theClass, string $because): Description
25+
{
26+
$descr = implode(', ', $this->namespaces);
27+
28+
return new Description("should not reside in one of these namespaces exactly: $descr", $because);
29+
}
30+
31+
public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void
32+
{
33+
$resideInNamespace = false;
34+
foreach ($this->namespaces as $namespace) {
35+
if ($theClass->namespaceMatchesExactly($namespace)) {
36+
$resideInNamespace = true;
37+
}
38+
}
39+
40+
if ($resideInNamespace) {
41+
$violation = Violation::create(
42+
$theClass->getFQCN(),
43+
ViolationMessage::selfExplanatory($this->describe($theClass, $because)),
44+
$theClass->getFilePath()
45+
);
46+
$violations->add($violation);
47+
}
48+
}
49+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arkitect\Expression\ForClasses;
6+
7+
use Arkitect\Analyzer\ClassDescription;
8+
use Arkitect\Expression\Description;
9+
use Arkitect\Expression\Expression;
10+
use Arkitect\Rules\Violation;
11+
use Arkitect\Rules\ViolationMessage;
12+
use Arkitect\Rules\Violations;
13+
14+
class ResideInOneOfTheseNamespacesExactly implements Expression
15+
{
16+
/** @var array<string> */
17+
private $namespaces;
18+
19+
public function __construct(string ...$namespaces)
20+
{
21+
$this->namespaces = array_values(array_unique($namespaces));
22+
}
23+
24+
public function describe(ClassDescription $theClass, string $because): Description
25+
{
26+
$descr = implode(', ', $this->namespaces);
27+
28+
return new Description("should reside in one of these namespaces exactly: $descr", $because);
29+
}
30+
31+
public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void
32+
{
33+
$resideInNamespace = false;
34+
foreach ($this->namespaces as $namespace) {
35+
if ($theClass->namespaceMatchesExactly($namespace)) {
36+
$resideInNamespace = true;
37+
}
38+
}
39+
40+
if (!$resideInNamespace) {
41+
$violation = Violation::create(
42+
$theClass->getFQCN(),
43+
ViolationMessage::selfExplanatory($this->describe($theClass, $because)),
44+
$theClass->getFilePath()
45+
);
46+
$violations->add($violation);
47+
}
48+
}
49+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arkitect\Tests\Unit\Expressions\ForClasses;
6+
7+
use Arkitect\Analyzer\ClassDescription;
8+
use Arkitect\Expression\ForClasses\NotResideInOneOfTheseNamespacesExactly;
9+
use Arkitect\Rules\Violations;
10+
use PHPUnit\Framework\TestCase;
11+
12+
class NotResideInOneOfTheseNamespacesExactlyTest extends TestCase
13+
{
14+
public function test_it_should_return_true_if_not_reside_in_namespace(): void
15+
{
16+
$haveNameMatching = new NotResideInOneOfTheseNamespacesExactly('MyNamespace');
17+
18+
$classDesc = ClassDescription::getBuilder('AnotherNamespace\HappyIsland', 'src/Foo.php')->build();
19+
$because = 'we want to add this rule for our software';
20+
$violations = new Violations();
21+
$haveNameMatching->evaluate($classDesc, $violations, $because);
22+
23+
self::assertEquals(0, $violations->count());
24+
}
25+
26+
public function test_it_should_return_true_if_reside_in_child_namespace(): void
27+
{
28+
$haveNameMatching = new NotResideInOneOfTheseNamespacesExactly('MyNamespace');
29+
30+
$classDesc = ClassDescription::getBuilder('MyNamespace\Child\HappyIsland', 'src/Foo.php')->build();
31+
$because = 'we want to add this rule for our software';
32+
$violations = new Violations();
33+
$haveNameMatching->evaluate($classDesc, $violations, $because);
34+
35+
self::assertEquals(0, $violations->count(), 'should not violate when in child namespace');
36+
}
37+
38+
public function test_it_should_return_false_if_reside_in_exact_namespace(): void
39+
{
40+
$namespace = 'MyNamespace';
41+
$haveNameMatching = new NotResideInOneOfTheseNamespacesExactly($namespace);
42+
43+
$classDesc = ClassDescription::getBuilder('MyNamespace\HappyIsland', 'src/Foo.php')->build();
44+
$because = 'we want to add this rule for our software';
45+
$violations = new Violations();
46+
$haveNameMatching->evaluate($classDesc, $violations, $because);
47+
48+
self::assertEquals(1, $violations->count());
49+
self::assertEquals(
50+
'should not reside in one of these namespaces exactly: '.$namespace.' because we want to add this rule for our software',
51+
$haveNameMatching->describe($classDesc, $because)->toString()
52+
);
53+
}
54+
55+
public function test_it_should_check_multiple_namespaces_in_or(): void
56+
{
57+
$haveNameMatching = new NotResideInOneOfTheseNamespacesExactly('AnotherNamespace', 'ASecondNamespace', 'AThirdNamespace');
58+
59+
$classDesc = ClassDescription::getBuilder('AnotherNamespace\HappyIsland', 'src/Foo.php')->build();
60+
$violations = new Violations();
61+
$because = 'we want to add this rule for our software';
62+
$haveNameMatching->evaluate($classDesc, $violations, $because);
63+
self::assertEquals(1, $violations->count());
64+
65+
$classDesc = ClassDescription::getBuilder('MyNamespace\HappyIsland', 'src/Foo.php')->build();
66+
$violations = new Violations();
67+
$haveNameMatching->evaluate($classDesc, $violations, $because);
68+
self::assertEquals(0, $violations->count());
69+
70+
$classDesc = ClassDescription::getBuilder('AThirdNamespace\HappyIsland', 'src/Foo.php')->build();
71+
$violations = new Violations();
72+
$haveNameMatching->evaluate($classDesc, $violations, $because);
73+
self::assertEquals(1, $violations->count());
74+
}
75+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arkitect\Tests\Unit\Expressions\ForClasses;
6+
7+
use Arkitect\Analyzer\ClassDescription;
8+
use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespacesExactly;
9+
use Arkitect\Rules\Violations;
10+
use PHPUnit\Framework\TestCase;
11+
12+
class ResideInOneOfTheseNamespacesExactlyTest extends TestCase
13+
{
14+
public static function shouldMatchNamespacesProvider(): array
15+
{
16+
return [
17+
['Food\Vegetables', 'Food\Vegetables\Carrot', 'matches a class in the exact namespace'],
18+
['Food', 'Food\Vegetables', 'matches a class in the exact namespace'],
19+
['', 'Carrot', 'matches a class in the root namespace'],
20+
];
21+
}
22+
23+
/**
24+
* @dataProvider shouldMatchNamespacesProvider
25+
*
26+
* @param mixed $expectedNamespace
27+
* @param mixed $actualFQCN
28+
* @param mixed $explanation
29+
*/
30+
public function test_it_should_match_exact_namespace($expectedNamespace, $actualFQCN, $explanation): void
31+
{
32+
$haveNameMatching = new ResideInOneOfTheseNamespacesExactly($expectedNamespace);
33+
34+
$classDesc = ClassDescription::getBuilder($actualFQCN, 'src/Foo.php')->build();
35+
$because = 'we want to add this rule for our software';
36+
$violations = new Violations();
37+
$haveNameMatching->evaluate($classDesc, $violations, $because);
38+
39+
self::assertEquals(0, $violations->count(), $explanation);
40+
}
41+
42+
public static function shouldNotMatchNamespacesProvider(): array
43+
{
44+
return [
45+
['Food\Vegetables', 'Food\Vegetables\Roots\Carrot', 'should not match a class in a child namespace'],
46+
['Food\Vegetables', 'Food\Vegetables\Roots\Orange\Carrot', 'should not match a class in a child of a child namespace'],
47+
['Food', 'Food\Vegetables\Carrot', 'should not match a class in a child namespace'],
48+
['Food\Vegetables\Roots', 'Food\Vegetables\Carrot', 'should not match a class in a different namespace'],
49+
];
50+
}
51+
52+
/**
53+
* @dataProvider shouldNotMatchNamespacesProvider
54+
*
55+
* @param mixed $expectedNamespace
56+
* @param mixed $actualFQCN
57+
* @param mixed $explanation
58+
*/
59+
public function test_it_should_not_match_child_namespaces($expectedNamespace, $actualFQCN, $explanation): void
60+
{
61+
$haveNameMatching = new ResideInOneOfTheseNamespacesExactly($expectedNamespace);
62+
63+
$classDesc = ClassDescription::getBuilder($actualFQCN, 'src/Foo.php')->build();
64+
$because = 'we want to add this rule for our software';
65+
$violations = new Violations();
66+
$haveNameMatching->evaluate($classDesc, $violations, $because);
67+
68+
self::assertNotEquals(0, $violations->count(), $explanation);
69+
}
70+
71+
public function test_it_should_return_false_if_not_reside_in_namespace(): void
72+
{
73+
$haveNameMatching = new ResideInOneOfTheseNamespacesExactly('MyNamespace');
74+
75+
$classDesc = ClassDescription::getBuilder('AnotherNamespace\HappyIsland', 'src/Foo.php')->build();
76+
$because = 'we want to add this rule for our software';
77+
$violations = new Violations();
78+
$haveNameMatching->evaluate($classDesc, $violations, $because);
79+
80+
self::assertNotEquals(0, $violations->count());
81+
}
82+
83+
public function test_it_should_check_multiple_namespaces_in_or(): void
84+
{
85+
$haveNameMatching = new ResideInOneOfTheseNamespacesExactly('MyNamespace', 'AnotherNamespace', 'AThirdNamespace');
86+
87+
$classDesc = ClassDescription::getBuilder('AnotherNamespace\HappyIsland', 'src/Foo.php')->build();
88+
$violations = new Violations();
89+
$because = 'we want to add this rule for our software';
90+
$haveNameMatching->evaluate($classDesc, $violations, $because);
91+
self::assertEquals(0, $violations->count());
92+
93+
$classDesc = ClassDescription::getBuilder('MyNamespace\HappyIsland', 'src/Foo.php')->build();
94+
$violations = new Violations();
95+
$haveNameMatching->evaluate($classDesc, $violations, $because);
96+
self::assertEquals(0, $violations->count());
97+
98+
$classDesc = ClassDescription::getBuilder('AThirdNamespace\HappyIsland', 'src/Foo.php')->build();
99+
$violations = new Violations();
100+
$haveNameMatching->evaluate($classDesc, $violations, $because);
101+
self::assertEquals(0, $violations->count());
102+
103+
$classDesc = ClassDescription::getBuilder('NopeNamespace\HappyIsland', 'src/Foo.php')->build();
104+
$violations = new Violations();
105+
$haveNameMatching->evaluate($classDesc, $violations, $because);
106+
self::assertNotEquals(0, $violations->count());
107+
}
108+
109+
public function test_duplicate_namespaces_are_removed(): void
110+
{
111+
$expression = new ResideInOneOfTheseNamespacesExactly('A', 'B', 'A', 'C', 'D', 'D');
112+
113+
self::assertSame(
114+
'should reside in one of these namespaces exactly: A, B, C, D because rave',
115+
$expression->describe(ClassDescription::getBuilder('Marko', 'src/Foo.php')->build(), 'rave')->toString()
116+
);
117+
}
118+
}

0 commit comments

Comments
 (0)