From 4eceb580d8721db6e2734b6b10366e254c2f546c Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:44:40 +0000 Subject: [PATCH] Resolve an inferred template argument to its bound when a lower bound refers back to a site still being resolved - TemplateArgumentSolver: count when substituting a marker reaches a site that is still being resolved (a cycle), and when that happens while substituting a lower bound, resolve the site to the template's bound. `T :> Foo` has no finite solution other than the bound; previously the cycle was broken with the delegate, inferring e.g. `WeakAnalysingMap>` for `$map = new WeakAnalysingMap(); $map->set($map);` and then reporting the call. - The counter is restored after each site's resolution, so a cycle settled inside another site (indirect cycle `$a->set($b); $b->set($a);`) does not widen the outer site too. - Also covers the analogous shapes: indirect cycles across two objects, the object wrapped in a list/array argument, a cycle after another lower bound, a constructor-inferred initial type, and contravariant templates. - TemplateArgumentSolver is not shadowed by turbo (only instantiated from C++), so no native mirror change is needed. --- .../Generics/TemplateArgumentSolver.php | 16 +++- tests/PHPStan/Analyser/nsrt/bug-15434.php | 86 +++++++++++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 8 ++ .../PHPStan/Rules/Methods/data/bug-15434.php | 82 ++++++++++++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15434.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-15434.php diff --git a/src/Analyser/Generics/TemplateArgumentSolver.php b/src/Analyser/Generics/TemplateArgumentSolver.php index 25fcb53900b..df66ea705cb 100644 --- a/src/Analyser/Generics/TemplateArgumentSolver.php +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -134,6 +134,9 @@ private function removeSelfBounds(Type $type, string $key): Type /** @var array */ private array $resolving = []; + /** How many times a substitution reached a site that was still being resolved. */ + private int $cyclesReached = 0; + private function resolveKey(string $key): Type { $key = $this->representative($key); @@ -144,14 +147,18 @@ private function resolveKey(string $key): Type if (isset($this->resolving[$key])) { // a site whose inferred argument refers back to itself through another // site (wrap($x = new Foo($x))): the inferred type stands + $this->cyclesReached++; return $observation['marker']->getDelegate(); } $this->resolving[$key] = true; + $cyclesReached = $this->cyclesReached; try { return $this->resolutions[$key] = $this->resolveObservation($observation); } finally { unset($this->resolving[$key]); + // a cycle closed inside another site's resolution was settled there + $this->cyclesReached = $cyclesReached; } } @@ -208,7 +215,14 @@ private function resolveObservation(array $observation): Type $template = $observation['marker']->getTemplate(); $lowerBounds = []; foreach ($observation['lowerBounds'] as $lowerBound) { - $inferred = $template->inferTemplateTypes($this->substituteResolutions($lowerBound))->getType($template->getName()); + $cyclesReached = $this->cyclesReached; + $lowerBound = $this->substituteResolutions($lowerBound); + if ($this->cyclesReached !== $cyclesReached) { + // the object is sent into its own argument ($map->set($map)): no + // finite type contains Foo, only the bound does + return $template->getBound(); + } + $inferred = $template->inferTemplateTypes($lowerBound)->getType($template->getName()); if ($inferred === null) { continue; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15434.php b/tests/PHPStan/Analyser/nsrt/bug-15434.php new file mode 100644 index 00000000000..330724c195b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15434.php @@ -0,0 +1,86 @@ +', $map); + $map->set($map); + assertType('Bug15434Types\WeakAnalysingMap', $map); + } + + public function indirectCycle(): void + { + $a = new WeakAnalysingMap(); + $b = new WeakAnalysingMap(); + $a->set($b); + $b->set($a); + assertType('Bug15434Types\WeakAnalysingMap>', $a); + assertType('Bug15434Types\WeakAnalysingMap', $b); + } + + public function afterAnotherLowerBound(): void + { + $b = new Box(); + $b->add(1); + $b->add($b); + assertType('Bug15434Types\Box', $b); + } + + public function contravariant(): void + { + $s = new Sink(); + $s->put($s); + assertType('Bug15434Types\Sink', $s); + } + + public function notACycle(): void + { + $b = new Box(); + $c = new Box(); + $b->add($c); + $c->add(1); + assertType('Bug15434Types\Box<1>', $c); + assertType('Bug15434Types\Box>', $b); + } + +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 8e6c4084026..f754af637bb 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4616,4 +4616,12 @@ public function testDeadCatch(): void ]); } + public function testBug15434(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-15434.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-15434.php b/tests/PHPStan/Rules/Methods/data/bug-15434.php new file mode 100644 index 00000000000..11752e1643b --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15434.php @@ -0,0 +1,82 @@ +set($map); + } +} + +/** + * @template TOwner of object + */ +class WeakAnalysingMap +{ + /** + * @param TOwner $owner + */ + public function set(object $owner): void {} +} + +/** @template T */ +class Box +{ + + /** @param T $v */ + public function __construct($v = null) + { + } + + /** @param T $v */ + public function add($v): void + { + } + + /** @param list $v */ + public function addAll(array $v): void + { + } + +} + +class Analogous +{ + + public function indirectCycle(): void + { + $a = new WeakAnalysingMap(); + $b = new WeakAnalysingMap(); + $a->set($b); + $b->set($a); + } + + public function afterAnotherLowerBound(): void + { + $b = new Box(); + $b->add(1); + $b->add($b); + } + + public function wrappedInList(): void + { + $b = new Box(); + $b->addAll([$b]); + } + + public function wrappedInArray(): void + { + $b = new Box(); + $b->add([$b]); + } + + public function withInitialType(): void + { + $b = new Box(1); + $b->add($b); + } + +}