From 967ed70f6e74e3d848732dc0b837dfa815924186 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:26:43 +0000 Subject: [PATCH 01/12] Fix array not narrowed to non-empty when count stored in variable - When $count = count($a) and later $count > 1 or $count === 1, the array $a was not narrowed to non-empty-array - Root cause: conditional expression matching required exact type equality, but count comparisons produce subtypes (e.g. int<2, max> is a subtype of int<1, max>) - Added conditionalExpressionHolderMatches() that uses isSuperTypeOf for IntegerRangeType conditions, enabling count-based narrowing - New regression test in tests/PHPStan/Analyser/nsrt/bug-4090.php Closes https://github.com/phpstan/phpstan/issues/4090 --- src/Analyser/MutatingScope.php | 16 +++++++- tests/PHPStan/Analyser/nsrt/bug-4090.php | 50 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-4090.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a43ba35dda..f6425e9399 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2144,6 +2144,20 @@ public function enterAnonymousFunctionWithoutReflection( ); } + private function conditionalExpressionHolderMatches(ExpressionTypeHolder $specified, ExpressionTypeHolder $condition): bool + { + if ($specified->equals($condition)) { + return true; + } + + $conditionType = $condition->getType(); + if (!$conditionType instanceof IntegerRangeType) { + return false; + } + + return $conditionType->isSuperTypeOf($specified->getType())->yes(); + } + private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): bool { $expr = $typeHolder->getExpr(); @@ -3218,7 +3232,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { - if (!array_key_exists($holderExprString, $specifiedExpressions) || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { + if (!array_key_exists($holderExprString, $specifiedExpressions) || !$this->conditionalExpressionHolderMatches($specifiedExpressions[$holderExprString], $conditionalTypeHolder)) { continue 2; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-4090.php b/tests/PHPStan/Analyser/nsrt/bug-4090.php new file mode 100644 index 0000000000..1617f42f42 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4090.php @@ -0,0 +1,50 @@ + 1) { + assertType('non-empty-array', $a); + echo implode(',', $a); + } elseif (count($a) === 1) { + assertType('non-empty-array', $a); + echo trim(current($a)); + } +} + + +/** @param string[] $a */ +function bar(array $a): void +{ + $count = count($a); + if ($count > 1) { + assertType('non-empty-array', $a); + echo implode(',', $a); + } elseif ($count === 1) { + assertType('non-empty-array', $a); + echo trim(current($a)); + } +} + + +/** @param string[] $a */ +function qux(array $a): void +{ + switch (count($a)) { + case 0: + assertType('array{}', $a); + break; + case 1: + assertType('non-empty-array', $a); + echo trim(current($a)); + break; + default: + assertType('non-empty-array', $a); + echo implode(',', $a); + break; + } +} From 5c37e8a97da8406c007ad39fe7ac95725d9918e0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 2 Apr 2026 18:09:49 +0000 Subject: [PATCH 02/12] Replace instanceof IntegerRangeType with Type methods in conditional matching Address review feedback: use Type::isInteger() and Type::isConstantScalarValue() instead of instanceof IntegerRangeType check. Also add explicit certainty check before the type comparison. The isSuperTypeOf matching is restricted to non-constant integer types (i.e. integer ranges like int<1, max>) because using it for constant scalar values (like unions of constant integers 0|2) causes cascading over-narrowing when multiple conditional expressions match and their types get intersected. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f6425e9399..159fd07d01 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2150,8 +2150,12 @@ private function conditionalExpressionHolderMatches(ExpressionTypeHolder $specif return true; } + if (!$specified->getCertainty()->equals($condition->getCertainty())) { + return false; + } + $conditionType = $condition->getType(); - if (!$conditionType instanceof IntegerRangeType) { + if (!$conditionType->isInteger()->yes() || $conditionType->isConstantScalarValue()->yes()) { return false; } From 13f95d59ac136c9e9ccf99a62af48a505bbcedc2 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 2 Apr 2026 18:29:51 +0000 Subject: [PATCH 03/12] Add comment explaining why isSuperTypeOf matching is limited to integer ranges The conditional expression system intersects results from ALL matching conditions. Using isSuperTypeOf broadly causes types like bool to match both 'false' and 'bool' conditions simultaneously, producing *NEVER* when their results conflict. Integer ranges (from count()) don't have this problem because their conditions are non-overlapping. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 159fd07d01..9bc92087c6 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2155,6 +2155,18 @@ private function conditionalExpressionHolderMatches(ExpressionTypeHolder $specif } $conditionType = $condition->getType(); + + // Only use isSuperTypeOf for non-constant integer types (i.e. IntegerRangeType). + // This is needed for count()-in-variable patterns: when $count = count($a) creates + // a condition on int<1, max>, and $count is later narrowed to int<2, max> or 1, + // the subtype relationship correctly preserves the array narrowing. + // + // We cannot safely extend this to all types because filterBySpecifiedTypes() + // intersects results from ALL matching conditional expressions (line ~3251). + // For types like bool, isSuperTypeOf would match both a 'false' condition AND + // a 'bool' condition simultaneously, and intersecting their (potentially conflicting) + // results produces *NEVER*. Integer ranges don't have this problem because + // count() creates non-overlapping conditions (e.g. int<1, max> vs 0). if (!$conditionType->isInteger()->yes() || $conditionType->isConstantScalarValue()->yes()) { return false; } From 3f5607476acb42b31f4eda260d7ca3d30010c238 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 2 Apr 2026 19:03:44 +0000 Subject: [PATCH 04/12] Use isSuperTypeOf for all types in conditional expression matching Instead of restricting isSuperTypeOf to integer ranges, use a two-pass strategy: try exact matches first, then fall back to isSuperTypeOf when no exact match exists. The fallback only accepts a single match to avoid intersecting potentially conflicting results from overlapping conditions (e.g. mixed~null, mixed~false, mixed~0 all being supertypes of false). This generalizes the fix beyond IntegerRangeType while keeping existing behavior intact. Updated bug-5051 test expectations to reflect improved precision (PHPStan can now narrow $update to false when $data is known). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 49 ++++++++++++++---------- tests/PHPStan/Analyser/nsrt/bug-5051.php | 14 +++---- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 9bc92087c6..afe3a206ff 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2154,24 +2154,7 @@ private function conditionalExpressionHolderMatches(ExpressionTypeHolder $specif return false; } - $conditionType = $condition->getType(); - - // Only use isSuperTypeOf for non-constant integer types (i.e. IntegerRangeType). - // This is needed for count()-in-variable patterns: when $count = count($a) creates - // a condition on int<1, max>, and $count is later narrowed to int<2, max> or 1, - // the subtype relationship correctly preserves the array narrowing. - // - // We cannot safely extend this to all types because filterBySpecifiedTypes() - // intersects results from ALL matching conditional expressions (line ~3251). - // For types like bool, isSuperTypeOf would match both a 'false' condition AND - // a 'bool' condition simultaneously, and intersecting their (potentially conflicting) - // results produces *NEVER*. Integer ranges don't have this problem because - // count() creates non-overlapping conditions (e.g. int<1, max> vs 0). - if (!$conditionType->isInteger()->yes() || $conditionType->isConstantScalarValue()->yes()) { - return false; - } - - return $conditionType->isSuperTypeOf($specified->getType())->yes(); + return $condition->getType()->isSuperTypeOf($specified->getType())->yes(); } private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): bool @@ -3246,6 +3229,28 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self if (array_key_exists($conditionalExprString, $conditions)) { continue; } + + // Pass 1: exact matches + foreach ($conditionalExpressions as $conditionalExpression) { + $allExact = true; + foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { + if (!array_key_exists($holderExprString, $specifiedExpressions) || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { + $allExact = false; + break; + } + } + if ($allExact) { + $conditions[$conditionalExprString][] = $conditionalExpression; + $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + } + } + + if (array_key_exists($conditionalExprString, $conditions)) { + continue; + } + + // Pass 2: isSuperTypeOf fallback when no exact match exists + $superTypeMatches = []; foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if (!array_key_exists($holderExprString, $specifiedExpressions) || !$this->conditionalExpressionHolderMatches($specifiedExpressions[$holderExprString], $conditionalTypeHolder)) { @@ -3253,8 +3258,12 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } - $conditions[$conditionalExprString][] = $conditionalExpression; - $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + $superTypeMatches[] = $conditionalExpression; + } + + if (count($superTypeMatches) === 1) { + $conditions[$conditionalExprString][] = $superTypeMatches[0]; + $specifiedExpressions[$conditionalExprString] = $superTypeMatches[0]->getTypeHolder(); } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5051.php b/tests/PHPStan/Analyser/nsrt/bug-5051.php index 6c3e80dce1..94ffc4711c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5051.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5051.php @@ -60,27 +60,27 @@ public function testWithBooleans($data): void assertType('bool', $update); } else { assertType('1|2', $data); - assertType('bool', $update); + assertType('false', $update); } if ($data === 1) { - assertType('bool', $update); - assertType('bool', $foo); + assertType('false', $update); + assertType('false', $foo); } else { assertType('bool', $update); assertType('bool', $foo); } if ($data === 2) { - assertType('bool', $update); - assertType('bool', $foo); + assertType('false', $update); + assertType('false', $foo); } else { assertType('bool', $update); assertType('bool', $foo); } if ($data === 3) { - assertType('bool', $update); + assertType('false', $update); assertType('true', $foo); } else { assertType('bool', $update); @@ -88,7 +88,7 @@ public function testWithBooleans($data): void } if ($data === 1 || $data === 2) { - assertType('bool', $update); + assertType('false', $update); assertType('false', $foo); } else { assertType('bool', $update); From 0e31ce7c42fea32c09b7d128e35b994f3c2b998c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 2 Apr 2026 20:45:03 +0000 Subject: [PATCH 05/12] Fix regression: only use isSuperTypeOf fallback when multiple conditional expressions exist The isSuperTypeOf fallback in Pass 2 incorrectly matched conditional expressions from disjunctive guards (e.g. `empty($x) && $y !== 'foo'`) where subtype matching does not preserve the logical implication. The fix requires at least 2 conditional expressions for the same target before using the isSuperTypeOf fallback, ensuring it only fires for proper partitions (like truthy/falsy from count() assignments). Also stops propagating Pass 2 results into specifiedExpressions to prevent cascading matches across iterations. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 4 +- .../Analyser/nsrt/bug-4090-regression.php | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-4090-regression.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index afe3a206ff..9a9a7f33f5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3251,6 +3251,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self // Pass 2: isSuperTypeOf fallback when no exact match exists $superTypeMatches = []; + $totalConditionalCount = count($conditionalExpressions); foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if (!array_key_exists($holderExprString, $specifiedExpressions) || !$this->conditionalExpressionHolderMatches($specifiedExpressions[$holderExprString], $conditionalTypeHolder)) { @@ -3261,9 +3262,8 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $superTypeMatches[] = $conditionalExpression; } - if (count($superTypeMatches) === 1) { + if (count($superTypeMatches) === 1 && $totalConditionalCount >= 2) { $conditions[$conditionalExprString][] = $superTypeMatches[0]; - $specifiedExpressions[$conditionalExprString] = $superTypeMatches[0]->getTypeHolder(); } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-4090-regression.php b/tests/PHPStan/Analyser/nsrt/bug-4090-regression.php new file mode 100644 index 0000000000..0b254291da --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4090-regression.php @@ -0,0 +1,37 @@ + $aggregation + */ + private function parseAggregation(array $aggregation): ?string + { + $type = $aggregation['type'] ?? null; + if (!\is_string($type) || empty($type) || is_numeric($type)) { + return null; + } + + if (empty($aggregation['field']) && $type !== 'filter') { + return null; + } + + $field = ''; + if ($type !== 'filter') { + $field = self::buildFieldName(); + } + + assertType('non-falsy-string', $type); + + return $field; + } + + private static function buildFieldName(): string + { + return 'field'; + } +} From 96c02492a81fdd487a540ba1a6112857015da4fb Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 2 Apr 2026 20:58:23 +0000 Subject: [PATCH 06/12] Merge two foreach loops over conditionalExpressions into a single loop Addresses review feedback: the exact-match and isSuperTypeOf-fallback passes now happen in a single iteration over conditionalExpressions, collecting exactMatches and superTypeMatches separately, then deciding which to use after the loop. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 41 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 9a9a7f33f5..e721d8bc7d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3230,39 +3230,38 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } - // Pass 1: exact matches + $exactMatches = []; + $superTypeMatches = []; foreach ($conditionalExpressions as $conditionalExpression) { $allExact = true; + $allSuperType = true; foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { - if (!array_key_exists($holderExprString, $specifiedExpressions) || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { + if (!array_key_exists($holderExprString, $specifiedExpressions)) { $allExact = false; + $allSuperType = false; break; } + if (!$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { + $allExact = false; + if (!$this->conditionalExpressionHolderMatches($specifiedExpressions[$holderExprString], $conditionalTypeHolder)) { + $allSuperType = false; + break; + } + } } if ($allExact) { - $conditions[$conditionalExprString][] = $conditionalExpression; - $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + $exactMatches[] = $conditionalExpression; + } elseif ($allSuperType) { + $superTypeMatches[] = $conditionalExpression; } } - if (array_key_exists($conditionalExprString, $conditions)) { - continue; - } - - // Pass 2: isSuperTypeOf fallback when no exact match exists - $superTypeMatches = []; - $totalConditionalCount = count($conditionalExpressions); - foreach ($conditionalExpressions as $conditionalExpression) { - foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { - if (!array_key_exists($holderExprString, $specifiedExpressions) || !$this->conditionalExpressionHolderMatches($specifiedExpressions[$holderExprString], $conditionalTypeHolder)) { - continue 2; - } + if (count($exactMatches) > 0) { + foreach ($exactMatches as $exactMatch) { + $conditions[$conditionalExprString][] = $exactMatch; + $specifiedExpressions[$conditionalExprString] = $exactMatch->getTypeHolder(); } - - $superTypeMatches[] = $conditionalExpression; - } - - if (count($superTypeMatches) === 1 && $totalConditionalCount >= 2) { + } elseif (count($superTypeMatches) === 1 && count($conditionalExpressions) >= 2) { $conditions[$conditionalExprString][] = $superTypeMatches[0]; } } From 992f4767906779308d180825cc4d8188dfc2f192 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 2 Apr 2026 21:11:56 +0000 Subject: [PATCH 07/12] Inline conditionalExpressionHolderMatches to avoid duplicating equals call Remove the separate method and inline the isSuperTypeOf check directly at the call site, since the equals() check is already performed in the surrounding code. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e721d8bc7d..767c5bddea 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2144,19 +2144,6 @@ public function enterAnonymousFunctionWithoutReflection( ); } - private function conditionalExpressionHolderMatches(ExpressionTypeHolder $specified, ExpressionTypeHolder $condition): bool - { - if ($specified->equals($condition)) { - return true; - } - - if (!$specified->getCertainty()->equals($condition->getCertainty())) { - return false; - } - - return $condition->getType()->isSuperTypeOf($specified->getType())->yes(); - } - private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): bool { $expr = $typeHolder->getExpr(); @@ -3243,7 +3230,10 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } if (!$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { $allExact = false; - if (!$this->conditionalExpressionHolderMatches($specifiedExpressions[$holderExprString], $conditionalTypeHolder)) { + if ( + !$specifiedExpressions[$holderExprString]->getCertainty()->equals($conditionalTypeHolder->getCertainty()) + || !$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedExpressions[$holderExprString]->getType())->yes() + ) { $allSuperType = false; break; } From 5241dfc346aa8d441704f46f32b05d8dd452fd37 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 3 Apr 2026 06:41:58 +0000 Subject: [PATCH 08/12] Extract isSuperTypeOf method on ExpressionTypeHolder Addresses review feedback: extract the certainty + isSuperTypeOf check into an ExpressionTypeHolder::isSuperTypeOf() method, mirroring the existing equals() method pattern. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExpressionTypeHolder.php | 13 +++++++++++++ src/Analyser/MutatingScope.php | 5 +---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExpressionTypeHolder.php b/src/Analyser/ExpressionTypeHolder.php index 7477dbf3dc..1044303eaa 100644 --- a/src/Analyser/ExpressionTypeHolder.php +++ b/src/Analyser/ExpressionTypeHolder.php @@ -50,6 +50,19 @@ public function equals(self $other): bool return $this->type === $other->type || $this->type->equals($other->type); } + public function isSuperTypeOf(self $other): bool + { + if ($this === $other) { + return true; + } + + if (!$this->certainty->equals($other->certainty)) { + return false; + } + + return $this->type === $other->type || $this->type->isSuperTypeOf($other->type)->yes(); + } + public function and(self $other): self { if ($this->type === $other->type || $this->type->equals($other->type)) { diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 767c5bddea..f3da65215d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3230,10 +3230,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } if (!$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { $allExact = false; - if ( - !$specifiedExpressions[$holderExprString]->getCertainty()->equals($conditionalTypeHolder->getCertainty()) - || !$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedExpressions[$holderExprString]->getType())->yes() - ) { + if (!$conditionalTypeHolder->isSuperTypeOf($specifiedExpressions[$holderExprString])) { $allSuperType = false; break; } From d5a6d296c514878e1ea8ae47532d2cf7cbb30de8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 3 Apr 2026 06:57:06 +0000 Subject: [PATCH 09/12] Return IsSuperTypeOfResult from ExpressionTypeHolder::isSuperTypeOf() for consistency Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExpressionTypeHolder.php | 13 +++++++++---- src/Analyser/MutatingScope.php | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExpressionTypeHolder.php b/src/Analyser/ExpressionTypeHolder.php index 1044303eaa..c7994b6db1 100644 --- a/src/Analyser/ExpressionTypeHolder.php +++ b/src/Analyser/ExpressionTypeHolder.php @@ -4,6 +4,7 @@ use PhpParser\Node\Expr; use PHPStan\TrinaryLogic; +use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -50,17 +51,21 @@ public function equals(self $other): bool return $this->type === $other->type || $this->type->equals($other->type); } - public function isSuperTypeOf(self $other): bool + public function isSuperTypeOf(self $other): IsSuperTypeOfResult { if ($this === $other) { - return true; + return IsSuperTypeOfResult::createYes(); } if (!$this->certainty->equals($other->certainty)) { - return false; + return IsSuperTypeOfResult::createNo(); + } + + if ($this->type === $other->type) { + return IsSuperTypeOfResult::createYes(); } - return $this->type === $other->type || $this->type->isSuperTypeOf($other->type)->yes(); + return $this->type->isSuperTypeOf($other->type); } public function and(self $other): self diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f3da65215d..4db8ef23d7 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3230,7 +3230,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } if (!$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { $allExact = false; - if (!$conditionalTypeHolder->isSuperTypeOf($specifiedExpressions[$holderExprString])) { + if (!$conditionalTypeHolder->isSuperTypeOf($specifiedExpressions[$holderExprString])->yes()) { $allSuperType = false; break; } From f7f30a7dfe0a745e33b3fe872ba4466f4b8cd1be Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 3 Apr 2026 09:13:06 +0200 Subject: [PATCH 10/12] Update MutatingScope.php --- src/Analyser/MutatingScope.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 4db8ef23d7..2eb95afb41 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3228,12 +3228,14 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $allSuperType = false; break; } - if (!$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { - $allExact = false; - if (!$conditionalTypeHolder->isSuperTypeOf($specifiedExpressions[$holderExprString])->yes()) { - $allSuperType = false; - break; - } + if ($specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { + continue; + } + + $allExact = false; + if (!$conditionalTypeHolder->isSuperTypeOf($specifiedExpressions[$holderExprString])->yes()) { + $allSuperType = false; + break; } } if ($allExact) { From 665d455e91232d2dad3f6721e9d1b37ac8b5e637 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 3 Apr 2026 09:26:34 +0200 Subject: [PATCH 11/12] Update MutatingScope.php --- src/Analyser/MutatingScope.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 2eb95afb41..68a570bfce 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3228,7 +3228,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $allSuperType = false; break; } - if ($specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { + if ($conditionalTypeHolder->equals($specifiedExpressions[$holderExprString])) { continue; } From 11d35710ed632ba8b495566018c028980b2cad70 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 3 Apr 2026 10:01:07 +0200 Subject: [PATCH 12/12] regression tests --- tests/PHPStan/Analyser/nsrt/bug-10422.php | 42 +++++++++++++++ .../CallToFunctionParametersRuleTest.php | 5 ++ .../Rules/Functions/data/bug-10055.php | 26 ++++++++++ .../Variables/DefinedVariableRuleTest.php | 29 +++++++++++ .../Rules/Variables/data/bug-11218.php | 29 +++++++++++ .../Variables/data/bug-12597-non-finite.php | 51 +++++++++++++++++++ .../Rules/Variables/data/bug-12597.php | 22 ++++++++ 7 files changed, 204 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10422.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-10055.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-11218.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-12597-non-finite.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-12597.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-10422.php b/tests/PHPStan/Analyser/nsrt/bug-10422.php new file mode 100644 index 0000000000..3b910a253b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10422.php @@ -0,0 +1,42 @@ +something()) { + $error = 'another'; + } + if ($error) { + die('Done'); + } + assertType(Foo::class, $test); +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index ee754a40d9..e3f03f7683 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -1851,6 +1851,11 @@ public function testBug10527(): void $this->analyse([__DIR__ . '/data/bug-10527.php'], []); } + public function testBug10055(): void + { + $this->analyse([__DIR__ . '/data/bug-10055.php'], []); + } + public function testBug10626(): void { $this->analyse([__DIR__ . '/data/bug-10626.php'], [ diff --git a/tests/PHPStan/Rules/Functions/data/bug-10055.php b/tests/PHPStan/Rules/Functions/data/bug-10055.php new file mode 100644 index 0000000000..a2caa08b73 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-10055.php @@ -0,0 +1,26 @@ + expectInt($param2), + 'value2' => expectInt($param2), + 'value3' => expectBool($param2), + }; +} diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index b6161df233..7549009c10 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1476,6 +1476,25 @@ public function testBug14227(): void $this->analyse([__DIR__ . '/data/bug-14227.php'], []); } + public function testBug12597(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-12597.php'], []); + } + + #[RequiresPhp('>= 8.0')] + public function testBug12597NonFinite(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-12597-non-finite.php'], []); + } + public function testBug14117(): void { $this->cliArgumentsVariablesRegistered = true; @@ -1499,4 +1518,14 @@ public function testBug14117(): void ]); } + public function testBug11218(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + + $this->analyse([__DIR__ . '/data/bug-11218.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-11218.php b/tests/PHPStan/Rules/Variables/data/bug-11218.php new file mode 100644 index 0000000000..a0b27708dd --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-11218.php @@ -0,0 +1,29 @@ +message($message); + } + } + + public function message(string $message): void {} +} + +class Foo {} +class Bar {} + +class HelloWorld2 +{ + public function test(mixed $type): void + { + if (is_int($type) || is_object($type)) { + $message = 'Hello!'; + } + + if (is_int($type)) { + $this->message($message); + } + } + + public function test2(mixed $type): void + { + if ($type instanceof Foo || $type instanceof Bar) { + $message = 'Hello!'; + } + + if ($type instanceof Foo) { + $this->message($message); + } + } + + public function message(string $message): void + { + } +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-12597.php b/tests/PHPStan/Rules/Variables/data/bug-12597.php new file mode 100644 index 0000000000..2cfdf45974 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12597.php @@ -0,0 +1,22 @@ +message($message); + } + } + + public function message(string $message): void {} +}