From 5c4a12dc41b06146fe27666e2c21ec03bee678c7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 13:50:23 +0100 Subject: [PATCH 1/7] Specify conditional types for all falsey scalars --- src/Analyser/NodeScopeResolver.php | 30 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13546.php | 4 +-- tests/PHPStan/Analyser/nsrt/bug-14081.php | 4 +-- ...nexistentOffsetInArrayDimFetchRuleTest.php | 2 +- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0ee1be1651..483937b860 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -173,6 +173,7 @@ use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\TemplateTypeHelper; @@ -6108,6 +6109,35 @@ private function processAssignVar( $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); } + foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { + $falseyType = ConstantTypeHelper::getTypeFromValue($falseyScalar); + $withoutFalseyType = TypeCombinator::remove($type, $falseyType); + if ( + $withoutFalseyType->equals($type) + || $withoutFalseyType->equals($truthyType) + ) { + continue; + } + + $astNode = match ($falseyScalar) { + null => new ConstFetch(new Name('null')), + false => new ConstFetch(new Name('false')), + 0 => new Node\Scalar\Int_($falseyScalar), + 0.0 => new Node\Scalar\Float_($falseyScalar), + '', '0' => new Node\Scalar\String_($falseyScalar), + [] => new Node\Expr\Array_($falseyScalar), + }; + $notConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); + $notSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notConditionExpr, TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notSpecifiedTypes, $withoutFalseyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notSpecifiedTypes, $withoutFalseyType); + + $conditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); + $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $conditionExpr, TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $specifiedTypes, $falseyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $specifiedTypes, $falseyType); + } + $this->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-13546.php b/tests/PHPStan/Analyser/nsrt/bug-13546.php index 465689b211..994039a6c1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13546.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13546.php @@ -66,10 +66,10 @@ function mixedLast($mixed): void function firstInCondition(array $array) { if (($key = array_key_first($array)) !== null) { - assertType('list', $array); // could be 'non-empty-list' + assertType('non-empty-list', $array); return $array[$key]; } - assertType('list', $array); + assertType('array{}', $array); return null; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php index 277c811499..5e1f3b9807 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14081.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -48,7 +48,7 @@ function firstNotNull(array $array): mixed { if (($key = array_key_first($array)) !== null) { assertType('int<0, max>', $key); - assertType('list', $array); // could be non-empty-list + assertType('non-empty-list', $array); assertType('string', $array[$key]); return $array[$key]; } @@ -60,7 +60,7 @@ function lastNotNull(array $array): mixed { if (($key = array_key_last($array)) !== null) { assertType('int<0, max>', $key); - assertType('list', $array); // could be non-empty-list + assertType('non-empty-list', $array); assertType('string', $array[$key]); return $array[$key]; } diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index a641163fe1..73d4f028e0 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -840,7 +840,7 @@ public function testArrayDimFetchAfterArraySearch(): void $this->analyse([__DIR__ . '/data/array-dim-after-array-search.php'], [ [ - 'Offset int|string might not exist on array.', + 'Offset int|string might not exist on non-empty-array.', 20, ], ]); From 07f992953995b00d74b0947f2e2b6bc7b5256999 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 13:52:29 +0100 Subject: [PATCH 2/7] fix --- phpstan-baseline.neon | 6 ------ src/Analyser/NodeScopeResolver.php | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a6d38c503c..fd1a720cb4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -411,12 +411,6 @@ parameters: count: 1 path: src/Reflection/ClassReflection.php - - - rawMessage: 'Method PHPStan\Reflection\ClassReflection::getCacheKey() should return string but returns string|null.' - identifier: return.type - count: 1 - path: src/Reflection/ClassReflection.php - - rawMessage: Binary operation "&" between bool|float|int|string|null and bool|float|int|string|null results in an error. identifier: binaryOp.invalid diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 483937b860..f50ccad0ed 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6121,9 +6121,9 @@ private function processAssignVar( $astNode = match ($falseyScalar) { null => new ConstFetch(new Name('null')), - false => new ConstFetch(new Name('false')), - 0 => new Node\Scalar\Int_($falseyScalar), - 0.0 => new Node\Scalar\Float_($falseyScalar), + false => new ConstFetch(new Name('false')), + 0 => new Node\Scalar\Int_($falseyScalar), + 0.0 => new Node\Scalar\Float_($falseyScalar), '', '0' => new Node\Scalar\String_($falseyScalar), [] => new Node\Expr\Array_($falseyScalar), }; From deb052adc214c57e66ba56b09a8cbdd16afd63b4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 13:53:53 +0100 Subject: [PATCH 3/7] fix downgrade --- src/Analyser/NodeScopeResolver.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f50ccad0ed..b4f58dd83f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6119,14 +6119,20 @@ private function processAssignVar( continue; } - $astNode = match ($falseyScalar) { - null => new ConstFetch(new Name('null')), - false => new ConstFetch(new Name('false')), - 0 => new Node\Scalar\Int_($falseyScalar), - 0.0 => new Node\Scalar\Float_($falseyScalar), - '', '0' => new Node\Scalar\String_($falseyScalar), - [] => new Node\Expr\Array_($falseyScalar), - }; + if ($falseyScalar == null) { + $astNode = new ConstFetch(new Name('null')); + } elseif ($falseyScalar == false) { + $astNode = new ConstFetch(new Name('false')); + } elseif ($falseyScalar == 0) { + $astNode = new Node\Scalar\Int_($falseyScalar); + } elseif ($falseyScalar == 0.0) { + $astNode = new Node\Scalar\Float_($falseyScalar); + } elseif ($falseyScalar == '' || $falseyScalar == '0') { + $astNode = new Node\Scalar\String_($falseyScalar); + } elseif ($falseyScalar == []) { + $astNode = new Node\Expr\Array_($falseyScalar); + } + $notConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); $notSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notConditionExpr, TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notSpecifiedTypes, $withoutFalseyType); From 421fd73a7f9c3647346933d365ab917af46549da Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 13:54:31 +0100 Subject: [PATCH 4/7] cs --- src/Analyser/NodeScopeResolver.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b4f58dd83f..67dc7040f1 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6119,17 +6119,17 @@ private function processAssignVar( continue; } - if ($falseyScalar == null) { + if ($falseyScalar === null) { $astNode = new ConstFetch(new Name('null')); - } elseif ($falseyScalar == false) { + } elseif ($falseyScalar === false) { $astNode = new ConstFetch(new Name('false')); - } elseif ($falseyScalar == 0) { + } elseif ($falseyScalar === 0) { $astNode = new Node\Scalar\Int_($falseyScalar); - } elseif ($falseyScalar == 0.0) { + } elseif ($falseyScalar === 0.0) { $astNode = new Node\Scalar\Float_($falseyScalar); - } elseif ($falseyScalar == '' || $falseyScalar == '0') { + } elseif ($falseyScalar === '' || $falseyScalar === '0') { $astNode = new Node\Scalar\String_($falseyScalar); - } elseif ($falseyScalar == []) { + } elseif ($falseyScalar === []) { $astNode = new Node\Expr\Array_($falseyScalar); } From 59ccfb9db1e0739a6b8fa1cdfbf4e1457689f901 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 14:00:48 +0100 Subject: [PATCH 5/7] fix build --- src/Analyser/NodeScopeResolver.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 67dc7040f1..aff674cd9f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6127,21 +6127,21 @@ private function processAssignVar( $astNode = new Node\Scalar\Int_($falseyScalar); } elseif ($falseyScalar === 0.0) { $astNode = new Node\Scalar\Float_($falseyScalar); - } elseif ($falseyScalar === '' || $falseyScalar === '0') { + } elseif (\in_array($falseyScalar, ['', '0'], true)) { $astNode = new Node\Scalar\String_($falseyScalar); } elseif ($falseyScalar === []) { $astNode = new Node\Expr\Array_($falseyScalar); } - $notConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); - $notSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notSpecifiedTypes, $withoutFalseyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notSpecifiedTypes, $withoutFalseyType); + $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); + $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType); - $conditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); - $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $conditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $specifiedTypes, $falseyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $specifiedTypes, $falseyType); + $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); + $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType); } $this->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); From 981f732a5dc680e63dbfb51c9591e8bfd6df56a5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 14:10:42 +0100 Subject: [PATCH 6/7] regression tests --- tests/PHPStan/Analyser/nsrt/bug-10482.php | 24 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13709.php | 17 +++++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 9 +++++++ tests/PHPStan/Rules/Methods/data/bug-6120.php | 20 ++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10482.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13709.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-6120.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-10482.php b/tests/PHPStan/Analyser/nsrt/bug-10482.php new file mode 100644 index 0000000000..d9ef43dc15 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10482.php @@ -0,0 +1,24 @@ +id; +if (null !== $testId) { + assertType('Bug10482\Test', $test); +} + +if ($testId) { + assertType('Bug10482\Test', $test); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13709.php b/tests/PHPStan/Analyser/nsrt/bug-13709.php new file mode 100644 index 0000000000..0b87324414 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13709.php @@ -0,0 +1,17 @@ += 8.1')] + public function testBug6120(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-6120.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-6120.php b/tests/PHPStan/Rules/Methods/data/bug-6120.php new file mode 100644 index 0000000000..a498ae73b3 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-6120.php @@ -0,0 +1,20 @@ += 8.0 + +declare(strict_types=1); + +namespace Bug6120; + +class Clazz +{ + + public int $foo = 0; + + public function bar(?Clazz $clazz): void + { + $result = $clazz?->foo; + if ($result !== null) { + $clazz->bar(null); + } + } + +} From 7501d2464af87d08dea122ce131c0320d82ad3d6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 14:16:36 +0100 Subject: [PATCH 7/7] Update NodeScopeResolver.php --- src/Analyser/NodeScopeResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index aff674cd9f..bb361d5615 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6127,7 +6127,7 @@ private function processAssignVar( $astNode = new Node\Scalar\Int_($falseyScalar); } elseif ($falseyScalar === 0.0) { $astNode = new Node\Scalar\Float_($falseyScalar); - } elseif (\in_array($falseyScalar, ['', '0'], true)) { + } elseif (in_array($falseyScalar, ['', '0'], true)) { $astNode = new Node\Scalar\String_($falseyScalar); } elseif ($falseyScalar === []) { $astNode = new Node\Expr\Array_($falseyScalar);