From 39a0ed94d8b5ad8d9c15da322b547d5b7ca10de7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 26 Jan 2026 14:57:41 +0100 Subject: [PATCH 01/13] Don't error with checkMissingCallableSignature about degraded closures --- .../Constant/ConstantArrayTypeBuilder.php | 4 +- .../Analyser/nsrt/degrade-closures.php | 2 +- .../MissingPropertyTypehintRuleTest.php | 5 +++ .../Rules/Properties/data/bug-14012.php | 40 +++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-14012.php diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 9396fec39f..b486a8e9ff 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -315,13 +315,15 @@ public function getArray(): Type if ($this->degradeClosures) { $itemTypes = []; - $itemTypes[] = new CallableType(); + $returnTypes = []; foreach ($this->valueTypes as $valueType) { if ($valueType instanceof ClosureType) { + $returnTypes[] = $valueType->getReturnType(); continue; } $itemTypes[] = $valueType; } + $itemTypes[] = new CallableType(null, TypeCombinator::union(...$returnTypes)); } else { $itemTypes = $this->valueTypes; } diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures.php b/tests/PHPStan/Analyser/nsrt/degrade-closures.php index a310d0d5f3..7cf361c226 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures.php @@ -23,4 +23,4 @@ assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void}', $arr); $arr[] = static function () {}; -assertType('non-empty-list&oversized-array', $arr); +assertType('non-empty-list&oversized-array', $arr); diff --git a/tests/PHPStan/Rules/Properties/MissingPropertyTypehintRuleTest.php b/tests/PHPStan/Rules/Properties/MissingPropertyTypehintRuleTest.php index c274aff53c..0da13c8cda 100644 --- a/tests/PHPStan/Rules/Properties/MissingPropertyTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Properties/MissingPropertyTypehintRuleTest.php @@ -76,4 +76,9 @@ public function testPromotedProperties(): void $this->analyse([__DIR__ . '/data/promoted-properties-missing-typehint.php'], []); } + public function testBug14012(): void + { + $this->analyse([__DIR__ . '/data/bug-14012.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14012.php b/tests/PHPStan/Rules/Properties/data/bug-14012.php new file mode 100644 index 0000000000..6dde790533 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-14012.php @@ -0,0 +1,40 @@ + Date: Mon, 26 Jan 2026 15:00:55 +0100 Subject: [PATCH 02/13] another test --- .../nsrt/degrade-closures-return-types.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php b/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php new file mode 100644 index 0000000000..0cefa53d6d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php @@ -0,0 +1,26 @@ +&oversized-array', $arr); From ba972a569e9995495e84cbc46616dbfa03f2e1c2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 26 Jan 2026 18:37:34 +0100 Subject: [PATCH 03/13] simpler fix --- src/Type/CallableType.php | 8 +++++++- src/Type/Constant/ConstantArrayTypeBuilder.php | 4 +--- .../Analyser/nsrt/degrade-closures-return-types.php | 2 +- tests/PHPStan/Analyser/nsrt/degrade-closures.php | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index cf9998fbe2..8bc809c18d 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -84,14 +84,20 @@ public function __construct( ?TemplateTypeMap $resolvedTemplateTypeMap = null, private array $templateTags = [], ?TrinaryLogic $isPure = null, + ?bool $isCommonCallable = null, ) { $this->parameters = $parameters ?? []; $this->returnType = $returnType ?? new MixedType(); - $this->isCommonCallable = $parameters === null && $returnType === null; $this->templateTypeMap = $templateTypeMap ?? TemplateTypeMap::createEmpty(); $this->resolvedTemplateTypeMap = $resolvedTemplateTypeMap ?? TemplateTypeMap::createEmpty(); $this->isPure = $isPure ?? TrinaryLogic::createMaybe(); + + if ($isCommonCallable !== null) { + $this->isCommonCallable = $isCommonCallable; + } else { + $this->isCommonCallable = $parameters === null && $returnType === null; + } } /** diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index b486a8e9ff..21a885b7e5 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -315,15 +315,13 @@ public function getArray(): Type if ($this->degradeClosures) { $itemTypes = []; - $returnTypes = []; + $itemTypes[] = new CallableType(isCommonCallable: false); foreach ($this->valueTypes as $valueType) { if ($valueType instanceof ClosureType) { - $returnTypes[] = $valueType->getReturnType(); continue; } $itemTypes[] = $valueType; } - $itemTypes[] = new CallableType(null, TypeCombinator::union(...$returnTypes)); } else { $itemTypes = $this->valueTypes; } diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php b/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php index 0cefa53d6d..5b5aa2dd75 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php @@ -23,4 +23,4 @@ assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): 1, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): true}', $arr); $arr[] = static function () {}; -assertType('non-empty-list&oversized-array', $arr); +assertType('non-empty-list&oversized-array', $arr); diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures.php b/tests/PHPStan/Analyser/nsrt/degrade-closures.php index 7cf361c226..a310d0d5f3 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures.php @@ -23,4 +23,4 @@ assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void}', $arr); $arr[] = static function () {}; -assertType('non-empty-list&oversized-array', $arr); +assertType('non-empty-list&oversized-array', $arr); From e3fe43d3ffdfa999525bc5eb395628cb1974b157 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 26 Jan 2026 20:18:16 +0100 Subject: [PATCH 04/13] assertType --- tests/PHPStan/Analyser/NodeScopeResolverTest.php | 1 + tests/PHPStan/Rules/Properties/data/bug-14012.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 92c6565789..9804bccd47 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -247,6 +247,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Methods/data/bug-4801.php'; yield __DIR__ . '/../Rules/Arrays/data/narrow-superglobal.php'; yield __DIR__ . '/../Rules/Methods/data/bug-12927.php'; + yield __DIR__ . '/../Rules/Properties/data/bug-14012.php'; } /** diff --git a/tests/PHPStan/Rules/Properties/data/bug-14012.php b/tests/PHPStan/Rules/Properties/data/bug-14012.php index 6dde790533..13b4c643a8 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-14012.php +++ b/tests/PHPStan/Rules/Properties/data/bug-14012.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; +use function PHPStan\Testing\assertType; final class ExpectationMethodResolver { @@ -38,3 +39,5 @@ final class ExpectationMethodResolver */ public static array $resolvers = []; } + +assertType("non-empty-array<'hasMethod'|'hasProperty'|'isArray'|'isBool'|'isCallable'|'isCountable'|'isFalse'|'isFloat'|'isInstanceOf'|'isInt'|'isIterable'|'isList'|'isMap'|'isNaturalInt'|'isNegativeInt'|'isNonEmptyString'|'isNull'|'isNumeric'|'isObject'|'isPositiveInt'|'isResource'|'isSameAs'|'isScalar'|'isString'|'isTrue', callable(): mixed>&oversized-array", ExpectationMethodResolver::$resolvers); From c9a817e4cb63ca447270baddc81e2d84d4d9ce90 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 08:03:43 +0100 Subject: [PATCH 05/13] added failling test --- .../Functions/CallToFunctionParametersRuleTest.php | 7 +++++++ tests/PHPStan/Rules/Properties/data/bug-14012.php | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index bdd0d7c73a..8b46bbecc9 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2591,4 +2591,11 @@ public function testBug8936(): void $this->analyse([__DIR__ . '/data/bug-8936.php'], []); } + public function testBug14012(): void + { + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = false; + $this->analyse([__DIR__ . '/../Properties/data/bug-14012.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14012.php b/tests/PHPStan/Rules/Properties/data/bug-14012.php index 13b4c643a8..8f562a10c8 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-14012.php +++ b/tests/PHPStan/Rules/Properties/data/bug-14012.php @@ -41,3 +41,15 @@ final class ExpectationMethodResolver } assertType("non-empty-array<'hasMethod'|'hasProperty'|'isArray'|'isBool'|'isCallable'|'isCountable'|'isFalse'|'isFloat'|'isInstanceOf'|'isInt'|'isIterable'|'isList'|'isMap'|'isNaturalInt'|'isNegativeInt'|'isNonEmptyString'|'isNull'|'isNumeric'|'isObject'|'isPositiveInt'|'isResource'|'isSameAs'|'isScalar'|'isString'|'isTrue', callable(): mixed>&oversized-array", ExpectationMethodResolver::$resolvers); + +/** + * @param callable(Scope, Node\Arg):Node\Expr $callable + */ +function doFoo($callable):void {} +doFoo(ExpectationMethodResolver::$resolvers['hasMethod']); + +/** + * @param \Closure(Scope, Node\Arg):Node\Expr $callable + */ +function doBar($callable):void {} +doBar(ExpectationMethodResolver::$resolvers['hasMethod']); From d0912a4cd4a2ed7ab14c0408fc71c8374feb2697 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 10:50:18 +0100 Subject: [PATCH 06/13] raise CLOSURES_COUNT_LIMIT --- src/Type/Constant/ConstantArrayTypeBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 21a885b7e5..7cdb94de60 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -31,7 +31,7 @@ final class ConstantArrayTypeBuilder { public const ARRAY_COUNT_LIMIT = 256; - private const CLOSURES_COUNT_LIMIT = 16; + private const CLOSURES_COUNT_LIMIT = 32; private bool $degradeToGeneralArray = false; From acdc223ca5801f01bfb0e188c6590bc7b2c2d995 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 10:54:33 +0100 Subject: [PATCH 07/13] Update CallToFunctionParametersRuleTest.php --- .../Functions/CallToFunctionParametersRuleTest.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 8b46bbecc9..7efdd74ad5 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2595,7 +2595,19 @@ public function testBug14012(): void { $this->checkExplicitMixed = true; $this->checkImplicitMixed = false; - $this->analyse([__DIR__ . '/../Properties/data/bug-14012.php'], []); + // would be great we would not have these 2 errors + $this->analyse([__DIR__ . '/../Properties/data/bug-14012.php'], [ + [ + 'Parameter #1 $callable of function Bug14012\doFoo expects callable(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr given.', + 49, + 'Parameter #3 of passed callable is required but accepting callable does not have that parameter. It will be called without it.', + ], + [ + 'Parameter #1 $callable of function Bug14012\doBar expects Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr given.', + 55, + 'Parameter #3 of passed callable is required but accepting callable does not have that parameter. It will be called without it.', + ], + ]); } } From ae1a7bc26f342add829b1500e053786a9ba35738 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 10:57:40 +0100 Subject: [PATCH 08/13] adjust tests --- .../nsrt/degrade-closures-return-types.php | 26 ------------------- .../Analyser/nsrt/degrade-closures.php | 18 ++++++++++++- .../Rules/Properties/data/bug-14012.php | 2 +- 3 files changed, 18 insertions(+), 28 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php b/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php deleted file mode 100644 index 5b5aa2dd75..0000000000 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures-return-types.php +++ /dev/null @@ -1,26 +0,0 @@ -&oversized-array', $arr); diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures.php b/tests/PHPStan/Analyser/nsrt/degrade-closures.php index a310d0d5f3..a7be72480e 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures.php @@ -20,7 +20,23 @@ $arr[] = static function () {}; $arr[] = static function () {}; $arr[] = static function () {}; -assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void}', $arr); +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +$arr[] = static function () {}; +assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void}', $arr); $arr[] = static function () {}; assertType('non-empty-list&oversized-array', $arr); diff --git a/tests/PHPStan/Rules/Properties/data/bug-14012.php b/tests/PHPStan/Rules/Properties/data/bug-14012.php index 8f562a10c8..0af11ff421 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-14012.php +++ b/tests/PHPStan/Rules/Properties/data/bug-14012.php @@ -40,7 +40,7 @@ final class ExpectationMethodResolver public static array $resolvers = []; } -assertType("non-empty-array<'hasMethod'|'hasProperty'|'isArray'|'isBool'|'isCallable'|'isCountable'|'isFalse'|'isFloat'|'isInstanceOf'|'isInt'|'isIterable'|'isList'|'isMap'|'isNaturalInt'|'isNegativeInt'|'isNonEmptyString'|'isNull'|'isNumeric'|'isObject'|'isPositiveInt'|'isResource'|'isSameAs'|'isScalar'|'isString'|'isTrue', callable(): mixed>&oversized-array", ExpectationMethodResolver::$resolvers); +assertType("array{hasMethod: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr, hasProperty: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr, isArray: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isBool: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isCallable: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isCountable: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isFalse: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isFloat: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isInstanceOf: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr, isInt: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isIterable: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isList: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isMap: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isNaturalInt: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isNegativeInt: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isNonEmptyString: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isNull: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isNumeric: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isObject: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isPositiveInt: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isResource: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isSameAs: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr, isScalar: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isString: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, isTrue: Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr}", ExpectationMethodResolver::$resolvers); /** * @param callable(Scope, Node\Arg):Node\Expr $callable From bd886d2883f2aec9849f4f3e7f73e07fc6e1fb14 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 10:57:57 +0100 Subject: [PATCH 09/13] Discard changes to src/Type/CallableType.php --- src/Type/CallableType.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 8bc809c18d..cf9998fbe2 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -84,20 +84,14 @@ public function __construct( ?TemplateTypeMap $resolvedTemplateTypeMap = null, private array $templateTags = [], ?TrinaryLogic $isPure = null, - ?bool $isCommonCallable = null, ) { $this->parameters = $parameters ?? []; $this->returnType = $returnType ?? new MixedType(); + $this->isCommonCallable = $parameters === null && $returnType === null; $this->templateTypeMap = $templateTypeMap ?? TemplateTypeMap::createEmpty(); $this->resolvedTemplateTypeMap = $resolvedTemplateTypeMap ?? TemplateTypeMap::createEmpty(); $this->isPure = $isPure ?? TrinaryLogic::createMaybe(); - - if ($isCommonCallable !== null) { - $this->isCommonCallable = $isCommonCallable; - } else { - $this->isCommonCallable = $parameters === null && $returnType === null; - } } /** From dd56b1ab22e38e6ee99105df25791d878bb6c795 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 10:58:25 +0100 Subject: [PATCH 10/13] Update ConstantArrayTypeBuilder.php --- src/Type/Constant/ConstantArrayTypeBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 7cdb94de60..bdc2fd0ff3 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -315,7 +315,7 @@ public function getArray(): Type if ($this->degradeClosures) { $itemTypes = []; - $itemTypes[] = new CallableType(isCommonCallable: false); + $itemTypes[] = new CallableType(); foreach ($this->valueTypes as $valueType) { if ($valueType instanceof ClosureType) { continue; From c76580e260ccc83ec72cf4328e7ee35c64b51989 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 11:01:56 +0100 Subject: [PATCH 11/13] simplify tests --- .../Functions/CallToFunctionParametersRuleTest.php | 14 +------------- tests/PHPStan/Rules/Properties/data/bug-14012.php | 4 ++-- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 7efdd74ad5..8b46bbecc9 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2595,19 +2595,7 @@ public function testBug14012(): void { $this->checkExplicitMixed = true; $this->checkImplicitMixed = false; - // would be great we would not have these 2 errors - $this->analyse([__DIR__ . '/../Properties/data/bug-14012.php'], [ - [ - 'Parameter #1 $callable of function Bug14012\doFoo expects callable(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr given.', - 49, - 'Parameter #3 of passed callable is required but accepting callable does not have that parameter. It will be called without it.', - ], - [ - 'Parameter #1 $callable of function Bug14012\doBar expects Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg): PhpParser\Node\Expr, Closure(PHPStan\Analyser\Scope, PhpParser\Node\Arg, PhpParser\Node\Arg): PhpParser\Node\Expr given.', - 55, - 'Parameter #3 of passed callable is required but accepting callable does not have that parameter. It will be called without it.', - ], - ]); + $this->analyse([__DIR__ . '/../Properties/data/bug-14012.php'], []); } } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14012.php b/tests/PHPStan/Rules/Properties/data/bug-14012.php index 0af11ff421..b341fe720a 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-14012.php +++ b/tests/PHPStan/Rules/Properties/data/bug-14012.php @@ -46,10 +46,10 @@ final class ExpectationMethodResolver * @param callable(Scope, Node\Arg):Node\Expr $callable */ function doFoo($callable):void {} -doFoo(ExpectationMethodResolver::$resolvers['hasMethod']); +doFoo(ExpectationMethodResolver::$resolvers['isArray']); /** * @param \Closure(Scope, Node\Arg):Node\Expr $callable */ function doBar($callable):void {} -doBar(ExpectationMethodResolver::$resolvers['hasMethod']); +doBar(ExpectationMethodResolver::$resolvers['isArray']); From 435724dde3c1909a0ad71c1c22a13f78308152f2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 15:54:05 +0100 Subject: [PATCH 12/13] Disable closure degradation for array-shape nodes Co-Authored-By: John Paul E. Balandan, CPA <51850998+paulbalandan@users.noreply.github.com> --- src/PhpDoc/TypeNodeResolver.php | 4 ++++ src/Type/Constant/ConstantArrayTypeBuilder.php | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 4df978002b..665ff594fe 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1051,6 +1051,10 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name } foreach ($typeNode->items as $itemNode) { + if ($itemNode->valueType instanceof CallableTypeNode) { + $builder->degradeClosures(false); // explicit opt-out of closure degradation + } + $offsetType = $this->resolveArrayShapeOffsetType($itemNode, $nameScope); $builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional); } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index bdc2fd0ff3..8c6f80197a 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -35,7 +35,7 @@ final class ConstantArrayTypeBuilder private bool $degradeToGeneralArray = false; - private bool $degradeClosures = false; + private ?bool $degradeClosures = null; private bool $oversized = false; @@ -84,7 +84,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } if (!$this->degradeToGeneralArray) { - if ($valueType instanceof ClosureType) { + if ($valueType instanceof ClosureType && $this->degradeClosures !== false) { $numClosures = 1; foreach ($this->valueTypes as $innerType) { if (!($innerType instanceof ClosureType)) { @@ -300,6 +300,15 @@ public function degradeToGeneralArray(bool $oversized = false): void $this->oversized = $this->oversized || $oversized; } + /** + * @param bool|null $degrade Use `null` to auto-detect based on closures count; + * Use boolean to explicitly enable/disable closure degradation. + */ + public function degradeClosures(?bool $degrade): void + { + $this->degradeClosures = $degrade; + } + public function getArray(): Type { $keyTypesCount = count($this->keyTypes); @@ -313,7 +322,7 @@ public function getArray(): Type return new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); } - if ($this->degradeClosures) { + if ($this->degradeClosures === true) { $itemTypes = []; $itemTypes[] = new CallableType(); foreach ($this->valueTypes as $valueType) { From 729c21044cd73da2df9174d4eb4fc22c88c1e23e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 16:05:58 +0100 Subject: [PATCH 13/13] simplify --- src/PhpDoc/TypeNodeResolver.php | 2 +- src/Type/Constant/ConstantArrayTypeBuilder.php | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 665ff594fe..d4145f27bb 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1052,7 +1052,7 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name foreach ($typeNode->items as $itemNode) { if ($itemNode->valueType instanceof CallableTypeNode) { - $builder->degradeClosures(false); // explicit opt-out of closure degradation + $builder->disableClosureDegradation(); } $offsetType = $this->resolveArrayShapeOffsetType($itemNode, $nameScope); diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 8c6f80197a..4d919a0e5a 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -300,13 +300,9 @@ public function degradeToGeneralArray(bool $oversized = false): void $this->oversized = $this->oversized || $oversized; } - /** - * @param bool|null $degrade Use `null` to auto-detect based on closures count; - * Use boolean to explicitly enable/disable closure degradation. - */ - public function degradeClosures(?bool $degrade): void + public function disableClosureDegradation(): void { - $this->degradeClosures = $degrade; + $this->degradeClosures = false; } public function getArray(): Type