Repository: Roave/BetterReflection (PHP)

Issue:
Not-circular interface detected as circular
Last minute changes to BetterReflection 6.0 before release around `AlreadyVisitedClasses` brought a bug that was familiar to me.

This situation is now detected as circular but in fact it works in PHP:

```php
<?php
interface CacheableDependencyInterface {
}
interface RefinableCacheableDependencyInterface extends CacheableDependencyInterface {
}
interface AccessibleInterface {
}
interface EntityInterface extends AccessibleInterface, CacheableDependencyInterface, RefinableCacheableDependencyInterface {
}
```

In the previous version that used references I accidentally introduced it to my fork and had to fix it too. This is how I introduced the bug: https://github.com/ondrejmirtes/BetterReflection/commit/258f60ea9a1209dfd2520c056fa3911d4496cf3a#diff-c58f24d134490b09a18aa65366bc9ab2d5bf2dbf493bea9f12b365807a5b5e22L1654-R1673

This is how I had to fix it: https://github.com/ondrejmirtes/BetterReflection/commit/22ae7d2c3ea34cf85703db4225e1d814a013b32d + https://github.com/ondrejmirtes/BetterReflection/commit/8c2480836646c23a3baa1c62aec7c1c256acb096

Because: It turns out it's hard to rewrite an arrow function to equivalent imperative code. Because an arrow function doesn't use outer scope variables by reference, the value is copied when array_map is called. And it's called only once so the callback always uses the initial variable value, it's not updated by reference.

The object version using AlreadyVisitedClasses in Roave/BetterReflection 6.0.0 has the same problem. The reference is now updated each time and breaks analysing legit code.

I'll submit a failing test PR too.


Discussion:
/cc @herndlm 



Patch:
diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php
index de7086185..0f4365773 100644
--- a/src/Reflection/ReflectionClass.php
+++ b/src/Reflection/ReflectionClass.php
@@ -1628,16 +1628,15 @@ private function getInterfacesHierarchy(AlreadyVisitedClasses $alreadyVisitedCla
         $interfaceClassName = $this->getName();
         $alreadyVisitedClasses->push($interfaceClassName);
 
+        $alreadyVisitedClassesCopy = clone $alreadyVisitedClasses;
+
         /** @var array<class-string, self> $interfaces */
-        $interfaces = array_merge(
-            [$interfaceClassName => $this],
-            ...array_map(
-                fn (string $interfaceClassName): array => $this->reflector
-                    ->reflectClass($interfaceClassName)
-                    ->getInterfacesHierarchy($alreadyVisitedClasses),
-                $this->implementsClassNames,
-            ),
-        );
+        $interfaces = [$interfaceClassName => $this];
+        foreach ($this->getImmediateInterfaces() as $interface) {
+            foreach ($interface->getInterfacesHierarchy(clone $alreadyVisitedClassesCopy) as $extendedInterfaceName => $extendedInterface) {
+                $interfaces[$extendedInterfaceName] = $extendedInterface;
+            }
+        }
 
         return $this->addStringableInterface($interfaces);
     }


Test patch:
diff --git a/test/unit/Reflection/ReflectionClassTest.php b/test/unit/Reflection/ReflectionClassTest.php
index e5059d585..74a5b785d 100644
--- a/test/unit/Reflection/ReflectionClassTest.php
+++ b/test/unit/Reflection/ReflectionClassTest.php
@@ -2750,4 +2750,27 @@ public function testGetPropertiesFailsWithCircularReference(string $className):
 
         $class->getProperties();
     }
+
+    public function testInterfacesNotCircular(): void
+    {
+        $php = <<<'PHP'
+            <?php
+            interface CacheableDependencyInterface {}
+            interface RefinableCacheableDependencyInterface extends CacheableDependencyInterface {}
+            interface AccessibleInterface {}
+            interface EntityInterface extends AccessibleInterface, CacheableDependencyInterface, RefinableCacheableDependencyInterface {}
+        PHP;
+
+        $reflector       = new DefaultReflector(new StringSourceLocator($php, $this->astLocator));
+        $classReflection = $reflector->reflectClass('EntityInterface');
+
+        /** @var list<class-string> $expectedInterfaceNames */
+        $expectedInterfaceNames = [
+            'AccessibleInterface',
+            'CacheableDependencyInterface',
+            'RefinableCacheableDependencyInterface',
+        ];
+
+        self::assertSame($expectedInterfaceNames, $classReflection->getInterfaceNames());
+    }
 }