From 9f1cf0751195ce9505fb7a684e6dde9948f0a2da Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 24 Aug 2026 20:03:59 +0800 Subject: [PATCH] feat(parser): add support for promoted asymmetric visibility properties - Implement validation for promoted asymmetric properties requiring explicit types - Add error handling for promoted asymmetric properties with wider set visibility - Support constructor property promotion with asymmetric visibility modifiers - Enable proper scope checking for promoted asymmetric properties in Zend-backed objects - Preserve promoted/set visibility and implicit final reflection flags - Add comprehensive test coverage for promoted asymmetric property scenarios - Implement native object compilation support for asymmetric property access checks - Remove pending status for ReflectionProperty::isPromoted() implementation - Update documentation to reflect complete asymmetric property visibility support --- docs/INCOMPATIBLE_PHP_FEATURES.md | 3 +- docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md | 3 +- ...eritance_error_final_property_set_hook.php | 18 ++ ...ce_error_promoted_private_set_property.php | 14 ++ ...ve-promoted-private-set-external-write.php | 16 ++ phpunit/code/promoted-asymmetric-untyped.php | 9 + .../code/promoted-asymmetric-wider-set.php | 9 + phpunit/src/ClassTest.php | 16 ++ phpunit/src/InheritanceErrorTest.php | 16 ++ phpunit/src/NativePropertyTest.php | 8 + src/Preprocessor.php | 31 ++++ .../promoted-asymmetric-property.phpt | 171 ++++++++++++++++++ .../property-hooks-reflection.phpt | 21 +++ 13 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 phpunit/code/inheritance_error_final_property_set_hook.php create mode 100644 phpunit/code/inheritance_error_promoted_private_set_property.php create mode 100644 phpunit/code/native-promoted-private-set-external-write.php create mode 100644 phpunit/code/promoted-asymmetric-untyped.php create mode 100644 phpunit/code/promoted-asymmetric-wider-set.php create mode 100644 tests/compiler/object_ctor/promoted-asymmetric-property.phpt diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 554160d3..0427f653 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -19,7 +19,7 @@ - PHP 8.5 `clone()` / clone-with 依赖实际链接的 `libphp` 版本不低于 8.5。公开、动态、private/protected/readonly 和 property hook 属性,以及调用顺序、错误传播和 callable 路径均有 PHPT 覆盖。 - PHP 8.4 property hooks 会编译为 AOT getter/setter,并注册对应的 Zend hook 元数据;直接属性读写、Reflection 和对象遍历均受支持。当前不支持对 hook 属性取引用。 - PHP 8.4 Reflection Lazy Object 不能用于 TypePHP AOT 类。AOT 类以 persistent internal class 注册,而 Zend 的 `zend_object_make_lazy()` 明确拒绝 internal class;运行时动态加载的 ZendPHP user class 不受此限制。 -- 支持 `private(set)` 与 `protected(set)` 非对称属性可见性,并通过 PHP 8.4+ 的类级对象 handler 执行同等作用域检查。 +- 支持 `private(set)` 与 `protected(set)` 非对称属性可见性,包括 constructor property promotion;Zend-backed 对象通过 PHP 8.4+ 类级 object handler 执行作用域检查,并保留 promoted/set visibility/implicit final 反射标志;Native 对象通过编译期访问检查执行同等作用域规则。 - 不支持闭包或箭头函数按引用返回。 - 暂不支持 PHP 8.5 在全局常量、类常量、参数默认值或属性默认值中使用 `static function`;初始化表达式内嵌套的闭包同样会在编译期被拒绝。 - `__construct()` 不允许返回值。 @@ -51,7 +51,6 @@ - 固定值类型属性未显式初始化时使用类型零值,不保留 ZendPHP 的完整 uninitialized 状态;因此 `??` 等依赖 uninitialized 状态的表达式可能不同。 - 禁止子类用同名 `private` 属性隐藏父类私有属性;`public` / `protected` 同名声明视为同一个继承 property slot,仍须满足类型、可见性和 `readonly` 兼容性要求。 - 为避免 typed property 写入路径引入额外动态检查,native typed property 在右值类型不确定或与属性类型不一致时会退化为 `setProperty()`;部分标量赋值可能遵循 Zend 弱类型转换,而不是 AOT 默认 strict 语义。 -- constructor property promotion 的运行时属性可用,但 `ReflectionProperty::isPromoted()` 目前不返回标准 PHP 结果。 ## 表达式与控制流 diff --git a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md index ba03f28d..dffac438 100644 --- a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md +++ b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md @@ -93,7 +93,6 @@ These items should be documented with the exact boundary. | Calls with unpack plus trailing named arguments staying native | Pending | Normalize and reorder call arguments in IR before native-call selection. | | Dynamic `parent::method()` name | Pending | Needs runtime parent method lookup with correct call scope. | | Private typed property access on cloned objects through variables | Pending / Partial | Requires a complete declaring-class-aware access resolver. | -| `ReflectionProperty::isPromoted()` for constructor-promoted properties | Pending | Generated class metadata should record promoted-property flags. | | `echo` with assignment expressions | Pending | Requires expression lowering that preserves evaluation order and returns the assigned value. | | Nested `match` expressions in arm conditions | Pending | Requires recursive match lowering and temporary value ordering. | | `foreach` by-reference value targets beyond simple variables | Pending | Requires explicit lvalue/reference target modeling. | @@ -112,7 +111,7 @@ These items should be documented with the exact boundary. | Dynamic calls and callbacks | Partial | Zend runtime fallback handles dynamic calls and callbacks. By-reference arguments still need explicit `refval()` / `toRef()`, and native-call optimization is not guaranteed. | | Dynamic properties and dynamic property chains | Partial | Dynamic property reads and writes use the runtime property API; native property optimization is not guaranteed. | | Native typed properties | Partial / Intentional Rule | Fast native paths may not preserve every PHP dynamic state transition. Unknown or incompatible values can fall back to `setProperty()`. | -| Reflection metadata | Partial | Runtime declarations exist, but some AOT-specific metadata such as promoted-property flags may be incomplete. | +| Reflection metadata | Partial | Runtime declarations preserve constructor-promotion and asymmetric-visibility flags; other AOT-specific metadata may still be incomplete. | ## Self-hosting Compatibility Notes diff --git a/phpunit/code/inheritance_error_final_property_set_hook.php b/phpunit/code/inheritance_error_final_property_set_hook.php new file mode 100644 index 00000000..5469cd3c --- /dev/null +++ b/phpunit/code/inheritance_error_final_property_set_hook.php @@ -0,0 +1,18 @@ + 'parent'; + final set { + } + } +} + +class FinalPropertySetHookChild extends FinalPropertySetHookParent +{ + public string $value { + set { + } + } +} diff --git a/phpunit/code/inheritance_error_promoted_private_set_property.php b/phpunit/code/inheritance_error_promoted_private_set_property.php new file mode 100644 index 00000000..47b13265 --- /dev/null +++ b/phpunit/code/inheritance_error_promoted_private_set_property.php @@ -0,0 +1,14 @@ +value = 2; +} diff --git a/phpunit/code/promoted-asymmetric-untyped.php b/phpunit/code/promoted-asymmetric-untyped.php new file mode 100644 index 00000000..7008e988 --- /dev/null +++ b/phpunit/code/promoted-asymmetric-untyped.php @@ -0,0 +1,9 @@ +exec('Cannot override private method `Base::doWork()`', 'override-private-method.php'); } + public function testPromotedAsymmetricPropertyRequiresType(): void + { + $this->exec( + 'Property with asymmetric visibility PromotedAsymmetricUntyped::$value must have type', + 'promoted-asymmetric-untyped.php', + ); + } + + public function testPromotedAsymmetricPropertyRejectsWiderSetVisibility(): void + { + $this->exec( + 'Visibility of property PromotedAsymmetricWiderSet::$value must not be weaker than set visibility', + 'promoted-asymmetric-wider-set.php', + ); + } + public function testTraitMayCallProtectedParentMethod() { // A protected parent method is reachable via parent:: from a trait, diff --git a/phpunit/src/InheritanceErrorTest.php b/phpunit/src/InheritanceErrorTest.php index 4a4fab91..de5ef54f 100644 --- a/phpunit/src/InheritanceErrorTest.php +++ b/phpunit/src/InheritanceErrorTest.php @@ -156,6 +156,14 @@ class InheritanceErrorTest extends TestCase ); } + public function testCannotOverrideFinalPropertySetHook(): void + { + $this->exec( + 'Cannot override final property hook FinalPropertySetHookParent::$value::set()', + 'inheritance_error_final_property_set_hook.php', + ); + } + public function testCannotOverrideFinalProperty(): void { $this->exec( @@ -180,6 +188,14 @@ class InheritanceErrorTest extends TestCase ); } + public function testPromotedPrivateSetPropertyIsImplicitlyFinal(): void + { + $this->exec( + 'Cannot override final property PromotedPrivateSetParent::$value', + 'inheritance_error_promoted_private_set_property.php', + ); + } + public function testInterfaceMethodStaticMismatch() { $this->exec('must be compatible', 'interface_method_static_mismatch.php'); diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index e8a9969d..59eab583 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -171,6 +171,14 @@ class NativePropertyTest extends \BaseTest ); } + public function testCannotWritePromotedPrivateSetNativePropertyOutsideDeclaringClass(): void + { + $this->exec( + 'Cannot modify private(set) property `NativePromotedPrivateSetExternalWrite::$value`', + 'native-promoted-private-set-external-write.php', + ); + } + public function testCannotAccessProtectedNativePropertyFromUnrelatedClass(): void { $this->exec('Cannot access protected property `value` of class `NativeProtectedOwner`', 'native-property-protected-unrelated-class.php'); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 597e0efc..de5cf3f0 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1412,6 +1412,7 @@ class Preprocessor extends CompilerBase protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typeNode, $defaultNode, bool $nullable, NodeAbstract $errorNode, bool $promoted = false): PropertyDef { $flags = $this->parseModifiers($flags); + $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); $nullableNative = $this->resolveNullableNativeObjectType( @@ -1480,6 +1481,36 @@ class Preprocessor extends CompilerBase return $propDef; } + private function validateAsymmetricPropertyDeclaration( + string $name, + int $flags, + ?NodeAbstract $typeNode, + NodeAbstract $errorNode, + ): void { + if (!($flags & (Modifiers::PRIVATE_SET | Modifiers::PROTECTED_SET))) { + return; + } + + $className = $this->classDef->getNamespacedName(false); + if ($typeNode === null) { + $this->fatalError( + $errorNode, + "Property with asymmetric visibility {$className}::\${$name} must have type", + ); + } + + $readVisibility = $flags & Modifiers::PRIVATE + ? 1 + : ($flags & Modifiers::PROTECTED ? 2 : 3); + $setVisibility = $flags & Modifiers::PRIVATE_SET ? 1 : 2; + if ($readVisibility < $setVisibility) { + $this->fatalError( + $errorNode, + "Visibility of property {$className}::\${$name} must not be weaker than set visibility", + ); + } + } + /** * gen_stub emits compile-time scalar values and empty arrays exactly into * the internal class default-property table. Non-empty arrays are emitted diff --git a/tests/compiler/object_ctor/promoted-asymmetric-property.phpt b/tests/compiler/object_ctor/promoted-asymmetric-property.phpt new file mode 100644 index 00000000..7af635ba --- /dev/null +++ b/tests/compiler/object_ctor/promoted-asymmetric-property.phpt @@ -0,0 +1,171 @@ +--TEST-- +PHP 8.4 promoted properties support asymmetric set visibility +--FILE-- +name = $name; + } +} + +class PromotedProtectedSet +{ + public function __construct( + public protected(set) int $score = 0, + ) { + } + + public function setFromParent(int $score): void + { + $this->score = $score; + } +} + +class PromotedProtectedSetChild extends PromotedProtectedSet +{ + public function setFromChild(int $score): void + { + $this->score = $score; + } +} + +class PromotedImplicitPublicPrivateSet +{ + public function __construct( + private(set) string $token, + ) { + } + + public function replace(string $token): void + { + $this->token = $token; + } +} + +#[Native] +class NativePromotedPrivateSet +{ + public function __construct( + public private(set) int $value, + ) { + } + + public function increment(): void + { + $this->value++; + } +} + +#[Native] +class NativePromotedProtectedSet +{ + public function __construct( + public protected(set) int $score, + ) { + } +} + +#[Native] +class NativePromotedProtectedSetChild extends NativePromotedProtectedSet +{ + public function update(int $score): void + { + $this->score = $score; + } +} + +function rejectExternalWrites(mixed $private, mixed $protected): void +{ + try { + $private->name = 'external'; + } catch (Error) { + echo "private blocked\n"; + } + try { + $protected->score = 99; + } catch (Error) { + echo "protected blocked\n"; + } +} + +function main(): void +{ + $defaultPrivate = new PromotedPrivateSet(); + var_dump($defaultPrivate->name); + + $private = new PromotedPrivateSet('constructor'); + var_dump($private->name); + $private->rename('class'); + var_dump($private->name); + + $protected = new PromotedProtectedSetChild(10); + $protected->setFromParent(20); + $protected->setFromChild(30); + var_dump($protected->score); + + $implicit = new PromotedImplicitPublicPrivateSet('implicit'); + var_dump($implicit->token); + $implicit->replace('replaced'); + var_dump($implicit->token); + + $native = new NativePromotedPrivateSet(40); + $native->increment(); + var_dump($native->value); + + $nativeChild = new NativePromotedProtectedSetChild(50); + $nativeChild->update(51); + var_dump($nativeChild->score); + + rejectExternalWrites($private, $protected); + var_dump($private->name, $protected->score); + + $privateProperty = new ReflectionProperty(PromotedPrivateSet::class, 'name'); + var_dump( + $privateProperty->isPromoted(), + $privateProperty->isPrivateSet(), + $privateProperty->isFinal(), + ); + $protectedProperty = new ReflectionProperty(PromotedProtectedSet::class, 'score'); + var_dump( + $protectedProperty->isPromoted(), + $protectedProperty->isProtectedSet(), + $protectedProperty->isFinal(), + ); + $implicitProperty = new ReflectionProperty(PromotedImplicitPublicPrivateSet::class, 'token'); + var_dump( + $implicitProperty->isPublic(), + $implicitProperty->isPrivateSet(), + $implicitProperty->isFinal(), + ); +} +?> +--EXPECT-- +string(7) "initial" +string(11) "constructor" +string(5) "class" +int(30) +string(8) "implicit" +string(8) "replaced" +int(41) +int(51) +private blocked +protected blocked +string(5) "class" +int(30) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(false) +bool(true) +bool(true) +bool(true) diff --git a/tests/compiler/object_property/property-hooks-reflection.phpt b/tests/compiler/object_property/property-hooks-reflection.phpt index d9a9840f..3b158cd4 100644 --- a/tests/compiler/object_property/property-hooks-reflection.phpt +++ b/tests/compiler/object_property/property-hooks-reflection.phpt @@ -5,11 +5,20 @@ PHP 8.4 property hooks expose Zend reflection metadata final class ReflectedPropertyHooks { + private string $stored = 'initial'; + public string $virtual { final get => 'value'; set { } } + + public string $finalSetter { + get => $this->stored; + final set { + $this->stored = $value; + } + } } function main(): void @@ -20,6 +29,15 @@ function main(): void foreach ($property->getHooks() as $kind => $hook) { echo $kind, ':', $hook->getName(), ':', $hook->isFinal() ? 'final' : 'not-final', "\n"; } + + $object = new ReflectedPropertyHooks(); + $object->finalSetter = 'updated'; + var_dump($object->finalSetter); + + $setterProperty = new ReflectionProperty(ReflectedPropertyHooks::class, 'finalSetter'); + foreach ($setterProperty->getHooks() as $kind => $hook) { + echo 'finalSetter-', $kind, ':', $hook->isFinal() ? 'final' : 'not-final', "\n"; + } } ?> --EXPECT-- @@ -27,3 +45,6 @@ bool(true) bool(true) get:$virtual::get:final set:$virtual::set:not-final +string(7) "updated" +finalSetter-get:not-final +finalSetter-set:final