diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index aaa08cf178..c226e2185b 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -327,4 +327,9 @@ public function hasDateTimeExceptions(): bool return $this->versionId >= 80300; } + public function supportsReadonlyPropertyReinitializationOnClone(): bool + { + return $this->versionId >= 80300; + } + } diff --git a/src/Rules/Properties/ReadOnlyPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php index 47d132b639..cf71693b90 100644 --- a/src/Rules/Properties/ReadOnlyPropertyAssignRule.php +++ b/src/Rules/Properties/ReadOnlyPropertyAssignRule.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Node\PropertyAssignNode; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ConstructorsHelper; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Rule; @@ -24,6 +25,7 @@ class ReadOnlyPropertyAssignRule implements Rule public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, private ConstructorsHelper $constructorsHelper, + private PhpVersion $phpVersion, ) { } @@ -76,9 +78,11 @@ public function processNode(Node $node, Scope $scope): array throw new ShouldNotHappenException(); } + $methodName = $scopeMethod->getName(); if ( - in_array($scopeMethod->getName(), $this->constructorsHelper->getConstructors($scopeClassReflection), true) - || strtolower($scopeMethod->getName()) === '__unserialize' + in_array($methodName, $this->constructorsHelper->getConstructors($scopeClassReflection), true) + || strtolower($methodName) === '__unserialize' + || ($this->phpVersion->supportsReadonlyPropertyReinitializationOnClone() && strtolower($methodName) === '__clone') ) { if (TypeUtils::findThisType($scope->getType($propertyFetch->var)) === null) { $errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is not assigned on $this.', $declaringClass->getDisplayName(), $propertyReflection->getName())) diff --git a/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php index 90da9c44ec..22b769b7cd 100644 --- a/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Properties; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ConstructorsHelper; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; @@ -23,6 +24,7 @@ protected function getRule(): Rule 'ReadonlyPropertyAssign\\TestCase::setUp', ], ), + new PhpVersion(PHP_VERSION_ID), ); } @@ -154,4 +156,33 @@ public function testBug6773(): void ]); } + public function testBug11495(): void + { + if (PHP_VERSION_ID < 80100) { + $this->markTestSkipped('Test requires PHP 8.1.'); + } + + if (PHP_VERSION_ID < 80300) { + $errors = [ + [ + 'Readonly property Bug11495\HelloWorld::$foo is assigned outside of the constructor.', + 17, + ], + [ + 'Readonly property Bug11495\HelloWorld::$foo is assigned outside of the constructor.', + 20, + ], + ]; + } else { + $errors = [ + [ + 'Readonly property Bug11495\HelloWorld::$foo is not assigned on $this.', + 20, + ], + ]; + } + + $this->analyse([__DIR__ . '/data/bug-11495.php'], $errors); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-11495.php b/tests/PHPStan/Rules/Properties/data/bug-11495.php new file mode 100644 index 0000000000..fa125f26cf --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-11495.php @@ -0,0 +1,27 @@ += 8.1 +declare(strict_types = 1); + +namespace Bug11495; + +class HelloWorld +{ + private readonly string $foo; + + public function __construct() + { + $this->foo = 'bar'; + } + + public function __clone() + { + $this->foo = 'baz'; + + $s = new self(); + $s->foo = 'baz'; + } + + public function getFoo(): string + { + return $this->foo; + } +}