From a073102eb90f9bac7ca1d2f7fbc5b15adc047423 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 2 Sep 2026 16:25:08 +0200 Subject: [PATCH 1/3] [TypeDeclarationDocblocks] Restrict ClassMethodArrayDocblockParamFromLocalCallsRector to safe cases Local calls are the complete caller set only for a private method, or for a method of a final class. For a protected or public method of a non-final class a child may call it with a wider type invisible here, so the inferred @param broke those external call sites. Also skip when the inferred type is a nested array (array of arrays): its generalization is imprecise and can diverge from the type PHPStan infers at the call site, producing a @param that rejects the very call it came from. --- .../Fixture/skip_nested_array_param.php.inc | 15 +++++++ .../Fixture/skip_non_final_protected.php.inc | 15 +++++++ ...ip_non_final_protected_with_parent.php.inc | 17 +++++++ ...ArrayDocblockParamFromLocalCallsRector.php | 44 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_nested_array_param.php.inc create mode 100644 rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected.php.inc create mode 100644 rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected_with_parent.php.inc diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_nested_array_param.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_nested_array_param.php.inc new file mode 100644 index 00000000000..22ed37dce2b --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_nested_array_param.php.inc @@ -0,0 +1,15 @@ +run([['name' => 'John', 'age' => 30]]); + } + + private function run(array $rows): void + { + } +} diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected.php.inc new file mode 100644 index 00000000000..f59b0bb5de2 --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected.php.inc @@ -0,0 +1,15 @@ +run(['item1', 'item2']); + } + + protected function run(array $items) + { + } +} diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected_with_parent.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected_with_parent.php.inc new file mode 100644 index 00000000000..a010431ec4f --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector/Fixture/skip_non_final_protected_with_parent.php.inc @@ -0,0 +1,17 @@ +run(['item1', 'item2']); + } + + protected function run(array $items) + { + } +} diff --git a/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php b/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php index c04a1b36053..a03be5cdd77 100644 --- a/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php +++ b/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php @@ -8,8 +8,10 @@ use PhpParser\Node\Expr; use PhpParser\Node\Param; use PhpParser\Node\Stmt\Class_; +use PHPStan\Type\ArrayType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory; use Rector\PhpParser\NodeFinder\LocalMethodCallFinder; use Rector\Rector\AbstractRector; @@ -94,6 +96,12 @@ public function refactor(Node $node): ?Node continue; } + // local calls are the complete caller set only for a private method, or for any method of a final class + // that cannot be extended; otherwise a child class may call it with a wider type we cannot see here + if (! $classMethod->isPrivate() && ! $node->isFinal()) { + continue; + } + $classMethodPhpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($classMethod); $methodCalls = $this->localMethodCallFinder->match($node, $classMethod); @@ -121,6 +129,12 @@ public function refactor(Node $node): ?Node // in case of array type declaration, null cannot be passed or is already casted $resolvedParameterType = TypeCombinator::removeNull($resolvedParameterType); + // the generalization of a nested array (an array of arrays) is imprecise and can diverge from the type + // PHPStan itself infers at the call site, producing a @param that rejects the very call it was built from + if ($this->hasNestedArray($resolvedParameterType)) { + continue; + } + // the param default value must always be accepted; a locally inferred, flow-narrowed type such as // "non-empty-array" would otherwise contradict an "= []" default - unite with the default type so // the resulting @param never conflicts with the method signature @@ -150,6 +164,36 @@ public function refactor(Node $node): ?Node return $node; } + private function hasNestedArray(Type $type): bool + { + if ($type instanceof UnionType) { + foreach ($type->getTypes() as $unionedType) { + if ($this->hasNestedArray($unionedType)) { + return true; + } + } + + return false; + } + + if (! $type instanceof ArrayType) { + return false; + } + + $itemType = $type->getItemType(); + if ($itemType instanceof UnionType) { + foreach ($itemType->getTypes() as $unionedItemType) { + if ($unionedItemType instanceof ArrayType) { + return true; + } + } + + return false; + } + + return $itemType instanceof ArrayType; + } + private function hasParamArrayType(Param $param): bool { if (! $param->type instanceof Node) { From a96025e204754334086aba2d5c100fe6c89ca72e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 2 Sep 2026 14:29:45 +0000 Subject: [PATCH 2/3] [ci-review] Rector Rectify --- ...hodArrayDocblockParamFromLocalCallsRector.php | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php b/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php index a03be5cdd77..7f19ebcdfcb 100644 --- a/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php +++ b/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php @@ -167,13 +167,7 @@ public function refactor(Node $node): ?Node private function hasNestedArray(Type $type): bool { if ($type instanceof UnionType) { - foreach ($type->getTypes() as $unionedType) { - if ($this->hasNestedArray($unionedType)) { - return true; - } - } - - return false; + return array_any($type->getTypes(), fn(Type $unionedType): bool => $this->hasNestedArray($unionedType)); } if (! $type instanceof ArrayType) { @@ -182,13 +176,7 @@ private function hasNestedArray(Type $type): bool $itemType = $type->getItemType(); if ($itemType instanceof UnionType) { - foreach ($itemType->getTypes() as $unionedItemType) { - if ($unionedItemType instanceof ArrayType) { - return true; - } - } - - return false; + return array_any($itemType->getTypes(), fn(Type $unionedItemType): bool => $unionedItemType instanceof ArrayType); } return $itemType instanceof ArrayType; From ad4f5b7a3ffbc098e2a7d20582a17e64de565153 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 4 Sep 2026 10:35:38 +0200 Subject: [PATCH 3/3] Remove dead final-class guard, private-only check already covers it Claude-Session: https://claude.ai/code/session_01GuifuKEtap1tEguM4cVRia --- ...ssMethodArrayDocblockParamFromLocalCallsRector.php | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php b/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php index 7f19ebcdfcb..b6cb0633e23 100644 --- a/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php +++ b/rules/TypeDeclarationDocblocks/Rector/Class_/ClassMethodArrayDocblockParamFromLocalCallsRector.php @@ -92,16 +92,11 @@ public function refactor(Node $node): ?Node } // only private methods have a closed set of local callers; public/protected can be called from outside + // with a wider type we cannot see here if (! $classMethod->isPrivate()) { continue; } - // local calls are the complete caller set only for a private method, or for any method of a final class - // that cannot be extended; otherwise a child class may call it with a wider type we cannot see here - if (! $classMethod->isPrivate() && ! $node->isFinal()) { - continue; - } - $classMethodPhpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($classMethod); $methodCalls = $this->localMethodCallFinder->match($node, $classMethod); @@ -167,7 +162,7 @@ public function refactor(Node $node): ?Node private function hasNestedArray(Type $type): bool { if ($type instanceof UnionType) { - return array_any($type->getTypes(), fn(Type $unionedType): bool => $this->hasNestedArray($unionedType)); + return array_any($type->getTypes(), fn (Type $unionedType): bool => $this->hasNestedArray($unionedType)); } if (! $type instanceof ArrayType) { @@ -176,7 +171,7 @@ private function hasNestedArray(Type $type): bool $itemType = $type->getItemType(); if ($itemType instanceof UnionType) { - return array_any($itemType->getTypes(), fn(Type $unionedItemType): bool => $unionedItemType instanceof ArrayType); + return array_any($itemType->getTypes(), fn (Type $unionedItemType): bool => $unionedItemType instanceof ArrayType); } return $itemType instanceof ArrayType;