diff --git a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php index e61a9a2426a..e4daff744ee 100644 --- a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php +++ b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php @@ -48,9 +48,6 @@ public function processNode(Node $node, Scope $scope): array } $inCloneWith = (bool) $propertyFetch->getAttribute('inCloneWith', false); - if ($inCloneWith) { - return []; - } $inFunction = $scope->getFunction(); if ( @@ -80,16 +77,26 @@ public function processNode(Node $node, Scope $scope): array $declaringClass = $nativeReflection->getDeclaringClass(); - if (!$scope->isInClass()) { - $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) - ->line($propertyFetch->name->getStartLine()) - ->identifier('property.readOnlyByPhpDocAssignOutOfClass') - ->build(); + $scopeClassReflection = $scope->isInClass() ? $scope->getClassReflection() : null; + $isOutsideDeclaringClass = $scopeClassReflection === null + || $scopeClassReflection->getName() !== $declaringClass->getName(); + + if ($inCloneWith) { + if ( + $isOutsideDeclaringClass + && $declaringClass->isReadOnly() + && $nativeReflection->isPublic() + && !$nativeReflection->isPrivateSet() + ) { + $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->line($propertyFetch->name->getStartLine()) + ->identifier('property.readOnlyByPhpDocAssignOutOfClass') + ->build(); + } continue; } - $scopeClassReflection = $scope->getClassReflection(); - if ($scopeClassReflection->getName() !== $declaringClass->getName()) { + if ($isOutsideDeclaringClass) { $errors[] = RuleErrorBuilder::message(sprintf('@readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) ->line($propertyFetch->name->getStartLine()) ->identifier('property.readOnlyByPhpDocAssignOutOfClass') diff --git a/src/Rules/Properties/ReadOnlyPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php index 987e000256e..3c0c3c17565 100644 --- a/src/Rules/Properties/ReadOnlyPropertyAssignRule.php +++ b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php @@ -47,9 +47,6 @@ public function processNode(Node $node, Scope $scope): array } $inCloneWith = (bool) $propertyFetch->getAttribute('inCloneWith', false); - if ($inCloneWith) { - return []; - } $errors = []; $reflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); @@ -67,16 +64,26 @@ public function processNode(Node $node, Scope $scope): array $declaringClass = $nativeReflection->getDeclaringClass(); - if (!$scope->isInClass()) { - $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) - ->line($propertyFetch->name->getStartLine()) - ->identifier('property.readOnlyAssignOutOfClass') - ->build(); + $scopeClassReflection = $scope->isInClass() ? $scope->getClassReflection() : null; + $isOutsideDeclaringClass = $scopeClassReflection === null + || $scopeClassReflection->getName() !== $declaringClass->getName(); + + if ($inCloneWith) { + if ( + $isOutsideDeclaringClass + && $declaringClass->isReadOnly() + && $nativeReflection->isPublic() + && !$nativeReflection->isPrivateSet() + ) { + $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) + ->line($propertyFetch->name->getStartLine()) + ->identifier('property.readOnlyAssignOutOfClass') + ->build(); + } continue; } - $scopeClassReflection = $scope->getClassReflection(); - if ($scopeClassReflection->getName() !== $declaringClass->getName()) { + if ($isOutsideDeclaringClass) { $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is assigned outside of its declaring class.', $declaringClass->getDisplayName(), $propertyReflection->getName())) ->line($propertyFetch->name->getStartLine()) ->identifier('property.readOnlyAssignOutOfClass') diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php index 6fb0fe27d5d..87f474cb50c 100644 --- a/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php @@ -234,4 +234,27 @@ public function testCloneWith(): void ]); } + #[RequiresPhp('>= 8.5')] + public function testBug14063(): void + { + $this->analyse([__DIR__ . '/data/bug-14063.php'], [ + [ + 'Assign to protected(set) property Bug14063\Qux::$value.', + 65, + ], + [ + 'Assign to protected(set) property Bug14063\Bar::$value.', + 68, + ], + [ + 'Access to protected property Bug14063\Baz::$prot.', + 71, + ], + [ + 'Access to private property Bug14063\Baz::$priv.', + 71, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php index 40bbb2d5c32..1654c528ce9 100644 --- a/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php @@ -180,4 +180,19 @@ public function testCloneWith(): void $this->analyse([__DIR__ . '/data/readonly-property-assign-clone-with.php'], []); } + #[RequiresPhp('>= 8.5')] + public function testBug14063(): void + { + $this->analyse([__DIR__ . '/data/bug-14063.php'], [ + [ + 'Readonly property Bug14063\Obj::$value is assigned outside of its declaring class.', + 62, + ], + [ + 'Readonly property Bug14063\Baz::$pub is assigned outside of its declaring class.', + 71, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14063.php b/tests/PHPStan/Rules/Properties/data/bug-14063.php new file mode 100644 index 00000000000..5d05a62a172 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-14063.php @@ -0,0 +1,76 @@ += 8.5 + +declare(strict_types = 1); + +namespace Bug14063; + +final readonly class Obj +{ + public function __construct(public string $value) {} + + public function doFoo(): void + { + clone($this, ['value' => 'newVal']); + } +} + +class Bar +{ + public readonly string $value; + + public function __construct(string $value) + { + $this->value = $value; + } + + public function doFoo(): void + { + clone($this, ['value' => 'newVal']); + } +} + +readonly class Baz +{ + public function __construct( + public string $pub, + protected string $prot, + private string $priv, + ) {} + + public function doFoo(): void + { + clone($this, [ + 'pub' => 'newVal', + 'prot' => 'newVal', + 'priv' => 'newVal', + ]); + } +} + +// non-readonly class with promoted public readonly property +final class Qux +{ + public function __construct(public readonly string $value) {} + + public function doFoo(): void + { + clone($this, ['value' => 'newVal']); + } +} + +$obj = new Obj('val'); +$newObj = clone($obj, ['value' => 'newVal']); + +$qux = new Qux('val'); +$newQux = clone($qux, ['value' => 'newVal']); + +$bar = new Bar('val'); +$newBar = clone($bar, ['value' => 'newVal']); + +function (Baz $baz): void { + clone($baz, [ + 'pub' => 'newVal', + 'prot' => 'newVal', + 'priv' => 'newVal', + ]); +};