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
master
韩天峰 1 day ago
parent 36fd0228f0
commit 9f1cf07511
  1. 3
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 3
      docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md
  3. 18
      phpunit/code/inheritance_error_final_property_set_hook.php
  4. 14
      phpunit/code/inheritance_error_promoted_private_set_property.php
  5. 16
      phpunit/code/native-promoted-private-set-external-write.php
  6. 9
      phpunit/code/promoted-asymmetric-untyped.php
  7. 9
      phpunit/code/promoted-asymmetric-wider-set.php
  8. 16
      phpunit/src/ClassTest.php
  9. 16
      phpunit/src/InheritanceErrorTest.php
  10. 8
      phpunit/src/NativePropertyTest.php
  11. 31
      src/Preprocessor.php
  12. 171
      tests/compiler/object_ctor/promoted-asymmetric-property.phpt
  13. 21
      tests/compiler/object_property/property-hooks-reflection.phpt

@ -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 结果。
## 表达式与控制流

@ -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

@ -0,0 +1,18 @@
<?php
class FinalPropertySetHookParent
{
public string $value {
get => 'parent';
final set {
}
}
}
class FinalPropertySetHookChild extends FinalPropertySetHookParent
{
public string $value {
set {
}
}
}

@ -0,0 +1,14 @@
<?php
class PromotedPrivateSetParent
{
public function __construct(
public private(set) string $value,
) {
}
}
class PromotedPrivateSetChild extends PromotedPrivateSetParent
{
public string $value = 'child';
}

@ -0,0 +1,16 @@
<?php
#[Native]
class NativePromotedPrivateSetExternalWrite
{
public function __construct(
public private(set) int $value,
) {
}
}
function main(): void
{
$object = new NativePromotedPrivateSetExternalWrite(1);
$object->value = 2;
}

@ -0,0 +1,9 @@
<?php
class PromotedAsymmetricUntyped
{
public function __construct(
public private(set) $value,
) {
}
}

@ -0,0 +1,9 @@
<?php
class PromotedAsymmetricWiderSet
{
public function __construct(
private protected(set) string $value,
) {
}
}

@ -663,6 +663,22 @@ class ClassTest extends \BaseTest
$this->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,

@ -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');

@ -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');

@ -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

@ -0,0 +1,171 @@
--TEST--
PHP 8.4 promoted properties support asymmetric set visibility
--FILE--
<?php
class PromotedPrivateSet
{
public function __construct(
public private(set) string $name = 'initial',
) {
}
public function rename(string $name): void
{
$this->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)

@ -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

Loading…
Cancel
Save