Repository: Roave/BetterReflection (PHP)

Issue:
Better detection of circular references
Hi,

indirectly, via [phpstan user report](https://github.com/phpstan/phpstan/issues/7787), I found out that there is valid PHP code that triggers endless recursion in Better Reflection.
E.g. circular class or interface extends statements like
```
class TestClass extends TestClass {}

class Foo extends Bar {}
class Bar extends Foo {}
```

This code will parse just fine, but lead to errors like `Class "TestClass" not found`, see https://3v4l.org/YIQNl

There are currently 2 cases for that specific example that I found that are problematic
1. `ReflectionClass::getParentClass()` will never return null and consumers can recurse forever. This is kind of expected IMO and fixing it screams BC break or am I wrong?
2. Everything that internally uses `ReflectionClass::getInheritanceClassHierarchy()`, which in term uses getParentClass to find the root parent, leads to endless recursion. E.g. `ReflectionClass::getInterfaces()`, IMO this could be already "fixed" without a BC break? But if getParentClass would be able to deal with it then nothing needs to be done here additionally I assume.

The second one would be easy to prevent in Better Reflection directly, I did not dig deep enough yet to have an idea for the first one. And by preventing I mean something as throwing a `CircularReferenceException`, or do you have any better ideas?

Ondrej also mentioned potential other problematic things via https://github.com/phpstan/phpstan/issues/7787#issuecomment-1214383974 and https://github.com/phpstan/phpstan/issues/7787#issuecomment-1214386506

What do you think? Looking forward to your opinions. Also, I'm happy to work on anything related to this.


Discussion:
The handling of this kind of feels related to https://github.com/Roave/BetterReflection/issues/69 (finding more than one unique symbol). If e.g. exceptions are decided to be used I could try to implement that too.
Unsure about how this one should be handled. #69 is about "locating" sources, while this one is about circular references in general.

What happens if you have `A -> B -> C -> A`, and in separate files?

I don't think we should complicate this for code that is invalid upfront. Note that BetterReflection already crashes on code that cannot be parsed.
Right, those 2 issues are not related much. I only thought both could lead to exceptions thrown.

With `A -> B -> C -> A` in separate files the same thing happens. E.g. `getParentClass()` can be called forever, `getInterfaceNames` leads to a segfault / endless recursion.

The argument about invalid code is understandable. I thought Better Reflection should be able to somehow handle all code that is valid in terms of it can be parsed. With having static code analysis using Better Reflection in mind that made sense to me. But yeah, this code will lead to a fatal error if being executed and trying to handle this might add complexity here.
Avoiding the endless recursion via e.g. `getInterfaceNames` would still be quite simple though, even if it's not a "handle circular refs" everywhere approach I guess.
Hard one 🤔
Any new ideas here? I didn't come up with anything so far.. The question is: should BR detect this or not?
If you say it shouldn't, I'm fine with closing this, I don't want to increase the issue counter for no reason :) I'm sure this can be somehow detected in consumer code as well.
IMO put it in a failing test PR first.

Then discuss from there.



Patch:
diff --git a/phpstan.neon b/phpstan.neon
index b380181e6..042770678 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -16,6 +16,8 @@ parameters:
     excludePaths:
         analyse:
             - test/unit/Fixture/*
+        analyseAndScan:
+            - test/unit/Fixture/InvalidParents.php
 
     bootstrapFiles:
         - phar://%currentWorkingDirectory%/test/unit/Fixture/autoload.phar/vendor/autoload.php
diff --git a/src/Reflection/Exception/CircularReference.php b/src/Reflection/Exception/CircularReference.php
new file mode 100644
index 000000000..3a34fc2ee
--- /dev/null
+++ b/src/Reflection/Exception/CircularReference.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Roave\BetterReflection\Reflection\Exception;
+
+use UnexpectedValueException;
+
+use function sprintf;
+
+class CircularReference extends UnexpectedValueException
+{
+    public static function fromClassName(string $className): self
+    {
+        return new self(sprintf('Circular reference to class "%s"', $className));
+    }
+}
diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php
index a386bab5f..f89fcab4b 100644
--- a/src/Reflection/ReflectionClass.php
+++ b/src/Reflection/ReflectionClass.php
@@ -21,6 +21,7 @@
 use Roave\BetterReflection\Reflection\Adapter\ReflectionProperty as ReflectionPropertyAdapter;
 use Roave\BetterReflection\Reflection\Annotation\AnnotationHelper;
 use Roave\BetterReflection\Reflection\Attribute\ReflectionAttributeHelper;
+use Roave\BetterReflection\Reflection\Exception\CircularReference;
 use Roave\BetterReflection\Reflection\Exception\ClassDoesNotExist;
 use Roave\BetterReflection\Reflection\Exception\NoObjectProvided;
 use Roave\BetterReflection\Reflection\Exception\NotAClassReflection;
@@ -128,7 +129,8 @@ class ReflectionClass implements Reflection
     /** @var array<lowercase-string, ReflectionMethod>|null */
     private array|null $cachedMethods = null;
 
-    private ReflectionClass|null $cachedParentClass = null;
+    /** @var list<ReflectionClass>|null */
+    private array|null $cachedParentClasses = null;
 
     /** @internal */
     protected function __construct(
@@ -1034,19 +1036,17 @@ public function getEndColumn(): int
      */
     public function getParentClass(): ReflectionClass|null
     {
-        if ($this->parentClassName === null) {
-            return null;
-        }
+        $parentClass = $this->getParentClasses()[0] ?? null;
 
-        if ($this->cachedParentClass === null) {
-            $this->cachedParentClass = $this->reflector->reflectClass($this->parentClassName);
+        if ($parentClass === null) {
+            return null;
         }
 
-        if ($this->cachedParentClass->isInterface() || $this->cachedParentClass->isTrait()) {
-            throw NotAClassReflection::fromReflectionClass($this->cachedParentClass);
+        if ($parentClass->isInterface() || $parentClass->isTrait()) {
+            throw NotAClassReflection::fromReflectionClass($parentClass);
         }
 
-        return $this->cachedParentClass;
+        return $parentClass;
     }
 
     /**
@@ -1056,7 +1056,35 @@ public function getParentClass(): ReflectionClass|null
      */
     public function getParentClassNames(): array
     {
-        return array_map(static fn (self $parentClass): string => $parentClass->getName(), array_slice(array_reverse($this->getInheritanceClassHierarchy()), 1));
+        return array_map(static fn (self $parentClass): string => $parentClass->getName(), $this->getParentClasses());
+    }
+
+    /** @return list<ReflectionClass> */
+    private function getParentClasses(): array
+    {
+        if ($this->cachedParentClasses === null) {
+            $parentClasses = [];
+
+            $parentClassName = $this->parentClassName;
+            while ($parentClassName !== null) {
+                $parentClass = $this->reflector->reflectClass($parentClassName);
+
+                if (
+                    $this->name === $parentClassName
+                    || array_key_exists($parentClassName, $parentClasses)
+                ) {
+                    throw CircularReference::fromClassName($parentClassName);
+                }
+
+                $parentClasses[$parentClassName] = $parentClass;
+
+                $parentClassName = $parentClass->parentClassName;
+            }
+
+            $this->cachedParentClasses = array_values($parentClasses);
+        }
+
+        return $this->cachedParentClasses;
     }
 
     public function getDocComment(): string|null
@@ -1388,10 +1416,15 @@ private function methodHash(string $className, string $methodName): string
      */
     public function getInterfaces(): array
     {
-        return array_merge(...array_map(
-            static fn (self $reflectionClass): array => $reflectionClass->getCurrentClassImplementedInterfacesIndexedByName(),
-            $this->getInheritanceClassHierarchy(),
-        ));
+        $interfaces = array_merge(
+            [$this->getCurrentClassImplementedInterfacesIndexedByName()],
+            array_map(
+                static fn (self $parentClass): array => $parentClass->getCurrentClassImplementedInterfacesIndexedByName(),
+                $this->getParentClasses(),
+            ),
+        );
+
+        return array_merge(...array_reverse($interfaces));
     }
 
     /**
@@ -1459,10 +1492,7 @@ public function isSubclassOf(string $className): bool
     {
         return in_array(
             ltrim($className, '\\'),
-            array_map(
-                static fn (self $reflectionClass): string => $reflectionClass->getName(),
-                array_slice(array_reverse($this->getInheritanceClassHierarchy()), 1),
-            ),
+            $this->getParentClassNames(),
             true,
         );
     }
@@ -1571,16 +1601,6 @@ private function getCurrentClassImplementedInterfacesIndexedByName(): array
         return $this->addStringableInterface($interfaces);
     }
 
-    /** @return list<ReflectionClass> ordered from inheritance root to leaf (this class) */
-    private function getInheritanceClassHierarchy(): array
-    {
-        $parentClass = $this->getParentClass();
-
-        return $parentClass
-            ? array_merge($parentClass->getInheritanceClassHierarchy(), [$this])
-            : [$this];
-    }
-
     /**
      * This method allows us to retrieve all interfaces parent of the this interface. Do not use on class nodes!
      *


Test patch:
diff --git a/test/unit/Fixture/InvalidParents.php b/test/unit/Fixture/InvalidParents.php
new file mode 100644
index 000000000..3042bb42a
--- /dev/null
+++ b/test/unit/Fixture/InvalidParents.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Roave\BetterReflectionTest\Fixture\InvalidParents;
+
+class ClassExtendsSelf extends ClassExtendsSelf {}
+
+class Class1 extends Class2 {}
+class Class2 extends Class1 {}
+class Class3 extends Class2 {}
diff --git a/test/unit/Reflection/Exception/CircularReferenceTest.php b/test/unit/Reflection/Exception/CircularReferenceTest.php
new file mode 100644
index 000000000..f3ce0604c
--- /dev/null
+++ b/test/unit/Reflection/Exception/CircularReferenceTest.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Roave\BetterReflectionTest\Reflection\Exception;
+
+use PHPUnit\Framework\TestCase;
+use Roave\BetterReflection\Reflection\Exception\CircularReference;
+
+/** @covers \Roave\BetterReflection\Reflection\Exception\CircularReference */
+class CircularReferenceTest extends TestCase
+{
+    public function testFromNonObject(): void
+    {
+        $exception = CircularReference::fromClassName('Whatever');
+
+        self::assertInstanceOf(CircularReference::class, $exception);
+        self::assertSame('Circular reference to class "Whatever"', $exception->getMessage());
+    }
+}
diff --git a/test/unit/Reflection/ReflectionClassTest.php b/test/unit/Reflection/ReflectionClassTest.php
index 90cbc4e58..b9d8cea58 100644
--- a/test/unit/Reflection/ReflectionClassTest.php
+++ b/test/unit/Reflection/ReflectionClassTest.php
@@ -21,6 +21,7 @@
 use ReflectionProperty as CoreReflectionProperty;
 use Roave\BetterReflection\Reflection\Adapter\ReflectionClass as ReflectionClassAdapter;
 use Roave\BetterReflection\Reflection\Adapter\ReflectionClassConstant as ReflectionClassConstantAdapter;
+use Roave\BetterReflection\Reflection\Exception\CircularReference;
 use Roave\BetterReflection\Reflection\Exception\NotAClassReflection;
 use Roave\BetterReflection\Reflection\Exception\NotAnInterfaceReflection;
 use Roave\BetterReflection\Reflection\Exception\PropertyDoesNotExist;
@@ -805,6 +806,32 @@ public function testGetParentClassNames(): void
         self::assertSame(['Roave\\BetterReflectionTest\\Fixture\\ClassWithParent', 'Roave\\BetterReflectionTest\\Fixture\\ExampleClass'], $childReflection->getParentClassNames());
     }
 
+    /** @return list<array{0: string}> */
+    public function circularReferencesProvider(): array
+    {
+        return [
+            ['Roave\\BetterReflectionTest\\Fixture\\InvalidParents\\ClassExtendsSelf'],
+            ['Roave\\BetterReflectionTest\\Fixture\\InvalidParents\\Class1'],
+            ['Roave\\BetterReflectionTest\\Fixture\\InvalidParents\\Class2'],
+            ['Roave\\BetterReflectionTest\\Fixture\\InvalidParents\\Class3'],
+        ];
+    }
+
+    /** @dataProvider circularReferencesProvider */
+    public function testGetParentClassNamesFailsWithCircularReferences(string $className): void
+    {
+        $reflector = new DefaultReflector(new SingleFileSourceLocator(
+            __DIR__ . '/../Fixture/InvalidParents.php',
+            $this->astLocator,
+        ));
+
+        $class = $reflector->reflectClass($className);
+
+        $this->expectException(CircularReference::class);
+
+        $class->getParentClassNames();
+    }
+
     /** @return list<array{0: non-empty-string, 1: int, 2: int}> */
     public function startEndLineProvider(): array
     {