From 64716c08e673de0a127e82193bedfae59ca775b8 Mon Sep 17 00:00:00 2001 From: Hans van Luttikhuizen-Ross Date: Thu, 8 Oct 2026 13:22:31 +0200 Subject: [PATCH 1/2] Reuse a stored narrowing result only when the asking scope matches array_filter() and array_find() narrow by their callback body with the parameter re-bound to the element type. obtainResultForNode() returned the body's stored result, whose narrowing was computed for the declared parameter type, so with `fn (array $frame) => isset($frame['file'])` mixed elements were narrowed to (array|ArrayAccess)&hasOffsetValue. getType() already re-prices such an ask. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0138Sy8qW1oxcf7yyQ2sWCub --- src/Analyser/MutatingScope.php | 6 ++++-- .../array-filter-typed-callback-parameter.php | 20 +++++++++++++++++++ turbo-ext/src/MutatingScope.cpp | 12 +++++++++-- 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-filter-typed-callback-parameter.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b31ccecf985..0cb332a9b42 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1417,7 +1417,7 @@ public function obtainResultForNode(Expr $node): ExpressionResult $storage = $this->expressionResultStorageStack->getCurrent(); if ($storage !== null) { $result = $storage->findExpressionResult($node); - if ($result !== null) { + if ($result !== null && $result->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted)) { return $result; } } @@ -1434,7 +1434,9 @@ public function obtainResultForNode(Expr $node): ExpressionResult )); } - // a synthetic node, or no analysis in progress + // a synthetic node, a counterfactual ask (the asking scope re-binds a + // variable the node reads, e.g. array_filter narrowing by its callback + // body per element), or no analysis in progress return $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $node, $scope, diff --git a/tests/PHPStan/Analyser/nsrt/array-filter-typed-callback-parameter.php b/tests/PHPStan/Analyser/nsrt/array-filter-typed-callback-parameter.php new file mode 100644 index 00000000000..8658a02735a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-filter-typed-callback-parameter.php @@ -0,0 +1,20 @@ + $trace + * @param array{a: array{file: string}|int, b: array{line: int}, c: mixed} $shape + */ +function narrowsTheElementNotTheDeclaredParameter(array $trace, array $shape): void +{ + assertType('list', array_values(array_filter($trace, fn (array $frame) => isset($frame['file'])))); + assertType('array', array_filter($trace, function (array $frame) { + return isset($frame['file']); + })); + assertType('array', array_filter($trace, fn ($frame) => isset($frame['file']))); + assertType('array', array_filter($trace, fn (array $frame) => !empty($frame['file']))); + assertType('array{a?: array{file: string}, c?: mixed~null}', array_filter($shape, fn (array $t) => isset($t['file']))); +} diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index 6b5567f58a8..be9b6788270 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -2938,7 +2938,13 @@ class MutatingScope if (!storage.isNull()) { zv::Val result = storageFind(storage, node); if (UNEXPECTED(result.isUndef())) return zv::Val(); - if (!result.isNull()) return result; + if (!result.isNull()) { + bool promoted; + if (UNEXPECTED(!scopeNativeTypesPromoted(Z_OBJ_P(scope.raw()), promoted))) return zv::Val(); + bool matches; + if (UNEXPECTED(!pt_expression_result_ask_scope_variable_state_matches(result.raw(), scope.raw(), promoted, matches))) return zv::Val(); + if (matches) return result; + } } bool fires; @@ -2948,7 +2954,9 @@ class MutatingScope return zv::Val(); } - /* a synthetic node, or no analysis in progress */ + /* a synthetic node, a counterfactual ask (the asking scope re-binds a + * variable the node reads, e.g. array_filter narrowing by its + * callback body per element), or no analysis in progress */ zv::Val resolver = containerGetByType(PT_LC("PHPStan\\Analyser\\NodeScopeResolver")); if (UNEXPECTED(resolver.isUndef())) return zv::Val(); zend_object *resolverObject = requireObject(resolver, "processExprOnDemand"); From 6506dd09fd50dc3d1e2be3f0b95b8b04dca4f41d Mon Sep 17 00:00:00 2001 From: Hans van Luttikhuizen-Ross Date: Thu, 8 Oct 2026 13:22:31 +0200 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 a7155bcc38f..243a114b792 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '48d4823'; + public const EXPECTED_EXTENSION_VERSION = '64716c0'; private static bool $active = false;