diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 4fbf14a44e..aac5b62766 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -454,7 +454,7 @@ public function resolveIsImpure(PhpDocNode $phpDocNode): bool public function resolveIsReadOnly(PhpDocNode $phpDocNode): bool { - foreach (['@readonly', '@psalm-readonly', '@phpstan-readonly', '@psalm-readonly-allow-private-mutation'] as $tagName) { + foreach (['@readonly', '@psalm-readonly', '@phpstan-readonly', '@phpstan-readonly-allow-private-mutation', '@psalm-readonly-allow-private-mutation'] as $tagName) { $tags = $phpDocNode->getTagsByName($tagName); if (count($tags) > 0) { @@ -505,4 +505,17 @@ private function shouldSkipType(string $tagName, Type $type): bool return $this->unresolvableTypeHelper->containsUnresolvableType($type); } + public function resolveAllowPrivateMutation(PhpDocNode $phpDocNode): bool + { + foreach (['@phpstan-readonly-allow-private-mutation', '@phpstan-allow-private-mutation', '@psalm-readonly-allow-private-mutation', '@psalm-allow-private-mutation'] as $tagName) { + $tags = $phpDocNode->getTagsByName($tagName); + + if (count($tags) > 0) { + return true; + } + } + + return false; + } + } diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 438825ab8e..2712ffc898 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -100,6 +100,8 @@ class ResolvedPhpDocBlock private ?bool $isImmutable = null; + private ?bool $isAllowedPrivateMutation = null; + private ?bool $hasConsistentConstructor = null; private ?bool $acceptsNamedArguments = null; @@ -162,6 +164,8 @@ public static function createEmpty(): self $self->isFinal = false; $self->isPure = null; $self->isReadOnly = false; + $self->isImmutable = false; + $self->isAllowedPrivateMutation = false; $self->hasConsistentConstructor = false; $self->acceptsNamedArguments = true; @@ -211,6 +215,8 @@ public function merge(array $parents, array $parentPhpDocBlocks): self $result->isFinal = $this->isFinal(); $result->isPure = $this->isPure(); $result->isReadOnly = $this->isReadOnly(); + $result->isImmutable = $this->isImmutable(); + $result->isAllowedPrivateMutation = $this->isAllowedPrivateMutation(); $result->hasConsistentConstructor = $this->hasConsistentConstructor(); $result->acceptsNamedArguments = $acceptsNamedArguments; @@ -593,6 +599,17 @@ public function isImmutable(): bool return $this->isImmutable; } + public function isAllowedPrivateMutation(): bool + { + if ($this->isAllowedPrivateMutation === null) { + $this->isAllowedPrivateMutation = $this->phpDocNodeResolver->resolveAllowPrivateMutation( + $this->phpDocNode, + ); + } + + return $this->isAllowedPrivateMutation; + } + /** * @param array $varTags * @param array $parents diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 8205d0957e..59ab947635 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -214,7 +214,7 @@ private function createProperty( $types[] = $value; } - return new PhpPropertyReflection($declaringClassReflection, null, null, TypeCombinator::union(...$types), $classReflection->getNativeReflection()->getProperty($propertyName), null, false, false, false); + return new PhpPropertyReflection($declaringClassReflection, null, null, TypeCombinator::union(...$types), $classReflection->getNativeReflection()->getProperty($propertyName), null, false, false, false, false); } } @@ -222,6 +222,7 @@ private function createProperty( $isDeprecated = false; $isInternal = false; $isReadOnlyByPhpDoc = $classReflection->isImmutable(); + $isAllowedPrivateMutation = false; if ( $includingAnnotations @@ -297,6 +298,7 @@ private function createProperty( $isDeprecated = $resolvedPhpDoc->isDeprecated(); $isInternal = $resolvedPhpDoc->isInternal(); $isReadOnlyByPhpDoc = $isReadOnlyByPhpDoc || $resolvedPhpDoc->isReadOnly(); + $isAllowedPrivateMutation = $resolvedPhpDoc->isAllowedPrivateMutation(); } if ($phpDocType === null) { @@ -361,6 +363,7 @@ private function createProperty( $isDeprecated, $isInternal, $isReadOnlyByPhpDoc, + $isAllowedPrivateMutation, ); } diff --git a/src/Reflection/Php/PhpPropertyReflection.php b/src/Reflection/Php/PhpPropertyReflection.php index 20dd103172..e48e8deee4 100644 --- a/src/Reflection/Php/PhpPropertyReflection.php +++ b/src/Reflection/Php/PhpPropertyReflection.php @@ -31,6 +31,7 @@ public function __construct( private bool $isDeprecated, private bool $isInternal, private bool $isReadOnlyByPhpDoc, + private bool $isAllowedPrivateMutation, ) { } @@ -169,6 +170,11 @@ public function isInternal(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isInternal); } + public function isAllowedPrivateMutation(): bool + { + return $this->isAllowedPrivateMutation; + } + public function getNativeReflection(): ReflectionProperty { return $this->reflection; diff --git a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php index 1949a85652..4eeeb13f50 100644 --- a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php +++ b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php @@ -83,6 +83,10 @@ public function processNode(Node $node, Scope $scope): array continue; } + if ($nativeReflection->isAllowedPrivateMutation()) { + continue; + } + $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned outside of the constructor.', $declaringClass->getDisplayName(), $propertyReflection->getName()))->build(); } diff --git a/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php index d481ecba1c..bac6bace61 100644 --- a/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php @@ -32,10 +32,6 @@ public function testRule(): void '@readonly property ReadonlyPropertyAssignPhpDoc\Foo::$foo is assigned outside of the constructor.', 40, ], - [ - '@readonly property ReadonlyPropertyAssignPhpDoc\Foo::$psalm is assigned outside of the constructor.', - 41, - ], [ '@readonly property ReadonlyPropertyAssignPhpDoc\Foo::$bar is assigned outside of its declaring class.', 53, diff --git a/tests/PHPStan/Rules/Properties/data/readonly-assign-phpdoc.php b/tests/PHPStan/Rules/Properties/data/readonly-assign-phpdoc.php index 920547cdfa..4d8c32463f 100644 --- a/tests/PHPStan/Rules/Properties/data/readonly-assign-phpdoc.php +++ b/tests/PHPStan/Rules/Properties/data/readonly-assign-phpdoc.php @@ -38,7 +38,7 @@ public function __construct(int $foo) public function setFoo(int $foo): void { $this->foo = $foo; // setter - report - $this->psalm = $foo; // setter - report, but Psalm allowed private mutation + $this->psalm = $foo; // do not report -allowed private mutation } }