From c80f7bf1efd290600a5e486def1912f5a8d15f99 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:09:20 +0000 Subject: [PATCH 01/18] Fix conditional parameter type narrowing for union types with 3+ members - Split union condition types into individual ConditionalExpressionHolders - When TypeCombinator::intersect or ::remove produces a UnionType, each member gets its own holder so the equals() check can match individual constant types - New regression test in tests/PHPStan/Analyser/nsrt/bug-10055.php --- src/Analyser/MutatingScope.php | 27 +++++++++++++++-------- tests/PHPStan/Analyser/nsrt/bug-10055.php | 20 +++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10055.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index de3c4179b22..39ec20a2813 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1758,15 +1758,24 @@ private function enterFunctionLike( $ifType = $parameterType->isNegated() ? $parameterType->getElse() : $parameterType->getIf(); $elseType = $parameterType->isNegated() ? $parameterType->getIf() : $parameterType->getElse(); - $holder = new ConditionalExpressionHolder([ - $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::intersect($targetParameter->getType(), $parameterType->getTarget())), - ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $ifType)); - $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; - - $holder = new ConditionalExpressionHolder([ - $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::remove($targetParameter->getType(), $parameterType->getTarget())), - ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $elseType)); - $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; + $ifConditionType = TypeCombinator::intersect($targetParameter->getType(), $parameterType->getTarget()); + $elseConditionType = TypeCombinator::remove($targetParameter->getType(), $parameterType->getTarget()); + + $ifConditionTypes = $ifConditionType instanceof UnionType ? $ifConditionType->getTypes() : [$ifConditionType]; + foreach ($ifConditionTypes as $conditionType) { + $holder = new ConditionalExpressionHolder([ + $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), $conditionType), + ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $ifType)); + $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; + } + + $elseConditionTypes = $elseConditionType instanceof UnionType ? $elseConditionType->getTypes() : [$elseConditionType]; + foreach ($elseConditionTypes as $conditionType) { + $holder = new ConditionalExpressionHolder([ + $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), $conditionType), + ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $elseType)); + $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; + } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10055.php b/tests/PHPStan/Analyser/nsrt/bug-10055.php new file mode 100644 index 00000000000..8dcfe7ecf18 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10055.php @@ -0,0 +1,20 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10055; + +use function PHPStan\Testing\assertType; + +/** + * @param 'value1'|'value2'|'value3' $param1 + * @param ($param1 is 'value3' ? bool : int) $param2 + */ +function test(string $param1, int|bool $param2): void +{ + match ($param1) { + 'value1' => assertType('int', $param2), + 'value2' => assertType('int', $param2), + 'value3' => assertType('bool', $param2), + }; +} From bf4eb5ac6d4183a514850b5605b1782e99e86ba0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 11:25:46 +0000 Subject: [PATCH 02/18] Use isSuperTypeOf for condition matching in conditional parameter types Instead of splitting union condition types into individual ConditionalExpressionHolder instances, use isSuperTypeOf at the matching site so that a narrowed type (e.g. 'value1') correctly matches a broader condition type (e.g. 'value1'|'value2'). A new useSubtypeForConditionMatching flag on ConditionalExpressionHolder limits this relaxed matching to conditional parameter type holders only. Other holders (from scope merging, assignments, type specifying) keep strict equals() matching to avoid cascading side effects. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ConditionalExpressionHolder.php | 6 +++ src/Analyser/MutatingScope.php | 42 +++++++++++--------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ConditionalExpressionHolder.php b/src/Analyser/ConditionalExpressionHolder.php index 69071839660..7e2fc68a039 100644 --- a/src/Analyser/ConditionalExpressionHolder.php +++ b/src/Analyser/ConditionalExpressionHolder.php @@ -17,6 +17,7 @@ final class ConditionalExpressionHolder public function __construct( private array $conditionExpressionTypeHolders, private ExpressionTypeHolder $typeHolder, + private bool $useSubtypeForConditionMatching = false, ) { if (count($conditionExpressionTypeHolders) === 0) { @@ -37,6 +38,11 @@ public function getTypeHolder(): ExpressionTypeHolder return $this->typeHolder; } + public function useSubtypeForConditionMatching(): bool + { + return $this->useSubtypeForConditionMatching; + } + public function getKey(): string { $parts = []; diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 39ec20a2813..9a8ac92527f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1758,24 +1758,15 @@ private function enterFunctionLike( $ifType = $parameterType->isNegated() ? $parameterType->getElse() : $parameterType->getIf(); $elseType = $parameterType->isNegated() ? $parameterType->getIf() : $parameterType->getElse(); - $ifConditionType = TypeCombinator::intersect($targetParameter->getType(), $parameterType->getTarget()); - $elseConditionType = TypeCombinator::remove($targetParameter->getType(), $parameterType->getTarget()); - - $ifConditionTypes = $ifConditionType instanceof UnionType ? $ifConditionType->getTypes() : [$ifConditionType]; - foreach ($ifConditionTypes as $conditionType) { - $holder = new ConditionalExpressionHolder([ - $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), $conditionType), - ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $ifType)); - $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; - } - - $elseConditionTypes = $elseConditionType instanceof UnionType ? $elseConditionType->getTypes() : [$elseConditionType]; - foreach ($elseConditionTypes as $conditionType) { - $holder = new ConditionalExpressionHolder([ - $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), $conditionType), - ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $elseType)); - $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; - } + $holder = new ConditionalExpressionHolder([ + $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::intersect($targetParameter->getType(), $parameterType->getTarget())), + ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $ifType), true); + $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; + + $holder = new ConditionalExpressionHolder([ + $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::remove($targetParameter->getType(), $parameterType->getTarget())), + ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $elseType), true); + $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; } } @@ -3227,9 +3218,22 @@ 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)) { continue 2; } + $specifiedHolder = $specifiedExpressions[$holderExprString]; + if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { + continue 2; + } + if ($conditionalExpression->useSubtypeForConditionMatching()) { + if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { + continue 2; + } + } else { + if (!$specifiedHolder->equals($conditionalTypeHolder)) { + continue 2; + } + } } $conditions[$conditionalExprString][] = $conditionalExpression; From 89f8a22d662cc01628b860bcbbf25dbde7a7ff32 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 12:17:01 +0000 Subject: [PATCH 03/18] Remove useSubtypeForConditionMatching flag, use two-pass matching instead Instead of a flag on ConditionalExpressionHolder to control whether condition matching uses equals() or isSuperTypeOf(), use a two-pass approach in filterBySpecifiedTypes(): Pass 1: exact matching via equals() (existing behavior) Pass 2: isSuperTypeOf for condition types with finite types (fallback) Pass 2 only runs when pass 1 found no matches for a target expression. This handles conditional parameter types where the condition is a union (e.g. 'value1'|'value2') that can't match a narrowed type ('value1') via equals(), but should match via isSuperTypeOf. The two-pass approach avoids regressions from using isSuperTypeOf globally: when scope merging creates both a specific condition (e.g. "if $key=2, then $value is Yes") and a broader condition (e.g. "if $key=0|2, then $value is Maybe"), exact matching in pass 1 prevents the broader condition from degrading variable certainty through extremeIdentity. The bug-5051 test expectations are updated to reflect more precise type narrowing: when narrowing $data to a specific constant, PHPStan now correctly determines which branch was taken and narrows related variables accordingly (e.g. $update becomes 'false' instead of 'bool' when $data === 1 because that branch always sets $update = false). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ConditionalExpressionHolder.php | 6 --- src/Analyser/MutatingScope.php | 46 ++++++++++++++------ tests/PHPStan/Analyser/nsrt/bug-5051.php | 14 +++--- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/Analyser/ConditionalExpressionHolder.php b/src/Analyser/ConditionalExpressionHolder.php index 7e2fc68a039..69071839660 100644 --- a/src/Analyser/ConditionalExpressionHolder.php +++ b/src/Analyser/ConditionalExpressionHolder.php @@ -17,7 +17,6 @@ final class ConditionalExpressionHolder public function __construct( private array $conditionExpressionTypeHolders, private ExpressionTypeHolder $typeHolder, - private bool $useSubtypeForConditionMatching = false, ) { if (count($conditionExpressionTypeHolders) === 0) { @@ -38,11 +37,6 @@ public function getTypeHolder(): ExpressionTypeHolder return $this->typeHolder; } - public function useSubtypeForConditionMatching(): bool - { - return $this->useSubtypeForConditionMatching; - } - public function getKey(): string { $parts = []; diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 9a8ac92527f..7174c80d1ff 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1760,12 +1760,12 @@ private function enterFunctionLike( $holder = new ConditionalExpressionHolder([ $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::intersect($targetParameter->getType(), $parameterType->getTarget())), - ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $ifType), true); + ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $ifType)); $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; $holder = new ConditionalExpressionHolder([ $parameterType->getParameterName() => ExpressionTypeHolder::createYes(new Variable($targetParameterName), TypeCombinator::remove($targetParameter->getType(), $parameterType->getTarget())), - ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $elseType), true); + ], ExpressionTypeHolder::createYes(new Variable($parameter->getName()), $elseType)); $conditionalTypes['$' . $parameter->getName()][$holder->getKey()] = $holder; } } @@ -3216,28 +3216,46 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self if (array_key_exists($conditionalExprString, $conditions)) { continue; } + + // Pass 1: exact matching via equals() foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { - if (!array_key_exists($holderExprString, $specifiedExpressions)) { - continue 2; - } - $specifiedHolder = $specifiedExpressions[$holderExprString]; - if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { + if (!array_key_exists($holderExprString, $specifiedExpressions) || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { continue 2; } - if ($conditionalExpression->useSubtypeForConditionMatching()) { - if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { + } + + $conditions[$conditionalExprString][] = $conditionalExpression; + $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + } + + // Pass 2: for condition types with finite types, use isSuperTypeOf + // This handles cases like conditional parameter types where the condition + // is a union (e.g. 'value1'|'value2') that won't match a narrowed type + // (e.g. 'value1') via equals(), but should match via isSuperTypeOf. + // Only attempted when pass 1 found no matches, to avoid conflicts with + // broader conditions that have lower certainty from scope merging. + if (!array_key_exists($conditionalExprString, $conditions)) { + foreach ($conditionalExpressions as $conditionalExpression) { + foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { + if (!array_key_exists($holderExprString, $specifiedExpressions)) { continue 2; } - } else { - if (!$specifiedHolder->equals($conditionalTypeHolder)) { + $specifiedHolder = $specifiedExpressions[$holderExprString]; + if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { + continue 2; + } + if (count($conditionalTypeHolder->getType()->getFiniteTypes()) === 0) { + continue 2; + } + if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { continue 2; } } - } - $conditions[$conditionalExprString][] = $conditionalExpression; - $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + $conditions[$conditionalExprString][] = $conditionalExpression; + $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + } } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5051.php b/tests/PHPStan/Analyser/nsrt/bug-5051.php index 6c3e80dce11..94ffc4711c8 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 553962a490a8fcb878c327182e1a17b322cd7de3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 15:55:39 +0000 Subject: [PATCH 04/18] Explain why getFiniteTypes() guard is needed in Pass 2 condition matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address reviewer question about why isSuperTypeOf cannot be used for all types. Conditional expression holders come from multiple sources (param conditional types, scope merging, assignment handlers, TypeSpecifier boolean processing). Non-finite condition types from scope merging and assignment handlers are too broad for isSuperTypeOf matching — e.g. non-falsy-string incorrectly matches 'filter', or mixed~false matches false, causing conflicting conditional expressions to produce *NEVER*. The finite types check restricts Pass 2 to closed sets of concrete values where subtype matching is safe. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 7174c80d1ff..f85a44d05d1 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3235,6 +3235,23 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self // (e.g. 'value1') via equals(), but should match via isSuperTypeOf. // Only attempted when pass 1 found no matches, to avoid conflicts with // broader conditions that have lower certainty from scope merging. + // + // The getFiniteTypes() guard is necessary because conditional expression + // holders are created from multiple sources: + // 1. Conditional parameter types (@param conditional types) — these have + // finite condition types like 'value1'|'value2' from TypeCombinator::intersect/remove + // 2. Scope merging (generateConditionalExpressions) — condition types can be + // any type like mixed~null, object, non-falsy-string + // 3. Assignment handlers — condition types like mixed~false from falsey checks + // 4. TypeSpecifier boolean processing — condition types from BooleanAnd/Or + // + // Using isSuperTypeOf without the finite types guard causes regressions because + // non-finite condition types from sources 2-4 are too broad: e.g. a condition + // type of non-falsy-string would incorrectly match a narrowed type 'filter', + // or a condition type of mixed~false would match false, causing unrelated + // conditional expressions to activate and produce conflicting types (*NEVER*). + // The finite types check restricts Pass 2 to closed sets of concrete values + // (constant strings, booleans, enum cases, etc.) where subtype matching is safe. if (!array_key_exists($conditionalExprString, $conditions)) { foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { From 819b7fc7a008d937b44ef02aac7cd0376f37c257 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 16:36:50 +0000 Subject: [PATCH 05/18] Use UnionType guard instead of getFiniteTypes() in Pass 2 condition matching Replace the getFiniteTypes() guard with an instanceof UnionType check in Pass 2 of filterBySpecifiedTypes(). This broadens Pass 2 to handle all union condition types (not just finite ones), which fixes cases where scope merging creates union conditions like int|string that should match a narrowed type like int via isSuperTypeOf. The UnionType guard still prevents regressions from non-union condition types (non-falsy-string from TypeSpecifier, mixed~false from assignment handlers) that are too broad for isSuperTypeOf matching. Added test case for scope merging with is_string()||is_int() narrowing. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 39 ++++++++++------------- tests/PHPStan/Analyser/nsrt/bug-10055.php | 12 +++++++ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f85a44d05d1..d82655ad7af 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3229,29 +3229,22 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); } - // Pass 2: for condition types with finite types, use isSuperTypeOf - // This handles cases like conditional parameter types where the condition - // is a union (e.g. 'value1'|'value2') that won't match a narrowed type - // (e.g. 'value1') via equals(), but should match via isSuperTypeOf. - // Only attempted when pass 1 found no matches, to avoid conflicts with - // broader conditions that have lower certainty from scope merging. + // Pass 2: for union condition types, use isSuperTypeOf + // This handles cases where the condition type is a union + // (e.g. 'value1'|'value2' or int|string) that won't match a narrowed + // type (e.g. 'value1' or int) via equals(), but should match via + // isSuperTypeOf. Only attempted when pass 1 found no matches, to avoid + // conflicts with broader conditions that have lower certainty from + // scope merging. // - // The getFiniteTypes() guard is necessary because conditional expression - // holders are created from multiple sources: - // 1. Conditional parameter types (@param conditional types) — these have - // finite condition types like 'value1'|'value2' from TypeCombinator::intersect/remove - // 2. Scope merging (generateConditionalExpressions) — condition types can be - // any type like mixed~null, object, non-falsy-string - // 3. Assignment handlers — condition types like mixed~false from falsey checks - // 4. TypeSpecifier boolean processing — condition types from BooleanAnd/Or - // - // Using isSuperTypeOf without the finite types guard causes regressions because - // non-finite condition types from sources 2-4 are too broad: e.g. a condition - // type of non-falsy-string would incorrectly match a narrowed type 'filter', - // or a condition type of mixed~false would match false, causing unrelated - // conditional expressions to activate and produce conflicting types (*NEVER*). - // The finite types check restricts Pass 2 to closed sets of concrete values - // (constant strings, booleans, enum cases, etc.) where subtype matching is safe. + // The UnionType guard is necessary because using isSuperTypeOf for all + // condition types causes regressions: non-union types like + // non-falsy-string (from TypeSpecifier boolean processing) or + // mixed~false (from assignment handlers) are too broad — e.g. + // non-falsy-string would incorrectly match 'filter', and mixed~false + // matching false causes conflicting conditional expressions to both + // activate, producing *NEVER* types. Union types are safe because they + // explicitly enumerate alternatives where subtype matching is correct. if (!array_key_exists($conditionalExprString, $conditions)) { foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { @@ -3262,7 +3255,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { continue 2; } - if (count($conditionalTypeHolder->getType()->getFiniteTypes()) === 0) { + if (!$conditionalTypeHolder->getType() instanceof UnionType) { continue 2; } if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-10055.php b/tests/PHPStan/Analyser/nsrt/bug-10055.php index 8dcfe7ecf18..2464880ffcc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10055.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10055.php @@ -18,3 +18,15 @@ function test(string $param1, int|bool $param2): void 'value3' => assertType('bool', $param2), }; } + +function testScopeMerging(mixed $foo): void +{ + $a = 0; + if (\is_string($foo) || \is_int($foo)) { + $a = 1; + } + + if (\is_int($foo)) { + assertType('1', $a); + } +} From 9c66e81d0c79ccd89e3ddc4179041726d2a580e7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 23:37:29 +0000 Subject: [PATCH 06/18] Remove UnionType guard from Pass 2 condition matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UnionType guard was a workaround for two issues that are now fixed at their root causes: 1. pr-5379 regression (non-falsy-string matching 'filter'): Fixed by skipping trivially-always-true conditions in processBooleanSureConditionalTypes — when TypeCombinator::remove has no effect, the condition is not created. 2. bug-14249 regression (mixed~false producing *NEVER*): Fixed by marking the getMixed() test helper as @phpstan-impure, preventing conflicting conditional expressions from assignment handlers. With these root causes addressed, Pass 2 can safely use isSuperTypeOf for all condition types without restriction. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index d82655ad7af..d91b69ec029 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3229,22 +3229,13 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); } - // Pass 2: for union condition types, use isSuperTypeOf - // This handles cases where the condition type is a union - // (e.g. 'value1'|'value2' or int|string) that won't match a narrowed - // type (e.g. 'value1' or int) via equals(), but should match via - // isSuperTypeOf. Only attempted when pass 1 found no matches, to avoid - // conflicts with broader conditions that have lower certainty from - // scope merging. - // - // The UnionType guard is necessary because using isSuperTypeOf for all - // condition types causes regressions: non-union types like - // non-falsy-string (from TypeSpecifier boolean processing) or - // mixed~false (from assignment handlers) are too broad — e.g. - // non-falsy-string would incorrectly match 'filter', and mixed~false - // matching false causes conflicting conditional expressions to both - // activate, producing *NEVER* types. Union types are safe because they - // explicitly enumerate alternatives where subtype matching is correct. + // Pass 2: use isSuperTypeOf for condition matching + // This handles cases where the condition type is broader than the + // narrowed type — e.g. 'value1'|'value2' (condition) won't match + // 'value1' (narrowed) via equals(), but should match via + // isSuperTypeOf. Only attempted when pass 1 found no matches, to + // avoid conflicts with broader conditions that have lower certainty + // from scope merging. if (!array_key_exists($conditionalExprString, $conditions)) { foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { @@ -3255,9 +3246,6 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { continue 2; } - if (!$conditionalTypeHolder->getType() instanceof UnionType) { - continue 2; - } if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { continue 2; } From 996ff3d154cdcd02915ee3fcedbb5456a829ce6e Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 12 Apr 2026 19:42:59 +0200 Subject: [PATCH 07/18] Fix lint --- src/Analyser/MutatingScope.php | 42 +++++++++++++++------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index d91b69ec029..04f20076ff0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3217,7 +3217,6 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } - // Pass 1: exact matching via equals() foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if (!array_key_exists($holderExprString, $specifiedExpressions) || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder)) { @@ -3229,31 +3228,26 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); } - // Pass 2: use isSuperTypeOf for condition matching - // This handles cases where the condition type is broader than the - // narrowed type — e.g. 'value1'|'value2' (condition) won't match - // 'value1' (narrowed) via equals(), but should match via - // isSuperTypeOf. Only attempted when pass 1 found no matches, to - // avoid conflicts with broader conditions that have lower certainty - // from scope merging. - if (!array_key_exists($conditionalExprString, $conditions)) { - foreach ($conditionalExpressions as $conditionalExpression) { - foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { - if (!array_key_exists($holderExprString, $specifiedExpressions)) { - continue 2; - } - $specifiedHolder = $specifiedExpressions[$holderExprString]; - if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { - continue 2; - } - if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { - continue 2; - } - } + if (array_key_exists($conditionalExprString, $conditions)) { + continue; + } - $conditions[$conditionalExprString][] = $conditionalExpression; - $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); + foreach ($conditionalExpressions as $conditionalExpression) { + foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { + if (!array_key_exists($holderExprString, $specifiedExpressions)) { + continue 2; + } + $specifiedHolder = $specifiedExpressions[$holderExprString]; + if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { + continue 2; + } + if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { + continue 2; + } } + + $conditions[$conditionalExprString][] = $conditionalExpression; + $specifiedExpressions[$conditionalExprString] = $conditionalExpression->getTypeHolder(); } } } From 6d27242b0d84bc627ee0e1f7a29bf965d835184d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 12 Apr 2026 18:13:22 +0000 Subject: [PATCH 08/18] Add non-regression tests for #13591, #12597, and #10422 - #13591: nsrt + rule test verifying conditional parameter type narrowing works with boolean-and guard (no false positive for int|null parameter) - #12597: rule test verifying variable definedness after in_array check (no false "Variable might not be defined" warning) - #10422: nsrt + rule test verifying method call on nullable after if/elseif guard with die() (no false "Cannot call method on null") Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-10422.php | 28 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13591.php | 24 ++++++++++++++++ .../CallToFunctionParametersRuleTest.php | 5 ++++ .../Rules/Functions/data/bug-13591.php | 21 ++++++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 8 ++++++ .../PHPStan/Rules/Methods/data/bug-10422.php | 25 +++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 9 ++++++ .../Rules/Variables/data/bug-12597.php | 24 ++++++++++++++++ 8 files changed, 144 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10422.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13591.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13591.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-10422.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 00000000000..5a161575cc6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10422.php @@ -0,0 +1,28 @@ +something()) { + $error = 'another'; + } + if ($error) { + die('Done'); + } + assertType('Bug10422\TestClass', $test); + $test->test(); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13591.php b/tests/PHPStan/Analyser/nsrt/bug-13591.php new file mode 100644 index 00000000000..4bdc227cd53 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13591.php @@ -0,0 +1,24 @@ +analyse([__DIR__ . '/data/bug-13591.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-13591.php b/tests/PHPStan/Rules/Functions/data/bug-13591.php new file mode 100644 index 00000000000..976fb1cf7e0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13591.php @@ -0,0 +1,21 @@ +checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-10422.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-10422.php b/tests/PHPStan/Rules/Methods/data/bug-10422.php new file mode 100644 index 00000000000..1f79a4370d0 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-10422.php @@ -0,0 +1,25 @@ +something()) { + $error = 'another'; + } + if ($error) { + die('Done'); + } + $test->test(); +} diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index b6161df2333..d770df8c932 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1499,4 +1499,13 @@ public function testBug14117(): void ]); } + public function testBug12597(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-12597.php'], []); + } + } 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 00000000000..30d1a8654ca --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12597.php @@ -0,0 +1,24 @@ +message($message); + } + } + + public function message(string $message): void {} +} From 34176518e7d24d59ba84764d030ff1c987bcda47 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 12 Apr 2026 18:28:32 +0000 Subject: [PATCH 09/18] Merge rule test data files into nsrt files for #10422 and #13591 Rule tests now reference the nsrt files directly instead of maintaining separate data files with duplicate code. Co-Authored-By: Claude Opus 4.6 --- .../CallToFunctionParametersRuleTest.php | 7 +++++- .../Rules/Functions/data/bug-13591.php | 21 ---------------- .../Rules/Methods/CallMethodsRuleTest.php | 2 +- .../PHPStan/Rules/Methods/data/bug-10422.php | 25 ------------------- 4 files changed, 7 insertions(+), 48 deletions(-) delete mode 100644 tests/PHPStan/Rules/Functions/data/bug-13591.php delete mode 100644 tests/PHPStan/Rules/Methods/data/bug-10422.php diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index d047857ca6b..ef5e6865769 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2830,7 +2830,12 @@ public function testBug4608(): void public function testBug13591(): void { - $this->analyse([__DIR__ . '/data/bug-13591.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13591.php'], []); + } + + public function testBug4090(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-4090.php'], []); } } diff --git a/tests/PHPStan/Rules/Functions/data/bug-13591.php b/tests/PHPStan/Rules/Functions/data/bug-13591.php deleted file mode 100644 index 976fb1cf7e0..00000000000 --- a/tests/PHPStan/Rules/Functions/data/bug-13591.php +++ /dev/null @@ -1,21 +0,0 @@ -checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; - $this->analyse([__DIR__ . '/data/bug-10422.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-10422.php'], []); } } diff --git a/tests/PHPStan/Rules/Methods/data/bug-10422.php b/tests/PHPStan/Rules/Methods/data/bug-10422.php deleted file mode 100644 index 1f79a4370d0..00000000000 --- a/tests/PHPStan/Rules/Methods/data/bug-10422.php +++ /dev/null @@ -1,25 +0,0 @@ -something()) { - $error = 'another'; - } - if ($error) { - die('Done'); - } - $test->test(); -} From 0fb28c754e1e3571484c39d8eec08f0300a8e333 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 12 Apr 2026 18:28:45 +0000 Subject: [PATCH 10/18] Add non-regression tests for #6663 and #4090 - #6663: nsrt test for type narrowing in nested if with || condition - #4090: nsrt + rule test verifying no "trim expects string, string|false given" error when array count is checked via elseif/switch Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-4090.php | 46 ++++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-6663.php | 24 +++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-4090.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6663.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-4090.php b/tests/PHPStan/Analyser/nsrt/bug-4090.php new file mode 100644 index 00000000000..6f02233c826 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4090.php @@ -0,0 +1,46 @@ + 1) { + echo implode(',', $a); + } elseif (count($a) === 1) { + assertType('string', current($a)); + echo trim(current($a)); + } +} + +/** @param string[] $a */ +function bar(array $a): void +{ + $count = count($a); + if ($count > 1) { + echo implode(',', $a); + } elseif ($count === 1) { + assertType('string', current($a)); + echo trim(current($a)); + } +} + +/** @param string[] $a */ +function qux(array $a): void +{ + switch (count($a)) { + case 0: + break; + case 1: + assertType('string', current($a)); + echo trim(current($a)); + break; + default: + echo implode(',', $a); + break; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-6663.php b/tests/PHPStan/Analyser/nsrt/bug-6663.php new file mode 100644 index 00000000000..13c30df4b25 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6663.php @@ -0,0 +1,24 @@ + Date: Sun, 12 Apr 2026 18:49:57 +0000 Subject: [PATCH 11/18] Add non-regression tests for #11218 - DefinedVariableRuleTest: ensure no "Variable $test might not be defined" error in for loop where variable is always defined on first iteration - NonexistentOffsetInArrayDimFetchRuleTest: ensure no "Offset 'test' does not exist" error when array offset is always set on first iteration Co-Authored-By: Claude Opus 4.6 --- ...onexistentOffsetInArrayDimFetchRuleTest.php | 7 +++++++ tests/PHPStan/Rules/Arrays/data/bug-11218.php | 18 ++++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 9 +++++++++ .../PHPStan/Rules/Variables/data/bug-11218.php | 16 ++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-11218.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-11218.php diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 7def992a257..94ba55317c9 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1277,4 +1277,11 @@ public function testBug14308(): void $this->analyse([__DIR__ . '/data/bug-14308.php'], []); } + public function testBug11218(): void + { + $this->reportPossiblyNonexistentConstantArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-11218.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-11218.php b/tests/PHPStan/Rules/Arrays/data/bug-11218.php new file mode 100644 index 00000000000..e2062abdd66 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-11218.php @@ -0,0 +1,18 @@ +analyse([__DIR__ . '/data/bug-12597.php'], []); } + 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 00000000000..aadd9d0cbcf --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-11218.php @@ -0,0 +1,16 @@ + Date: Sun, 12 Apr 2026 21:00:03 +0200 Subject: [PATCH 12/18] Fix --- src/Analyser/MutatingScope.php | 13 +++++-------- tests/PHPStan/Analyser/nsrt/bug-6663.php | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 04f20076ff0..fb74a3c6e3b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3234,14 +3234,11 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { - if (!array_key_exists($holderExprString, $specifiedExpressions)) { - continue 2; - } - $specifiedHolder = $specifiedExpressions[$holderExprString]; - if (!$specifiedHolder->getCertainty()->equals($conditionalTypeHolder->getCertainty())) { - continue 2; - } - if (!$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedHolder->getType())->yes()) { + if ( + !array_key_exists($holderExprString, $specifiedExpressions) + || !$specifiedExpressions[$holderExprString]->getCertainty()->equals($conditionalTypeHolder->getCertainty()) + || !$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedExpressions[$holderExprString]->getType())->yes() + ) { continue 2; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-6663.php b/tests/PHPStan/Analyser/nsrt/bug-6663.php index 13c30df4b25..228a70b821a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6663.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6663.php @@ -1,4 +1,4 @@ -= 8.0 declare(strict_types = 1); From af79cb58647339ddb1426309eb317997465dcf1b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 13 Apr 2026 08:42:30 +0200 Subject: [PATCH 13/18] more readable --- 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 fb74a3c6e3b..dd9559bf7c9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3236,7 +3236,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( !array_key_exists($holderExprString, $specifiedExpressions) - || !$specifiedExpressions[$holderExprString]->getCertainty()->equals($conditionalTypeHolder->getCertainty()) + || !$conditionalTypeHolder->getCertainty()->equals($specifiedExpressions[$holderExprString]->getCertainty()) || !$conditionalTypeHolder->getType()->isSuperTypeOf($specifiedExpressions[$holderExprString]->getType())->yes() ) { continue 2; From f9e6e1f22e658ea5b4f67a59dacc65c4e0322972 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 13 Apr 2026 08:50:28 +0200 Subject: [PATCH 14/18] cs --- src/Analyser/MutatingScope.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index dd9559bf7c9..81d98e3c9b0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3219,7 +3219,10 @@ 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) + || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder) + ) { continue 2; } } From df3ed28347ae41d41f47f01a86b377cb0eb7d46a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 13 Apr 2026 08:51:37 +0200 Subject: [PATCH 15/18] more readable --- 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 81d98e3c9b0..e1e86ab9a42 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3221,7 +3221,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( !array_key_exists($holderExprString, $specifiedExpressions) - || !$specifiedExpressions[$holderExprString]->equals($conditionalTypeHolder) + || !$conditionalTypeHolder->equals($specifiedExpressions[$holderExprString]) ) { continue 2; } From aa1f96e56c4ff21a28f9f06e3a7c010a04f4e344 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 13 Apr 2026 07:31:53 +0000 Subject: [PATCH 16/18] Add comments explaining the two-pass condition matching in filterBySpecifiedTypes Pass 1 uses exact equals() matching. Pass 2 falls back to isSuperTypeOf() for cases where the condition type is broader than the narrowed expression type (e.g. union condition types from conditional parameter types or scope merging). Pass 1 takes priority to prevent broader conditions from degrading certainty calculations. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e1e86ab9a42..8154273b87e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3217,6 +3217,8 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + // Pass 1: Exact match - condition type must equal the specified expression type. + // This handles most cases and takes priority over Pass 2. foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( @@ -3235,6 +3237,22 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + // Pass 2: Supertype match - condition type must be a supertype of the specified + // expression type. Only runs when Pass 1 found no exact match for this expression. + // + // This is needed for conditional parameter types with union conditions: + // e.g. @param ($p is 'a' ? bool : int) with $p typed as 'a'|'b'|'c' + // creates a condition type 'b'|'c' (from TypeCombinator::remove) for the int result. + // When $p is narrowed to 'b', equals('b', 'b'|'c') fails but isSuperTypeOf succeeds. + // + // Also needed for scope merging: e.g. $a=0 then if (is_string||is_int) $a=1 + // creates a condition type int|string. When narrowed to is_int, the condition + // int|string is a supertype of int, so the match succeeds. + // + // Pass 1 must take priority because when both exact and broader conditions exist + // (e.g. from scope merging creating both "$key=2 => $value Yes" and + // "$key=0|2 => $value Maybe"), the broader match would degrade certainty + // via extremeIdentity (Yes AND Maybe = Maybe), causing false positives. foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( From 219e791a0103a91a390aa685422a7dd641f8791a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 13 Apr 2026 08:19:59 +0000 Subject: [PATCH 17/18] Clarify comments: two-pass matching is about preferring exact matches, not speed Move the explanation of why Pass 1 takes priority from Pass 2's comment block to Pass 1's comment, making it clear that the two-pass design is about preferring exact matches over supertype matches to avoid certainty degradation. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8154273b87e..bc047e7a3da 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3218,7 +3218,10 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } // Pass 1: Exact match - condition type must equal the specified expression type. - // This handles most cases and takes priority over Pass 2. + // We prefer exact matches over supertype matches (Pass 2) because when both + // exist (e.g. from scope merging creating both "$key=2 => $value Yes" and + // "$key=0|2 => $value Maybe"), the broader match would degrade certainty + // via extremeIdentity (Yes AND Maybe = Maybe), causing false positives. foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( @@ -3248,11 +3251,6 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self // Also needed for scope merging: e.g. $a=0 then if (is_string||is_int) $a=1 // creates a condition type int|string. When narrowed to is_int, the condition // int|string is a supertype of int, so the match succeeds. - // - // Pass 1 must take priority because when both exact and broader conditions exist - // (e.g. from scope merging creating both "$key=2 => $value Yes" and - // "$key=0|2 => $value Maybe"), the broader match would degrade certainty - // via extremeIdentity (Yes AND Maybe = Maybe), causing false positives. foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( From ffd567d3c8c88c32e058a034326052e68ffce7fe Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 13 Apr 2026 10:54:05 +0200 Subject: [PATCH 18/18] Update MutatingScope.php --- src/Analyser/MutatingScope.php | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index bc047e7a3da..94510cd3a66 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3217,11 +3217,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } - // Pass 1: Exact match - condition type must equal the specified expression type. - // We prefer exact matches over supertype matches (Pass 2) because when both - // exist (e.g. from scope merging creating both "$key=2 => $value Yes" and - // "$key=0|2 => $value Maybe"), the broader match would degrade certainty - // via extremeIdentity (Yes AND Maybe = Maybe), causing false positives. + // Pass 1: Prefer exact matches foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if ( @@ -3240,17 +3236,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } - // Pass 2: Supertype match - condition type must be a supertype of the specified - // expression type. Only runs when Pass 1 found no exact match for this expression. - // - // This is needed for conditional parameter types with union conditions: - // e.g. @param ($p is 'a' ? bool : int) with $p typed as 'a'|'b'|'c' - // creates a condition type 'b'|'c' (from TypeCombinator::remove) for the int result. - // When $p is narrowed to 'b', equals('b', 'b'|'c') fails but isSuperTypeOf succeeds. - // - // Also needed for scope merging: e.g. $a=0 then if (is_string||is_int) $a=1 - // creates a condition type int|string. When narrowed to is_int, the condition - // int|string is a supertype of int, so the match succeeds. + // Pass 2: Supertype match. Only runs when Pass 1 found no exact match for this expression. foreach ($conditionalExpressions as $conditionalExpression) { foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) { if (