diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8d43a7d6137..9ad4d75e88f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3348,7 +3348,18 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self public function addConditionalExpressions(string $exprString, array $conditionalExpressionHolders): self { $conditionalExpressions = $this->conditionalExpressions; - $conditionalExpressions[$exprString] = $conditionalExpressionHolders; + // Merge rather than overwrite: multiple independent holders can target the same + // expression (e.g. `$xIsA = $x instanceof A && $y instanceof A` stores a holder + // for `$x` keyed on `$xIsA`; later `$yIsA = $y instanceof A && $x instanceof A` + // stores another holder for the same target `$x` keyed on `$yIsA`). Replacing + // the existing entry here would throw away the earlier binding, breaking + // narrowing inside later `if ($xIsA) { … }` inside `if ($xIsA || $yIsA)`. + // Holder keys (`getKey()`) disambiguate identical entries so we still dedupe. + $existing = $conditionalExpressions[$exprString] ?? []; + foreach ($conditionalExpressionHolders as $holder) { + $existing[$holder->getKey()] = $holder; + } + $conditionalExpressions[$exprString] = $existing; return $this->scopeFactory->create( $this->context, $this->isDeclareStrictTypes(), diff --git a/tests/PHPStan/Analyser/nsrt/bug-12677.php b/tests/PHPStan/Analyser/nsrt/bug-12677.php new file mode 100644 index 00000000000..04c955be2db --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12677.php @@ -0,0 +1,46 @@ +getBool() ?? false; + + if ($hasFoo) { + assertType(Foo::class, $foo); + } +} + +function test2(?Foo $foo): void +{ + $hasFoo = $foo !== null; + + $bool = $foo !== null ? $foo->getBool() : false; + + if ($hasFoo) { + assertType(Foo::class, $foo); + } +} + +function test3(?Foo $foo): void +{ + $hasFoo = $foo !== null; + + $bool = $hasFoo ? $foo->getBool() : false; + + if ($hasFoo) { + assertType(Foo::class, $foo); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-7716.php b/tests/PHPStan/Analyser/nsrt/bug-7716.php index fca0a7cfd8a..63280c3b466 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7716.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7716.php @@ -15,7 +15,7 @@ public function sayHello(array $array): int $hasBar = isset($array['bar']) && $array['bar'] > 1; if ($hasFoo) { - assertType('array{foo?: int, bar?: int}', $array); + assertType('array{foo: int, bar?: int}', $array); assertType('int<2, max>', $array['foo']); return $array['foo']; } @@ -44,7 +44,7 @@ public function sayHello2(array $array): int } if ($hasBar) { - assertType('array{foo?: int, bar?: int}', $array); + assertType('array{foo?: int, bar: int}', $array); assertType('int<2, max>', $array['bar']); return $array['bar']; } diff --git a/tests/PHPStan/Analyser/nsrt/multi-assign.php b/tests/PHPStan/Analyser/nsrt/multi-assign.php index ab4e4de1cf8..6a2b4a5f645 100644 --- a/tests/PHPStan/Analyser/nsrt/multi-assign.php +++ b/tests/PHPStan/Analyser/nsrt/multi-assign.php @@ -55,12 +55,12 @@ function (bool $b): void { function (bool $b): void { $foo = $bar = $baz = $b; if ($bar) { - assertType('bool', $b); + assertType('true', $b); assertType('bool', $foo); assertType('true', $bar); assertType('bool', $baz); } else { - assertType('bool', $b); + assertType('false', $b); assertType('bool', $foo); assertType('false', $bar); assertType('bool', $baz); @@ -70,12 +70,12 @@ function (bool $b): void { function (bool $b): void { $foo = $bar = $baz = $b; if ($baz) { - assertType('bool', $b); + assertType('true', $b); assertType('bool', $foo); assertType('bool', $bar); assertType('true', $baz); } else { - assertType('bool', $b); + assertType('false', $b); assertType('bool', $foo); assertType('bool', $bar); assertType('false', $baz); diff --git a/tests/PHPStan/Analyser/nsrt/or-condition-ternary-narrowing.php b/tests/PHPStan/Analyser/nsrt/or-condition-ternary-narrowing.php new file mode 100644 index 00000000000..64372d8567a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/or-condition-ternary-narrowing.php @@ -0,0 +1,63 @@ + $types + */ +function loopWithTernary(array $types): void +{ + $typesCount = count($types); + for ($i = 0; $i < $typesCount; $i++) { + for ($j = $i + 1; $j < $typesCount; $j++) { + $iIsA = $types[$i] instanceof A && ($types[$j] instanceof A || $types[$j] instanceof B); + $jIsA = $types[$j] instanceof A && ($types[$i] instanceof A || $types[$i] instanceof B); + + if ($iIsA || $jIsA) { + $a = $iIsA ? $types[$i] : $types[$j]; + + // `$a` is definitely A: when `$iIsA` holds, the ternary picks + // `$types[$i]` which is A; when `$iIsA` is false, the outer + // OR forces `$jIsA` so the ternary picks `$types[$j]` which + // is A. + assertType(A::class, $a); + } + } + } +} + +/** + * Same pattern but with plain variables — each `$xIsA`/`$yIsA` records two + * narrowings (both `$x` and `$y`). The earlier holder (from the first + * assignment) must survive the second assignment so that inside the + * outer `if ($xIsA || $yIsA)` the nested `if ($xIsA)` can still fire the + * conditional holders attached to `$xIsA`. + * + * @param A|B $x + * @param A|B $y + */ +function twoStoredAndNarrowings($x, $y): void +{ + $xBothA = $x instanceof A && $y instanceof A; + $yBothA = $y instanceof A && $x instanceof A; + + if ($xBothA || $yBothA) { + if ($xBothA) { + assertType(A::class, $x); + assertType(A::class, $y); + } else { + assertType(A::class, $x); + assertType(A::class, $y); + } + } +}