From 7e2ab561cd0ee7c15598b237eb130560f57e2cde Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 13 Jul 2025 21:24:08 +0200 Subject: [PATCH 1/5] Add phpstan-sealed support --- src/Dependency/DependencyResolver.php | 9 ++ src/PhpDoc/PhpDocNodeResolver.php | 19 ++++ src/PhpDoc/ResolvedPhpDocBlock.php | 21 ++++ src/PhpDoc/Tag/SealedTypeTag.php | 27 +++++ src/Reflection/ClassReflection.php | 14 +++ src/Rules/ClassNameUsageLocation.php | 3 + src/Rules/Classes/SealedRule.php | 69 ++++++++++++ src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 1 + .../PhpDoc/SealedDefinitionClassRule.php | 100 ++++++++++++++++++ .../PhpDoc/SealedDefinitionTraitRule.php | 54 ++++++++++ .../nsrt/class-name-usage-location.php | 2 +- .../PHPStan/Rules/Classes/SealedRuleTest.php | 37 +++++++ tests/PHPStan/Rules/Classes/data/sealed.php | 27 +++++ .../PhpDoc/SealedDefinitionClassRuleTest.php | 55 ++++++++++ .../PhpDoc/SealedDefinitionTraitRuleTest.php | 33 ++++++ .../Rules/PhpDoc/data/incompatible-sealed.php | 41 +++++++ 16 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 src/PhpDoc/Tag/SealedTypeTag.php create mode 100644 src/Rules/Classes/SealedRule.php create mode 100644 src/Rules/PhpDoc/SealedDefinitionClassRule.php create mode 100644 src/Rules/PhpDoc/SealedDefinitionTraitRule.php create mode 100644 tests/PHPStan/Rules/Classes/SealedRuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/data/sealed.php create mode 100644 tests/PHPStan/Rules/PhpDoc/SealedDefinitionClassRuleTest.php create mode 100644 tests/PHPStan/Rules/PhpDoc/SealedDefinitionTraitRuleTest.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/incompatible-sealed.php 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/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/Classes/SealedRule.php b/src/Rules/Classes/SealedRule.php new file mode 100644 index 0000000000..05b56c496a --- /dev/null +++ b/src/Rules/Classes/SealedRule.php @@ -0,0 +1,69 @@ + + */ +#[RegisteredRule(level: 0)] +final class SealedRule implements Rule +{ + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + if ($classReflection->isEnum()) { + return []; + } + + $className = $classReflection->getName(); + + $parents = array_values($classReflection->getImmediateInterfaces()); + $parentClass = $classReflection->getParentClass(); + if ($parentClass !== null) { + $parents[] = $parentClass; + } + + $errors = []; + foreach ($parents as $parent) { + $sealedTags = $parent->getSealedTags(); + foreach ($sealedTags as $sealedTag) { + $type = $sealedTag->getType(); + if ($type->isSuperTypeOf(new ObjectType($className))->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message( + sprintf( + '%s %s is sealed and only permits %s as subtypes, %s given.', + $parent->isInterface() ? 'Interface' : 'Class', + $parent->getDisplayName(), + $type->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + ), + ) + ->identifier('class.sealed') + ->build(); + } + } + + return $errors; + } + +} 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..ade9cc2de9 --- /dev/null +++ b/src/Rules/PhpDoc/SealedDefinitionClassRule.php @@ -0,0 +1,100 @@ + + */ +#[RegisteredRule(level: 0)] +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/SealedRuleTest.php b/tests/PHPStan/Rules/Classes/SealedRuleTest.php new file mode 100644 index 0000000000..235ec2b6ea --- /dev/null +++ b/tests/PHPStan/Rules/Classes/SealedRuleTest.php @@ -0,0 +1,37 @@ + + */ +class SealedRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new SealedRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/sealed.php'], [ + [ + 'Class Sealed\BaseClass is sealed and only permits Sealed\BarClass|Sealed\FooClass as subtypes, Sealed\BazClass given.', + 11, + ], + [ + 'Interface Sealed\BaseInterface is sealed and only permits Sealed\BarClass2|Sealed\FooClass2 as subtypes, Sealed\BazClass2 given.', + 19, + ], + [ + 'Interface Sealed\BaseInterface2 is sealed and only permits Sealed\BarInterface|Sealed\FooInterface as subtypes, Sealed\BazInterface given.', + 27, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/sealed.php b/tests/PHPStan/Rules/Classes/data/sealed.php new file mode 100644 index 0000000000..f67b9ea34e --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/sealed.php @@ -0,0 +1,27 @@ + + */ +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 {} From 841c855aa3feb11b4049abb6f930aad05249e95e Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 13 Jul 2025 22:55:42 +0200 Subject: [PATCH 2/5] Rework --- ...llowedSubTypesClassReflectionExtension.php | 36 ++++++++++++++++++ .../Rules/Classes/AllowedSubTypesRuleTest.php | 18 +++++++++ .../PHPStan/Rules/Classes/SealedRuleTest.php | 37 ------------------- 3 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 src/Reflection/Php/SealedAllowedSubTypesClassReflectionExtension.php delete mode 100644 tests/PHPStan/Rules/Classes/SealedRuleTest.php 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/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/SealedRuleTest.php b/tests/PHPStan/Rules/Classes/SealedRuleTest.php deleted file mode 100644 index 235ec2b6ea..0000000000 --- a/tests/PHPStan/Rules/Classes/SealedRuleTest.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -class SealedRuleTest extends RuleTestCase -{ - - protected function getRule(): Rule - { - return new SealedRule(); - } - - public function testRule(): void - { - $this->analyse([__DIR__ . '/data/sealed.php'], [ - [ - 'Class Sealed\BaseClass is sealed and only permits Sealed\BarClass|Sealed\FooClass as subtypes, Sealed\BazClass given.', - 11, - ], - [ - 'Interface Sealed\BaseInterface is sealed and only permits Sealed\BarClass2|Sealed\FooClass2 as subtypes, Sealed\BazClass2 given.', - 19, - ], - [ - 'Interface Sealed\BaseInterface2 is sealed and only permits Sealed\BarInterface|Sealed\FooInterface as subtypes, Sealed\BazInterface given.', - 27, - ], - ]); - } - -} From c213e9e71fcbfede9e9f6ac5b2e869d5fc1dc38c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 14 Jul 2025 20:13:12 +0200 Subject: [PATCH 3/5] Add tests --- tests/PHPStan/Rules/Classes/data/sealed.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Rules/Classes/data/sealed.php b/tests/PHPStan/Rules/Classes/data/sealed.php index f67b9ea34e..d512db7f68 100644 --- a/tests/PHPStan/Rules/Classes/data/sealed.php +++ b/tests/PHPStan/Rules/Classes/data/sealed.php @@ -25,3 +25,7 @@ interface BaseInterface2 {} interface FooInterface extends BaseInterface2 {} interface BarInterface extends BaseInterface2 {} interface BazInterface extends BaseInterface2 {} // this is an error + +class BarClassChild extends BarClass {} +class BarClass2Child extends BarClass2 {} +interface BarInterfaceChild extends BarInterface {} From bea91a5d3a1cc1a6ee0cd6acecc906342f452933 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 15 Jul 2025 14:27:08 +0200 Subject: [PATCH 4/5] Remove rule --- src/Rules/Classes/SealedRule.php | 69 -------------------------------- 1 file changed, 69 deletions(-) delete mode 100644 src/Rules/Classes/SealedRule.php diff --git a/src/Rules/Classes/SealedRule.php b/src/Rules/Classes/SealedRule.php deleted file mode 100644 index 05b56c496a..0000000000 --- a/src/Rules/Classes/SealedRule.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ -#[RegisteredRule(level: 0)] -final class SealedRule implements Rule -{ - - public function getNodeType(): string - { - return InClassNode::class; - } - - public function processNode(Node $node, Scope $scope): array - { - $classReflection = $node->getClassReflection(); - if ($classReflection->isEnum()) { - return []; - } - - $className = $classReflection->getName(); - - $parents = array_values($classReflection->getImmediateInterfaces()); - $parentClass = $classReflection->getParentClass(); - if ($parentClass !== null) { - $parents[] = $parentClass; - } - - $errors = []; - foreach ($parents as $parent) { - $sealedTags = $parent->getSealedTags(); - foreach ($sealedTags as $sealedTag) { - $type = $sealedTag->getType(); - if ($type->isSuperTypeOf(new ObjectType($className))->yes()) { - continue; - } - - $errors[] = RuleErrorBuilder::message( - sprintf( - '%s %s is sealed and only permits %s as subtypes, %s given.', - $parent->isInterface() ? 'Interface' : 'Class', - $parent->getDisplayName(), - $type->describe(VerbosityLevel::typeOnly()), - $classReflection->getDisplayName(), - ), - ) - ->identifier('class.sealed') - ->build(); - } - } - - return $errors; - } - -} From c8320fdc85413c215452785027c1527c81618ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Tue, 15 Jul 2025 14:37:14 +0200 Subject: [PATCH 5/5] Update src/Rules/PhpDoc/SealedDefinitionClassRule.php --- src/Rules/PhpDoc/SealedDefinitionClassRule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rules/PhpDoc/SealedDefinitionClassRule.php b/src/Rules/PhpDoc/SealedDefinitionClassRule.php index ade9cc2de9..3ef882d2ae 100644 --- a/src/Rules/PhpDoc/SealedDefinitionClassRule.php +++ b/src/Rules/PhpDoc/SealedDefinitionClassRule.php @@ -22,7 +22,7 @@ /** * @implements Rule */ -#[RegisteredRule(level: 0)] +#[RegisteredRule(level: 2)] final class SealedDefinitionClassRule implements Rule {