From 51ba7c58292dc92522423429c559e7c3e24aecfc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Dec 2025 15:23:17 +0000 Subject: [PATCH] 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() + ); + } +}