From 5abc4b40f5bcc0d972b190f7f49ba04d5b50d90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Wer=C5=82os?= Date: Sat, 2 May 2026 13:06:38 +0200 Subject: [PATCH 1/2] Report `@inheritDoc` when there is no PHPDoc to inherit from --- conf/bleedingEdge.neon | 1 + conf/config.level2.neon | 5 + conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Rules/PhpDoc/InvalidInheritDocTagRule.php | 131 +++++++ .../PhpDoc/InvalidInheritDocTagRuleTest.php | 66 ++++ .../PhpDoc/data/invalid-inherit-doc-tag.php | 325 ++++++++++++++++++ 7 files changed, 530 insertions(+) create mode 100644 src/Rules/PhpDoc/InvalidInheritDocTagRule.php create mode 100644 tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a68d1a352a2..c5e1a878919 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -16,3 +16,4 @@ parameters: reportNestedTooWideType: false # tmp assignToByRefForeachExpr: true curlSetOptArrayTypes: true + reportInvalidInheritDocTag: true diff --git a/conf/config.level2.neon b/conf/config.level2.neon index dd50138b541..ca9f9780566 100644 --- a/conf/config.level2.neon +++ b/conf/config.level2.neon @@ -15,6 +15,8 @@ conditionalTags: phpstan.restrictedPropertyUsageExtension: %featureToggles.internalTag% PHPStan\Rules\InternalTag\RestrictedInternalMethodUsageExtension: phpstan.restrictedMethodUsageExtension: %featureToggles.internalTag% + PHPStan\Rules\PhpDoc\InvalidInheritDocTagRule: + phpstan.rules.rule: %featureToggles.reportInvalidInheritDocTag% services: - @@ -22,3 +24,6 @@ services: - class: PHPStan\Rules\InternalTag\RestrictedInternalMethodUsageExtension + + - + class: PHPStan\Rules\PhpDoc\InvalidInheritDocTagRule diff --git a/conf/config.neon b/conf/config.neon index 6c71dae9225..98eb0f4e569 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -43,6 +43,7 @@ parameters: reportNestedTooWideType: false assignToByRefForeachExpr: false curlSetOptArrayTypes: false + reportInvalidInheritDocTag: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index bc79fe7c401..ff2fa61e760 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -45,6 +45,7 @@ parametersSchema: reportNestedTooWideType: bool() assignToByRefForeachExpr: bool() curlSetOptArrayTypes: bool() + reportInvalidInheritDocTag: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/PhpDoc/InvalidInheritDocTagRule.php b/src/Rules/PhpDoc/InvalidInheritDocTagRule.php new file mode 100644 index 00000000000..d7c6f6b0d72 --- /dev/null +++ b/src/Rules/PhpDoc/InvalidInheritDocTagRule.php @@ -0,0 +1,131 @@ + + */ +final class InvalidInheritDocTagRule implements Rule +{ + + private const INLINE_INHERIT_DOC_REGEX = '~(?getOriginalNode()->getDocComment(); + if ($docComment === null) { + return []; + } + + $tokens = new TokenIterator($this->phpDocLexer->tokenize($docComment->getText())); + $phpDocNode = $this->phpDocParser->parse($tokens); + + $inheritDocTagName = null; + foreach ($phpDocNode->getTags() as $tag) { + if (strtolower($tag->name) !== '@inheritdoc') { + continue; + } + + $inheritDocTagName = $tag->name; + break; + } + + if ($inheritDocTagName === null) { + foreach ($phpDocNode->children as $child) { + if (!$child instanceof PhpDocTextNode) { + continue; + } + + if (preg_match(self::INLINE_INHERIT_DOC_REGEX, $child->text, $matches) !== 1) { + continue; + } + + $inheritDocTagName = $matches[0]; + break; + } + } + + if ($inheritDocTagName === null) { + return []; + } + + $inheritanceClass = $scope->isInTrait() ? $scope->getTraitReflection() : $node->getClassReflection(); + $methodName = $node->getMethodReflection()->getName(); + + if ($this->hasInheritablePhpDoc($inheritanceClass, $methodName)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s on method %s::%s() refers to a parent method that does not have a PHPDoc.', + $inheritDocTagName, + $inheritanceClass->getDisplayName(), + $methodName, + ))->identifier('phpDoc.invalidInheritDoc')->build(), + ]; + } + + private function hasInheritablePhpDoc(ClassReflection $classReflection, string $methodName): bool + { + $parent = $classReflection->getParentClass(); + if ($parent !== null && $this->parentHasPhpDocForMethod($parent, $methodName)) { + return true; + } + + foreach ($classReflection->getImmediateInterfaces() as $interface) { + if ($this->parentHasPhpDocForMethod($interface, $methodName)) { + return true; + } + } + + foreach ($classReflection->getTraits() as $trait) { + if ($this->parentHasPhpDocForMethod($trait, $methodName)) { + return true; + } + } + + return false; + } + + private function parentHasPhpDocForMethod(ClassReflection $parent, string $methodName): bool + { + if (!$parent->hasNativeMethod($methodName)) { + return false; + } + + $parentMethod = $parent->getNativeMethod($methodName); + if ($parentMethod->isPrivate()) { + return false; + } + + return $parentMethod->getResolvedPhpDoc() !== null; + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php new file mode 100644 index 00000000000..b58dc1f8cc1 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php @@ -0,0 +1,66 @@ + + */ +class InvalidInheritDocTagRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new InvalidInheritDocTagRule( + self::getContainer()->getByType(Lexer::class), + self::getContainer()->getByType(PhpDocParser::class), + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/invalid-inherit-doc-tag.php'], [ + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ChildWithInlineInheritDoc::methodWithoutPhpDoc() refers to a parent method that does not have a PHPDoc.', + 31, + ], + [ + 'PHPDoc tag @inheritdoc on method InvalidInheritDocTag\ChildWithBlockInheritDoc::methodWithoutPhpDoc() refers to a parent method that does not have a PHPDoc.', + 52, + ], + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ClassWithoutParent::orphanedInheritDoc() refers to a parent method that does not have a PHPDoc.', + 73, + ], + [ + 'PHPDoc tag @inheritdoc on method InvalidInheritDocTag\ClassWithoutParent::orphanedBlockInheritDoc() refers to a parent method that does not have a PHPDoc.', + 81, + ], + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ImplementsInterface::interfaceMethodWithoutPhpDoc() refers to a parent method that does not have a PHPDoc.', + 106, + ], + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\UsesTraitWithoutPhpDoc::traitMethodWithoutPhpDoc() refers to a parent method that does not have a PHPDoc.', + 216, + ], + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\IssueExampleChild::f() refers to a parent method that does not have a PHPDoc.', + 254, + ], + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ChildOfPrivateParentMethod::privateMethod() refers to a parent method that does not have a PHPDoc.', + 280, + ], + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\OrphanedInheritDocTrait::orphaned() refers to a parent method that does not have a PHPDoc.', + 293, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php b/tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php new file mode 100644 index 00000000000..8c5c651921a --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php @@ -0,0 +1,325 @@ + Date: Sat, 2 May 2026 15:52:03 +0200 Subject: [PATCH 2/2] Use `ParentMethodHelper` and separate error identifiers --- src/Rules/PhpDoc/InvalidInheritDocTagRule.php | 60 +++++++------------ .../PhpDoc/InvalidInheritDocTagRuleTest.php | 16 +++-- 2 files changed, 31 insertions(+), 45 deletions(-) diff --git a/src/Rules/PhpDoc/InvalidInheritDocTagRule.php b/src/Rules/PhpDoc/InvalidInheritDocTagRule.php index d7c6f6b0d72..45052bc1096 100644 --- a/src/Rules/PhpDoc/InvalidInheritDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidInheritDocTagRule.php @@ -9,7 +9,7 @@ use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\PhpDocParser\Parser\TokenIterator; -use PHPStan\Reflection\ClassReflection; +use PHPStan\Rules\Methods\ParentMethodHelper; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use function preg_match; @@ -27,6 +27,7 @@ final class InvalidInheritDocTagRule implements Rule public function __construct( private Lexer $phpDocLexer, private PhpDocParser $phpDocParser, + private ParentMethodHelper $parentMethodHelper, ) { } @@ -78,8 +79,23 @@ public function processNode(Node $node, Scope $scope): array $inheritanceClass = $scope->isInTrait() ? $scope->getTraitReflection() : $node->getClassReflection(); $methodName = $node->getMethodReflection()->getName(); - if ($this->hasInheritablePhpDoc($inheritanceClass, $methodName)) { - return []; + $parentMethods = $this->parentMethodHelper->collectParentMethods($methodName, $inheritanceClass); + + if ($parentMethods === []) { + return [ + RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s on method %s::%s() does not override or implement any other method.', + $inheritDocTagName, + $inheritanceClass->getDisplayName(), + $methodName, + ))->identifier('inheritDoc.noParent')->build(), + ]; + } + + foreach ($parentMethods as [$parentMethod]) { + if ($parentMethod->getResolvedPhpDoc() !== null) { + return []; + } } return [ @@ -88,44 +104,8 @@ public function processNode(Node $node, Scope $scope): array $inheritDocTagName, $inheritanceClass->getDisplayName(), $methodName, - ))->identifier('phpDoc.invalidInheritDoc')->build(), + ))->identifier('inheritDoc.parentWithoutPhpDoc')->build(), ]; } - private function hasInheritablePhpDoc(ClassReflection $classReflection, string $methodName): bool - { - $parent = $classReflection->getParentClass(); - if ($parent !== null && $this->parentHasPhpDocForMethod($parent, $methodName)) { - return true; - } - - foreach ($classReflection->getImmediateInterfaces() as $interface) { - if ($this->parentHasPhpDocForMethod($interface, $methodName)) { - return true; - } - } - - foreach ($classReflection->getTraits() as $trait) { - if ($this->parentHasPhpDocForMethod($trait, $methodName)) { - return true; - } - } - - return false; - } - - private function parentHasPhpDocForMethod(ClassReflection $parent, string $methodName): bool - { - if (!$parent->hasNativeMethod($methodName)) { - return false; - } - - $parentMethod = $parent->getNativeMethod($methodName); - if ($parentMethod->isPrivate()) { - return false; - } - - return $parentMethod->getResolvedPhpDoc() !== null; - } - } diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php index b58dc1f8cc1..522a43c4b57 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidInheritDocTagRuleTest.php @@ -4,6 +4,7 @@ use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\Rules\Methods\ParentMethodHelper; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; @@ -18,6 +19,7 @@ protected function getRule(): Rule return new InvalidInheritDocTagRule( self::getContainer()->getByType(Lexer::class), self::getContainer()->getByType(PhpDocParser::class), + self::getContainer()->getByType(ParentMethodHelper::class), ); } @@ -33,11 +35,11 @@ public function testRule(): void 52, ], [ - 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ClassWithoutParent::orphanedInheritDoc() refers to a parent method that does not have a PHPDoc.', + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ClassWithoutParent::orphanedInheritDoc() does not override or implement any other method.', 73, ], [ - 'PHPDoc tag @inheritdoc on method InvalidInheritDocTag\ClassWithoutParent::orphanedBlockInheritDoc() refers to a parent method that does not have a PHPDoc.', + 'PHPDoc tag @inheritdoc on method InvalidInheritDocTag\ClassWithoutParent::orphanedBlockInheritDoc() does not override or implement any other method.', 81, ], [ @@ -45,19 +47,23 @@ public function testRule(): void 106, ], [ - 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\UsesTraitWithoutPhpDoc::traitMethodWithoutPhpDoc() refers to a parent method that does not have a PHPDoc.', + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\UsesTraitWithoutPhpDoc::traitMethodWithoutPhpDoc() does not override or implement any other method.', 216, ], + [ + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\UsesTraitWithPhpDoc::traitMethodWithPhpDoc() does not override or implement any other method.', + 231, + ], [ 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\IssueExampleChild::f() refers to a parent method that does not have a PHPDoc.', 254, ], [ - 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ChildOfPrivateParentMethod::privateMethod() refers to a parent method that does not have a PHPDoc.', + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\ChildOfPrivateParentMethod::privateMethod() does not override or implement any other method.', 280, ], [ - 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\OrphanedInheritDocTrait::orphaned() refers to a parent method that does not have a PHPDoc.', + 'PHPDoc tag {@inheritdoc} on method InvalidInheritDocTag\OrphanedInheritDocTrait::orphaned() does not override or implement any other method.', 293, ], ]);