From a153f57658904ab9e274347bff4ca575e5f9b0a3 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:40:36 +0000 Subject: [PATCH] Re-price conditional holder expressions whose stored result was walked on a branch narrowing that expression - ExpressionResult::answersOnScope() now returns false when the result's own evaluation position (beforeScope) tracked a narrowing of this very expression that the asking scope does not carry - e.g. the `$a['k']` in `$a['k'] === null || is_int($a['k'])` was walked where `$a['k']` is `mixed~null`, so its memoized type is not valid after the `||`. - AssignHandler::currentTypeForConditionalHolder() only reads the stored result when it answersOnScope(), otherwise it reads scope state / prices the expression on demand. Previously a stored boolean such as `$ok = $a['k'] === null || is_int($a['k'])` produced a conditional holder `$a['k']: int` instead of `int|null`. - Same change mirrored in turbo-ext/src/ExpressionResult.cpp and turbo-ext/src/AssignHandler.cpp. - Also fixed by the same change (covered in the test): the falsey branch of `$ok = $a['k'] !== null && !is_int($a['k'])`, ternary-assigned conditions, nested offsets `$a['k']['j']`, `is_string(..) || is_int(..)` and `$a['k'] === 1 || is_string($a['k'])`. - Probed and already fine: plain variables and instance property fetches. Static property fetches are not projected through stored booleans at all (unchanged before/after, not a regression). Co-Authored-By: Claude Opus 5.5 --- src/Analyser/ExprHandler/AssignHandler.php | 4 +- src/Analyser/ExpressionResult.php | 14 ++++- tests/PHPStan/Analyser/nsrt/bug-15479.php | 71 ++++++++++++++++++++++ turbo-ext/src/AssignHandler.cpp | 10 ++- turbo-ext/src/ExpressionResult.cpp | 15 +++++ 5 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15479.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 62ee67ed27b..fa52e2b2a49 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -2170,8 +2170,10 @@ private function currentTypeForConditionalHolder(NodeScopeResolver $nodeScopeRes // them from the walk's own storage (the scope's storage stack misses it // on loop-convergence passes); synthetic terms narrowing extensions built // (@phpstan-assert property fetches etc.) answer from scope state or a walk + // - unless the walk read them on a narrowed branch (the right operand of + // `$a['k'] === null || is_int($a['k'])`), then they are re-priced here $storedResult = $storage->findExpressionResult($expr); - if ($storedResult !== null) { + if ($storedResult !== null && $storedResult->answersOnScope($scope, $scope->nativeTypesPromoted)) { return $storedResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 9f69558d9b1..4b6c7d89d52 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -699,7 +699,19 @@ public function answersOnScope(MutatingScope $scope, bool $useNativeTypes): bool $useNativeTypes = $useNativeTypes || $scope->nativeTypesPromoted; $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; - return $this->isScopeAuthoritative($readScope) || $this->askScopeVariableStateMatches($scope, $useNativeTypes); + if ($this->isScopeAuthoritative($readScope)) { + return true; + } + + // the walk position narrowed this very expression (e.g. the right + // operand of `$a['k'] === null || is_int($a['k'])`) - a position not + // carrying that narrowing must not see it through the memoized type + $positionScope = $useNativeTypes ? $this->beforeScope->doNotTreatPhpDocTypesAsCertain() : $this->beforeScope; + if ($this->isScopeAuthoritative($positionScope)) { + return false; + } + + return $this->askScopeVariableStateMatches($scope, $useNativeTypes); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-15479.php b/tests/PHPStan/Analyser/nsrt/bug-15479.php new file mode 100644 index 00000000000..ea2285df247 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15479.php @@ -0,0 +1,71 @@ += 8.0 + +namespace Bug15479; + +use function PHPStan\Testing\assertType; + +/** @param array $a */ +function f(array $a): void { + $ok = array_key_exists('k', $a) && ($a['k'] === null || is_int($a['k'])); + if ($ok) { assertType('int|null', $a['k']); } +} + +/** @param array $a */ +function g(array $a): void { + $ok = ($a['k'] === null || is_int($a['k'])); + if ($ok) { assertType('int|null', $a['k']); } +} + +/** @param array $a */ +function h(array $a): void { + if (array_key_exists('k', $a) && ($a['k'] === null || is_int($a['k']))) { assertType('int|null', $a['k']); } +} + +function i(mixed $v): void { + $ok = $v === null || is_int($v); + if ($ok) { assertType('int|null', $v); } +} + +/** @param array $a */ +function negated(array $a): void { + $ok = ($a['k'] !== null && !is_int($a['k'])); + if (!$ok) { + assertType('int|null', $a['k']); + } else { + assertType('mixed~(int|null)', $a['k']); + } +} + +/** @param array $a */ +function ternary(array $a): void { + $ok = $a['k'] === null || is_int($a['k']) ? 1 : 0; + if ($ok === 1) { assertType('int|null', $a['k']); } +} + +/** @param array> $a */ +function nested(array $a): void { + $ok = ($a['k']['j'] === null || is_int($a['k']['j'])); + if ($ok) { assertType('int|null', $a['k']['j']); } +} + +/** @param array $a */ +function typeChecks(array $a): void { + $ok = (is_string($a['k']) || is_int($a['k'])); + if ($ok) { assertType('int|string', $a['k']); } +} + +/** @param array $a */ +function constantOrType(array $a): void { + $ok = ($a['k'] === 1 || is_string($a['k'])); + if ($ok) { assertType('1|string', $a['k']); } +} + +/** @param array $a */ +function elseBranch(array $a): void { + $ok = ($a['k'] === null || is_string($a['k'])); + if ($ok) { + assertType('string|null', $a['k']); + } else { + assertType('mixed~(string|null)', $a['k']); + } +} diff --git a/turbo-ext/src/AssignHandler.cpp b/turbo-ext/src/AssignHandler.cpp index e249818c5d4..f5dd909b880 100644 --- a/turbo-ext/src/AssignHandler.cpp +++ b/turbo-ext/src/AssignHandler.cpp @@ -2489,8 +2489,16 @@ class AssignHandler // them from the walk's own storage (the scope's storage stack misses it // on loop-convergence passes); synthetic terms narrowing extensions built // (@phpstan-assert property fetches etc.) answer from scope state or a walk + // - unless the walk read them on a narrowed branch (the right operand of + // `$a['k'] === null || is_int($a['k'])`), then they are re-priced here AH_VAL(storedResult, pt_expression_result_storage_find(storage, expr)); - if (!storedResult.isNull()) return ahTypeOnScope(storedResult.raw(), scope); + if (!storedResult.isNull()) { + bool promoted; + AH_OK(ahPromoted(scope, promoted)); + bool answers; + AH_OK(pt_expression_result_answers_on_scope(storedResult.raw(), scope, promoted, answers)); + if (answers) return ahTypeOnScope(storedResult.raw(), scope); + } return nsrReadScopeStateOrSyntheticType(nsr, expr, scope); } diff --git a/turbo-ext/src/ExpressionResult.cpp b/turbo-ext/src/ExpressionResult.cpp index c72c7984242..7d2a5b9a9cd 100644 --- a/turbo-ext/src/ExpressionResult.cpp +++ b/turbo-ext/src/ExpressionResult.cpp @@ -600,6 +600,21 @@ class ExpressionResult zend_long authoritative = isScopeAuthoritative(readScope); if (authoritative != 0) return authoritative; + + // the walk position narrowed this very expression (e.g. the right + // operand of `$a['k'] === null || is_int($a['k'])`) - a position not + // carrying that narrowing must not see it through the memoized type + zv::Val positionScopeHolder; + zval *positionScope = slot(slots::beforeScope); + if (useNativeTypes) { + positionScopeHolder = scopeNativeView(positionScope); + if (UNEXPECTED(positionScopeHolder.isUndef())) return -1; + positionScope = positionScopeHolder.raw(); + } + zend_long positionAuthoritative = isScopeAuthoritative(positionScope); + if (positionAuthoritative < 0) return -1; + if (positionAuthoritative == 1) return 0; + return askScopeVariableStateMatches(scope, useNativeTypes, false); }