diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c81d35e98f..5c73886992 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -305,8 +305,17 @@ 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 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). $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) { $expr = $expressionTypeHolder->getExpr(); @@ -319,7 +328,14 @@ private function rememberConstructorExpressions(array $currentExpressionTypes): continue; } } elseif ($expr instanceof PropertyFetch) { - if (!$this->isReadonlyPropertyFetch($expr, true)) { + 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) { @@ -336,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, @@ -396,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/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-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); + } +} 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); + }; + } } }