diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index e47528377f..c487355dcf 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -536,6 +536,15 @@ private function addClassToDependencies(string $className, array &$dependenciesR } } + foreach ($classReflection->getSealedTags() as $sealedTag) { + foreach ($sealedTag->getType()->getReferencedClasses() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + $dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass); + } + } + foreach ($classReflection->getTemplateTags() as $templateTag) { foreach ($templateTag->getBound()->getReferencedClasses() as $referencedClass) { if (!$this->reflectionProvider->hasClass($referencedClass)) { diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 5701f75403..ff91a44225 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -19,6 +19,7 @@ use PHPStan\PhpDoc\Tag\RequireExtendsTag; use PHPStan\PhpDoc\Tag\RequireImplementsTag; use PHPStan\PhpDoc\Tag\ReturnTag; +use PHPStan\PhpDoc\Tag\SealedTypeTag; use PHPStan\PhpDoc\Tag\SelfOutTypeTag; use PHPStan\PhpDoc\Tag\TemplateTag; use PHPStan\PhpDoc\Tag\ThrowsTag; @@ -524,6 +525,24 @@ public function resolveRequireImplementsTags(PhpDocNode $phpDocNode, NameScope $ return $resolved; } + /** + * @return array + */ + public function resolveSealedTags(PhpDocNode $phpDocNode, NameScope $nameScope): array + { + $resolved = []; + + foreach (['@psalm-inheritors', '@phpstan-sealed'] as $tagName) { + foreach ($phpDocNode->getSealedTagValues($tagName) as $tagValue) { + $resolved[] = new SealedTypeTag( + $this->typeNodeResolver->resolve($tagValue->type, $nameScope), + ); + } + } + + return $resolved; + } + /** * @return array */ diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 94274f4c46..dc43e9af5e 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -16,6 +16,7 @@ use PHPStan\PhpDoc\Tag\RequireExtendsTag; use PHPStan\PhpDoc\Tag\RequireImplementsTag; use PHPStan\PhpDoc\Tag\ReturnTag; +use PHPStan\PhpDoc\Tag\SealedTypeTag; use PHPStan\PhpDoc\Tag\SelfOutTypeTag; use PHPStan\PhpDoc\Tag\TemplateTag; use PHPStan\PhpDoc\Tag\ThrowsTag; @@ -111,6 +112,9 @@ final class ResolvedPhpDocBlock /** @var array|false */ private array|false $requireImplementsTags = false; + /** @var array|false */ + private array|false $sealedTypeTags = false; + /** @var array|false */ private array|false $typeAliasTags = false; @@ -218,6 +222,7 @@ public static function createEmpty(): self $self->mixinTags = []; $self->requireExtendsTags = []; $self->requireImplementsTags = []; + $self->sealedTypeTags = []; $self->typeAliasTags = []; $self->typeAliasImportTags = []; $self->assertTags = []; @@ -282,6 +287,7 @@ public function merge(array $parents, array $parentPhpDocBlocks): self $result->mixinTags = $this->getMixinTags(); $result->requireExtendsTags = $this->getRequireExtendsTags(); $result->requireImplementsTags = $this->getRequireImplementsTags(); + $result->sealedTypeTags = $this->getSealedTags(); $result->typeAliasTags = $this->getTypeAliasTags(); $result->typeAliasImportTags = $this->getTypeAliasImportTags(); $result->assertTags = self::mergeAssertTags($this->getAssertTags(), $parents, $parentPhpDocBlocks); @@ -663,6 +669,21 @@ public function getRequireImplementsTags(): array return $this->requireImplementsTags; } + /** + * @return array + */ + public function getSealedTags(): array + { + if ($this->sealedTypeTags === false) { + $this->sealedTypeTags = $this->phpDocNodeResolver->resolveSealedTags( + $this->phpDocNode, + $this->getNameScope(), + ); + } + + return $this->sealedTypeTags; + } + /** * @return array */ diff --git a/src/PhpDoc/Tag/SealedTypeTag.php b/src/PhpDoc/Tag/SealedTypeTag.php new file mode 100644 index 0000000000..51d73aacf7 --- /dev/null +++ b/src/PhpDoc/Tag/SealedTypeTag.php @@ -0,0 +1,27 @@ +type; + } + + public function withType(Type $type): self + { + return new self($type); + } + +} diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 45c0fd4bff..d1a6a8f0f3 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -23,6 +23,7 @@ use PHPStan\PhpDoc\Tag\PropertyTag; use PHPStan\PhpDoc\Tag\RequireExtendsTag; use PHPStan\PhpDoc\Tag\RequireImplementsTag; +use PHPStan\PhpDoc\Tag\SealedTypeTag; use PHPStan\PhpDoc\Tag\TemplateTag; use PHPStan\PhpDoc\Tag\TypeAliasImportTag; use PHPStan\PhpDoc\Tag\TypeAliasTag; @@ -1887,6 +1888,19 @@ public function getRequireImplementsTags(): array return $resolvedPhpDoc->getRequireImplementsTags(); } + /** + * @return array + */ + public function getSealedTags(): array + { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + return []; + } + + return $resolvedPhpDoc->getSealedTags(); + } + /** * @return array */ diff --git a/src/Reflection/Php/SealedAllowedSubTypesClassReflectionExtension.php b/src/Reflection/Php/SealedAllowedSubTypesClassReflectionExtension.php new file mode 100644 index 0000000000..a40ac80997 --- /dev/null +++ b/src/Reflection/Php/SealedAllowedSubTypesClassReflectionExtension.php @@ -0,0 +1,36 @@ +getSealedTags()) > 0; + } + + public function getAllowedSubTypes(ClassReflection $classReflection): array + { + $types = []; + + foreach ($classReflection->getSealedTags() as $sealedTag) { + $type = $sealedTag->getType(); + if ($type instanceof UnionType) { + $types = $type->getTypes(); + } else { + $types = [$type]; + } + } + + return $types; + } + +} diff --git a/src/Rules/ClassNameUsageLocation.php b/src/Rules/ClassNameUsageLocation.php index d8727ac5f2..6a2dfdc52e 100644 --- a/src/Rules/ClassNameUsageLocation.php +++ b/src/Rules/ClassNameUsageLocation.php @@ -38,6 +38,7 @@ final class ClassNameUsageLocation public const PHPDOC_TAG_PROPERTY = 'propertyTag'; public const PHPDOC_TAG_REQUIRE_EXTENDS = 'requireExtends'; public const PHPDOC_TAG_REQUIRE_IMPLEMENTS = 'requireImplements'; + public const PHPDOC_TAG_SEALED = 'sealed'; public const STATIC_METHOD_CALL = 'staticMethod'; public const PHPDOC_TAG_TEMPLATE_BOUND = 'templateBound'; public const PHPDOC_TAG_TEMPLATE_DEFAULT = 'templateDefault'; @@ -255,6 +256,8 @@ public function createMessage(string $part): string return sprintf('PHPDoc tag @phpstan-require-extends references %s.', $part); case self::PHPDOC_TAG_REQUIRE_IMPLEMENTS: return sprintf('PHPDoc tag @phpstan-require-implements references %s.', $part); + case self::PHPDOC_TAG_SEALED: + return sprintf('PHPDoc tag @phpstan-sealed references %s.', $part); case self::STATIC_METHOD_CALL: $method = $this->getMethod(); if ($method !== null) { diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index 9134a8cabc..d909dfab72 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -58,6 +58,7 @@ final class InvalidPHPStanDocTagRule implements Rule '@phpstan-readonly-allow-private-mutation', '@phpstan-require-extends', '@phpstan-require-implements', + '@phpstan-sealed', '@phpstan-param-immediately-invoked-callable', '@phpstan-param-later-invoked-callable', '@phpstan-param-closure-this', diff --git a/src/Rules/PhpDoc/SealedDefinitionClassRule.php b/src/Rules/PhpDoc/SealedDefinitionClassRule.php new file mode 100644 index 0000000000..3ef882d2ae --- /dev/null +++ b/src/Rules/PhpDoc/SealedDefinitionClassRule.php @@ -0,0 +1,100 @@ + + */ +#[RegisteredRule(level: 2)] +final class SealedDefinitionClassRule implements Rule +{ + + public function __construct( + private ClassNameCheck $classCheck, + #[AutowiredParameter] + private bool $checkClassCaseSensitivity, + #[AutowiredParameter(ref: '%tips.discoveringSymbols%')] + private bool $discoveringSymbolsTip, + ) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $sealedTags = $classReflection->getSealedTags(); + + if (count($sealedTags) === 0) { + return []; + } + + if ($classReflection->isEnum()) { + return [ + RuleErrorBuilder::message('PHPDoc tag @phpstan-sealed is only valid on class or interface.') + ->identifier('sealed.onEnum') + ->build(), + ]; + } + + $errors = []; + foreach ($sealedTags as $sealedTag) { + $type = $sealedTag->getType(); + $classNames = $type->getObjectClassNames(); + if (count($classNames) === 0) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-sealed contains non-object type %s.', $type->describe(VerbosityLevel::typeOnly()))) + ->identifier('sealed.nonObject') + ->build(); + continue; + } + + $referencedClassReflections = array_map(static fn ($reflection) => [$reflection, $reflection->getName()], $type->getObjectClassReflections()); + $referencedClassReflectionsMap = array_column($referencedClassReflections, 0, 1); + foreach ($classNames as $class) { + $referencedClassReflection = $referencedClassReflectionsMap[$class] ?? null; + if ($referencedClassReflection === null) { + $errorBuilder = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-sealed contains unknown class %s.', $class)) + ->identifier('class.notFound'); + + if ($this->discoveringSymbolsTip) { + $errorBuilder->discoveringSymbolsTip(); + } + + $errors[] = $errorBuilder->build(); + continue; + } + + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames($scope, [ + new ClassNameNodePair($class, $node), + ], ClassNameUsageLocation::from(ClassNameUsageLocation::PHPDOC_TAG_SEALED), $this->checkClassCaseSensitivity), + ); + } + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/SealedDefinitionTraitRule.php b/src/Rules/PhpDoc/SealedDefinitionTraitRule.php new file mode 100644 index 0000000000..7a0c9bafd6 --- /dev/null +++ b/src/Rules/PhpDoc/SealedDefinitionTraitRule.php @@ -0,0 +1,54 @@ + + */ +#[RegisteredRule(level: 0)] +final class SealedDefinitionTraitRule implements Rule +{ + + public function __construct( + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ( + $node->namespacedName === null + || !$this->reflectionProvider->hasClass($node->namespacedName->toString()) + ) { + return []; + } + + $traitReflection = $this->reflectionProvider->getClass($node->namespacedName->toString()); + $sealedTags = $traitReflection->getSealedTags(); + + if (count($sealedTags) === 0) { + return []; + } + + return [ + RuleErrorBuilder::message('PHPDoc tag @phpstan-sealed is only valid on class or interface.') + ->identifier('sealed.onTrait') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/class-name-usage-location.php b/tests/PHPStan/Analyser/nsrt/class-name-usage-location.php index b892739f19..2523461229 100644 --- a/tests/PHPStan/Analyser/nsrt/class-name-usage-location.php +++ b/tests/PHPStan/Analyser/nsrt/class-name-usage-location.php @@ -6,7 +6,7 @@ use function PHPStan\Testing\assertType; function (ClassNameUsageLocation $location): void { - assertType("'assert.test'|'attribute.test'|'catch.test'|'class.extendsTest'|'class.implementsTest'|'classConstant.test'|'enum.implementsTest'|'generics.testBound'|'generics.testDefault'|'instanceof.test'|'interface.extendsTest'|'methodTag.test'|'mixin.test'|'new.test'|'parameter.test'|'property.test'|'propertyTag.test'|'requireExtends.test'|'requireImplements.test'|'return.test'|'selfOut.test'|'staticMethod.test'|'staticProperty.test'|'traitUse.test'|'typeAlias.test'|'varTag.test'", $location->createIdentifier('test')); + assertType("'assert.test'|'attribute.test'|'catch.test'|'class.extendsTest'|'class.implementsTest'|'classConstant.test'|'enum.implementsTest'|'generics.testBound'|'generics.testDefault'|'instanceof.test'|'interface.extendsTest'|'methodTag.test'|'mixin.test'|'new.test'|'parameter.test'|'property.test'|'propertyTag.test'|'requireExtends.test'|'requireImplements.test'|'return.test'|'sealed.test'|'selfOut.test'|'staticMethod.test'|'staticProperty.test'|'traitUse.test'|'typeAlias.test'|'varTag.test'", $location->createIdentifier('test')); if ($location->value === ClassNameUsageLocation::INSTANTIATION || $location->value === ClassNameUsageLocation::PROPERTY_TYPE) { assertType("'new.test'|'property.test'", $location->createIdentifier('test')); diff --git a/tests/PHPStan/Rules/Classes/AllowedSubTypesRuleTest.php b/tests/PHPStan/Rules/Classes/AllowedSubTypesRuleTest.php index 403a35e6ff..21852d48fa 100644 --- a/tests/PHPStan/Rules/Classes/AllowedSubTypesRuleTest.php +++ b/tests/PHPStan/Rules/Classes/AllowedSubTypesRuleTest.php @@ -26,6 +26,24 @@ public function testRule(): void ]); } + public function testSealed(): void + { + $this->analyse([__DIR__ . '/data/sealed.php'], [ + [ + 'Type Sealed\BazClass is not allowed to be a subtype of Sealed\BaseClass.', + 11, + ], + [ + 'Type Sealed\BazClass2 is not allowed to be a subtype of Sealed\BaseInterface.', + 19, + ], + [ + 'Type Sealed\BazInterface is not allowed to be a subtype of Sealed\BaseInterface2.', + 27, + ], + ]); + } + public static function getAdditionalConfigFiles(): array { return [ diff --git a/tests/PHPStan/Rules/Classes/data/sealed.php b/tests/PHPStan/Rules/Classes/data/sealed.php new file mode 100644 index 0000000000..d512db7f68 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/sealed.php @@ -0,0 +1,31 @@ + + */ +class SealedDefinitionClassRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + + return new SealedDefinitionClassRule( + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, true), + new ClassForbiddenNameCheck(self::getContainer()), + $reflectionProvider, + self::getContainer(), + ), + true, + true, + ); + } + + #[RequiresPhp('>= 8.1')] + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/incompatible-sealed.php'], [ + [ + 'PHPDoc tag @phpstan-sealed is only valid on class or interface.', + 16, + ], + [ + 'PHPDoc tag @phpstan-sealed contains unknown class IncompatibleSealed\UnknownClass.', + 21, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + [ + 'PHPDoc tag @phpstan-sealed contains unknown class IncompatibleSealed\UnknownClass.', + 26, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/SealedDefinitionTraitRuleTest.php b/tests/PHPStan/Rules/PhpDoc/SealedDefinitionTraitRuleTest.php new file mode 100644 index 0000000000..788a06f55a --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/SealedDefinitionTraitRuleTest.php @@ -0,0 +1,33 @@ + + */ +class SealedDefinitionTraitRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + + return new SealedDefinitionTraitRule($reflectionProvider); + } + + #[RequiresPhp('>= 8.1')] + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/incompatible-sealed.php'], [ + [ + 'PHPDoc tag @phpstan-sealed is only valid on class or interface.', + 11, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/incompatible-sealed.php b/tests/PHPStan/Rules/PhpDoc/data/incompatible-sealed.php new file mode 100644 index 0000000000..ab1f47ba28 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/incompatible-sealed.php @@ -0,0 +1,41 @@ += 8.1 + +namespace IncompatibleSealed; + +class SomeClass {}; +interface SomeInterface {}; + +/** + * @phpstan-sealed SomeClass + */ +trait InvalidTrait1 {} + +/** + * @phpstan-sealed SomeClass + */ +enum InvalidEnum {} + +/** + * @phpstan-sealed UnknownClass + */ +class InvalidClass {} + +/** + * @phpstan-sealed UnknownClass + */ +interface InvalidInterface {} + +/** + * @phpstan-sealed SomeClass + */ +class Valid {} + +/** + * @phpstan-sealed SomeClass + */ +interface ValidInterface {} + +/** + * @phpstan-sealed SomeInterface + */ +interface ValidInterface2 {}