From c3912ea2a3bdb2f40276ad7caf2b6e65aa96f1d3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 19 Sep 2026 01:30:14 +0800 Subject: [PATCH] =?UTF-8?q?fix=20bug=20on=20A=20promoted=20readonly=20para?= =?UTF-8?q?meter=20with=20an=20iterable=20or=20Closure(=E2=80=A6)=20doc?= =?UTF-8?q?block=20fails=20with=20"Cannot=20modify=20readonly=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Internal/Ast/ContractVisitor.php | 3 + src/Internal/Ast/FunctionContractInjector.php | 18 +++-- tests/Fixtures/Forwarding/AbstractBox.php | 2 +- tests/Fixtures/Forwarding/BoxConsumer.php | 2 +- tests/Fixtures/Forwarding/BoxInterface.php | 2 +- tests/Fixtures/Forwarding/ConcreteBox.php | 2 +- tests/Fixtures/Forwarding/ItemA.php | 2 +- tests/Fixtures/Forwarding/ItemB.php | 2 +- tests/Fixtures/Forwarding/ItemBase.php | 2 +- .../PromotedReadonlyPropertyTest.php | 77 +++++++++++++++++++ .../GenericForwardingInheritanceTest.php | 2 +- 11 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 tests/TypeChecking/Boundaries/PromotedReadonlyPropertyTest.php diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index b4a5531..bc7874d 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -191,11 +191,13 @@ private function enterClassLike(Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\ $typeName = $this->resolveQualifiedName($node->name); $hasInheritance = true; $hasPropertyWithDoc = false; + $isReadonly = false; if ($node instanceof Node\Stmt\Class_) { $hasExtends = $node->extends !== null; $hasImplements = $node->implements !== []; $hasTraits = false; + $isReadonly = ($node->flags & Node\Stmt\Class_::MODIFIER_READONLY) !== 0; foreach ($node->stmts as $stmt) { if ($stmt instanceof Node\Stmt\TraitUse) { @@ -222,6 +224,7 @@ private function enterClassLike(Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\ 'isAnonymous' => ($node instanceof Node\Stmt\Class_ && $node->name === null), 'hasInheritance' => $hasInheritance, 'hasPropertyWithDoc' => $hasPropertyWithDoc, + 'isReadonly' => $isReadonly, ]; } diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index dff14da..7a04eac 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -42,6 +42,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $hasInheritance = $classContext['hasInheritance'] ?? true; $hasPropertyWithDoc = $classContext['hasPropertyWithDoc'] ?? true; + $isReadonlyClass = $classContext['isReadonly'] ?? false; $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; $isConstructor = $isClassMethod && $methodName === '__construct'; @@ -100,7 +101,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $injectedStmts = []; if ($hasParam) { - $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg); + $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg, $isReadonlyClass); } if ($hasReturn || $hasParamOut || $hasSelfOut) { @@ -322,11 +323,12 @@ public function enterNode(Node $n): ?int private static function buildParamInjections( array $params, string $docText, - Node\Expr $thisArg + Node\Expr $thisArg, + bool $isReadonlyClass = false ): array { $injectedStmts = [self::buildSetupScopeStmt($params, $thisArg)]; - $callableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isCallableCandidate'], 'wrapCallable'); - $iterableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isIterableCandidate'], 'wrapIterable'); + $callableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isCallableCandidate'], 'wrapCallable', $isReadonlyClass); + $iterableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isIterableCandidate'], 'wrapIterable', $isReadonlyClass); return [...$injectedStmts, ...$callableWrappers, ...$iterableWrappers]; } @@ -457,7 +459,8 @@ private static function buildParamWrappers( string $docText, Node\Expr $thisArg, callable $predicate, - string $wrapperMethod + string $wrapperMethod, + bool $isReadonlyClass = false ): array { $wrappers = []; @@ -481,7 +484,10 @@ private static function buildParamWrappers( $expr->setAttribute('typephp_injected', true); $wrappers[] = $expr; - if ($param->isPromoted()) { + $isReadonlyParam = ($param->flags & Node\Stmt\Class_::MODIFIER_READONLY) !== 0; + $isReadonly = $isReadonlyParam || $isReadonlyClass; + + if ($param->isPromoted() && ! $isReadonly) { $propAssign = new Node\Stmt\Expression( new Node\Expr\Assign( new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $paramName), diff --git a/tests/Fixtures/Forwarding/AbstractBox.php b/tests/Fixtures/Forwarding/AbstractBox.php index 9bb6f8f..922d196 100644 --- a/tests/Fixtures/Forwarding/AbstractBox.php +++ b/tests/Fixtures/Forwarding/AbstractBox.php @@ -15,4 +15,4 @@ */ abstract class AbstractBox implements BoxInterface { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Forwarding/BoxConsumer.php b/tests/Fixtures/Forwarding/BoxConsumer.php index 44e7171..72e74ca 100644 --- a/tests/Fixtures/Forwarding/BoxConsumer.php +++ b/tests/Fixtures/Forwarding/BoxConsumer.php @@ -69,4 +69,4 @@ public function passThrough(BoxInterface $box): BoxInterface { return $box; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Forwarding/BoxInterface.php b/tests/Fixtures/Forwarding/BoxInterface.php index ba67c3d..bed8688 100644 --- a/tests/Fixtures/Forwarding/BoxInterface.php +++ b/tests/Fixtures/Forwarding/BoxInterface.php @@ -10,4 +10,4 @@ */ interface BoxInterface { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Forwarding/ConcreteBox.php b/tests/Fixtures/Forwarding/ConcreteBox.php index dbd42b7..51f200a 100644 --- a/tests/Fixtures/Forwarding/ConcreteBox.php +++ b/tests/Fixtures/Forwarding/ConcreteBox.php @@ -9,4 +9,4 @@ */ final class ConcreteBox extends AbstractBox { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Forwarding/ItemA.php b/tests/Fixtures/Forwarding/ItemA.php index 03be03a..20f9e49 100644 --- a/tests/Fixtures/Forwarding/ItemA.php +++ b/tests/Fixtures/Forwarding/ItemA.php @@ -9,4 +9,4 @@ final class ItemA implements ItemBase public function __construct(public string $name = 'A') { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Forwarding/ItemB.php b/tests/Fixtures/Forwarding/ItemB.php index e72ad71..1cd71da 100644 --- a/tests/Fixtures/Forwarding/ItemB.php +++ b/tests/Fixtures/Forwarding/ItemB.php @@ -9,4 +9,4 @@ final class ItemB implements ItemBase public function __construct(public string $name = 'B') { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Forwarding/ItemBase.php b/tests/Fixtures/Forwarding/ItemBase.php index 5869148..516d986 100644 --- a/tests/Fixtures/Forwarding/ItemBase.php +++ b/tests/Fixtures/Forwarding/ItemBase.php @@ -6,4 +6,4 @@ interface ItemBase { -} \ No newline at end of file +} diff --git a/tests/TypeChecking/Boundaries/PromotedReadonlyPropertyTest.php b/tests/TypeChecking/Boundaries/PromotedReadonlyPropertyTest.php new file mode 100644 index 0000000..87e1413 --- /dev/null +++ b/tests/TypeChecking/Boundaries/PromotedReadonlyPropertyTest.php @@ -0,0 +1,77 @@ + $adapters + */ + public function __construct(public readonly iterable $adapters) + { + } +} + +final class PromotedReadonlyClosure +{ + /** + * @param Closure(int): int $factory + */ + public function __construct(public readonly Closure $factory) + { + } +} + +readonly class ReadonlyClassWithPromotedProperty +{ + /** + * @param iterable $adapters + */ + public function __construct(public iterable $adapters) + { + } +} + +describe('Promoted Readonly Property Contracts', function () { + test('instantiates class with promoted readonly iterable without throwing Cannot modify readonly property error', function () { + $instance = new PromotedReadonlyIterable([new ReadonlyAdapterA()]); + + expect($instance->adapters)->toHaveCount(1); + }); + + test('instantiates class with promoted readonly closure without throwing Cannot modify readonly property error', function () { + $fn = static fn (int $i): int => $i + 1; + $instance = new PromotedReadonlyClosure($fn); + + expect($instance->factory)->toBe($fn); + }); + + test('instantiates PHP 8.2 readonly class with promoted property without throwing Cannot modify readonly property error', function () { + $instance = new ReadonlyClassWithPromotedProperty([new ReadonlyAdapterA()]); + + expect($instance->adapters)->toHaveCount(1); + }); + + test('still validates incoming constructor arguments on promoted readonly properties upon entry', function () { + expect(fn () => new PromotedReadonlyIterable([new ReadonlyAdapterInvalid()])) + ->toThrow(TypeError::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php b/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php index a4534ce..42e3f52 100644 --- a/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php +++ b/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php @@ -64,4 +64,4 @@ expect($result)->toBe($concreteBox); }); }); -}); \ No newline at end of file +});