Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Detect trait and parent class reference cycles instead of walking the…
…m forever

- `ClassReflection::collectTraits()` compared already collected traits with
  `in_array($subTrait, $traits, true)`. BetterReflection hands out a fresh
  reflection object on every `getTraits()` call, so a trait reachable from
  itself was never recognised as already seen and the worklist grew forever.
  Collect the traits keyed by name instead.
- `TraitUseHandler` derived the "already inside this trait" set from
  `Scope::getParentScope()`, which only ever sees a trait that is an enclosing
  scope (the anonymous-class case of bug 7214) because `enterTrait()` does not
  set a parent scope. Keep a stack of the traits whose statements are currently
  being processed and skip a `use` of any of them.
- `ClassReflection::getAncestors()` recursed through `$trait->getAncestors()`,
  which never terminates for two traits using each other. It now descends
  through a private helper that uses the collected ancestors as the visited set,
  keeping the previous ordering.
- BetterReflection only rejects a class extending itself directly, so a cycle
  spanning several classes (`A extends B`, `B extends A`) let every walk over
  the class hierarchy run forever. `ClassReflection::getParentClass()` now looks
  for such a cycle and throws `CircularReference`, the same error PHPStan
  already reported for `class A extends A`.
- `ClassReflection::getTraits(true)` and `getClassHierarchyDistances()` walked
  the parents through the native reflection, bypassing that check - they go
  through `getParentClass()` now.

Probed and found already correct: `@mixin` cycles, interface `extends` cycles,
trait cycles with `insteadof`/`as` adaptations, enums using a cyclic trait,
trait cycles spanning several files, and anonymous classes using the trait they
are declared in.
  • Loading branch information
ondrejmirtes authored and phpstan-bot committed Sep 17, 2026
commit ea2833c695e61c6ffbb19897364cff82c89d4927
1 change: 1 addition & 0 deletions build/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ parameters:
- ../tests/PHPStan/Analyser/traits/*
- ../tests/notAutoloaded/*
- ../tests/bench/*
- ../tests/PHPStan/Reflection/CircularReferenceClassReflectionTest.php
- ../tests/PHPStan/Reflection/UnionTypesTest.php
- ../tests/PHPStan/Reflection/MixedTypeTest.php
- ../tests/e2e/magic-setter/*
Expand Down
29 changes: 17 additions & 12 deletions src/Analyser/StmtHandler/TraitUseHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\ShouldNotHappenException;
use function array_key_exists;
use function in_array;
use function is_array;
use function strtolower;

/**
* @implements StmtHandler<TraitUse>
Expand All @@ -33,6 +33,13 @@
final class TraitUseHandler implements StmtHandler
{

/**
* Names of traits whose statements are currently being processed, keyed by lowercased name.
*
* @var array<string, true>
*/
private array $currentlyProcessedTraits = [];

