From f5fee8848c321233b85b8fb7dec853827848ec38 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/4] 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 76afb8bdd54..660ec34be90 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -18,3 +18,4 @@ parameters: curlSetOptArrayTypes: true checkDateIntervalConstructor: true reportMethodPurityOverride: 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 1628b4b83a0..96bf62e9f04 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -45,6 +45,7 @@ parameters: curlSetOptArrayTypes: false checkDateIntervalConstructor: false reportMethodPurityOverride: false + reportInvalidInheritDocTag: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 15e6e02c215..874a172c2d1 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -47,6 +47,7 @@ parametersSchema: curlSetOptArrayTypes: bool() checkDateIntervalConstructor: bool() reportMethodPurityOverride: 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/4] 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, ], ]); From 00f4776bb942ec7c435214b1eb98cc9db29e18a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Wer=C5=82os?= Date: Wed, 6 May 2026 22:44:53 +0200 Subject: [PATCH 3/4] Skip `{@inheritDoc}` inside backticks --- src/Rules/PhpDoc/InvalidInheritDocTagRule.php | 2 +- .../Rules/PhpDoc/data/invalid-inherit-doc-tag.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Rules/PhpDoc/InvalidInheritDocTagRule.php b/src/Rules/PhpDoc/InvalidInheritDocTagRule.php index 45052bc1096..689806bebf6 100644 --- a/src/Rules/PhpDoc/InvalidInheritDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidInheritDocTagRule.php @@ -22,7 +22,7 @@ final class InvalidInheritDocTagRule implements Rule { - private const INLINE_INHERIT_DOC_REGEX = '~(? Date: Wed, 6 May 2026 22:58:04 +0200 Subject: [PATCH 4/4] Update test --- .../Rules/PhpDoc/data/invalid-inherit-doc-tag.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php b/tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php index 35451491244..e76727d9a2a 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php +++ b/tests/PHPStan/Rules/PhpDoc/data/invalid-inherit-doc-tag.php @@ -324,7 +324,7 @@ public function baseMethod(): int; } -class InheritDocInsideBackticks +class InheritDocMentionedInDescription { /** @@ -335,4 +335,12 @@ public function methodWithInheritDocInBackticks(): int return 0; } + /** + * Foo @inheritDoc + */ + public function methodWithInheritDocInTextNotAtLineStart(): int + { + return 0; + } + }