From 74f9d7ec3db6bbb21bb68cf2ab6cca9e0b08449b Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:54:52 +0000 Subject: [PATCH 1/2] Do not remember readonly properties' constructor-narrowed types in other methods when `clone with` is available (PHP 8.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MutatingScope::rememberConstructorExpressions()` carries readonly property fetches narrowed in the constructor into the scope of other methods. Since PHP 8.5, `clone($obj, [...])` ("clone with") can reinitialize any readonly property — even private ones, reinitialized by the declaring class — and the modified instance escapes, so `$this` in another method is no longer guaranteed to hold the constructor value. Skip remembering readonly property fetches then, so they widen back to their declared type. - The version check uses the scope-narrowable `Scope::getPhpVersion()` (new `PhpVersions::supportsCloneWith()`), so the behavior respects an enclosing `if (PHP_VERSION_ID >= 80500)` and is testable on older runtimes. - Added `PhpVersions::supportsCloneWith()` (>= 8.0500). - Regression test `nsrt/bug-14838.php` (the issue's clone-with reproducer). - Updated existing remember-readonly tests (`bug-12902(.|-non-strict)`, `remember-non-nullable-property-non-strict`, `remember-readonly-constructor-narrowed(.|-hooks)`) to assert the widened types under PHP 8.5 via `PHP_VERSION_ID` branches. --- src/Analyser/MutatingScope.php | 8 +- src/Php/PhpVersions.php | 5 + .../Analyser/nsrt/bug-12902-non-strict.php | 78 ++-- tests/PHPStan/Analyser/nsrt/bug-12902.php | 78 ++-- tests/PHPStan/Analyser/nsrt/bug-14838.php | 31 ++ ...ember-non-nullable-property-non-strict.php | 38 +- ...er-readonly-constructor-narrowed-hooks.php | 74 +++- ...remember-readonly-constructor-narrowed.php | 348 ++++++++++++------ 8 files changed, 482 insertions(+), 178 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14838.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c81d35e98f..8ccf6ea535 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -307,6 +307,12 @@ public function enterDeclareStrictTypes(): self */ private function rememberConstructorExpressions(array $currentExpressionTypes): array { + // Since PHP 8.5, "clone with" can reinitialize any readonly property, so the value + // assigned in the constructor is no longer guaranteed to hold in other methods + // (where $this might be a clone-modified instance). Don't remember readonly + // properties then so they widen back to their declared type. + $cloneWithCanReinitializeReadonly = !$this->getPhpVersion()->supportsCloneWith()->no(); + $expressionTypes = []; foreach ($currentExpressionTypes as $exprString => $expressionTypeHolder) { $expr = $expressionTypeHolder->getExpr(); @@ -319,7 +325,7 @@ private function rememberConstructorExpressions(array $currentExpressionTypes): continue; } } elseif ($expr instanceof PropertyFetch) { - if (!$this->isReadonlyPropertyFetch($expr, true)) { + if ($cloneWithCanReinitializeReadonly || !$this->isReadonlyPropertyFetch($expr, true)) { continue; } } elseif (!$expr instanceof ConstFetch && !$expr instanceof PropertyInitializationExpr) { diff --git a/src/Php/PhpVersions.php b/src/Php/PhpVersions.php index a6849c0584..a13236b6a5 100644 --- a/src/Php/PhpVersions.php +++ b/src/Php/PhpVersions.php @@ -61,4 +61,9 @@ public function supportsMaxMemoryLimit(): TrinaryLogic return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result; } + public function supportsCloneWith(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12902-non-strict.php b/tests/PHPStan/Analyser/nsrt/bug-12902-non-strict.php index 33f8a11e26..0fe5b62564 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12902-non-strict.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12902-non-strict.php @@ -7,35 +7,71 @@ use function PHPStan\Testing\assertNativeType; use function PHPStan\Testing\assertType; -class NarrowsNativeConstantValue -{ - private readonly int|float $i; - - public function __construct() +// Since PHP 8.5, "clone with" can reinitialize any readonly property, so the value +// assigned in the constructor is no longer remembered in other methods. +if (PHP_VERSION_ID >= 80500) { + class NarrowsNativeConstantValueCloneWith { - $this->i = 1; + private readonly int|float $i; + + public function __construct() + { + $this->i = 1; + } + + public function doFoo(): void + { + assertType('float|int', $this->i); + assertNativeType('float|int', $this->i); + } } - public function doFoo(): void - { - assertType('1', $this->i); - assertNativeType('1', $this->i); - } -} + class NarrowsNativeReadonlyUnionCloneWith { + private readonly int|float $i; -class NarrowsNativeReadonlyUnion { - private readonly int|float $i; + public function __construct() + { + $this->i = getInt(); + assertType('int', $this->i); + assertNativeType('int', $this->i); + } - public function __construct() + public function doFoo(): void { + assertType('float|int', $this->i); + assertNativeType('float|int', $this->i); + } + } +} else { + class NarrowsNativeConstantValue { - $this->i = getInt(); - assertType('int', $this->i); - assertNativeType('int', $this->i); + private readonly int|float $i; + + public function __construct() + { + $this->i = 1; + } + + public function doFoo(): void + { + assertType('1', $this->i); + assertNativeType('1', $this->i); + } } - public function doFoo(): void { - assertType('int', $this->i); - assertNativeType('int', $this->i); + class NarrowsNativeReadonlyUnion { + private readonly int|float $i; + + public function __construct() + { + $this->i = getInt(); + assertType('int', $this->i); + assertNativeType('int', $this->i); + } + + public function doFoo(): void { + assertType('int', $this->i); + assertNativeType('int', $this->i); + } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12902.php b/tests/PHPStan/Analyser/nsrt/bug-12902.php index 2330c0c130..3ac0914a45 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12902.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12902.php @@ -7,35 +7,71 @@ use function PHPStan\Testing\assertNativeType; use function PHPStan\Testing\assertType; -class NarrowsNativeConstantValue -{ - private readonly int|float $i; - - public function __construct() +// Since PHP 8.5, "clone with" can reinitialize any readonly property, so the value +// assigned in the constructor is no longer remembered in other methods. +if (PHP_VERSION_ID >= 80500) { + class NarrowsNativeConstantValueCloneWith { - $this->i = 1; + private readonly int|float $i; + + public function __construct() + { + $this->i = 1; + } + + public function doFoo(): void + { + assertType('float|int', $this->i); + assertNativeType('float|int', $this->i); + } } - public function doFoo(): void - { - assertType('1', $this->i); - assertNativeType('1', $this->i); - } -} + class NarrowsNativeReadonlyUnionCloneWith { + private readonly int|float $i; -class NarrowsNativeReadonlyUnion { - private readonly int|float $i; + public function __construct() + { + $this->i = getInt(); + assertType('int', $this->i); + assertNativeType('int', $this->i); + } - public function __construct() + public function doFoo(): void { + assertType('float|int', $this->i); + assertNativeType('float|int', $this->i); + } + } +} else { + class NarrowsNativeConstantValue { - $this->i = getInt(); - assertType('int', $this->i); - assertNativeType('int', $this->i); + private readonly int|float $i; + + public function __construct() + { + $this->i = 1; + } + + public function doFoo(): void + { + assertType('1', $this->i); + assertNativeType('1', $this->i); + } } - public function doFoo(): void { - assertType('int', $this->i); - assertNativeType('int', $this->i); + class NarrowsNativeReadonlyUnion { + private readonly int|float $i; + + public function __construct() + { + $this->i = getInt(); + assertType('int', $this->i); + assertNativeType('int', $this->i); + } + + public function doFoo(): void { + assertType('int', $this->i); + assertNativeType('int', $this->i); + } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14838.php b/tests/PHPStan/Analyser/nsrt/bug-14838.php new file mode 100644 index 0000000000..2c14db0de5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14838.php @@ -0,0 +1,31 @@ += 8.5 + +namespace Bug14838; + +use function PHPStan\Testing\assertType; + +readonly class CoffeeBreak +{ + public ?int $durationInMinutes; + + public function __construct( + public string $name, + ) { + $this->durationInMinutes = null; + } + + public function setDuration(): self + { + return clone($this, [ + 'durationInMinutes' => 15, + ]); + } + + public function hasDuration(): bool + { + // "clone with" may have reinitialized the readonly property, so the value + // assigned in the constructor is no longer guaranteed here. + assertType('int|null', $this->durationInMinutes); + return $this->durationInMinutes !== null; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/remember-non-nullable-property-non-strict.php b/tests/PHPStan/Analyser/nsrt/remember-non-nullable-property-non-strict.php index ed949a846f..f660028d5d 100644 --- a/tests/PHPStan/Analyser/nsrt/remember-non-nullable-property-non-strict.php +++ b/tests/PHPStan/Analyser/nsrt/remember-non-nullable-property-non-strict.php @@ -69,17 +69,35 @@ function getIntOrFloatOrNull(): null|int|float { return 1; } -class NarrowsNativeUnion { - private readonly int|float $i; - - public function __construct() - { - $this->i = getInt(); +// Since PHP 8.5, "clone with" can reinitialize any readonly property, so the value +// assigned in the constructor is no longer remembered in other methods. +if (PHP_VERSION_ID >= 80500) { + class NarrowsNativeUnionCloneWith { + private readonly int|float $i; + + public function __construct() + { + $this->i = getInt(); + } + + public function doFoo(): void { + assertType('float|int', $this->i); + assertNativeType('float|int', $this->i); + } } - - public function doFoo(): void { - assertType('int', $this->i); - assertNativeType('int', $this->i); +} else { + class NarrowsNativeUnion { + private readonly int|float $i; + + public function __construct() + { + $this->i = getInt(); + } + + public function doFoo(): void { + assertType('int', $this->i); + assertNativeType('int', $this->i); + } } } diff --git a/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed-hooks.php b/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed-hooks.php index 8f03858767..d20d47291e 100644 --- a/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed-hooks.php +++ b/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed-hooks.php @@ -4,32 +4,66 @@ use function PHPStan\Testing\assertType; -class User -{ - public string $name { - get { - assertType('1|2', $this->type); - return $this->name ; +if (PHP_VERSION_ID >= 80500) { + // Since PHP 8.5, "clone with" can reinitialize any readonly property, so the + // value assigned in the constructor is no longer remembered in other scopes. + class UserCloneWith + { + public string $name { + get { + assertType('int', $this->type); + return $this->name ; + } + set { + if (strlen($value) === 0) { + throw new ValueError("Name must be non-empty"); + } + assertType('int', $this->type); + $this->name = $value; + } } - set { - if (strlen($value) === 0) { - throw new ValueError("Name must be non-empty"); + + private readonly int $type; + + public function __construct( + string $name + ) { + $this->name = $name; + if (rand(0,1)) { + $this->type = 1; + } else { + $this->type = 2; } - assertType('1|2', $this->type); - $this->name = $value; } } +} else { + class User + { + public string $name { + get { + assertType('1|2', $this->type); + return $this->name ; + } + set { + if (strlen($value) === 0) { + throw new ValueError("Name must be non-empty"); + } + assertType('1|2', $this->type); + $this->name = $value; + } + } - private readonly int $type; + private readonly int $type; - public function __construct( - string $name - ) { - $this->name = $name; - if (rand(0,1)) { - $this->type = 1; - } else { - $this->type = 2; + public function __construct( + string $name + ) { + $this->name = $name; + if (rand(0,1)) { + $this->type = 1; + } else { + $this->type = 2; + } } } } diff --git a/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed.php b/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed.php index ab12c2b2cb..3dedf0d5a8 100644 --- a/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed.php +++ b/tests/PHPStan/Analyser/nsrt/remember-readonly-constructor-narrowed.php @@ -5,8 +5,8 @@ use LogicException; use function PHPStan\Testing\assertType; -class HelloWorldReadonlyProperty { - private readonly int $i; +class HelloWorldRegular { + private int $i; public function __construct() { @@ -18,153 +18,291 @@ public function __construct() } public function doFoo() { - assertType('4|10', $this->i); + assertType('int', $this->i); } } -readonly class HelloWorldReadonlyClass { - private int $i; - private string $class; - private string $interface; - private string $enum; - private string $trait; +class Foo { + public readonly int $readonly; + public int $writable; - public function __construct(string $class, string $interface, string $enum, string $trait) + public function __construct() { - if (rand(0,1)) { - $this->i = 4; - } else { - $this->i = 10; - } + $this->readonly = 5; + $this->writable = rand(0,1) ? 5 : 10; + } +} - if (!class_exists($class)) { - throw new \LogicException(); +// Since PHP 8.5, "clone with" can reinitialize any readonly property, so the value +// assigned in the constructor is no longer remembered in other methods. +if (PHP_VERSION_ID >= 80500) { + class HelloWorldReadonlyPropertyCloneWith { + private readonly int $i; + + public function __construct() + { + if (rand(0,1)) { + $this->i = 4; + } else { + $this->i = 10; + } } - $this->class = $class; - if (!interface_exists($interface)) { - throw new \LogicException(); + public function doFoo() { + assertType('int', $this->i); } - $this->interface = $interface; + } - if (!enum_exists($enum)) { - throw new \LogicException(); + readonly class HelloWorldReadonlyClassCloneWith { + private int $i; + private string $class; + private string $interface; + private string $enum; + private string $trait; + + public function __construct(string $class, string $interface, string $enum, string $trait) + { + if (rand(0,1)) { + $this->i = 4; + } else { + $this->i = 10; + } + + if (!class_exists($class)) { + throw new \LogicException(); + } + $this->class = $class; + + if (!interface_exists($interface)) { + throw new \LogicException(); + } + $this->interface = $interface; + + if (!enum_exists($enum)) { + throw new \LogicException(); + } + $this->enum = $enum; + + if (!trait_exists($trait)) { + throw new \LogicException(); + } + $this->trait = $trait; } - $this->enum = $enum; - if (!trait_exists($trait)) { - throw new \LogicException(); + public function doFoo() { + assertType('int', $this->i); + assertType('string', $this->class); + assertType('string', $this->interface); + assertType('string', $this->enum); + assertType('string', $this->trait); } - $this->trait = $trait; } - public function doFoo() { - assertType('4|10', $this->i); - assertType('class-string', $this->class); - assertType('class-string', $this->interface); - assertType('class-string', $this->enum); - assertType('class-string', $this->trait); - } -} + class HelloWorldReadonlyPropertySometimesThrowingCloneWith { + private readonly int $i; + public function __construct() + { + if (rand(0,1)) { + $this->i = 4; -class HelloWorldRegular { - private int $i; + return; + } elseif (rand(10,100)) { + $this->i = 10; + return; + } else { + $this->i = 20; + } - public function __construct() - { - if (rand(0,1)) { - $this->i = 4; - } else { - $this->i = 10; + throw new \LogicException(); } - } - public function doFoo() { - assertType('int', $this->i); + public function doFoo() { + assertType('int', $this->i); + } } -} -class HelloWorldReadonlyPropertySometimesThrowing { - private readonly int $i; + class DeepPropertyFetchingCloneWith { + public readonly ?Foo $prop; + + public function __construct() { + $this->prop = new Foo(); + if($this->prop->readonly != 5) { + throw new LogicException(); + } + if ($this->prop->writable != 5) { + throw new LogicException(); + } + + assertType(Foo::class, $this->prop); + assertType('5', $this->prop->readonly); + assertType('5', $this->prop->writable); + } - public function __construct() - { - if (rand(0,1)) { - $this->i = 4; + public function doFoo() { + assertType(Foo::class . '|null', $this->prop); + assertType('int', $this->prop->readonly); + assertType('int', $this->prop->writable); + } + } - return; - } elseif (rand(10,100)) { - $this->i = 10; - return; - } else { - $this->i = 20; + class HelloWorldReadonlyPropertyInClosureScopeCloneWith { + private readonly int $i; + + public function __construct() + { + if (rand(0,1)) { + $this->i = 4; + } else { + $this->i = 10; + } } - throw new \LogicException(); + public function doFoo() { + assertType('int', $this->i); + + (function() { + assertType('int', $this->i); + })(); + + $func = function() { + assertType('int', $this->i); + }; + } } +} else { + class HelloWorldReadonlyProperty { + private readonly int $i; + + public function __construct() + { + if (rand(0,1)) { + $this->i = 4; + } else { + $this->i = 10; + } + } - public function doFoo() { - assertType('4|10', $this->i); + public function doFoo() { + assertType('4|10', $this->i); + } } -} -class Foo { - public readonly int $readonly; - public int $writable; + readonly class HelloWorldReadonlyClass { + private int $i; + private string $class; + private string $interface; + private string $enum; + private string $trait; + + public function __construct(string $class, string $interface, string $enum, string $trait) + { + if (rand(0,1)) { + $this->i = 4; + } else { + $this->i = 10; + } + + if (!class_exists($class)) { + throw new \LogicException(); + } + $this->class = $class; + + if (!interface_exists($interface)) { + throw new \LogicException(); + } + $this->interface = $interface; + + if (!enum_exists($enum)) { + throw new \LogicException(); + } + $this->enum = $enum; + + if (!trait_exists($trait)) { + throw new \LogicException(); + } + $this->trait = $trait; + } - public function __construct() - { - $this->readonly = 5; - $this->writable = rand(0,1) ? 5 : 10; + public function doFoo() { + assertType('4|10', $this->i); + assertType('class-string', $this->class); + assertType('class-string', $this->interface); + assertType('class-string', $this->enum); + assertType('class-string', $this->trait); + } } -} -class DeepPropertyFetching { - public readonly ?Foo $prop; + class HelloWorldReadonlyPropertySometimesThrowing { + private readonly int $i; - public function __construct() { - $this->prop = new Foo(); - if($this->prop->readonly != 5) { - throw new LogicException(); - } - if ($this->prop->writable != 5) { - throw new LogicException(); - } + public function __construct() + { + if (rand(0,1)) { + $this->i = 4; - assertType(Foo::class, $this->prop); - assertType('5', $this->prop->readonly); - assertType('5', $this->prop->writable); - } + return; + } elseif (rand(10,100)) { + $this->i = 10; + return; + } else { + $this->i = 20; + } - public function doFoo() { - assertType(Foo::class, $this->prop); - assertType('5', $this->prop->readonly); - assertType('int', $this->prop->writable); + throw new \LogicException(); + } + + public function doFoo() { + assertType('4|10', $this->i); + } } -} -class HelloWorldReadonlyPropertyInClosureScope { - private readonly int $i; + class DeepPropertyFetching { + public readonly ?Foo $prop; + + public function __construct() { + $this->prop = new Foo(); + if($this->prop->readonly != 5) { + throw new LogicException(); + } + if ($this->prop->writable != 5) { + throw new LogicException(); + } + + assertType(Foo::class, $this->prop); + assertType('5', $this->prop->readonly); + assertType('5', $this->prop->writable); + } - public function __construct() - { - if (rand(0,1)) { - $this->i = 4; - } else { - $this->i = 10; + public function doFoo() { + assertType(Foo::class, $this->prop); + assertType('5', $this->prop->readonly); + assertType('int', $this->prop->writable); } } - public function doFoo() { - assertType('4|10', $this->i); + class HelloWorldReadonlyPropertyInClosureScope { + private readonly int $i; - (function() { - assertType('4|10', $this->i); - })(); + public function __construct() + { + if (rand(0,1)) { + $this->i = 4; + } else { + $this->i = 10; + } + } - $func = function() { + public function doFoo() { assertType('4|10', $this->i); - }; + + (function() { + assertType('4|10', $this->i); + })(); + + $func = function() { + assertType('4|10', $this->i); + }; + } } } From 93121cba17fdac5abeecaf7207cd3e560a50d2bb Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 18 Jun 2026 07:41:12 +0000 Subject: [PATCH 2/2] Keep readonly constructor types for final classes and enums that cannot use `clone with` Only widen a readonly property's constructor-narrowed type on PHP 8.5 when the class can actually be affected by `clone with`. Enums cannot be cloned, and a final class that contains no `clone with` expression cannot reinitialize its own readonly properties, so both keep their narrowed types. Other classes stay conservative. On PHP 8.5 only readonly properties declared in the current class are remembered, since inherited ones might be reinitialized by a parent's `clone with`. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/MutatingScope.php | 50 ++++++++-- src/Analyser/NodeScopeResolver.php | 49 +++++++++- ...member-readonly-clone-with-class-aware.php | 94 +++++++++++++++++++ 3 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/remember-readonly-clone-with-class-aware.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8ccf6ea535..5c73886992 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -305,13 +305,16 @@ public function enterDeclareStrictTypes(): self * @param array $currentExpressionTypes * @return array */ - private function rememberConstructorExpressions(array $currentExpressionTypes): array + private function rememberConstructorExpressions(array $currentExpressionTypes, bool $classReadonlyPropertiesCanBeReinitializedByCloneWith): array { - // Since PHP 8.5, "clone with" can reinitialize any readonly property, so the value + // Since PHP 8.5, "clone with" can reinitialize a readonly property, so the value // assigned in the constructor is no longer guaranteed to hold in other methods - // (where $this might be a clone-modified instance). Don't remember readonly - // properties then so they widen back to their declared type. - $cloneWithCanReinitializeReadonly = !$this->getPhpVersion()->supportsCloneWith()->no(); + // (where $this might be a clone-modified instance). $classReadonlyPropertiesCanBeReinitializedByCloneWith + // tells us whether this concerns the current class - it stays false for classes + // that provably cannot be affected by "clone with" (e.g. enums, or final classes + // without any "clone with" expression), so they keep their narrowed readonly types. + $cloneWithAvailable = !$this->getPhpVersion()->supportsCloneWith()->no(); + $cloneWithCanReinitializeReadonly = $cloneWithAvailable && $classReadonlyPropertiesCanBeReinitializedByCloneWith; $expressionTypes = []; foreach ($currentExpressionTypes as $exprString => $expressionTypeHolder) { @@ -328,6 +331,13 @@ private function rememberConstructorExpressions(array $currentExpressionTypes): if ($cloneWithCanReinitializeReadonly || !$this->isReadonlyPropertyFetch($expr, true)) { continue; } + // On PHP 8.5, even a class whose own readonly properties are safe from "clone with" + // may inherit readonly properties from a parent class that does use it (the parent's + // body is not scanned here), so only remember readonly properties declared in the + // current class. + if ($cloneWithAvailable && !$this->isReadonlyPropertyFetchDeclaredInCurrentClass($expr)) { + continue; + } } elseif (!$expr instanceof ConstFetch && !$expr instanceof PropertyInitializationExpr) { continue; } @@ -342,15 +352,15 @@ private function rememberConstructorExpressions(array $currentExpressionTypes): return $expressionTypes; } - public function rememberConstructorScope(): self + public function rememberConstructorScope(bool $classReadonlyPropertiesCanBeReinitializedByCloneWith): self { return $this->scopeFactory->create( $this->context, $this->isDeclareStrictTypes(), null, $this->getNamespace(), - $this->rememberConstructorExpressions($this->expressionTypes), - $this->rememberConstructorExpressions($this->nativeExpressionTypes), + $this->rememberConstructorExpressions($this->expressionTypes, $classReadonlyPropertiesCanBeReinitializedByCloneWith), + $this->rememberConstructorExpressions($this->nativeExpressionTypes, $classReadonlyPropertiesCanBeReinitializedByCloneWith), $this->conditionalExpressions, $this->inClosureBindScopeClasses, $this->anonymousFunctionReflection, @@ -402,6 +412,30 @@ private function isReadonlyPropertyFetch(PropertyFetch $expr, bool $allowOnlyOnT return true; } + private function isReadonlyPropertyFetchDeclaredInCurrentClass(PropertyFetch $expr): bool + { + if ( + !$expr->var instanceof Variable + || !is_string($expr->var->name) + || $expr->var->name !== 'this' + || !$expr->name instanceof Node\Identifier + ) { + return false; + } + + $classReflection = $this->getClassReflection(); + if ($classReflection === null) { + return false; + } + + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this); + if ($propertyReflection === null) { + return false; + } + + return $propertyReflection->getDeclaringClass()->getName() === $classReflection->getName(); + } + /** @api */ public function isInClass(): bool { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3f63434d03..2122f7efc1 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -206,6 +206,9 @@ class NodeScopeResolver /** @var array */ private array $calledMethodResults = []; + /** @var array className(string) => whether its readonly properties can be reinitialized via PHP 8.5 "clone with" */ + private array $classReadonlyPropertiesCloneWithReinitialization = []; + /** * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) * @param array $earlyTerminatingFunctionCalls @@ -1041,7 +1044,9 @@ public function processStmtNode( } if ($finalScope !== null) { - $scope = $finalScope->rememberConstructorScope(); + $scope = $finalScope->rememberConstructorScope( + $this->classReadonlyPropertiesCloneWithReinitialization[$classReflection->getName()] ?? true, + ); } } @@ -1197,6 +1202,8 @@ public function processStmtNode( throw new ShouldNotHappenException(); } + $this->classReadonlyPropertiesCloneWithReinitialization[$classReflection->getName()] = $this->classReadonlyPropertiesCanBeReinitializedByCloneWith($classReflection, $stmt); + $classStatementsGatherer = new ClassStatementsGatherer($classReflection, $nodeCallback); $this->processAttributeGroups($stmt, $stmt->attrGroups, $classScope, $storage, $classStatementsGatherer); @@ -2623,6 +2630,46 @@ private function getCurrentClassReflection(Node\Stmt\ClassLike $stmt, string $cl return $defaultClassReflection; } + /** + * Whether the readonly properties of the given class could be reinitialized via the PHP 8.5 + * "clone with" syntax, which would make their constructor-narrowed types unsound in other methods. + * + * A readonly property can only be reinitialized by "clone with" from within its declaring class + * scope, so a final class that contains no "clone with" expression keeps the narrowed types of its + * own readonly properties. Enums are never affected because they cannot be cloned. Everything else + * is treated conservatively. + */ + private function classReadonlyPropertiesCanBeReinitializedByCloneWith(ClassReflection $classReflection, Node\Stmt\ClassLike $classNode): bool + { + // enums cannot be cloned, so "clone with" can never reinitialize their properties + if ($classReflection->isEnum()) { + return false; + } + + // for non-final classes a subclass we cannot see might introduce "clone with", so stay conservative. + // isFinalByKeyword() is used on purpose: it does not resolve the class PHPDoc (which would happen + // too early here and break lazy generics resolution), and "clone with" precision only matters for + // classes that are final at the language level anyway. + if (!$classReflection->isFinalByKeyword()) { + return true; + } + + // trait bodies are not part of $classNode, so we cannot rule out a "clone with" in there + if (count($classNode->getTraitUses()) > 0) { + return true; + } + + $cloneWithCall = (new NodeFinder())->findFirst( + $classNode->stmts, + static fn (Node $node): bool => $node instanceof FuncCall + && $node->name instanceof Name + && $node->name->toLowerString() === 'clone' + && count($node->getArgs()) === 2, + ); + + return $cloneWithCall !== null; + } + private function createAstClassReflection(Node\Stmt\ClassLike $stmt, string $className, Scope $scope): ClassReflection { $nodeToReflection = new NodeToReflection(); diff --git a/tests/PHPStan/Analyser/nsrt/remember-readonly-clone-with-class-aware.php b/tests/PHPStan/Analyser/nsrt/remember-readonly-clone-with-class-aware.php new file mode 100644 index 0000000000..14b9e2d200 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/remember-readonly-clone-with-class-aware.php @@ -0,0 +1,94 @@ += 8.5 + +namespace RememberReadonlyCloneWithClassAware; + +use function PHPStan\Testing\assertType; + +// A final class that contains no "clone with" expression cannot have its own readonly +// properties reinitialized, so the constructor-narrowed types are kept. +final class FinalWithoutCloneWith +{ + private readonly int $i; + + public function __construct() + { + if (rand(0, 1)) { + $this->i = 4; + } else { + $this->i = 10; + } + } + + public function doFoo(): void + { + assertType('4|10', $this->i); + } +} + +// A final class that uses "clone with" can reinitialize its readonly properties, so the +// narrowed types are widened back to the declared type. +final class FinalWithCloneWith +{ + private readonly int $i; + + public function __construct() + { + if (rand(0, 1)) { + $this->i = 4; + } else { + $this->i = 10; + } + } + + public function withI(int $i): self + { + return clone($this, ['i' => $i]); + } + + public function doFoo(): void + { + assertType('int', $this->i); + } +} + +// A non-final class might be extended by a subclass we cannot see, so stay conservative +// and widen the readonly property. +class NonFinal +{ + private readonly int $i; + + public function __construct() + { + if (rand(0, 1)) { + $this->i = 4; + } else { + $this->i = 10; + } + } + + public function doFoo(): void + { + assertType('int', $this->i); + } +} + +final class FinalSubclass extends NonFinal +{ + private readonly int $j; + + public function __construct() + { + parent::__construct(); + if (rand(0, 1)) { + $this->j = 4; + } else { + $this->j = 10; + } + } + + public function doFoo(): void + { + // own readonly property is remembered + assertType('4|10', $this->j); + } +}