From 561f85c1c50192f2c99630fc6b245f15cf3664b5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 27 Aug 2026 17:01:37 +0700 Subject: [PATCH] fix: Make implementsInterface() cover interfaceExtends and drop extendsInterface() --- src/Analyser/ClassNode.php | 11 ++-- tests/Analyser/ClassNodeTest.php | 11 ++-- .../MayNotImplementInterfaceRuleTest.php | 51 ++++++++++++++++++- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 55addacf..e8f84b2d 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -206,9 +206,14 @@ public function dependsOnNamespace(string $namespace): bool return false; } + /** + * Classes and enums implement interfaces; interfaces extend them. Both + * relations are matched here, directly or through any ancestor. + */ public function implementsInterface(string $interface): bool { return $this->matchesAnyClassLike($interface, $this->implements) + || $this->matchesAnyClassLike($interface, $this->interfaceExtends) || $this->matchesAnyClassLike($interface, $this->parentInterfaces); } @@ -221,12 +226,6 @@ public function extendsClass(string $class): bool return $this->matchesAnyClassLike($class, $this->parentClasses); } - public function extendsInterface(string $interface): bool - { - return $this->matchesAnyClassLike($interface, $this->interfaceExtends) - || $this->matchesAnyClassLike($interface, $this->parentInterfaces); - } - /** * Class-like names are case-insensitive in PHP. This matching is kept * separate from dependencies, which may also contain constants. diff --git a/tests/Analyser/ClassNodeTest.php b/tests/Analyser/ClassNodeTest.php index 3e472dcb..2d6e28be 100644 --- a/tests/Analyser/ClassNodeTest.php +++ b/tests/Analyser/ClassNodeTest.php @@ -192,8 +192,7 @@ interfaceExtends: ['App\\Contracts\\BaseOrderService'], $this->assertTrue($classNode->implementsInterface('App\\Contracts\\OrderService')); $this->assertTrue($classNode->implementsInterface('App\\Contracts\\RootOrderService')); $this->assertTrue($classNode->extendsClass('App\\Support\\BaseOrderService')); - $this->assertTrue($classNode->extendsInterface('App\\Contracts\\BaseOrderService')); - $this->assertTrue($classNode->extendsInterface('App\\Contracts\\RootOrderService')); + $this->assertTrue($classNode->implementsInterface('App\\Contracts\\BaseOrderService')); $this->assertTrue($classNode->callsFunction('var_dump')); $this->assertTrue($classNode->callsFunction('VAR_DUMP')); $this->assertFalse($classNode->callsFunction('array_map')); @@ -282,7 +281,7 @@ className: Foo::class, $this->assertFalse($classNode->extendsClass('App\\Support\\OtherClass')); } - public function testExtendsInterfaceIsCaseInsensitive(): void + public function testImplementsInterfaceMatchesInterfaceExtendsCaseInsensitively(): void { $classNode = new ClassNode( className: 'App\\Contracts\\FooInterface', @@ -298,9 +297,9 @@ interfaceExtends: ['App\\Contracts\\baseinterface'], parentInterfaces: ['App\\Contracts\\rootinterface'], ); - $this->assertTrue($classNode->extendsInterface('App\\Contracts\\BaseInterface')); - $this->assertTrue($classNode->extendsInterface('App\\Contracts\\RootInterface')); - $this->assertFalse($classNode->extendsInterface('App\\Contracts\\OtherInterface')); + $this->assertTrue($classNode->implementsInterface('App\\Contracts\\BaseInterface')); + $this->assertTrue($classNode->implementsInterface('App\\Contracts\\RootInterface')); + $this->assertFalse($classNode->implementsInterface('App\\Contracts\\OtherInterface')); } public function testSetRecursiveParents(): void diff --git a/tests/Rule/Class_/MayNotImplementInterfaceRuleTest.php b/tests/Rule/Class_/MayNotImplementInterfaceRuleTest.php index 907663a0..95d18223 100644 --- a/tests/Rule/Class_/MayNotImplementInterfaceRuleTest.php +++ b/tests/Rule/Class_/MayNotImplementInterfaceRuleTest.php @@ -90,14 +90,60 @@ interface: JsonSerializable::class ); } + public function testViolatesWhenInterfaceDirectlyExtendsForbiddenInterface(): void + { + $mayNotImplementInterfaceRule = new MayNotImplementInterfaceRule( + layer: 'Domain', + interface: JsonSerializable::class + ); + + $violation = $mayNotImplementInterfaceRule->evaluate($this->makeNode( + [], + isInterface: true, + interfaceExtends: [JsonSerializable::class], + )); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + 'Interface [App\\Domain\\Order] must not extend interface [JsonSerializable]', + $violation->message + ); + } + + public function testViolatesWhenInterfaceIndirectlyExtendsForbiddenInterface(): void + { + $mayNotImplementInterfaceRule = new MayNotImplementInterfaceRule( + layer: 'Domain', + interface: JsonSerializable::class + ); + + // App\Domain\Order extends App\Domain\Serializable, which extends JsonSerializable. + $classNode = $this->makeNode( + [], + isInterface: true, + interfaceExtends: ['App\\Domain\\Serializable'], + ); + $classNode->setRecursiveParents([], ['App\\Domain\\Serializable', JsonSerializable::class]); + + $violation = $mayNotImplementInterfaceRule->evaluate($classNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + 'Interface [App\\Domain\\Order] must not extend interface [JsonSerializable]', + $violation->message + ); + } + /** * @param string[] $implements + * @param string[] $interfaceExtends */ private function makeNode( array $implements, string $layer = 'Domain', bool $isInterface = false, bool $isEnum = false, + array $interfaceExtends = [], ): ClassNode { return new ClassNode( className: 'App\\Domain\\Order', @@ -109,8 +155,9 @@ className: 'App\\Domain\\Order', isFinal: false, isInterface: $isInterface, isReadonly: false, - implements: $implements, - isEnum: $isEnum, + implements: $implements, + isEnum: $isEnum, + interfaceExtends: $interfaceExtends, ); } }