From 7bcf6c4d919641b7c8edf828aea5c72672267625 Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:15:18 +0600 Subject: [PATCH 1/2] Re-price a stored condition operand that does not answer on the assign scope When a condition is stored in a variable, the conditional holders for the narrowed expressions intersect the narrowing with the expression's current type. That type was read from the stored result of the subexpression, but the specified types of `$a['k'] === null || is_int($a['k'])` carry the `$a['k']` node from the right operand, which was walked on the left's falsey branch and so has the type mixed~null. Intersecting it with int|null dropped null, and `if ($ok)` then narrowed `$a['k']` to int. The stored result is now only used when it answers on the assign scope; otherwise the expression is re-priced on that scope. Closes https://github.com/phpstan/phpstan/issues/15479 --- src/Analyser/ExprHandler/AssignHandler.php | 7 ++++++ tests/PHPStan/Analyser/nsrt/bug-15479.php | 26 ++++++++++++++++++++++ turbo-ext/src/AssignHandler.cpp | 17 +++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) 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 0a1f5f83e84..d9c45d8064a 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -2156,6 +2156,13 @@ private function currentTypeForConditionalHolder(NodeScopeResolver $nodeScopeRes // (@phpstan-assert property fetches etc.) answer from scope state or a walk $storedResult = $storage->findExpressionResult($expr); if ($storedResult !== null) { + // a subexpression walked on a narrower branch (the right side of + // `$a['k'] === null || is_int($a['k'])` sees `$a['k']` without null) + // does not answer on this scope - re-price it here + if (!$storedResult->answersOnScope($scope, $scope->nativeTypesPromoted)) { + return $nodeScopeResolver->processExprOnDemand($expr, $scope, new ExpressionResultStorage())->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + return $storedResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15479.php b/tests/PHPStan/Analyser/nsrt/bug-15479.php new file mode 100644 index 00000000000..a9dc81a7f02 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15479.php @@ -0,0 +1,26 @@ += 8.0 + +declare(strict_types = 1); + +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); } +} diff --git a/turbo-ext/src/AssignHandler.cpp b/turbo-ext/src/AssignHandler.cpp index f5a81e3b4bc..95ebcf41251 100644 --- a/turbo-ext/src/AssignHandler.cpp +++ b/turbo-ext/src/AssignHandler.cpp @@ -2447,7 +2447,22 @@ class AssignHandler // on loop-convergence passes); synthetic terms narrowing extensions built // (@phpstan-assert property fetches etc.) answer from scope state or a walk AH_VAL(storedResult, pt_expression_result_storage_find(storage, expr)); - if (!storedResult.isNull()) return ahTypeOnScope(storedResult.raw(), scope); + if (!storedResult.isNull()) { + // a subexpression walked on a narrower branch (the right side of + // `$a['k'] === null || is_int($a['k'])` sees `$a['k']` without null) + // does not answer on this scope - re-price it here + bool promoted; + AH_OK(ahPromoted(scope, promoted)); + bool answers; + AH_OK(pt_expression_result_answers_on_scope(storedResult.raw(), scope, promoted, answers)); + if (!answers) { + AH_VAL(onDemandStorage, pt_expression_result_storage_new()); + AH_VAL(onDemandResult, pt_node_scope_resolver_process_expr_on_demand(nsr, expr, scope, onDemandStorage.raw())); + return ahTypeOnScope(onDemandResult.raw(), scope); + } + + return ahTypeOnScope(storedResult.raw(), scope); + } return nsrReadScopeStateOrSyntheticType(nsr, expr, scope); } From a30af5ab97642725aedcf1636380bb8d9f689acb Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:15:18 +0600 Subject: [PATCH 2/2] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 374d29043e6..1c3cb2e5662 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = 'a0f7086'; + public const EXPECTED_EXTENSION_VERSION = '7bcf6c4'; private static bool $active = false;