From 5f688c7f460ce37a2273f50556ea33228ff6a25c Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:43:35 +0000 Subject: [PATCH] Generalize a template argument resolution when writes widen the object beyond its initial type - TemplateArgumentSolver::resolveObservation(): when the lower bounds put into an object (`$box->set(...)`) are not covered by the type it was created with, the union of the initial type and the lower bounds is generalized (GeneralizePrecision::lessSpecific()). The observation pass reads the object as its initial type, so a value derived from a read and written back (`$box->set($box->get() + 1)`) is one step of a loop: the second pass read `0|1` and wrote `1|2`, which `Box<0|1>` rejected. - Covers `new`, static generic factories and generic functions creating the object, in straight-line code, loops, closures and arrow functions, and int/string/array values. - Writes that stay within the initial type keep the precise resolution. - Updated two expectations that pinned the literal union after an unrelated write widened a known initial type (now generalized). - Probed: writes to template-typed properties are not observed as lower bounds at all (a separate gap in AssignHandler, unchanged here). --- .../Generics/TemplateArgumentSolver.php | 12 +- .../Generics/TemplateArgumentResolverTest.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-15430.php | 148 ++++++++++++++++++ .../nsrt/template-argument-multiple-sites.php | 2 +- .../Rules/Methods/CallMethodsRuleTest.php | 9 ++ .../PHPStan/Rules/Methods/data/bug-15430.php | 49 ++++++ 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15430.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-15430.php diff --git a/src/Analyser/Generics/TemplateArgumentSolver.php b/src/Analyser/Generics/TemplateArgumentSolver.php index 25fcb53900b..0c26dc449da 100644 --- a/src/Analyser/Generics/TemplateArgumentSolver.php +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\MutatingScope; use PHPStan\Turbo\ReferencedByTurboExtension; +use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\MixedType; @@ -297,7 +298,16 @@ private function resolveObservation(array $observation): Type if (TemplateArgumentStats::$enabled) { TemplateArgumentStats::increment(count($lowerBounds) > 0 ? 'resolvedWithLowerBounds' : 'resolvedToInitial'); } - return TypeCombinator::union(...$parts); + $resolved = TypeCombinator::union(...$parts); + if ($initial !== null && !$initial instanceof NeverType && !$initial->isSuperTypeOf($resolved)->yes()) { + // the body puts values into the object it was not created with: what + // the observation pass read out of it was the initial type alone, so + // a value derived from such a read and put back ($box->set($box->get() + 1)) + // is one step of a loop - generalized like a loop's variables + return $resolved->generalize(GeneralizePrecision::lessSpecific()); + } + + return $resolved; } /** diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php index eb6e2d2ae33..f7ccb80fabc 100644 --- a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php @@ -128,7 +128,7 @@ public function testLowerBoundsUnionWithTheInitialUnlessASendWins(): void $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument(new CallableType([], $marker, false), new CallableType([], new StringType(), false))); $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); - $this->assertSame("1|2|'a'|null", self::describe($frame->resolve($site, 'T')), 'callable parameters are contravariant and contribute nothing'); + $this->assertSame('int|string|null', self::describe($frame->resolve($site, 'T')), 'callable parameters are contravariant and contribute nothing'); [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument($ofMarker->getTypes()[0], new ConstantStringType('a'))); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15430.php b/tests/PHPStan/Analyser/nsrt/bug-15430.php new file mode 100644 index 00000000000..121bd5df3c1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15430.php @@ -0,0 +1,148 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15430; + +use function PHPStan\Testing\assertType; + +/** @template T */ +final class Box +{ + /** @param T $value */ + public function __construct(private mixed $value) + { + } + + /** @return T */ + public function get(): mixed + { + return $this->value; + } + + /** @param T $value */ + public function set(mixed $value): void + { + $this->value = $value; + } + + /** + * @template U + * @param U $value + * @return self + */ + public static function of(mixed $value): self + { + return new self($value); + } +} + +/** @param callable(): void $callback */ +function repeat(int $times, callable $callback): void +{ + for ($i = 0; $i < $times; $i++) { + $callback(); + } +} + +/** + * @template U + * @param U $value + * @return Box + */ +function wrap(mixed $value): Box +{ + return new Box($value); +} + +function test(): void +{ + $count = new Box(0); + + repeat(5, function () use ($count): void { + $count->set($count->get() + 1); + }); + + assertType('Bug15430\Box', $count); + + if ($count->get() === 5) { + echo 'reached at runtime'; + } +} + +function straight(): void +{ + $count = new Box(0); + $count->set($count->get() + 1); + assertType('Bug15430\Box', $count); +} + +function loop(): void +{ + $count = new Box(0); + for ($i = 0; $i < 5; $i++) { + $count->set($count->get() + 1); + } + assertType('Bug15430\Box', $count); +} + +function arrowFunction(): void +{ + $count = new Box(0); + repeat(5, fn () => $count->set($count->get() + 1)); + assertType('Bug15430\Box', $count); +} + +function staticFactory(): void +{ + $count = Box::of(0); + repeat(5, function () use ($count): void { + $count->set($count->get() + 1); + }); + assertType('Bug15430\Box', $count); +} + +function genericFunction(): void +{ + $count = wrap(0); + repeat(5, function () use ($count): void { + $count->set($count->get() + 1); + }); + assertType('Bug15430\Box', $count); +} + +function concat(): void +{ + $s = new Box('a'); + repeat(5, function () use ($s): void { + $s->set($s->get() . 'b'); + }); + assertType('Bug15430\Box', $s); +} + +function append(): void +{ + $s = new Box([]); + repeat(5, function () use ($s): void { + $a = $s->get(); + $a[] = 1; + $s->set($a); + }); + assertType('Bug15430\Box>', $s); +} + +function shape(): void +{ + $s = new Box(['a' => 1]); + repeat(5, function () use ($s): void { + $s->set(['a' => $s->get()['a'] + 1]); + }); + assertType('Bug15430\Box>', $s); +} + +function writtenBackUnchanged(): void +{ + $b = new Box(0); + $b->set($b->get()); + assertType('Bug15430\Box<0>', $b); +} diff --git a/tests/PHPStan/Analyser/nsrt/template-argument-multiple-sites.php b/tests/PHPStan/Analyser/nsrt/template-argument-multiple-sites.php index b917657b054..4ea1941650a 100644 --- a/tests/PHPStan/Analyser/nsrt/template-argument-multiple-sites.php +++ b/tests/PHPStan/Analyser/nsrt/template-argument-multiple-sites.php @@ -205,7 +205,7 @@ function covariantIntoInvariantInferred(): void $z = collectBoxes($a, $b); $z->add(3); assertType('TemplateArgumentMultipleSites\Box<1>', $a); - assertType('TemplateArgumentMultipleSites\Coll<1|2|3>', $z); + assertType('TemplateArgumentMultipleSites\Coll', $z); } function invariantIntoCovariant(): void diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 8e6c4084026..84b2f42076b 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4560,6 +4560,15 @@ public function testBug15251(): void $this->analyse([__DIR__ . '/data/bug-15251.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug15430(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-15430.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testBug12820(): void { diff --git a/tests/PHPStan/Rules/Methods/data/bug-15430.php b/tests/PHPStan/Rules/Methods/data/bug-15430.php new file mode 100644 index 00000000000..52edbdc0f6d --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15430.php @@ -0,0 +1,49 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15430Rule; + + +/** @template T */ +final class Box +{ + /** @param T $value */ + public function __construct(private mixed $value) + { + } + + /** @return T */ + public function get(): mixed + { + return $this->value; + } + + /** @param T $value */ + public function set(mixed $value): void + { + $this->value = $value; + } +} + +/** @param callable(): void $callback */ +function repeat(int $times, callable $callback): void +{ + for ($i = 0; $i < $times; $i++) { + $callback(); + } +} + +function test(): void +{ + $count = new Box(0); + + repeat(5, function () use ($count): void { + $count->set($count->get() + 1); + }); + + + if ($count->get() === 5) { + echo 'reached at runtime'; + } +}