From 66274bab83f653aacd06f62578088c2103b28f7e Mon Sep 17 00:00:00 2001 From: PurHur Date: Sat, 25 Jul 2026 17:54:53 +0000 Subject: [PATCH] Language: treat private(set) as implicitly final for Reflection (#23068) Mirror php-src zend_API.c: stamp ZEND_ACC_FINAL when asymmetric set visibility is private so isFinal()/getModifiers() and override checks match Zend on PROFILE=8.4. Co-authored-by: Cursor --- ext/standard/VmReflection.php | 9 ++-- lib/Compiler.php | 24 +++++++---- lib/Compiler/FinalPropertyOverrideCheck.php | 43 +++++++++++++++++-- lib/PropertyVisibility.php | 9 ++++ lib/VM.php | 12 +++++- lib/VM/ReflectionPropertyHookSupport.php | 13 +++++- ..._private_set_is_final_forward_profile.phpt | 31 +++++++++++++ ...flection_property_private_set_is_final.php | 22 ++++++++++ 8 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 test/compliance/cases/reflection/reflection_property_private_set_is_final_forward_profile.phpt create mode 100644 test/repro/reflection_property_private_set_is_final.php diff --git a/ext/standard/VmReflection.php b/ext/standard/VmReflection.php index 0cd59cf2112..48870004b98 100644 --- a/ext/standard/VmReflection.php +++ b/ext/standard/VmReflection.php @@ -2981,9 +2981,12 @@ public static function propertyReflectionModifiers( $modifiers |= self::REFLECTION_IS_READONLY; } // php-src prop->flags & ZEND_ACC_FINAL → ReflectionProperty::IS_FINAL (#22341). - if (null !== $instance && $instance->propertyFinal) { - $modifiers |= self::REFLECTION_IS_FINAL; - } elseif (self::propertyIsFinalFromHookRegistry($entry, $property, $ctx)) { + // private(set) is implicitly final (zend_API.c, #23068). + if ( + (null !== $instance && $instance->propertyFinal) + || self::propertyIsFinalFromHookRegistry($entry, $property, $ctx) + || \PHPCompiler\PropertyVisibility::isImplicitlyFinalFromPrivateSet($meta['setVisibility']) + ) { $modifiers |= self::REFLECTION_IS_FINAL; } diff --git a/lib/Compiler.php b/lib/Compiler.php index 9be5b6b62f0..1ef6f86a7a3 100755 --- a/lib/Compiler.php +++ b/lib/Compiler.php @@ -7484,8 +7484,13 @@ protected function compileClassBody(CfgBlock $block, int $type, ?string $classNa || $this->isReadonlyPropertyFlags($child->visibility); $declare->propertyLazy = (property_exists($child, 'propertyLazy') && $child->propertyLazy) || LazyPropertyRewriter::isLazyFromAttributes($child->getAttributes()); - $declare->propertyFinal = $this->isFinalPropertyDeclaration($child); - if ($declare->propertyFinal && !CompilerVersion::supportsFinalProperties()) { + // Explicit `final` vs implicit from private(set) (zend_API.c, #23068). + $explicitFinal = $this->isFinalPropertyDeclaration($child); + $declare->propertyFinal = $explicitFinal + || PropertyVisibility::isImplicitlyFinalFromPrivateSet( + (int) $declare->propertySetVisibility + ); + if ($explicitFinal && !CompilerVersion::supportsFinalProperties()) { // php-src Zend/zend_compile.c — pre-8.4 (#22308, re-#22241). $classDisplay = $this->compilingClassDisplayName ?? '{unknown}'; $this->throwCompileError(sprintf( @@ -8207,9 +8212,16 @@ protected function compilePromotedPropertyDeclaration(Op\Expr\Param $param, Bloc $typeSlot ); $declare->propertyReadonly = $this->isPromotedParamReadonly($param); - $declare->propertyFinal = FinalPromotedPropertyRewriter::isFinalFromAttributes($param->getAttributes()) + $declare->propertyFromConstructorPromotion = true; + $declare->propertyVisibility = MethodVisibility::mask($param->promotionFlags); + $declare->propertySetVisibility = $this->asymmetricSetVisibilityFromCfgOp($param); + $declare->propertyGetVisibility = $this->asymmetricGetVisibilityFromCfgOp($param); + $explicitFinal = FinalPromotedPropertyRewriter::isFinalFromAttributes($param->getAttributes()) || (property_exists($param, 'promotionFinal') && $param->promotionFinal); - if ($declare->propertyFinal && !CompilerVersion::supportsFinalProperties()) { + // php-src zend_API.c — private(set) promoted props are implicitly final (#23068). + $declare->propertyFinal = $explicitFinal + || PropertyVisibility::isImplicitlyFinalFromPrivateSet((int) $declare->propertySetVisibility); + if ($explicitFinal && !CompilerVersion::supportsFinalProperties()) { // php-src Zend/zend_compile.c — pre-8.4 (#22451, re-#22308). $classDisplay = $this->compilingClassDisplayName ?? '{unknown}'; $propName = $param->name instanceof Operand\Literal && is_string($param->name->value) @@ -8221,10 +8233,6 @@ protected function compilePromotedPropertyDeclaration(Op\Expr\Param $param, Bloc $propName )); } - $declare->propertyFromConstructorPromotion = true; - $declare->propertyVisibility = MethodVisibility::mask($param->promotionFlags); - $declare->propertySetVisibility = $this->asymmetricSetVisibilityFromCfgOp($param); - $declare->propertyGetVisibility = $this->asymmetricGetVisibilityFromCfgOp($param); $declare->propertyAsymmetricExplicitRead = Ast\AsymmetricVisibilityRewriter::hasExplicitReadModifierFromAttributes( $param->getAttributes() ); diff --git a/lib/Compiler/FinalPropertyOverrideCheck.php b/lib/Compiler/FinalPropertyOverrideCheck.php index fd5b3682d15..ed55c67d4f5 100644 --- a/lib/Compiler/FinalPropertyOverrideCheck.php +++ b/lib/Compiler/FinalPropertyOverrideCheck.php @@ -80,9 +80,10 @@ private function collectInstanceProperties(Op\Stmt\Class_ $class, string $classL $propDisplay = $this->propertyDisplayName($member->name); $propLc = strtolower($propDisplay); $fromFlags = $this->isFinalFromFlags($member); + $fromPrivateSet = $this->isImplicitlyFinalFromPrivateSet($member); $fromRegistry = $this->isFinalFromHookRegistry($classLc, $propDisplay); $properties[$propLc] = [ - 'final' => $fromFlags || $fromRegistry, + 'final' => $fromFlags || $fromPrivateSet || $fromRegistry, 'fromFlags' => $fromFlags, 'display' => $propDisplay, ]; @@ -107,12 +108,13 @@ private function collectInstanceProperties(Op\Stmt\Class_ $class, string $classL $param->getAttributes() ); $fromField = property_exists($param, 'promotionFinal') && $param->promotionFinal; - $isFinal = $fromMarker || $fromField; + $fromPrivateSet = $this->isImplicitlyFinalFromPrivateSetParam($param); + $isFinal = $fromMarker || $fromField || $fromPrivateSet; // Always record promoted props so a non-final child redeclaration can // trip "Cannot override final property" against a final parent (#22451). $properties[$propLc] = [ 'final' => $isFinal, - 'fromFlags' => $isFinal, + 'fromFlags' => $fromMarker || $fromField, 'display' => $propDisplay, ]; } @@ -131,6 +133,41 @@ private function isFinalFromFlags(Op\Stmt\Property $member): bool return ClassFinal::fromClassFlags((int) $member->visibility); } + /** php-src zend_API.c — private(set) ⇒ ZEND_ACC_FINAL (#23068). */ + private function isImplicitlyFinalFromPrivateSet(Op\Stmt\Property $member): bool + { + $setVis = 0; + if (property_exists($member, 'setVisibility')) { + $setVis = (int) $member->setVisibility; + } + if (0 === $setVis) { + $setVis = \PHPCompiler\Ast\AsymmetricVisibilityRewriter::extractSetVisibilityFromAttributes( + $member->getAttributes() + ); + } + + return \PHPCompiler\PropertyVisibility::isImplicitlyFinalFromPrivateSet($setVis); + } + + /** @param Op\Expr\Param $param */ + private function isImplicitlyFinalFromPrivateSetParam(Op\Expr\Param $param): bool + { + $setVis = 0; + if (property_exists($param, 'setVisibility')) { + $setVis = (int) $param->setVisibility; + } + if (0 === $setVis && property_exists($param, 'promotionSetVisibility')) { + $setVis = (int) $param->promotionSetVisibility; + } + if (0 === $setVis) { + $setVis = \PHPCompiler\Ast\AsymmetricVisibilityRewriter::extractSetVisibilityFromAttributes( + $param->getAttributes() + ); + } + + return \PHPCompiler\PropertyVisibility::isImplicitlyFinalFromPrivateSet($setVis); + } + private function isFinalFromHookRegistry(string $classLc, string $propDisplay): bool { $hooks = $this->propertyHookRegistry[$classLc][$propDisplay] diff --git a/lib/PropertyVisibility.php b/lib/PropertyVisibility.php index 56dc224f1a1..e2b19c7dcac 100644 --- a/lib/PropertyVisibility.php +++ b/lib/PropertyVisibility.php @@ -21,6 +21,15 @@ public static function effectiveGetVisibility(int $writeVisibility, int $getVisi return 0 !== $getVisibility ? $getVisibility : $writeVisibility; } + /** + * php-src zend_API.c — `private(set)` properties are implicitly final (#23068). + * Uses the asymmetric set-visibility flag (not effective write = private). + */ + public static function isImplicitlyFinalFromPrivateSet(int $setVisibilityFlags): bool + { + return ($setVisibilityFlags & CfgFunc::FLAG_PRIVATE) !== 0; + } + /** * Parent private slots are not visible by plain name from a child method scope (zend_fetch_property). */ diff --git a/lib/VM.php b/lib/VM.php index 81cd42c9928..d174c67d498 100755 --- a/lib/VM.php +++ b/lib/VM.php @@ -17230,7 +17230,11 @@ protected function defineClass(ClassEntry $entry, Block $block, ?Frame $warningF (bool) ($op->propertyLazy ?? false) ); $incoming->fromConstructorPromotion = $op->propertyFromConstructorPromotion; - $incoming->propertyFinal = (bool) ($op->propertyFinal ?? false); + // php-src zend_API.c — private(set) ⇒ ZEND_ACC_FINAL (#23068). + $incoming->propertyFinal = (bool) ($op->propertyFinal ?? false) + || PropertyVisibility::isImplicitlyFinalFromPrivateSet( + (int) ($op->propertySetVisibility ?? 0) + ); if ($entry->readonly) { $incoming->readonly = true; } @@ -17893,7 +17897,11 @@ private function finalizePendingNewPropertyDefault( ); $property->defaultInitBlock = $block->fragmentForOpcodes($pendingNewDefaultOps); $property->defaultInitResultSlot = $resultSlot; - $property->propertyFinal = (bool) ($declareOp->propertyFinal ?? false); + // php-src zend_API.c — private(set) ⇒ ZEND_ACC_FINAL (#23068). + $property->propertyFinal = (bool) ($declareOp->propertyFinal ?? false) + || PropertyVisibility::isImplicitlyFinalFromPrivateSet( + (int) ($declareOp->propertySetVisibility ?? 0) + ); $entry->properties[] = $property; } diff --git a/lib/VM/ReflectionPropertyHookSupport.php b/lib/VM/ReflectionPropertyHookSupport.php index be974e9ff93..1ba49fdf7e8 100644 --- a/lib/VM/ReflectionPropertyHookSupport.php +++ b/lib/VM/ReflectionPropertyHookSupport.php @@ -59,11 +59,22 @@ public static function isVirtual(ClassEntry $entry, ?ClassProperty $meta, string /** * php-src ReflectionProperty::isFinal — prop->flags & ZEND_ACC_FINAL (#20511). + * private(set) is implicitly final (zend_API.c / manual, #23068). */ public static function isFinal(ClassEntry $entry, ?ClassProperty $meta, string $property, Context $ctx): bool { if (null !== $meta) { - return $meta->propertyFinal; + if ($meta->propertyFinal + || \PHPCompiler\PropertyVisibility::isImplicitlyFinalFromPrivateSet($meta->setVisibility) + ) { + return true; + } + } + $vis = VmReflection::propertyVisibilityMeta($entry, $property, $ctx); + if (null !== $vis + && \PHPCompiler\PropertyVisibility::isImplicitlyFinalFromPrivateSet($vis['setVisibility']) + ) { + return true; } $lcClass = strtolower($entry->name); $propLc = strtolower($property); diff --git a/test/compliance/cases/reflection/reflection_property_private_set_is_final_forward_profile.phpt b/test/compliance/cases/reflection/reflection_property_private_set_is_final_forward_profile.phpt new file mode 100644 index 00000000000..bb4c2e5016f --- /dev/null +++ b/test/compliance/cases/reflection/reflection_property_private_set_is_final_forward_profile.phpt @@ -0,0 +1,31 @@ +--TEST-- +ReflectionProperty::isFinal() true for public private(set) (#23068, zend_API.c / ext/reflection) +--ENV-- +PHP_COMPILER_PROFILE=8.4 +--FILE-- +getProperty('name')->isFinal(); +$age = $rClass->getProperty('age')->isFinal(); +$jobFinal = $rClass->getProperty('job')->isFinal(); +$flags = [$name, $age, $jobFinal]; +var_export($flags); +echo "\n"; +$job = $rClass->getProperty('job'); +echo ($job->getModifiers() & ReflectionProperty::IS_FINAL) !== 0 ? "final-bit\n" : "no-final-bit\n"; +var_export($job->isPrivateSet()); +echo "\n"; +--EXPECT-- +array ( + 0 => false, + 1 => true, + 2 => true, +) +final-bit +true diff --git a/test/repro/reflection_property_private_set_is_final.php b/test/repro/reflection_property_private_set_is_final.php new file mode 100644 index 00000000000..d787c6f2d3b --- /dev/null +++ b/test/repro/reflection_property_private_set_is_final.php @@ -0,0 +1,22 @@ +isFinal()) { + fwrite(STDERR, "fail: private(set) isFinal false\n"); + exit(1); +} +if ($name->isFinal()) { + fwrite(STDERR, "fail: plain public isFinal true\n"); + exit(1); +} +if (($job->getModifiers() & ReflectionProperty::IS_FINAL) === 0) { + fwrite(STDERR, 'fail: getModifiers missing IS_FINAL bit mods=' . $job->getModifiers() . "\n"); + exit(1); +} +echo "ok\n";