From 03ab0d0e3c161af94ee03c05266d2e2302601e2e Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:23:23 +0600 Subject: [PATCH] Ignore a lower bound that refers to the inferred template argument itself `$map = new WeakAnalysingMap(); $map->set($map);` with `@param TOwner $owner` puts `WeakAnalysingMap` as a lower bound on the `new` site's own `TOwner`. Resolving it substituted the site's default for the inner `TOwner`, so `$map` became `WeakAnalysingMap>` - and then no longer matched the parameter it was inferred from. A bound that contains the argument it bounds says nothing about it. Skip it, so the argument falls back to what 2.2 inferred. Closes https://github.com/phpstan/phpstan/issues/15434 --- .../Generics/TemplateArgumentSolver.php | 19 +++++++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 8 +++++++ .../PHPStan/Rules/Methods/data/bug-15434.php | 23 +++++++++++++++++++ 3 files changed, 50 insertions(+) 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..310d7d426c6 100644 --- a/src/Analyser/Generics/TemplateArgumentSolver.php +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -131,6 +131,18 @@ private function removeSelfBounds(Type $type, string $key): Type return $type; } + private function refersToKey(Type $type, string $key): bool + { + $refers = false; + TypeTraverser::map($type, function (Type $type, callable $traverse) use ($key, &$refers): Type { + if ($type instanceof UnresolvedTemplateArgumentType && $this->representative(self::key($type->getSite(), $type->getTemplateName())) === $key) { + $refers = true; + } + return $refers ? $type : $traverse($type); + }); + return $refers; + } + /** @var array */ private array $resolving = []; @@ -207,7 +219,14 @@ private function resolveObservation(array $observation): Type $initial = $observation['initial'] !== null ? $this->substituteResolutions($observation['initial']) : null; $template = $observation['marker']->getTemplate(); $lowerBounds = []; + $key = $this->representative(self::key($observation['marker']->getSite(), $observation['marker']->getTemplateName())); foreach ($observation['lowerBounds'] as $lowerBound) { + if ($this->refersToKey($lowerBound, $key)) { + // the object passed to its own method ($map->set($map) with + // set(TOwner $owner)) can only be bounded by itself - it says + // nothing about the argument + continue; + } $inferred = $template->inferTemplateTypes($this->substituteResolutions($lowerBound))->getType($template->getName()); if ($inferred === null) { continue; diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index e03a97fc739..25765ed7ee4 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4560,6 +4560,14 @@ public function testBug15251(): void $this->analyse([__DIR__ . '/data/bug-15251.php'], []); } + public function testBug15434(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-15434.php'], []); + } + #[RequiresPhp('>= 8.1.0')] public function testClassConstantWildcardArguments(): void { 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..1aa4d33de20 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15434.php @@ -0,0 +1,23 @@ +set($map); + } +} + +/** + * @template TOwner of object + */ +class WeakAnalysingMap +{ + /** + * @param TOwner $owner + */ + public function set(object $owner): void {} +} \ No newline at end of file