From 7e21ba67cc28249b22411e0b771149ec4d752bb5 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:43:56 +0000 Subject: [PATCH] Fall back to the full branch type for a conditional guard when the partial set difference still overlaps the other branch - ScopeOps::createConditionalExpressions() derives guard types from TypeCombinator::remove(ours, theirs). When remove() can only subtract part of their type (e.g. null from string|null, but not non-empty-string from string), the remainder (string) still overlaps the other branch, so seeing it later does not prove our branch ran. Such guards produced conditional expressions like "if $title=string then $input->isInteractive() is true". Now the guard falls back to our full type when the remainder is not disjoint from their type, which restores the merged-type comparison that was used before. - Ported the same check to the native mirror in turbo-ext/src/ScopeOps.cpp. - Variants also covered: plain ?string narrowed against non-empty-string|null, and ?array against non-empty-array|null (both were broken). The int<1, max>|null variant already worked, because int is representable; it is kept as coverage. --- src/Analyser/ScopeOps.php | 9 +++ tests/PHPStan/Analyser/nsrt/bug-15483.php | 77 +++++++++++++++++++++++ turbo-ext/src/ScopeOps.cpp | 9 +++ 3 files changed, 95 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15483.php diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 268620c4706..a7375c5cf99 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -505,6 +505,15 @@ public static function createConditionalExpressions( if ($remainder instanceof NeverType) { continue; } + if ( + $remainder !== $holder->getType() + && !$theirHolder->getType()->isSuperTypeOf($remainder)->no() + ) { + // only part of their type was subtracted (e.g. null from + // string|null, but not non-empty-string from string), so the + // remainder still overlaps their branch and cannot prove ours + $remainder = $holder->getType(); + } if ($mergedExpressionTypes[$exprString]->getType()->equals($remainder)) { // matching this guard later would not discriminate the branches - // the merged scope already guarantees it diff --git a/tests/PHPStan/Analyser/nsrt/bug-15483.php b/tests/PHPStan/Analyser/nsrt/bug-15483.php new file mode 100644 index 00000000000..72b2a5bc57c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15483.php @@ -0,0 +1,77 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15483; + +use function PHPStan\Testing\assertType; + +interface Input +{ + public function isInteractive(): bool; +} + +function prompt(): ?string +{ + return null; +} + +function f(Input $input, mixed $option): void +{ + $title = \is_string($option) && $option !== '' ? $option : null; + if ($title === null && $input->isInteractive()) { + $title = prompt(); + } + if ($title === null) { + return; + } + assertType('bool', $input->isInteractive()); +} + +function g(Input $input, mixed $option): void +{ + $title = \is_string($option) ? $option : null; + if ($title === null && $input->isInteractive()) { + $title = prompt(); + } + if ($title === null) { + return; + } + assertType('bool', $input->isInteractive()); +} + +/** @param non-empty-string|null $title */ +function h(Input $input, ?string $title): void +{ + if ($title === null && $input->isInteractive()) { + $title = prompt(); + } + if ($title === null) { + return; + } + assertType('bool', $input->isInteractive()); +} + +/** @param int<1, max>|null $count */ +function i(Input $input, ?int $count, ?int $other): void +{ + if ($count === null && $input->isInteractive()) { + $count = $other; + } + if ($count === null) { + return; + } + assertType('bool', $input->isInteractive()); +} + +/** @param non-empty-array|null $items */ +function j(Input $input, ?array $items, ?array $other): void +{ + if ($items === null && $input->isInteractive()) { + $items = $other; + } + if ($items === null) { + return; + } + assertType('bool', $input->isInteractive()); +} diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index 42c3a956baf..06ec4dc65b3 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -603,6 +603,15 @@ class ScopeOps zv::Val remainder = typeCombinatorRemove(holderType(holder), holderType(theirHolder)); if (UNEXPECTED(remainder.isUndef())) return zv::Val(); if (remainder.ref().instanceOf(neverTypeCe)) continue; + if (Z_OBJ_P(remainder.raw()) != Z_OBJ_P(holderType(holder))) { + /* a partial subtraction that still overlaps their branch cannot + * prove ours - fall back to our full type, like the twin */ + zend_long theirIsSuperTypeOfRemainder; + if (UNEXPECTED(!isSuperTypeOfValue(holderType(theirHolder), remainder.raw(), &theirIsSuperTypeOfRemainder))) return zv::Val(); + if (theirIsSuperTypeOfRemainder != PT_TRI_NO) { + remainder = zv::Val::copyOf(zv::Ref(holderType(holder))); + } + } { zv::Ref mergedHolder = zv::Ref(mergedSlot).deref(); if (UNEXPECTED(!pt_check_holder(mergedHolder.raw()))) return zv::Val();