From 54320e18bc6e0f01621ec1d81bd5f297bbe5789c Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:47:10 +0000 Subject: [PATCH] Recurse into parent expression in `IssetCheck` and `MutatingScope::issetCheck` when property has propagated error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In `IssetCheck::check()`, when a non-null `$error` is passed in from a deeper property check, recurse into `$expr->var` (or `$expr->class` for static properties) instead of returning the error immediately. This lets array dim fetches further up the chain clear the error when the offset might not exist. - Apply the same fix in `MutatingScope::issetCheck()` for the `$result` parameter, ensuring type inference for `??` expressions also considers parent array accesses. - Affects `??`, `??=`, `isset()`, and `empty()` — all share the same `IssetCheck::check()` code path. `empty()` was probed but doesn't trigger the false positive for scalar types because the falsiness callback returns null before an error is generated. - Static property access (`$array[0]::$prop->value ?? null`) also fixed by the same change (the `$expr->class instanceof Expr` branch). --- src/Analyser/MutatingScope.php | 8 +++ src/Rules/IssetCheck.php | 8 +++ tests/PHPStan/Analyser/nsrt/bug-14555.php | 26 ++++++++++ .../PHPStan/Rules/Variables/IssetRuleTest.php | 7 +++ .../Rules/Variables/NullCoalesceRuleTest.php | 6 +++ .../Rules/Variables/data/bug-14555.php | 50 +++++++++++++++++++ 6 files changed, 105 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14555.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-14555.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 64e46074738..310c280c2a5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1087,6 +1087,14 @@ public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = n } if ($result !== null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->issetCheck($expr->var, $typeCallback, $result); + } + + if ($expr->class instanceof Expr) { + return $this->issetCheck($expr->class, $typeCallback, $result); + } + return $result; } diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index 594d01dd259..35195e7ea52 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -196,6 +196,14 @@ static function (Type $type) use ($typeMessageCallback): ?string { $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $expr); $propertyType = $propertyReflection->getWritableType(); if ($error !== null) { + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); + } + + if ($expr->class instanceof Expr) { + return $this->check($expr->class, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); + } + return $error; } if (!$this->checkAdvancedIsset) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14555.php b/tests/PHPStan/Analyser/nsrt/bug-14555.php new file mode 100644 index 00000000000..61e210bbec6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14555.php @@ -0,0 +1,26 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14555Nsrt; + +use function PHPStan\Testing\assertType; + +class ValueObject { + function __construct( + public readonly string $value, + ) {} +} + +class SomeDTO { + function __construct( + public readonly ValueObject $value, + ) {} +} + +/** @param array> $array */ +function testCoalesceType(array $array): void +{ + $someValue = $array['someKey'][0]->value->value ?? null; + assertType('string|null', $someValue); +} diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index cbf9d52759b..c1492e62901 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -531,6 +531,13 @@ public function testBug9503(): void $this->analyse([__DIR__ . '/data/bug-9503.php'], []); } + public function testBug14555(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/bug-14555.php'], []); + } + public function testBug14393(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 09960f4a32f..b8ed61a1d96 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -397,6 +397,12 @@ public function testBug14458(): void $this->analyse([__DIR__ . '/data/bug-14458.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testBug14555(): void + { + $this->analyse([__DIR__ . '/data/bug-14555.php'], []); + } + #[RequiresPhp('>= 8.1.0')] public function testBug14459(): void { diff --git a/tests/PHPStan/Rules/Variables/data/bug-14555.php b/tests/PHPStan/Rules/Variables/data/bug-14555.php new file mode 100644 index 00000000000..25aa52f126a --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-14555.php @@ -0,0 +1,50 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14555; + +class ValueObject { + function __construct( + public readonly string $value, + ) {} +} + +class SomeDTO { + function __construct( + public readonly ValueObject $value, + ) {} +} + +class StaticHolder { + public static ValueObject $value; +} + +/** @param array> $array */ +function exampleNullCoalesce(array $array): void +{ + $someValue = $array['someKey'][0]->value->value ?? null; + + $dto = $array['someKey'][0] ?? null; + $someValue2 = $dto->value->value ?? null; +} + +/** @param array> $array */ +function exampleIsset(array $array): void +{ + if (isset($array['someKey'][0]->value->value)) { + echo 'yes'; + } +} + +/** @param array> $array */ +function exampleNullCoalesceAssign(array $array): void +{ + $someValue = $array['someKey'][0]->value->value ??= 'default'; +} + +/** @param array> $array */ +function exampleStaticProperty(array $array): void +{ + $someValue = $array['someKey'][0]::$value->value ?? null; +}