From 51ba7c58292dc92522423429c559e7c3e24aecfc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Dec 2025 15:23:17 +0000 Subject: [PATCH 1/2] 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 --- src/Analyzer/ClassDescription.php | 5 + ...NotResideInOneOfTheseNamespacesExactly.php | 49 ++++++++ .../ResideInOneOfTheseNamespacesExactly.php | 49 ++++++++ ...esideInOneOfTheseNamespacesExactlyTest.php | 75 +++++++++++ ...esideInOneOfTheseNamespacesExactlyTest.php | 118 ++++++++++++++++++ 5 files changed, 296 insertions(+) create mode 100644 src/Expression/ForClasses/NotResideInOneOfTheseNamespacesExactly.php create mode 100644 src/Expression/ForClasses/ResideInOneOfTheseNamespacesExactly.php create mode 100644 tests/Unit/Expressions/ForClasses/NotResideInOneOfTheseNamespacesExactlyTest.php create mode 100644 tests/Unit/Expressions/ForClasses/ResideInOneOfTheseNamespacesExactlyTest.php diff --git a/src/Analyzer/ClassDescription.php b/src/Analyzer/ClassDescription.php index 93545a98..9e8d7899 100644 --- a/src/Analyzer/ClassDescription.php +++ b/src/Analyzer/ClassDescription.php @@ -103,6 +103,11 @@ public function namespaceMatches(string $pattern): bool return $this->FQCN->matches($pattern); } + public function namespaceMatchesExactly(string $namespace): bool + { + return $this->FQCN->namespace() === $namespace; + } + public function namespaceMatchesOneOfTheseNamespaces(array $classesToBeExcluded): bool { foreach ($classesToBeExcluded as $classToBeExcluded) { diff --git a/src/Expression/ForClasses/NotResideInOneOfTheseNamespacesExactly.php b/src/Expression/ForClasses/NotResideInOneOfTheseNamespacesExactly.php new file mode 100644 index 00000000..a35524c2 --- /dev/null +++ b/src/Expression/ForClasses/NotResideInOneOfTheseNamespacesExactly.php @@ -0,0 +1,49 @@ + */ + private $namespaces; + + public function __construct(string ...$namespaces) + { + $this->namespaces = $namespaces; + } + + public function describe(ClassDescription $theClass, string $because): Description + { + $descr = implode(', ', $this->namespaces); + + return new Description("should not reside in one of these namespaces exactly: $descr", $because); + } + + public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void + { + $resideInNamespace = false; + foreach ($this->namespaces as $namespace) { + if ($theClass->namespaceMatchesExactly($namespace)) { + $resideInNamespace = true; + } + } + + if ($resideInNamespace) { + $violation = Violation::create( + $theClass->getFQCN(), + ViolationMessage::selfExplanatory($this->describe($theClass, $because)), + $theClass->getFilePath() + ); + $violations->add($violation); + } + } +} diff --git a/src/Expression/ForClasses/ResideInOneOfTheseNamespacesExactly.php b/src/Expression/ForClasses/ResideInOneOfTheseNamespacesExactly.php new file mode 100644 index 00000000..92e498e3 --- /dev/null +++ b/src/Expression/ForClasses/ResideInOneOfTheseNamespacesExactly.php @@ -0,0 +1,49 @@ + */ + private $namespaces; + + public function __construct(string ...$namespaces) + { + $this->namespaces = array_values(array_unique($namespaces)); + } + + public function describe(ClassDescription $theClass, string $because): Description + { + $descr = implode(', ', $this->namespaces); + + return new Description("should reside in one of these namespaces exactly: $descr", $because); + } + + public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void + { + $resideInNamespace = false; + foreach ($this->namespaces as $namespace) { + if ($theClass->namespaceMatchesExactly($namespace)) { + $resideInNamespace = true; + } + } + + if (!$resideInNamespace) { + $violation = Violation::create( + $theClass->getFQCN(), + ViolationMessage::selfExplanatory($this->describe($theClass, $because)), + $theClass->getFilePath() + ); + $violations->add($violation); + } + } +} diff --git a/tests/Unit/Expressions/ForClasses/NotResideInOneOfTheseNamespacesExactlyTest.php b/tests/Unit/Expressions/ForClasses/NotResideInOneOfTheseNamespacesExactlyTest.php new file mode 100644 index 00000000..d20dfc89 --- /dev/null +++ b/tests/Unit/Expressions/ForClasses/NotResideInOneOfTheseNamespacesExactlyTest.php @@ -0,0 +1,75 @@ +build(); + $because = 'we want to add this rule for our software'; + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + + self::assertEquals(0, $violations->count()); + } + + public function test_it_should_return_true_if_reside_in_child_namespace(): void + { + $haveNameMatching = new NotResideInOneOfTheseNamespacesExactly('MyNamespace'); + + $classDesc = ClassDescription::getBuilder('MyNamespace\Child\HappyIsland', 'src/Foo.php')->build(); + $because = 'we want to add this rule for our software'; + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + + self::assertEquals(0, $violations->count(), 'should not violate when in child namespace'); + } + + public function test_it_should_return_false_if_reside_in_exact_namespace(): void + { + $namespace = 'MyNamespace'; + $haveNameMatching = new NotResideInOneOfTheseNamespacesExactly($namespace); + + $classDesc = ClassDescription::getBuilder('MyNamespace\HappyIsland', 'src/Foo.php')->build(); + $because = 'we want to add this rule for our software'; + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + + self::assertEquals(1, $violations->count()); + self::assertEquals( + 'should not reside in one of these namespaces exactly: '.$namespace.' because we want to add this rule for our software', + $haveNameMatching->describe($classDesc, $because)->toString() + ); + } + + public function test_it_should_check_multiple_namespaces_in_or(): void + { + $haveNameMatching = new NotResideInOneOfTheseNamespacesExactly('AnotherNamespace', 'ASecondNamespace', 'AThirdNamespace'); + + $classDesc = ClassDescription::getBuilder('AnotherNamespace\HappyIsland', 'src/Foo.php')->build(); + $violations = new Violations(); + $because = 'we want to add this rule for our software'; + $haveNameMatching->evaluate($classDesc, $violations, $because); + self::assertEquals(1, $violations->count()); + + $classDesc = ClassDescription::getBuilder('MyNamespace\HappyIsland', 'src/Foo.php')->build(); + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + self::assertEquals(0, $violations->count()); + + $classDesc = ClassDescription::getBuilder('AThirdNamespace\HappyIsland', 'src/Foo.php')->build(); + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + self::assertEquals(1, $violations->count()); + } +} diff --git a/tests/Unit/Expressions/ForClasses/ResideInOneOfTheseNamespacesExactlyTest.php b/tests/Unit/Expressions/ForClasses/ResideInOneOfTheseNamespacesExactlyTest.php new file mode 100644 index 00000000..f324cd84 --- /dev/null +++ b/tests/Unit/Expressions/ForClasses/ResideInOneOfTheseNamespacesExactlyTest.php @@ -0,0 +1,118 @@ +build(); + $because = 'we want to add this rule for our software'; + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + + self::assertEquals(0, $violations->count(), $explanation); + } + + public static function shouldNotMatchNamespacesProvider(): array + { + return [ + ['Food\Vegetables', 'Food\Vegetables\Roots\Carrot', 'should not match a class in a child namespace'], + ['Food\Vegetables', 'Food\Vegetables\Roots\Orange\Carrot', 'should not match a class in a child of a child namespace'], + ['Food', 'Food\Vegetables\Carrot', 'should not match a class in a child namespace'], + ['Food\Vegetables\Roots', 'Food\Vegetables\Carrot', 'should not match a class in a different namespace'], + ]; + } + + /** + * @dataProvider shouldNotMatchNamespacesProvider + * + * @param mixed $expectedNamespace + * @param mixed $actualFQCN + * @param mixed $explanation + */ + public function test_it_should_not_match_child_namespaces($expectedNamespace, $actualFQCN, $explanation): void + { + $haveNameMatching = new ResideInOneOfTheseNamespacesExactly($expectedNamespace); + + $classDesc = ClassDescription::getBuilder($actualFQCN, 'src/Foo.php')->build(); + $because = 'we want to add this rule for our software'; + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + + self::assertNotEquals(0, $violations->count(), $explanation); + } + + public function test_it_should_return_false_if_not_reside_in_namespace(): void + { + $haveNameMatching = new ResideInOneOfTheseNamespacesExactly('MyNamespace'); + + $classDesc = ClassDescription::getBuilder('AnotherNamespace\HappyIsland', 'src/Foo.php')->build(); + $because = 'we want to add this rule for our software'; + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + + self::assertNotEquals(0, $violations->count()); + } + + public function test_it_should_check_multiple_namespaces_in_or(): void + { + $haveNameMatching = new ResideInOneOfTheseNamespacesExactly('MyNamespace', 'AnotherNamespace', 'AThirdNamespace'); + + $classDesc = ClassDescription::getBuilder('AnotherNamespace\HappyIsland', 'src/Foo.php')->build(); + $violations = new Violations(); + $because = 'we want to add this rule for our software'; + $haveNameMatching->evaluate($classDesc, $violations, $because); + self::assertEquals(0, $violations->count()); + + $classDesc = ClassDescription::getBuilder('MyNamespace\HappyIsland', 'src/Foo.php')->build(); + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + self::assertEquals(0, $violations->count()); + + $classDesc = ClassDescription::getBuilder('AThirdNamespace\HappyIsland', 'src/Foo.php')->build(); + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + self::assertEquals(0, $violations->count()); + + $classDesc = ClassDescription::getBuilder('NopeNamespace\HappyIsland', 'src/Foo.php')->build(); + $violations = new Violations(); + $haveNameMatching->evaluate($classDesc, $violations, $because); + self::assertNotEquals(0, $violations->count()); + } + + public function test_duplicate_namespaces_are_removed(): void + { + $expression = new ResideInOneOfTheseNamespacesExactly('A', 'B', 'A', 'C', 'D', 'D'); + + self::assertSame( + 'should reside in one of these namespaces exactly: A, B, C, D because rave', + $expression->describe(ClassDescription::getBuilder('Marko', 'src/Foo.php')->build(), 'rave')->toString() + ); + } +} From 53d43d5242f9174fa62e4b8d895de83f02ead77f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 19:09:42 +0000 Subject: [PATCH 2/2] Add documentation for exact namespace matching rules Updated README.md to include documentation for the two new namespace matching rules introduced in the previous commit: - ResideInOneOfTheseNamespacesExactly - NotResideInOneOfTheseNamespacesExactly The documentation includes: - Clear descriptions of how exact matching differs from recursive matching - Code examples showing typical use cases - Visual examples demonstrating what matches and what doesn't These rules address issue #406 by providing stricter namespace validation options. --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 882dc41a..d0c2be56 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,36 @@ $rules[] = Rule::allClasses() ->because('we want to be sure that all events not reside in wrong layers'); ``` +### Reside in a namespace exactly + +This rule checks that classes reside **exactly** in one of the specified namespaces, without matching child namespaces. Unlike `ResideInOneOfTheseNamespaces` which matches recursively, this rule only matches classes directly in the namespace. + +```php +$rules[] = Rule::allClasses() + ->that(new HaveNameMatching('*Entity')) + ->should(new ResideInOneOfTheseNamespacesExactly('App\Domain\Entity')) + ->because('we want entity classes only in the root Entity namespace, not in subdirectories'); +``` + +For example, with namespace `App\Domain\Entity`: +- `App\Domain\Entity\User` ✅ matches (same namespace) +- `App\Domain\Entity\ValueObject\Email` ❌ does not match (child namespace) + +### Not reside in a namespace exactly + +This rule checks that classes do **not** reside exactly in one of the specified namespaces. Classes in child namespaces are allowed. + +```php +$rules[] = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Legacy')) + ->should(new NotResideInOneOfTheseNamespacesExactly('App\Legacy')) + ->because('we want to avoid classes directly in the Legacy namespace root'); +``` + +For example, with namespace `App\Legacy`: +- `App\Legacy\OldCode` ❌ violation (exact match) +- `App\Legacy\Module\OldCode` ✅ allowed (child namespace) + You can also define components and ensure that a component: - should not depend on any component - may depend on specific components