diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a2f57a4871f..9bc27b265e8 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -1,6 +1,7 @@ parameters: featureToggles: bleedingEdge: true + checkSealedSubtypes: true checkNonStringableDynamicAccess: true checkParameterCastableToNumberFunctions: true skipCheckGenericClasses!: [] diff --git a/conf/config.neon b/conf/config.neon index 16e588434ac..9e5757598b7 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -26,6 +26,7 @@ parameters: throwTypeCovariance: false featureToggles: bleedingEdge: false + checkSealedSubtypes: false checkNonStringableDynamicAccess: false checkParameterCastableToNumberFunctions: false skipCheckGenericClasses: diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 5d968573089..4240bc01e24 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -30,6 +30,7 @@ parametersSchema: ]) featureToggles: structure([ bleedingEdge: bool(), + checkSealedSubtypes: bool(), checkNonStringableDynamicAccess: bool(), checkParameterCastableToNumberFunctions: bool(), skipCheckGenericClasses: listOf(string()), diff --git a/src/Rules/PhpDoc/SealedDefinitionClassRule.php b/src/Rules/PhpDoc/SealedDefinitionClassRule.php index a00a6b2f64d..6e46dc00033 100644 --- a/src/Rules/PhpDoc/SealedDefinitionClassRule.php +++ b/src/Rules/PhpDoc/SealedDefinitionClassRule.php @@ -34,6 +34,8 @@ public function __construct( private bool $checkClassCaseSensitivity, #[AutowiredParameter(ref: '%tips.discoveringSymbols%')] private bool $discoveringSymbolsTip, + #[AutowiredParameter(ref: '%featureToggles.checkSealedSubtypes%')] + private bool $checkSealedSubtypes, ) { } @@ -84,6 +86,24 @@ public function processNode(Node $node, Scope $scope): array continue; } + $sealedTypeReflection = $this->reflectionProvider->getClass($class); + + if ( + $this->checkSealedSubtypes + && ($sealedTypeReflection->isEnum() || $sealedTypeReflection->isFinal()) + && !$sealedTypeReflection->is($classReflection->getName()) + ) { + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @phpstan-sealed contains final type %s that is not subtype of %s.', + $class, + $classReflection->getName(), + ))->identifier('sealed.notSubtype'); + + $errors[] = $errorBuilder->build(); + + continue; + } + $errors = array_merge( $errors, $this->classCheck->checkClassNames($scope, [ diff --git a/tests/PHPStan/Rules/PhpDoc/SealedDefinitionClassRuleTest.php b/tests/PHPStan/Rules/PhpDoc/SealedDefinitionClassRuleTest.php index 92a409fd914..4f2f8a2c1b7 100644 --- a/tests/PHPStan/Rules/PhpDoc/SealedDefinitionClassRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/SealedDefinitionClassRuleTest.php @@ -17,6 +17,8 @@ class SealedDefinitionClassRuleTest extends RuleTestCase { + private bool $checkSealedSubtypes = true; + protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); @@ -32,6 +34,7 @@ protected function getRule(): Rule ), true, true, + $this->checkSealedSubtypes, ); } @@ -61,4 +64,37 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.2.0')] + public function testSubtypes(): void + { + $this->analyse([__DIR__ . '/data/sealed-subtypes.php'], [ + [ + 'PHPDoc tag @phpstan-sealed contains final type SealedSubtypes\\__YEnumInvalid that is not subtype of SealedSubtypes\\__EnumError.', + 10, + ], + [ + 'PHPDoc tag @phpstan-sealed contains final type SealedSubtypes\\__YClassInvalid that is not subtype of SealedSubtypes\\__ClassError.', + 20, + ], + ]); + } + + #[RequiresPhp('>= 8.1.0')] + public function testNonFinalSubtypes(): void + { + $this->analyse([__DIR__ . '/data/sealed-non-final-subtypes.php'], [ + [ + 'PHPDoc tag @phpstan-sealed contains final type SealedNonFinalSubtypes\\InvalidZ that is not subtype of SealedNonFinalSubtypes\\InvalidSealed.', + 8, + ], + ]); + } + + #[RequiresPhp('>= 8.1.0')] + public function testFinalSubtypesAreNotCheckedWhenDisabled(): void + { + $this->checkSealedSubtypes = false; + $this->analyse([__DIR__ . '/data/sealed-non-final-subtypes.php'], []); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/sealed-non-final-subtypes.php b/tests/PHPStan/Rules/PhpDoc/data/sealed-non-final-subtypes.php new file mode 100644 index 00000000000..76204fa16e9 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/sealed-non-final-subtypes.php @@ -0,0 +1,22 @@ += 8.1 + +namespace SealedNonFinalSubtypes; + +/** + * @phpstan-sealed InvalidX|InvalidY|InvalidZ + */ +interface InvalidSealed {} + +final class InvalidX implements InvalidSealed {} +final class InvalidY implements InvalidSealed {} +final class InvalidZ {} + +/** + * @phpstan-sealed ValidX|ValidY|ValidZ + */ +interface ValidSealed {} + +final class ValidX implements ValidSealed {} +final class ValidY implements ValidSealed {} +class ValidZ {} +class ValidZZ extends ValidZ {} diff --git a/tests/PHPStan/Rules/PhpDoc/data/sealed-subtypes.php b/tests/PHPStan/Rules/PhpDoc/data/sealed-subtypes.php new file mode 100644 index 00000000000..70f35536e3c --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/sealed-subtypes.php @@ -0,0 +1,22 @@ += 8.2 + +namespace SealedSubtypes; + +/** @phpstan-sealed _XEnum */ +interface _Enum {} +enum _XEnum implements _Enum { case Value; } + +/** @phpstan-sealed __XEnumValid | __YEnumInvalid */ +interface __EnumError {} +enum __XEnumValid implements __EnumError { case Value; } +enum __YEnumInvalid { case Value; } + +/** @phpstan-sealed __XClass | __YClass */ +abstract readonly class __Class {} +final readonly class __XClass extends __Class {} +final readonly class __YClass extends __Class {} + +/** @phpstan-sealed __XClassValid | __YClassInvalid */ +abstract readonly class __ClassError {} +final readonly class __XClassValid extends __ClassError {} +final readonly class __YClassInvalid {}