public function __construct(
private ReflectionProvider $reflectionProvider,
private FileHelper $fileHelper,
Expand Down Expand Up @@ -67,18 +74,11 @@ public function processStmt(
*/
private function processTraitUse(NodeScopeResolver $nodeScopeResolver, Node\Stmt\TraitUse $node, MutatingScope $classScope, ExpressionResultStorage $storage, callable $nodeCallback): void
{
$parentTraitNames = [];
$parent = $classScope->getParentScope();
while ($parent !== null) {
if ($parent->isInTrait()) {
$parentTraitNames[] = $parent->getTraitReflection()->getName();
}
$parent = $parent->getParentScope();
}

foreach ($node->traits as $trait) {
$traitName = (string) $trait;
if (in_array($traitName, $parentTraitNames, true)) {
// traits can use each other in a cycle (even use themselves) which is a runtime
// fatal error in PHP, but must not send the analyser into an endless recursion
if (array_key_exists(strtolower($traitName), $this->currentlyProcessedTraits)) {
continue;
}
if (!$this->reflectionProvider->hasClass($traitName)) {
Expand Down Expand Up @@ -106,7 +106,12 @@ private function processTraitUse(NodeScopeResolver $nodeScopeResolver, Node\Stmt
$adaptations[] = $adaptation;
}
$parserNodes = $this->parser->parseFile($fileName);
$this->processNodesForTraitUse($nodeScopeResolver, $parserNodes, $traitReflection, $classScope, $storage, $adaptations, $nodeCallback);
$this->currentlyProcessedTraits[strtolower($traitName)] = true;
try {
$this->processNodesForTraitUse($nodeScopeResolver, $parserNodes, $traitReflection, $classScope, $storage, $adaptations, $nodeCallback);
} finally {
unset($this->currentlyProcessedTraits[strtolower($traitName)]);
}
}
}

Expand Down
147 changes: 103 additions & 44 deletions src/Reflection/ClassReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum;
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnumBackedCase;
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionMethod;
use PHPStan\BetterReflection\Reflection\Exception\CircularReference;
use PHPStan\DependencyInjection\GenerateFactory;
use PHPStan\DependencyInjection\Reflection\ClassReflectionExtensionRegistryProvider;
use PHPStan\Php\PhpVersion;
Expand Down Expand Up @@ -74,7 +75,6 @@
use function is_bool;
use function is_file;
use function is_int;
use function reset;
use function sprintf;
use function strtolower;

Expand Down Expand Up @@ -153,6 +153,13 @@ final class ClassReflection

private ClassReflection|false|null $cachedParentClass = false;

/**
* Name of the class the parent class chain loops back to, false when it has not been looked for yet.
*
* @var class-string|false|null
*/
private string|false|null $circularParentClassName = false;

/** @var array<string, TypeAlias>|null */
private ?array $typeAliases = null;

Expand Down Expand Up @@ -244,6 +251,11 @@ public function getParentClass(): ?ClassReflection
return $this->cachedParentClass = null;
}

$circularParentClassName = $this->findCircularParentClassName($parentClass->getName());
if ($circularParentClassName !== null) {
throw CircularReference::fromClassName($circularParentClassName);
}

$extendsTag = $this->getFirstExtendsTag();

if ($extendsTag !== null && $this->isValidAncestorType($extendsTag->getType(), [$parentClass->getName()])) {
Expand Down Expand Up @@ -277,6 +289,46 @@ public function getParentClass(): ?ClassReflection
return $parentReflection;
}

/**
* BetterReflection only rejects a class extending itself directly. A cycle spanning
* several classes (A extends B, B extends A) reaches this class intact and would make
* every walk over the class hierarchy run forever, so it is detected here instead.
*
* @param class-string $parentClassName
* @return class-string|null Name of the class the chain loops back to
*/
private function findCircularParentClassName(string $parentClassName): ?string
{
if (!is_bool($this->circularParentClassName)) {
return $this->circularParentClassName;
}

$this->circularParentClassName = null;

$visitedClassNames = [strtolower($this->getName()) => true];
$currentClassName = $parentClassName;

while (true) {
$lowercasedClassName = strtolower($currentClassName);
if (array_key_exists($lowercasedClassName, $visitedClassNames)) {
return $this->circularParentClassName = $currentClassName;
}

$visitedClassNames[$lowercasedClassName] = true;

if (!$this->reflectionProvider->hasClass($currentClassName)) {
return null;
}

$parentClass = $this->reflectionProvider->getClass($currentClassName)->reflection->getParentClass();
if ($parentClass === false) {
return null;
}

$currentClassName = $parentClass->getName();
}
}

/**
* @return class-string
*/
Expand Down Expand Up @@ -356,7 +408,7 @@ public function getClassHierarchyDistances(): array
$distances = [
$this->getName() => $distance,
];
$currentClassReflection = $this->getNativeReflection();
$currentClassReflection = $this;
foreach ($this->collectTraits($this->getNativeReflection()) as $trait) {
$distance++;
if (array_key_exists($trait->getName(), $distances)) {
Expand All @@ -366,14 +418,16 @@ public function getClassHierarchyDistances(): array
$distances[$trait->getName()] = $distance;
}

while ($currentClassReflection->getParentClass() !== false) {
// walking the parents through getParentClass() and not through the native
// reflection makes a cyclic class hierarchy end in a CircularReference
// exception instead of looping forever
while (($currentClassReflection = $currentClassReflection->getParentClass()) !== null) {
$distance++;
$parentClassName = $currentClassReflection->getParentClass()->getName();
$parentClassName = $currentClassReflection->getName();
if (!array_key_exists($parentClassName, $distances)) {
$distances[$parentClassName] = $distance;
}
$currentClassReflection = $currentClassReflection->getParentClass();
foreach ($this->collectTraits($currentClassReflection) as $trait) {
foreach ($this->collectTraits($currentClassReflection->getNativeReflection()) as $trait) {
$distance++;
if (array_key_exists($trait->getName(), $distances)) {
continue;
Expand Down Expand Up @@ -406,21 +460,21 @@ private function collectTraits(ReflectionClass|ReflectionEnum $class): array
$traitsLeftToAnalyze = $class->getTraits();

while (count($traitsLeftToAnalyze) !== 0) {
$trait = reset($traitsLeftToAnalyze);
$traits[] = $trait;
$trait = array_shift($traitsLeftToAnalyze);
// traits can use each other in a cycle - compare by name because
// the reflection objects are not guaranteed to be identical
if (array_key_exists($trait->getName(), $traits)) {
continue;
}

foreach ($trait->getTraits() as $subTrait) {
if (in_array($subTrait, $traits, true)) {
continue;
}
$traits[$trait->getName()] = $trait;

foreach ($trait->getTraits() as $subTrait) {
$traitsLeftToAnalyze[] = $subTrait;
}

array_shift($traitsLeftToAnalyze);
}

return $traits;
return array_values($traits);
}

public function allowsDynamicProperties(): bool
Expand Down Expand Up @@ -1248,12 +1302,12 @@ public function getTraits(bool $recursive = false): array
$traits = array_map(fn (ReflectionClass $trait): ClassReflection => $this->reflectionProvider->getClass($trait->getName()), $traits);

if ($recursive) {
$parentClass = $this->getNativeReflection()->getParentClass();
$parentClass = $this->getParentClass();

if ($parentClass !== false) {
if ($parentClass !== null) {
return array_merge(
$traits,
$this->reflectionProvider->getClass($parentClass->getName())->getTraits(true),
$parentClass->getTraits(true),
);
}
}
Expand Down Expand Up @@ -2165,41 +2219,46 @@ public function getAncestors(): array
$ancestors = [
$this->getName() => $this,
];
$this->collectAncestors($ancestors);

$addToAncestors = static function (string $name, ClassReflection $classReflection) use (&$ancestors): void {
if (array_key_exists($name, $ancestors)) {
return;
}
$this->ancestors = $ancestors;
}

$ancestors[$name] = $classReflection;
};
return $ancestors;
}

foreach ($this->getInterfaces() as $interface) {
$addToAncestors($interface->getName(), $interface);
foreach ($interface->getAncestors() as $name => $ancestor) {
$addToAncestors($name, $ancestor);
}
/**
* Descends into the interfaces, traits and parent classes, using the collected ancestors
* as the set of already visited classes. Traits can use each other in a cycle, which is a
* fatal error in PHP but must not make this walk run forever.
*
* @param array<string, ClassReflection> $ancestors
*/
private function collectAncestors(array &$ancestors): void
{
$addToAncestors = static function (ClassReflection $classReflection) use (&$ancestors): void {
if (array_key_exists($classReflection->getName(), $ancestors)) {
return;
}

foreach ($this->getTraits() as $trait) {
$addToAncestors($trait->getName(), $trait);
foreach ($trait->getAncestors() as $name => $ancestor) {
$addToAncestors($name, $ancestor);
}
}
$ancestors[$classReflection->getName()] = $classReflection;
$classReflection->collectAncestors($ancestors);
};

$parent = $this->getParentClass();
if ($parent !== null) {
$addToAncestors($parent->getName(), $parent);
foreach ($parent->getAncestors() as $name => $ancestor) {
$addToAncestors($name, $ancestor);
}
}
foreach ($this->getInterfaces() as $interface) {
$addToAncestors($interface);
}

$this->ancestors = $ancestors;
foreach ($this->getTraits() as $trait) {
$addToAncestors($trait);
}

return $ancestors;
$parent = $this->getParentClass();
if ($parent === null) {
return;
}

$addToAncestors($parent);
}

public function getAncestorWithClassName(string $className): ?self
Expand Down
42 changes: 42 additions & 0 deletions tests/PHPStan/Analyser/AnalyserIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
use PHPStan\Type\Constant\ConstantStringType;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\RequiresPhp;
use function array_map;
use function array_unique;
use function array_values;
use function extension_loaded;
use function sprintf;
use const PHP_VERSION_ID;
Expand Down Expand Up @@ -1024,6 +1027,45 @@ public function testBug8072(): void
$this->assertNoErrors($errors);
}

public function testBug8082(): void
{
// crash
$errors = $this->runAnalyse(__DIR__ . '/data/bug-8082.php');
$this->assertSame(
['Reflection error: Circular reference to class "Bug8082\\TraitUsesSelf"'],
$this->uniqueErrorMessages($errors),
);
}

public function testBug8082TraitCycle(): void
{
// crash
$errors = $this->runAnalyse(__DIR__ . '/data/bug-8082-trait-cycle.php');
$this->assertSame(
['Reflection error: Circular reference to class "Bug8082TraitCycle\\TraitA"'],
$this->uniqueErrorMessages($errors),
);
}

public function testCircularParentClass(): void
{
// crash
$errors = $this->runAnalyse(__DIR__ . '/data/circular-parent-class.php');
$this->assertSame(
['Reflection error: Circular reference to class "CircularParentClass\\Foo"'],
$this->uniqueErrorMessages($errors),
);
}

/**
* @param Error[] $errors
* @return list<string>
*/
private function uniqueErrorMessages(array $errors): array
{
return array_values(array_unique(array_map(static fn (Error $error): string => $error->getMessage(), $errors)));
}

public function testBug7787(): void
{
// crash
Expand Down
18 changes: 18 additions & 0 deletions tests/PHPStan/Analyser/data/bug-8082-trait-cycle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php declare(strict_types = 1);

namespace Bug8082TraitCycle;

trait TraitA
{
use TraitB;
}

trait TraitB
{
use TraitA;
}

class Foo
{
use TraitA;
}
13 changes: 13 additions & 0 deletions tests/PHPStan/Analyser/data/bug-8082.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace Bug8082;

trait TraitUsesSelf
{
use TraitUsesSelf;
}

class Foo
{
use TraitUsesSelf;
}
Loading
Loading