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
50 changes: 45 additions & 5 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,17 @@ public function enterDeclareStrictTypes(): self
* @param array<string, ExpressionTypeHolder> $currentExpressionTypes
* @return array<string, ExpressionTypeHolder>
*/
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();
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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
{
Expand Down
49 changes: 48 additions & 1 deletion src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ class NodeScopeResolver
/** @var array<string, MutatingScope|null> */
private array $calledMethodResults = [];

/** @var array<string, bool> 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<int, string> $earlyTerminatingFunctionCalls
Expand Down Expand Up @@ -1041,7 +1044,9 @@ public function processStmtNode(
}

if ($finalScope !== null) {
$scope = $finalScope->rememberConstructorScope();
$scope = $finalScope->rememberConstructorScope(
$this->classReadonlyPropertiesCloneWithReinitialization[$classReflection->getName()] ?? true,
);
}

}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/Php/PhpVersions.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,9 @@
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function supportsCloneWith(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;

Check warning on line 66 in src/Php/PhpVersions.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ public function supportsCloneWith(): TrinaryLogic { - return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result; + return $this->phpVersions->isSuperTypeOf(IntegerRangeType::fromInterval(80500, null))->result; } }

Check warning on line 66 in src/Php/PhpVersions.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ public function supportsCloneWith(): TrinaryLogic { - return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result; + return $this->phpVersions->isSuperTypeOf(IntegerRangeType::fromInterval(80500, null))->result; } }
}

}
78 changes: 57 additions & 21 deletions tests/PHPStan/Analyser/nsrt/bug-12902-non-strict.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
78 changes: 57 additions & 21 deletions tests/PHPStan/Analyser/nsrt/bug-12902.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
31 changes: 31 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14838.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php // lint >= 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;
}
}
Loading
Loading