From 9cebb2a99a34951bf32c50edda9376b07fba2a06 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:10:16 +0000 Subject: [PATCH 1/2] Ask `isCallable()` of callable-like arrays and strings only where the answer is consumed - ArrayHandler: check for a narrowed `is_callable()` expression in scope before asking the array literal type `isCallable()`, instead of asking it for every two-item array literal - ArgumentsHandler: ask the argument type `isCallable()` only when the result is used - when the callback is called immediately, or when invalidation applies and the argument can be an object (only closures carry expressions to invalidate) - DependencyResolver: skip the callable-array dependency for class-level initializers (class constants, property defaults, enum case values), which are never called where declared - GenericParametersAcceptorResolver: ask the argument type `isCallable()` only once a callable parameter with asserts is found (same eager reflection for generic function arguments) - Mirror the ArrayHandler and ArgumentsHandler changes in turbo-ext - Probed and left as is: UnusedPrivateMethodRule, SimpleImpurePoint, FuncCallHandler and other callers ask `isCallable()` only of values that are actually used as callables Answering `isCallable()` of `['SomeClass', 'someString']` or `'SomeClass::someString'` locates and reflects `SomeClass`, which for a huge generated class costs hundreds of MB. Co-Authored-By: Claude Opus 5.5 --- src/Analyser/ArgumentsHandler.php | 11 ++- src/Analyser/ExprHandler/ArrayHandler.php | 5 +- src/Dependency/DependencyResolver.php | 11 +++ .../GenericParametersAcceptorResolver.php | 10 ++- ...ug15292MethodsClassReflectionExtension.php | 32 ++++++++ tests/PHPStan/Analyser/Bug15292Test.php | 76 +++++++++++++++++++ tests/PHPStan/Analyser/bug-15292.neon | 5 ++ .../Analyser/data/bug-15292-callable.php | 15 ++++ .../Analyser/data/bug-15292-generic.php | 34 +++++++++ tests/PHPStan/Analyser/data/bug-15292.php | 46 +++++++++++ turbo-ext/src/ArgumentsHandler.cpp | 46 +++++++---- turbo-ext/src/ArrayHandler.cpp | 18 +++-- 12 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 tests/PHPStan/Analyser/Bug15292MethodsClassReflectionExtension.php create mode 100644 tests/PHPStan/Analyser/Bug15292Test.php create mode 100644 tests/PHPStan/Analyser/bug-15292.neon create mode 100644 tests/PHPStan/Analyser/data/bug-15292-callable.php create mode 100644 tests/PHPStan/Analyser/data/bug-15292-generic.php create mode 100644 tests/PHPStan/Analyser/data/bug-15292.php diff --git a/src/Analyser/ArgumentsHandler.php b/src/Analyser/ArgumentsHandler.php index edccec5bf58..444f91d6092 100644 --- a/src/Analyser/ArgumentsHandler.php +++ b/src/Analyser/ArgumentsHandler.php @@ -654,13 +654,18 @@ public function processArgs( } $hasYield = $hasYield || $exprResult->hasYield(); - if ($exprType->isCallable()->yes()) { + // only callable objects (closures) carry expressions to invalidate - asking + // isCallable() of other arguments reflects the classes named by callable-like + // strings and arrays, so it is skipped when nothing would come of it + $invalidateCallbackExpressions = $this->shouldInvalidateCallbackExpressions($parameter) && !$exprType->isObject()->no(); + $callCallbackImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); + if (($invalidateCallbackExpressions || $callCallbackImmediately) && $exprType->isCallable()->yes()) { $acceptors = $exprType->getCallableParametersAcceptors($scope); if (count($acceptors) === 1) { - if ($this->shouldInvalidateCallbackExpressions($parameter)) { + if ($invalidateCallbackExpressions) { $deferredInvalidateExpressions[] = [$acceptors[0]->getInvalidateExpressions(), $acceptors[0]->getUsedVariables()]; } - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + if ($callCallbackImmediately) { $callableThrowPoints = array_map(static fn (SimpleThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable(), $throwPoint->isFromThrowExpr()) : InternalThrowPoint::createImplicit($scope, $arg->value), $acceptors[0]->getThrowPoints()); if (!$this->implicitThrows) { $callableThrowPoints = array_values(array_filter($callableThrowPoints, static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit())); diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index a63f7c79a81..c015a8f83ac 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -174,18 +174,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ( count($expr->items) === 2 && isset($expr->items[0], $expr->items[1]) - && $type->isCallable()->maybe() ) { $isCallableCall = new FuncCall( new FullyQualified('is_callable'), [new Arg($expr)], ); + // isCallable() is asked last - it reflects the class named by the + // first item, which is expensive and unnecessary for arrays never + // narrowed by is_callable() if ( $beforeScope->hasExpressionType($isCallableCall)->yes() // read the narrowed type from expressionTypes directly (the // synthetic is_callable() call was never processed as a child), // mirroring ConstFetchHandler's narrowed-constant lookup && $beforeScope->expressionTypes[$beforeScope->getNodeKey($isCallableCall)]->getType()->isTrue()->yes() + && $type->isCallable()->maybe() ) { $type = TypeCombinator::intersect($type, new CallableType()); } diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index 507ca0e3b30..c029983950d 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -670,6 +670,17 @@ private function considerArrayForCallableTest(Scope $scope, Array_ $arrayNode): return false; } + // a class constant, property default or enum case value is not called where it is + // declared - testing it would reflect whatever class its first item happens to name + if ( + $scope->isInClass() + && $scope->getFunction() === null + && !$scope->isInAnonymousFunction() + && $scope->getFunctionCallStack() === [] + ) { + return false; + } + $itemType = $scope->getType($items[0]->value); return $itemType->isClassString()->yes(); } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index 5b25a23be4c..9b88fd38b08 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -171,10 +171,6 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): TemplateTypeMap { $typeMap = TemplateTypeMap::createEmpty(); - if (!$argType->isCallable()->yes()) { - return $typeMap; - } - foreach ($paramType instanceof UnionType ? $paramType->getTypes() : [$paramType] as $innerType) { if (!$innerType instanceof CallableParametersAcceptor) { continue; @@ -183,6 +179,12 @@ private static function inferPredicateTemplateTypes(Type $paramType, Type $argTy continue; } + // asked only for parameters with asserts - it reflects the class named by + // a callable-like argument + if (!$argType->isCallable()->yes()) { + return $typeMap; + } + foreach ($argType->getCallableParametersAcceptors(new OutOfClassScope()) as $receivedAcceptor) { $typeMap = $typeMap->union(CallableAssertionsHelper::inferTemplateTypesOnAsserts($innerType, $receivedAcceptor)); } diff --git a/tests/PHPStan/Analyser/Bug15292MethodsClassReflectionExtension.php b/tests/PHPStan/Analyser/Bug15292MethodsClassReflectionExtension.php new file mode 100644 index 00000000000..7ea2b02da86 --- /dev/null +++ b/tests/PHPStan/Analyser/Bug15292MethodsClassReflectionExtension.php @@ -0,0 +1,32 @@ + */ + public static array $askedMethods = []; + + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + self::$askedMethods[] = $classReflection->getName() . '::' . $methodName; + + return false; + } + + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + throw new ShouldNotHappenException(); + } + +} diff --git a/tests/PHPStan/Analyser/Bug15292Test.php b/tests/PHPStan/Analyser/Bug15292Test.php new file mode 100644 index 00000000000..e872ec7c4ac --- /dev/null +++ b/tests/PHPStan/Analyser/Bug15292Test.php @@ -0,0 +1,76 @@ +> + */ +class Bug15292Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new class implements Rule { + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + + }; + } + + #[RequiresPhp('>= 8.1')] + public function testCallableLikeValuesAreNotReflected(): void + { + Bug15292MethodsClassReflectionExtension::$askedMethods = []; + $this->analyse([__DIR__ . '/data/bug-15292.php'], []); + $this->assertSame([], $this->getAskedMethods('Bug15292\\')); + } + + public function testCallableLikeGenericArgumentsAreNotReflected(): void + { + Bug15292MethodsClassReflectionExtension::$askedMethods = []; + $this->analyse([__DIR__ . '/data/bug-15292-generic.php'], []); + $this->assertSame([], $this->getAskedMethods('Bug15292Generic\\')); + } + + public function testCallableIsReflected(): void + { + Bug15292MethodsClassReflectionExtension::$askedMethods = []; + $this->analyse([__DIR__ . '/data/bug-15292-callable.php'], []); + $this->assertContains('Bug15292Callable\BigContainer::callable_ident', $this->getAskedMethods('Bug15292Callable\\')); + } + + /** + * @return list + */ + private function getAskedMethods(string $prefix): array + { + return array_values(array_filter( + Bug15292MethodsClassReflectionExtension::$askedMethods, + static fn (string $method): bool => str_starts_with($method, $prefix), + )); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/bug-15292.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/bug-15292.neon b/tests/PHPStan/Analyser/bug-15292.neon new file mode 100644 index 00000000000..0a6a687f941 --- /dev/null +++ b/tests/PHPStan/Analyser/bug-15292.neon @@ -0,0 +1,5 @@ +services: + - + class: PHPStan\Analyser\Bug15292MethodsClassReflectionExtension + tags: + - phpstan.broker.methodsClassReflectionExtension diff --git a/tests/PHPStan/Analyser/data/bug-15292-callable.php b/tests/PHPStan/Analyser/data/bug-15292-callable.php new file mode 100644 index 00000000000..bb31422e5d4 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-15292-callable.php @@ -0,0 +1,15 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15292; + +class BigContainer +{ + +} + +enum Modality: string +{ + + case First = 'first'; + + public const ALLOWED = [ + 'Bug15292\BigContainer', + 'enum_constant_ident', + ]; + +} + +final class FooLeaking +{ + + private const ALLOWED_MODALITIES = [ + 'Bug15292\BigContainer', + 'arbitrary_ident', + ]; + + /** @var list */ + private array $allowedProperty = [ + 'Bug15292\BigContainer', + 'property_ident', + ]; + + public function isAllowed(string $item): bool + { + return in_array($item, self::ALLOWED_MODALITIES, true) + || in_array($item, $this->allowedProperty, true) + || in_array($item, Modality::ALLOWED, true) + || in_array('Bug15292\BigContainer::string_ident', [$item], true); + } + +} diff --git a/turbo-ext/src/ArgumentsHandler.cpp b/turbo-ext/src/ArgumentsHandler.cpp index eab64d05220..20748542d59 100644 --- a/turbo-ext/src/ArgumentsHandler.cpp +++ b/turbo-ext/src/ArgumentsHandler.cpp @@ -2030,10 +2030,8 @@ class ArgumentsHandler /* the callable-argument bookkeeping of a non-closure argument whose type * is callable with a single acceptor; false = pending exception */ - zend_never_inline bool processCallableArg(Walk &w, zval *value, ArgLocals &a, zval *acceptor) const + zend_never_inline bool processCallableArg(Walk &w, zval *value, zval *acceptor, bool invalidateCallback, bool immediately) const { - bool invalidateCallback = false; - if (UNEXPECTED(!shouldInvalidateCallbackExpressions(a.parameter, invalidateCallback))) return false; if (invalidateCallback) { zv::Val invalidateExpressions = callByName(acceptor, PT_LC("getinvalidateexpressions"), "getInvalidateExpressions", 0, NULL); if (UNEXPECTED(invalidateExpressions.isUndef())) return false; @@ -2042,8 +2040,6 @@ class ArgumentsHandler w.deferredInvalidateExpressions.push(std::move(invalidateExpressions)); w.deferredUses.push(std::move(usedVariables)); } - bool immediately = false; - if (UNEXPECTED(!callCallbackImmediately(a.parameter, a.parameterType.isUndef() ? NULL : a.parameterType.raw(), w.calleeReflection, immediately))) return false; if (!immediately) return true; static pt_method_site throwPointsSite, impurePointsSite; @@ -2136,18 +2132,36 @@ class ArgumentsHandler } if (!w.hasYield && UNEXPECTED(!pt_expression_result_has_yield(exprResult, w.hasYield))) return false; - if (UNEXPECTED(!exprType.ref().isObject())) { - zend_throw_error(NULL, "Call to a member function isCallable() on %s", zend_zval_value_name(exprType.raw())); - return false; + // only callable objects (closures) carry expressions to invalidate - asking + // isCallable() of other arguments reflects the classes named by callable-like + // strings and arrays, so it is skipped when nothing would come of it + bool invalidateCallback = false; + if (UNEXPECTED(!shouldInvalidateCallbackExpressions(a.parameter, invalidateCallback))) return false; + if (invalidateCallback) { + if (UNEXPECTED(!exprType.ref().isObject())) { + zend_throw_error(NULL, "Call to a member function isObject() on %s", zend_zval_value_name(exprType.raw())); + return false; + } + zend_long isObject = pt_type_call_trinary(Z_OBJ_P(exprType.raw()), PT_LC("isobject"), 0, NULL); + if (UNEXPECTED(isObject < 0)) return false; + invalidateCallback = isObject != PT_TRI_NO; } - zend_long isCallable = pt_type_op_trinary(Z_OBJ_P(exprType.raw()), PT_OP_IS_CALLABLE, 0, NULL); - if (UNEXPECTED(isCallable < 0)) return false; - if (isCallable == PT_TRI_YES) { - zv::Val acceptors = pt_type_call(Z_OBJ_P(exprType.raw()), PT_LC("getcallableparametersacceptors"), 1, w.scope.raw()); - if (UNEXPECTED(acceptors.isUndef() || !requireArray(acceptors.raw(), "count(): Argument #1 ($value)"))) return false; - if (zend_hash_num_elements(Z_ARRVAL_P(acceptors.raw())) == 1) { - zval *acceptor = readIndex(Z_ARRVAL_P(acceptors.raw()), 0); - if (UNEXPECTED(acceptor == NULL || !processCallableArg(w, value, a, acceptor))) return false; + bool immediately = false; + if (UNEXPECTED(!callCallbackImmediately(a.parameter, a.parameterType.isUndef() ? NULL : a.parameterType.raw(), w.calleeReflection, immediately))) return false; + if (invalidateCallback || immediately) { + if (UNEXPECTED(!exprType.ref().isObject())) { + zend_throw_error(NULL, "Call to a member function isCallable() on %s", zend_zval_value_name(exprType.raw())); + return false; + } + zend_long isCallable = pt_type_op_trinary(Z_OBJ_P(exprType.raw()), PT_OP_IS_CALLABLE, 0, NULL); + if (UNEXPECTED(isCallable < 0)) return false; + if (isCallable == PT_TRI_YES) { + zv::Val acceptors = pt_type_call(Z_OBJ_P(exprType.raw()), PT_LC("getcallableparametersacceptors"), 1, w.scope.raw()); + if (UNEXPECTED(acceptors.isUndef() || !requireArray(acceptors.raw(), "count(): Argument #1 ($value)"))) return false; + if (zend_hash_num_elements(Z_ARRVAL_P(acceptors.raw())) == 1) { + zval *acceptor = readIndex(Z_ARRVAL_P(acceptors.raw()), 0); + if (UNEXPECTED(acceptor == NULL || !processCallableArg(w, value, acceptor, invalidateCallback, immediately))) return false; + } } } diff --git a/turbo-ext/src/ArrayHandler.cpp b/turbo-ext/src/ArrayHandler.cpp index c40a263dbb9..c9a7b1fb4c0 100644 --- a/turbo-ext/src/ArrayHandler.cpp +++ b/turbo-ext/src/ArrayHandler.cpp @@ -522,14 +522,6 @@ class ArrayHandler if (first == NULL || Z_TYPE_P(first) == IS_NULL) return type; zval *second = zend_hash_index_find(Z_ARRVAL_P(items), 1); if (second == NULL || Z_TYPE_P(second) == IS_NULL) return type; - if (UNEXPECTED(!type.ref().isObject())) { - zend_throw_error(NULL, "Call to a member function isCallable() on %s", zend_zval_value_name(type.raw())); - return zv::Val(); - } - zend_long isCallable = pt_type_op_trinary(Z_OBJ_P(type.raw()), PT_OP_IS_CALLABLE, 0, NULL); - if (UNEXPECTED(isCallable < 0)) return zv::Val(); - if (isCallable != PT_TRI_MAYBE) return type; - zv::Val isCallableCall; { zv::Val name = pt_type_new(PT_CLASS_FULLY_QUALIFIED, 1, zv::Args{pt_arh_is_callable}); @@ -556,6 +548,16 @@ class ArrayHandler zend_long isTrue = pt_type_call_trinary(Z_OBJ_P(trackedType.raw()), PT_LC("istrue"), 0, NULL); if (UNEXPECTED(isTrue < 0)) return zv::Val(); if (isTrue != PT_TRI_YES) return type; + // isCallable() is asked last - it reflects the class named by the first + // item, which is expensive and unnecessary for arrays never narrowed by + // is_callable() + if (UNEXPECTED(!type.ref().isObject())) { + zend_throw_error(NULL, "Call to a member function isCallable() on %s", zend_zval_value_name(type.raw())); + return zv::Val(); + } + zend_long isCallable = pt_type_op_trinary(Z_OBJ_P(type.raw()), PT_OP_IS_CALLABLE, 0, NULL); + if (UNEXPECTED(isCallable < 0)) return zv::Val(); + if (isCallable != PT_TRI_MAYBE) return type; zval callableType; if (UNEXPECTED(!pt_callable_type_new(&callableType))) return zv::Val(); From 1f8dd0a92413acff9d2e44569b76eda85ca82bc0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 23 Sep 2026 12:28:20 +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 f3bea26a417..3c064c284c2 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '341ab11'; + public const EXPECTED_EXTENSION_VERSION = '9cebb2a'; private static bool $active = false;