Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use PHPStan\Analyser\Traverser\TransformStaticTypeTraverser;
use PHPStan\DependencyInjection\Container;
use PHPStan\Node\Expr\AlwaysRememberedExpr;
use PHPStan\Node\Expr\CloneReinitializationExpr;
use PHPStan\Node\Expr\GetIterableKeyTypeExpr;
use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr;
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
Expand Down Expand Up @@ -2893,7 +2894,18 @@ public function assignInitializedProperty(Type $fetchedOnType, string $propertyN
return $this;
}

return $this->assignExpression(new PropertyInitializationExpr($propertyName), new MixedType(), new MixedType());
$scope = $this->assignExpression(new PropertyInitializationExpr($propertyName), new MixedType(), new MixedType());

$function = $scope->getFunction();
if (
$function instanceof MethodReflection
&& strtolower($function->getName()) === '__clone'
&& $scope->phpVersion->supportsReadonlyPropertyReinitializationOnClone()
) {
$scope = $scope->assignExpression(new CloneReinitializationExpr($propertyName), new MixedType(), new MixedType());
}

return $scope;
}

public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): self
Expand Down
48 changes: 48 additions & 0 deletions src/Node/Expr/CloneReinitializationExpr.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node\Expr;

use Override;
use PhpParser\Node\Expr;
use PHPStan\Node\VirtualNode;

/**
* Tracks that a readonly property has been re-assigned within the current __clone() body.
*
* Distinct from PropertyInitializationExpr because PHP 8.3+ allows readonly properties
* to be re-initialized once inside __clone — but the property is already initialized at
* __clone's entry (carried over from the post-construction class scope), so the standard
* initialization tracker can't distinguish "first write inside __clone" from "no write yet
* inside __clone". This expression is only set when an assignment actually happens inside
* __clone, and is excluded from rememberConstructorExpressions() so it never leaks into
* __clone's entry scope.
*/
final class CloneReinitializationExpr extends Expr implements VirtualNode
{

public function __construct(private string $propertyName)
{
parent::__construct([]);
}

public function getPropertyName(): string
{
return $this->propertyName;
}

#[Override]
public function getType(): string
{
return 'PHPStan_Node_CloneReinitializationExpr';
}

/**
* @return string[]
*/
#[Override]
public function getSubNodeNames(): array
{
return [];
}

}
6 changes: 6 additions & 0 deletions src/Node/Printer/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PHPStan\Node\BooleanAndNode;
use PHPStan\Node\BooleanOrNode;
use PHPStan\Node\Expr\AlwaysRememberedExpr;
use PHPStan\Node\Expr\CloneReinitializationExpr;
use PHPStan\Node\Expr\ExistingArrayDimFetch;
use PHPStan\Node\Expr\ForeachValueByRefExpr;
use PHPStan\Node\Expr\GetIterableKeyTypeExpr;
Expand Down Expand Up @@ -104,6 +105,11 @@ protected function pPHPStan_Node_PropertyInitializationExpr(PropertyInitializati
return sprintf('__phpstanPropertyInitialization(%s)', $expr->getPropertyName());
}

protected function pPHPStan_Node_CloneReinitializationExpr(CloneReinitializationExpr $expr): string // phpcs:ignore
{
return sprintf('__phpstanCloneReinitialization(%s)', $expr->getPropertyName());
}

protected function pPHPStan_Node_ForeachValueByRefExpr(ForeachValueByRefExpr $expr): string // phpcs:ignore
{
return sprintf('__phpstanForeachValueByRef(%s)', $this->p($expr->getExpr()));
Expand Down
5 changes: 5 additions & 0 deletions src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ public function supportsReadOnlyAnonymousClasses(): bool
return $this->versionId >= 80300;
}

public function supportsReadonlyPropertyReinitializationOnClone(): bool
{
return $this->versionId >= 80300;
}

public function supportsNeverReturnTypeInArrowFunction(): bool
{
return $this->versionId >= 80200;
Expand Down
16 changes: 14 additions & 2 deletions src/Rules/Properties/ReadOnlyPropertyAssignRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Node\Expr\CloneReinitializationExpr;
use PHPStan\Node\Expr\SetOffsetValueTypeExpr;
use PHPStan\Node\Expr\UnsetOffsetExpr;
use PHPStan\Node\PropertyAssignNode;
Expand Down Expand Up @@ -96,15 +97,26 @@ public function processNode(Node $node, Scope $scope): array
throw new ShouldNotHappenException();
}

$methodName = $scopeMethod->getName();
$inClone = $this->phpVersion->supportsReadonlyPropertyReinitializationOnClone() && strtolower($methodName) === '__clone';
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'
|| $inClone
) {
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()))
->line($propertyFetch->name->getStartLine())
->identifier('property.readOnlyAssignNotOnThis')
->build();
} elseif (
$inClone
&& !$scope->hasExpressionType(new CloneReinitializationExpr($propertyReflection->getName()))->no()
) {
$errors[] = RuleErrorBuilder::message(sprintf('Readonly property %s::$%s is already assigned.', $declaringClass->getDisplayName(), $propertyReflection->getName()))
->line($propertyFetch->name->getStartLine())
->identifier('assign.readOnlyProperty')
->build();
}

continue;
Expand Down
58 changes: 58 additions & 0 deletions tests/PHPStan/Rules/Properties/ReadOnlyPropertyAssignRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,64 @@ public function testCloneWith(): void
$this->analyse([__DIR__ . '/data/readonly-property-assign-clone-with.php'], []);
}

#[RequiresPhp('>= 8.1.0')]
public function testBug11495(): void
{
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,
],
[
'Readonly property Bug11495\DoubleAssign::$foo is assigned outside of the constructor.',
40,
],
[
'Readonly property Bug11495\DoubleAssign::$foo is assigned outside of the constructor.',
41,
],
[
'Readonly property Bug11495\BranchedAssign::$foo is assigned outside of the constructor.',
57,
],
[
'Readonly property Bug11495\BranchedAssign::$foo is assigned outside of the constructor.',
59,
],
[
'Readonly property Bug11495\ConditionalThenAssign::$foo is assigned outside of the constructor.',
76,
],
[
'Readonly property Bug11495\ConditionalThenAssign::$foo is assigned outside of the constructor.',
78,
],
];
} else {
$errors = [
[
'Readonly property Bug11495\HelloWorld::$foo is not assigned on $this.',
20,
],
[
'Readonly property Bug11495\DoubleAssign::$foo is already assigned.',
41,
],
[
'Readonly property Bug11495\ConditionalThenAssign::$foo is already assigned.',
78,
],
];
}

$this->analyse([__DIR__ . '/data/bug-11495.php'], $errors);
}

#[RequiresPhp('>= 8.4.0')]
public function testBug12871(): void
{
Expand Down
80 changes: 80 additions & 0 deletions tests/PHPStan/Rules/Properties/data/bug-11495.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php // lint >= 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;
}
}

class DoubleAssign
{
private readonly string $foo;

public function __construct()
{
$this->foo = 'bar';
}

public function __clone()
{
$this->foo = 'baz';
$this->foo = 'qux';
}
}

class BranchedAssign
{
private readonly string $foo;

public function __construct()
{
$this->foo = 'bar';
}

public function __clone()
{
if (rand(0, 1)) {
$this->foo = 'a';
} else {
$this->foo = 'b';
}
}
}

class ConditionalThenAssign
{
private readonly string $foo;

public function __construct()
{
$this->foo = 'bar';
}

public function __clone()
{
if (rand(0, 1)) {
$this->foo = 'a';
}
$this->foo = 'b';
}
}
Loading