From b046c7bebfc2973aac403ad90cc44fb941abd05e Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 13:20:38 +0200 Subject: [PATCH 1/2] fix(preprocessor): enforce Zend readonly property declaration rules The readonly checks previously lived only in the Native-class branch; ZendVM-backed classes accepted declarations Zend rejects at compile time. addClassProperty now enforces, for declared and promoted properties alike (probed against Zend 8.4.13): - readonly property with a default value ("Readonly property A::$x cannot have default value") - a readonly property carries runtime initialization state, so a compile-time default is meaningless - untyped readonly property, including untyped promoted readonly ctor params ("Readonly property A::$x must have type") - static readonly ("Static property A::$x cannot be readonly") - a `readonly class` applies the same three rules to every property: the class-level Modifiers::READONLY flag (already recorded on ClassDef->flags for the Translator-side inheritance check) is OR-ed into the per-property check Promoted readonly params keep accepting parameter defaults: the default belongs to the constructor argument, not the property (Zend-verified). The inheritance_error_prop_readonly fixture used `readonly int $x = 2`, which Zend itself rejects with the default-value error before ever reaching the readonly-mismatch link error; the default is dropped so the fixture still exercises the inheritance mismatch. --- phpunit/code/inheritance_error_prop_readonly.php | 2 +- src/Preprocessor.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/phpunit/code/inheritance_error_prop_readonly.php b/phpunit/code/inheritance_error_prop_readonly.php index f9bca1e0..0e54ffd3 100644 --- a/phpunit/code/inheritance_error_prop_readonly.php +++ b/phpunit/code/inheritance_error_prop_readonly.php @@ -6,7 +6,7 @@ class A class B extends A { - public readonly int $x = 2; + public readonly int $x; } function main() {} diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 18b99a6e..f0c84bf2 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1674,6 +1674,21 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ ); } $flags = $this->parseModifiers($flags); + // A `readonly class` marks every property readonly, so the class-level + // flag participates in the same Zend declaration rules as an explicit + // per-property `readonly` modifier. + if (($flags | $this->classDef->flags) & Modifiers::READONLY) { + $className = $this->classDef->getNamespacedName(false); + if ($flags & Modifiers::STATIC) { + $this->fatalError($errorNode, "Static property `{$className}::\${$name}` cannot be readonly"); + } + if ($typeNode === null) { + $this->fatalError($errorNode, "Readonly property `{$className}::\${$name}` must have type"); + } + if ($defaultNode !== null) { + $this->fatalError($errorNode, "Readonly property `{$className}::\${$name}` cannot have default value"); + } + } $this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode); [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); From f9f6e78af117d09424f7d51814b6b32631f6aec1 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 12:14:53 +0200 Subject: [PATCH 2/2] fix(translator): enforce readonly-class inheritance in both directions Zend seals readonly-ness across a hierarchy: a non-readonly class cannot extend a readonly one and vice versa. Both directions compiled silently. --- phpunit/code/readonly_class_extends.php | 5 ++ phpunit/code/readonly_class_extends_rev.php | 5 ++ phpunit/code/readonly_class_extends_valid.php | 5 ++ phpunit/code/readonly_rule_class_static.php | 4 ++ phpunit/code/readonly_rule_class_untyped.php | 4 ++ phpunit/code/readonly_rule_default.php | 4 ++ .../code/readonly_rule_promoted_untyped.php | 4 ++ phpunit/code/readonly_rule_static.php | 4 ++ phpunit/code/readonly_rule_untyped.php | 4 ++ phpunit/code/readonly_rule_valid.php | 4 ++ phpunit/src/ClassKindInheritanceTest.php | 30 ++++++++++++ phpunit/src/ReadonlyDeclarationRulesTest.php | 46 +++++++++++++++++++ src/Translator.php | 10 ++++ 13 files changed, 129 insertions(+) create mode 100644 phpunit/code/readonly_class_extends.php create mode 100644 phpunit/code/readonly_class_extends_rev.php create mode 100644 phpunit/code/readonly_class_extends_valid.php create mode 100644 phpunit/code/readonly_rule_class_static.php create mode 100644 phpunit/code/readonly_rule_class_untyped.php create mode 100644 phpunit/code/readonly_rule_default.php create mode 100644 phpunit/code/readonly_rule_promoted_untyped.php create mode 100644 phpunit/code/readonly_rule_static.php create mode 100644 phpunit/code/readonly_rule_untyped.php create mode 100644 phpunit/code/readonly_rule_valid.php create mode 100644 phpunit/src/ClassKindInheritanceTest.php create mode 100644 phpunit/src/ReadonlyDeclarationRulesTest.php diff --git a/phpunit/code/readonly_class_extends.php b/phpunit/code/readonly_class_extends.php new file mode 100644 index 00000000..bb9e21bf --- /dev/null +++ b/phpunit/code/readonly_class_extends.php @@ -0,0 +1,5 @@ +port = 80; } } + +function main() {} diff --git a/phpunit/src/ClassKindInheritanceTest.php b/phpunit/src/ClassKindInheritanceTest.php new file mode 100644 index 00000000..86a2d28d --- /dev/null +++ b/phpunit/src/ClassKindInheritanceTest.php @@ -0,0 +1,30 @@ +exec( + 'Non-readonly class `B` cannot extend readonly class `A`', + 'readonly_class_extends.php' + ); + } + + public function testReadonlyCannotExtendNonReadonly(): void + { + $this->exec( + 'Readonly class `B` cannot extend non-readonly class `A`', + 'readonly_class_extends_rev.php' + ); + } + + + public function testReadonlyExtendsReadonlyIsValid(): void + { + $this->compile('readonly_class_extends_valid.php'); + } +} diff --git a/phpunit/src/ReadonlyDeclarationRulesTest.php b/phpunit/src/ReadonlyDeclarationRulesTest.php new file mode 100644 index 00000000..ec851d5a --- /dev/null +++ b/phpunit/src/ReadonlyDeclarationRulesTest.php @@ -0,0 +1,46 @@ +exec('Readonly property `Cfg::$port` cannot have default value', 'readonly_rule_default.php'); + } + + public function testReadonlyPropertyMustHaveType(): void + { + $this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_untyped.php'); + } + + public function testStaticPropertyCannotBeReadonly(): void + { + $this->exec('Static property `Cfg::$port` cannot be readonly', 'readonly_rule_static.php'); + } + + public function testPromotedReadonlyParamMustHaveType(): void + { + $this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_promoted_untyped.php'); + } + + public function testReadonlyClassPropertyMustHaveType(): void + { + $this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_class_untyped.php'); + } + + public function testReadonlyClassCannotDeclareStaticProperty(): void + { + $this->exec('Static property `Cfg::$port` cannot be readonly', 'readonly_rule_class_static.php'); + } + + public function testWellFormedReadonlyDeclarationsStillCompile(): void + { + // Promoted readonly params may keep a parameter default: it belongs + // to the constructor argument, not to the property. + $this->compile('readonly_rule_valid.php'); + } +} diff --git a/src/Translator.php b/src/Translator.php index be6aa21d..91a4a5a6 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -4027,6 +4027,16 @@ protected function parseClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ if ($parent->flags & Modifiers::FINAL) { $this->fatalError($class, "Class `{$this->class}` cannot extend final class `{$parentClass}`"); } + // Readonly-ness is part of the inheritance contract in both + // directions (Zend: a readonly class seals its property + // semantics for the whole hierarchy). + $childReadonly = (bool) ($this->classDef->flags & Modifiers::READONLY); + $parentReadonly = (bool) ($parent->flags & Modifiers::READONLY); + if ($childReadonly !== $parentReadonly) { + $this->fatalError($class, $parentReadonly + ? "Non-readonly class `{$this->class}` cannot extend readonly class `{$parentClass}`" + : "Readonly class `{$this->class}` cannot extend non-readonly class `{$parentClass}`"); + } } else { $this->fatalError($class, "Class `{$this->class}` inherits from a non-existent class `{$parentClass}`"); }