diff --git a/.github/workflows/code_analysis.yaml b/.github/workflows/code_analysis.yaml index d4869162c92..145eb155035 100644 --- a/.github/workflows/code_analysis.yaml +++ b/.github/workflows/code_analysis.yaml @@ -53,7 +53,7 @@ jobs: - name: 'Active Classes' run: | - vendor/bin/class-leak check bin config src rules utils --skip-suffix "Rector" --skip-type="Rector\\Utils\\Compiler\\Unprefixer" --skip-type="Rector\\NodeCollector\\BinaryOpConditionsCollector" --skip-type="Rector\\Set\\Contract\\SetListInterface" --skip-type="Rector\\DependencyInjection\\PHPStan\\RichParserFactory" --skip-type="Rector\\Utils\\PHPStan\\Tests\\Rule\\SingleServiceRegistrationRule\\Source\\DuplicateRegistrationFactory" --skip-type="Rector\\Utils\\PHPStan\\Tests\\Rule\\SingleServiceRegistrationRule\\Source\\SingleRegistrationFactory" --skip-type="Rector\\ChangesReporting\\Contract\\Output\\OutputFormatterInterface" --skip-type="Rector\\NodeTypeResolver\\Contract\\NodeTypeResolverInterface" --skip-type="Rector\\CodingStyle\\Contract\\ClassNameImport\\ClassNameImportSkipVoterInterface" --skip-type="Rector\\Php80\\Contract\\ConverterAttributeDecoratorInterface" --skip-type="Rector\\NodeNameResolver\\Contract\\NodeNameResolverInterface" --skip-type="Rector\\StaticTypeMapper\\Contract\\PhpDocParser\\PhpDocTypeMapperInterface" --skip-type="Rector\\PhpAttribute\\Contract\\AnnotationToAttributeMapperInterface" --skip-type="Rector\\PHPStanStaticTypeMapper\\Contract\\TypeMapperInterface" --skip-type="Rector\\StaticTypeMapper\\Contract\\PhpParser\\PhpParserNodeMapperInterface" + vendor/bin/class-leak check bin config src rules utils --skip-suffix "Rector" --skip-type="Rector\\Utils\\Compiler\\Unprefixer" --skip-type="Rector\\NodeCollector\\BinaryOpConditionsCollector" --skip-type="Rector\\Set\\Contract\\SetListInterface" --skip-type="Rector\\DependencyInjection\\PHPStan\\RichParserFactory" --skip-type="Rector\\Utils\\PHPStan\\Tests\\Rule\\SingleServiceRegistrationRule\\Source\\DuplicateRegistrationFactory" --skip-type="Rector\\Utils\\PHPStan\\Tests\\Rule\\SingleServiceRegistrationRule\\Source\\SingleRegistrationFactory" --skip-type="Rector\\Utils\\PHPStan\\Tests\\Rule\\PreferGenericTemplateOnSameUnionTypeRule\\Source\\SameUnionParamAndReturn" --skip-type="Rector\\Utils\\PHPStan\\Tests\\Rule\\PreferGenericTemplateOnSameUnionTypeRule\\Source\\SkipGenericTemplate" --skip-type="Rector\\ChangesReporting\\Contract\\Output\\OutputFormatterInterface" --skip-type="Rector\\NodeTypeResolver\\Contract\\NodeTypeResolverInterface" --skip-type="Rector\\CodingStyle\\Contract\\ClassNameImport\\ClassNameImportSkipVoterInterface" --skip-type="Rector\\Php80\\Contract\\ConverterAttributeDecoratorInterface" --skip-type="Rector\\NodeNameResolver\\Contract\\NodeNameResolverInterface" --skip-type="Rector\\StaticTypeMapper\\Contract\\PhpDocParser\\PhpDocTypeMapperInterface" --skip-type="Rector\\PhpAttribute\\Contract\\AnnotationToAttributeMapperInterface" --skip-type="Rector\\PHPStanStaticTypeMapper\\Contract\\TypeMapperInterface" --skip-type="Rector\\StaticTypeMapper\\Contract\\PhpParser\\PhpParserNodeMapperInterface" - name: 'Compatible PHPStan versions' diff --git a/phpstan.neon b/phpstan.neon index dd1e3bf83b4..a7e0a4a8799 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,6 +4,7 @@ includes: rules: - Rector\Utils\PHPStan\Rule\RegisterRelatedPolyfillRectorRule - Rector\Utils\PHPStan\Rule\NoScopeFetchBeforeCheapGuardRule + - Rector\Utils\PHPStan\Rule\PreferGenericTemplateOnSameUnionTypeRule parameters: level: 8 diff --git a/rules/CodingStyle/Rector/ClassMethod/FuncGetArgsToVariadicParamRector.php b/rules/CodingStyle/Rector/ClassMethod/FuncGetArgsToVariadicParamRector.php index 7109c562152..19de3d5306d 100644 --- a/rules/CodingStyle/Rector/ClassMethod/FuncGetArgsToVariadicParamRector.php +++ b/rules/CodingStyle/Rector/ClassMethod/FuncGetArgsToVariadicParamRector.php @@ -109,6 +109,11 @@ public function provideMinPhpVersion(): int return PhpVersionFeature::VARIADIC_PARAM; } + /** + * @template TFunctionLike of ClassMethod|Function_|Closure + * @param TFunctionLike $node + * @return TFunctionLike|null + */ private function applyVariadicParams( ClassMethod|Function_|Closure $node, string $variableName diff --git a/rules/Php81/Rector/MethodCall/MyCLabsMethodCallToEnumConstRector.php b/rules/Php81/Rector/MethodCall/MyCLabsMethodCallToEnumConstRector.php index c09ab986c2f..3b1f844d7e2 100644 --- a/rules/Php81/Rector/MethodCall/MyCLabsMethodCallToEnumConstRector.php +++ b/rules/Php81/Rector/MethodCall/MyCLabsMethodCallToEnumConstRector.php @@ -244,6 +244,11 @@ private function getValidEnumExpr(Node $node): null|ClassConstFetch|Expr }; } + /** + * @template TExpr of PropertyFetch|Variable + * @param TExpr $expr + * @return TExpr|null + */ private function getPropertyFetchOrVariable(PropertyFetch|Variable $expr): null|PropertyFetch|Variable { if (! $this->isObjectType($expr, new ObjectType('MyCLabs\Enum\Enum'))) { diff --git a/rules/Privatization/NodeManipulator/VisibilityManipulator.php b/rules/Privatization/NodeManipulator/VisibilityManipulator.php index a83d600b4da..85aab8be505 100644 --- a/rules/Privatization/NodeManipulator/VisibilityManipulator.php +++ b/rules/Privatization/NodeManipulator/VisibilityManipulator.php @@ -159,6 +159,11 @@ public function removeReadonly(Class_|Property|Param $node): void } } + /** + * @template TNode of ClassConst|ClassMethod|Property + * @param TNode $node + * @return TNode|null + */ public function publicize(ClassConst|ClassMethod|Property $node): ClassConst|ClassMethod|Property|null { // already non-public diff --git a/rules/Renaming/Rector/MethodCall/RenameMethodRector.php b/rules/Renaming/Rector/MethodCall/RenameMethodRector.php index f4d883852a0..f7d16d04b1c 100644 --- a/rules/Renaming/Rector/MethodCall/RenameMethodRector.php +++ b/rules/Renaming/Rector/MethodCall/RenameMethodRector.php @@ -157,6 +157,11 @@ private function shouldKeepForParentInterface( ); } + /** + * @template TClassLike of Class_|Trait_|Interface_ + * @param TClassLike $classOrInterface + * @return TClassLike|null + */ private function refactorClass( Class_|Trait_|Interface_ $classOrInterface, Scope $scope diff --git a/rules/Renaming/Rector/PropertyFetch/RenamePropertyRector.php b/rules/Renaming/Rector/PropertyFetch/RenamePropertyRector.php index 7e76e462a54..da68389fe30 100644 --- a/rules/Renaming/Rector/PropertyFetch/RenamePropertyRector.php +++ b/rules/Renaming/Rector/PropertyFetch/RenamePropertyRector.php @@ -128,6 +128,11 @@ private function isUsingTrait(ClassLike $classLike, string $traitName): bool return $classReflection->hasTraitUse($traitName); } + /** + * @template TPropertyFetch of PropertyFetch|StaticPropertyFetch + * @param TPropertyFetch $propertyFetch + * @return TPropertyFetch|null + */ private function refactorPropertyFetch( PropertyFetch|StaticPropertyFetch $propertyFetch ): null|PropertyFetch|StaticPropertyFetch { diff --git a/rules/TypeDeclaration/NodeAnalyzer/StrictReturnNewArrayResolver.php b/rules/TypeDeclaration/NodeAnalyzer/StrictReturnNewArrayResolver.php index 10fda6a5a8a..cb9beb6b9ef 100644 --- a/rules/TypeDeclaration/NodeAnalyzer/StrictReturnNewArrayResolver.php +++ b/rules/TypeDeclaration/NodeAnalyzer/StrictReturnNewArrayResolver.php @@ -49,6 +49,11 @@ public function __construct( /** * @return ClassMethod|Function_|null the passed node when an array return type was added, null otherwise */ + /** + * @template TFunctionLike of ClassMethod|Function_ + * @param TFunctionLike $node + * @return TFunctionLike|null + */ public function resolve(ClassMethod|Function_ $node): ClassMethod|Function_|null { // 1. is variable instantiated with array @@ -90,6 +95,11 @@ public function resolve(ClassMethod|Function_ $node): ClassMethod|Function_|null return $this->processAddArrayReturnType($node, $returnType); } + /** + * @template TFunctionLike of ClassMethod|Function_ + * @param TFunctionLike $node + * @return TFunctionLike|null + */ private function processAddArrayReturnType( ClassMethod|Function_ $node, Type $returnType diff --git a/rules/TypeDeclaration/NodeManipulator/AddNeverReturnType.php b/rules/TypeDeclaration/NodeManipulator/AddNeverReturnType.php index c5a74d67293..a10257839ba 100644 --- a/rules/TypeDeclaration/NodeManipulator/AddNeverReturnType.php +++ b/rules/TypeDeclaration/NodeManipulator/AddNeverReturnType.php @@ -33,6 +33,11 @@ public function __construct( ) { } + /** + * @template TFunctionLike of ClassMethod|Function_|Closure + * @param TFunctionLike $node + * @return TFunctionLike|null + */ public function add(ClassMethod|Function_|Closure $node, Scope $scope): ClassMethod|Function_|Closure|null { if ($this->shouldSkip($node, $scope)) { diff --git a/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromCast.php b/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromCast.php index 6bcbd520f22..83307d27c7c 100644 --- a/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromCast.php +++ b/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromCast.php @@ -27,6 +27,11 @@ public function __construct( ) { } + /** + * @template TFunctionLike of ClassMethod|Function_ + * @param TFunctionLike $functionLike + * @return TFunctionLike|null + */ public function add(ClassMethod|Function_ $functionLike, Scope $scope): ClassMethod|Function_|null { if ($functionLike->returnType instanceof Node) { diff --git a/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromParam.php b/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromParam.php index bce482f80cd..a9d156d7a9b 100644 --- a/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromParam.php +++ b/rules/TypeDeclaration/NodeManipulator/AddReturnTypeFromParam.php @@ -36,6 +36,11 @@ public function __construct( ) { } + /** + * @template TFunctionLike of ClassMethod|Function_ + * @param TFunctionLike $functionLike + * @return TFunctionLike|null + */ public function add(ClassMethod|Function_ $functionLike, Scope $scope): ClassMethod|Function_|null { if ($functionLike->stmts === null) { diff --git a/rules/TypeDeclaration/Rector/ClassMethod/ReturnTypeFromStrictTypedCallRector.php b/rules/TypeDeclaration/Rector/ClassMethod/ReturnTypeFromStrictTypedCallRector.php index 3fe8b85b8d1..bc343f365c7 100644 --- a/rules/TypeDeclaration/Rector/ClassMethod/ReturnTypeFromStrictTypedCallRector.php +++ b/rules/TypeDeclaration/Rector/ClassMethod/ReturnTypeFromStrictTypedCallRector.php @@ -170,6 +170,11 @@ private function isUnionPossibleReturnsVoid(ClassMethod|Function_|Closure $node) return false; } + /** + * @template TFunctionLike of ClassMethod|Function_|Closure + * @param TFunctionLike $node + * @return TFunctionLike + */ private function processSingleUnionType( ClassMethod|Function_|Closure $node, UnionType $unionType, @@ -200,6 +205,11 @@ private function shouldSkip(ClassMethod|Function_|Closure $node, Scope $scope): return $this->isUnionPossibleReturnsVoid($node); } + /** + * @template TFunctionLike of ClassMethod|Function_|Closure + * @param TFunctionLike $functionLike + * @return TFunctionLike + */ private function refactorSingleReturnType( Return_ $return, Identifier|Name|NullableType|ComplexType $returnedStrictTypeNode, diff --git a/rules/TypeDeclaration/Rector/While_/WhileNullableToInstanceofRector.php b/rules/TypeDeclaration/Rector/While_/WhileNullableToInstanceofRector.php index 0d7a4c69363..249d70b1826 100644 --- a/rules/TypeDeclaration/Rector/While_/WhileNullableToInstanceofRector.php +++ b/rules/TypeDeclaration/Rector/While_/WhileNullableToInstanceofRector.php @@ -99,6 +99,11 @@ private function createInstanceof(Expr $expr, ObjectType $objectType): Instanceo return new Instanceof_($expr, $fullyQualified); } + /** + * @template TLoop of While_|Do_ + * @param TLoop $while + * @return TLoop|null + */ private function refactorNotIdentical(While_|Do_ $while, NotIdentical $notIdentical): While_|Do_|null { if (! $this->valueResolver->isNull($notIdentical->right)) { diff --git a/utils/phpstan/src/Rule/PreferGenericTemplateOnSameUnionTypeRule.php b/utils/phpstan/src/Rule/PreferGenericTemplateOnSameUnionTypeRule.php new file mode 100644 index 00000000000..32d3b952ece --- /dev/null +++ b/utils/phpstan/src/Rule/PreferGenericTemplateOnSameUnionTypeRule.php @@ -0,0 +1,121 @@ + + * @see \Rector\Utils\PHPStan\Tests\Rule\PreferGenericTemplateOnSameUnionTypeRule\PreferGenericTemplateOnSameUnionTypeRuleTest + */ +final class PreferGenericTemplateOnSameUnionTypeRule implements Rule +{ + private const string ERROR_MESSAGE = 'Param and return share the same class-type union "%s". Add a @template generic to narrow the return type to the passed one.'; + + public function getNodeType(): string + { + return ClassMethod::class; + } + + /** + * @param ClassMethod $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment instanceof Doc && str_contains($docComment->getText(), '@template')) { + return []; + } + + $returnNames = $this->resolveClassUnionNames($node->returnType); + if ($returnNames === null) { + return []; + } + + foreach ($node->params as $param) { + $paramNames = $this->resolveClassUnionNames($param->type); + if ($paramNames === null) { + continue; + } + + if ($paramNames !== $returnNames) { + continue; + } + + $shortNames = array_map( + static function (string $name): string { + $parts = explode('\\', $name); + return end($parts); + }, + $returnNames + ); + + return [ + RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, implode('|', $shortNames))) + ->identifier('rector.genericTemplateOverSameUnionType') + ->line($node->getStartLine()) + ->build(), + ]; + } + + return []; + } + + /** + * Returns sorted class-type member names (null stripped), or null when the type is not a + * union of 2+ class types - scalars, array and intersections disqualify it. + * + * @return list|null + */ + private function resolveClassUnionNames(null|Identifier|Name|ComplexType $type): ?array + { + if (! $type instanceof UnionType) { + return null; + } + + $names = []; + foreach ($type->types as $memberType) { + if ($memberType instanceof Identifier) { + if ($memberType->toLowerString() === 'null') { + continue; + } + + // built-in scalar/array type, not a class + return null; + } + + if ($memberType instanceof Name) { + $names[] = $memberType->toString(); + continue; + } + + // IntersectionType or anything else + return null; + } + + if (count($names) < 2) { + return null; + } + + $names = array_unique($names); + sort($names); + + return $names; + } +} diff --git a/utils/phpstan/tests/Rule/PreferGenericTemplateOnSameUnionTypeRule/PreferGenericTemplateOnSameUnionTypeRuleTest.php b/utils/phpstan/tests/Rule/PreferGenericTemplateOnSameUnionTypeRule/PreferGenericTemplateOnSameUnionTypeRuleTest.php new file mode 100644 index 00000000000..61005a393ef --- /dev/null +++ b/utils/phpstan/tests/Rule/PreferGenericTemplateOnSameUnionTypeRule/PreferGenericTemplateOnSameUnionTypeRuleTest.php @@ -0,0 +1,32 @@ + + */ +final class PreferGenericTemplateOnSameUnionTypeRuleTest extends RuleTestCase +{ + public function testSameUnionParamAndReturn(): void + { + $expectedErrorMessage = 'Param and return share the same class-type union "ClassMethod|Function_". Add a @template generic to narrow the return type to the passed one.'; + + $this->analyse([__DIR__ . '/Source/SameUnionParamAndReturn.php'], [[$expectedErrorMessage, 12]]); + } + + public function testSkip(): void + { + $this->analyse([__DIR__ . '/Source/SkipGenericTemplate.php'], []); + } + + protected function getRule(): Rule + { + return new PreferGenericTemplateOnSameUnionTypeRule(); + } +} diff --git a/utils/phpstan/tests/Rule/PreferGenericTemplateOnSameUnionTypeRule/Source/SameUnionParamAndReturn.php b/utils/phpstan/tests/Rule/PreferGenericTemplateOnSameUnionTypeRule/Source/SameUnionParamAndReturn.php new file mode 100644 index 00000000000..2caca6b02cc --- /dev/null +++ b/utils/phpstan/tests/Rule/PreferGenericTemplateOnSameUnionTypeRule/Source/SameUnionParamAndReturn.php @@ -0,0 +1,16 @@ +