From 30ab75537a0909a04f397ae5352419852b2ae2bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 09:56:14 +0000 Subject: [PATCH 1/2] fix: initialize default value expression fields to avoid access before initialization `ReflectionProperty::getDefaultValueExpression()` and `ReflectionParameter::getDefaultValueExpression()` read backing fields that were only assigned when a default value node is present, so calling them on a typed property or parameter without a default aborted with "Typed property ... must not be accessed before initialization". This shows up directly on `readonly` properties that declare no default value, which is part of the PHP 8.6 readonly-defaults surface, but the problem is generic for any typed declaration without a default. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn --- src/ReflectionParameter.php | 6 +++--- src/ReflectionProperty.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ReflectionParameter.php b/src/ReflectionParameter.php index 51e8b95..53fa592 100644 --- a/src/ReflectionParameter.php +++ b/src/ReflectionParameter.php @@ -62,7 +62,7 @@ final class ReflectionParameter extends BaseReflectionParameter implements NodeA * * @see $isDefaultValueConstant */ - private ?string $defaultValueConstantName; + private ?string $defaultValueConstantName = null; /** * Index of parameter in the list @@ -74,9 +74,9 @@ final class ReflectionParameter extends BaseReflectionParameter implements NodeA */ private Param $parameterNode; - private bool $isDefaultValueConstExpr; + private bool $isDefaultValueConstExpr = false; - private ?string $defaultValueConstExpr; + private ?string $defaultValueConstExpr = null; private \ReflectionUnionType|\ReflectionNamedType|\ReflectionIntersectionType|null $type = null; diff --git a/src/ReflectionProperty.php b/src/ReflectionProperty.php index a1def9e..e45efc4 100644 --- a/src/ReflectionProperty.php +++ b/src/ReflectionProperty.php @@ -65,11 +65,11 @@ final class ReflectionProperty extends BaseReflectionProperty implements NodeAwa private bool $isDefaultValueConstant = false; - private ?string $defaultValueConstantName; + private ?string $defaultValueConstantName = null; private bool $isDefaultValueConstExpr = false; - private ?string $defaultValueConstExpr; + private ?string $defaultValueConstExpr = null; /** * Initializes a reflection for the property From bdda459474cac0135765c0414bd2e833ff175e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 09:56:24 +0000 Subject: [PATCH 2/2] test: cover static reflection of PHP 8.6 readonly property defaults Adds a parse-only stub with the whole readonly-defaults matrix (scalar, null, constant expression, class constant, enum case, array, readonly class, trait, inheritance, promoted parameters) and tests that document how static reflection reports it today. The "Readonly Property Defaults" RFC is accepted for PHP 8.6, but its implementation landed in php-src after the php-8.6.0beta1 tag, so no released runtime compiles this syntax yet. The stub is therefore never loaded, and the native parity test stays skipped until a runtime accepts readonly defaults, detected by a sub-process probe because the rejection is an uncatchable compile-time fatal error. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn --- tests/ReadonlyPropertyDefaultsTest.php | 353 ++++++++++++++++++++++ tests/Stub/FileWithReadonlyDefaults86.php | 89 ++++++ 2 files changed, 442 insertions(+) create mode 100644 tests/ReadonlyPropertyDefaultsTest.php create mode 100644 tests/Stub/FileWithReadonlyDefaults86.php diff --git a/tests/ReadonlyPropertyDefaultsTest.php b/tests/ReadonlyPropertyDefaultsTest.php new file mode 100644 index 0000000..184f693 --- /dev/null +++ b/tests/ReadonlyPropertyDefaultsTest.php @@ -0,0 +1,353 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Documents how static reflection handles readonly properties that declare a default value. + * + * The "Readonly Property Defaults" RFC (https://wiki.php.net/rfc/readonly_property_defaults) was + * accepted for PHP 8.6, but its implementation landed in php-src after the `php-8.6.0beta1` tag, + * therefore no released runtime accepts this syntax yet: even 8.6.0beta1 aborts with + * "Readonly property X::$y cannot have default value" at compile time. + * + * PHP-Parser parses the syntax regardless, so the whole matrix can be verified statically today. + * The stub file must therefore never be included or autoloaded, it is only parsed. Assertions that + * require executing such a class are collected in the single parity test at the bottom, which stays + * skipped until the runtime really accepts readonly defaults (detected by a sub-process probe, + * because the failure is an uncatchable compile-time fatal error and cannot be probed via eval()). + */ +class ReadonlyPropertyDefaultsTest extends TestCase +{ + public const STUB_FILE = '/Stub/FileWithReadonlyDefaults86.php'; + + public const STUB_NAMESPACE = 'Go\ParserReflection\Stub\\'; + + public const STUB_CLASS = self::STUB_NAMESPACE . 'ClassWithReadonlyDefaults86'; + + public const READONLY_CLASS = self::STUB_NAMESPACE . 'ReadonlyClassWithDefaults86'; + + public const PROMOTED_CLASS = self::STUB_NAMESPACE . 'ClassWithPromotedReadonlyDefaults86'; + + public const CHILD_CLASS = self::STUB_NAMESPACE . 'ChildWithReadonlyDefaults86'; + + private string $stubFileName; + + protected function setUp(): void + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . self::STUB_FILE); + $this->assertIsString($resolvedFileName, 'PHP 8.6 readonly-defaults stub file should be available'); + + $this->stubFileName = $resolvedFileName; + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because this one replaces it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testReadonlyPropertyWithScalarDefault(): void + { + $parsedProperty = $this->getParsedClass(self::STUB_CLASS)->getProperty('name'); + + $this->assertTrue($parsedProperty->isReadOnly(), 'Property should be readonly'); + $this->assertFalse($parsedProperty->isPromoted(), 'Property should not be promoted'); + $this->assertTrue($parsedProperty->hasDefaultValue(), 'Readonly property should report its default value'); + $this->assertSame('create_books_table', $parsedProperty->getDefaultValue()); + $this->assertNull($parsedProperty->getDefaultValueExpression(), 'Plain scalar default is not a const expression'); + $this->assertSame( + "Property [ public protected(set) readonly string \$name = 'create_books_table' ]\n", + (string) $parsedProperty + ); + } + + /** + * Defaults must be resolved independently of the property visibility + */ + public function testReadonlyPropertyDefaultsForEveryVisibility(): void + { + $parsedClass = $this->getParsedClass(self::STUB_CLASS); + + $protectedProperty = $parsedClass->getProperty('version'); + $this->assertTrue($protectedProperty->isProtected()); + $this->assertTrue($protectedProperty->hasDefaultValue()); + $this->assertSame(1, $protectedProperty->getDefaultValue()); + + $privateProperty = $parsedClass->getProperty('ratio'); + $this->assertTrue($privateProperty->isPrivate()); + $this->assertTrue($privateProperty->hasDefaultValue()); + $this->assertSame(0.5, $privateProperty->getDefaultValue()); + + $boolProperty = $parsedClass->getProperty('enabled'); + $this->assertTrue($boolProperty->hasDefaultValue()); + $this->assertTrue($boolProperty->getDefaultValue()); + + $nullableProperty = $parsedClass->getProperty('nullableWithNull'); + $this->assertTrue($nullableProperty->hasDefaultValue(), 'Explicit null default is still a default value'); + $this->assertNull($nullableProperty->getDefaultValue()); + } + + /** + * Constant expressions in readonly defaults are evaluated by NodeExpressionResolver + */ + public function testReadonlyPropertyWithConstantExpressionDefault(): void + { + $parsedClass = $this->getParsedClass(self::STUB_CLASS); + + $sumProperty = $parsedClass->getProperty('constExprSum'); + $this->assertTrue($sumProperty->hasDefaultValue()); + $this->assertSame(14, $sumProperty->getDefaultValue()); + + $concatProperty = $parsedClass->getProperty('classConstantConcat'); + $this->assertTrue($concatProperty->hasDefaultValue()); + $this->assertSame('migration_books', $concatProperty->getDefaultValue()); + $this->assertSame("self::PREFIX . 'books'", $concatProperty->getDefaultValueExpression()); + } + + public function testReadonlyPropertyWithArrayDefault(): void + { + $parsedProperty = $this->getParsedClass(self::STUB_CLASS)->getProperty('list'); + + $this->assertTrue($parsedProperty->hasDefaultValue()); + $this->assertSame(['a', 'b'], $parsedProperty->getDefaultValue()); + $this->assertSame("['a', 'b']", $parsedProperty->getDefaultValueExpression()); + } + + /** + * An enum case used as a readonly default is recognised as a constant expression. + * + * The concrete enum instance can not be materialised while the enum itself is not loaded, + * this is a general limitation of static reflection for enum-case constants and is unrelated + * to the readonly modifier, therefore only the expression is asserted here. + */ + public function testReadonlyPropertyWithEnumCaseDefault(): void + { + $parsedProperty = $this->getParsedClass(self::STUB_CLASS)->getProperty('suit'); + + $this->assertTrue($parsedProperty->isReadOnly()); + $this->assertTrue($parsedProperty->hasDefaultValue()); + $this->assertSame('ReadonlyDefaultSuit86::Spades', $parsedProperty->getDefaultValueExpression()); + } + + /** + * A readonly property without a default must keep reporting "no default value" + */ + public function testReadonlyPropertyWithoutDefault(): void + { + $parsedProperty = $this->getParsedClass(self::STUB_CLASS)->getProperty('noDefault'); + + $this->assertTrue($parsedProperty->isReadOnly()); + $this->assertFalse($parsedProperty->hasDefaultValue()); + $this->assertNull($parsedProperty->getDefaultValue()); + // Must not fail with "typed property must not be accessed before initialization" + $this->assertNull($parsedProperty->getDefaultValueExpression()); + $this->assertSame( + "Property [ public protected(set) readonly string \$noDefault ]\n", + (string) $parsedProperty + ); + } + + public function testPrivateSetReadonlyPropertyWithDefault(): void + { + $parsedProperty = $this->getParsedClass(self::STUB_CLASS)->getProperty('privateSetWithDefault'); + + $this->assertTrue($parsedProperty->isReadOnly()); + $this->assertTrue($parsedProperty->isPrivateSet()); + $this->assertTrue($parsedProperty->isFinal(), 'Property with private(set) is implicitly final'); + $this->assertTrue($parsedProperty->hasDefaultValue()); + $this->assertSame('restricted', $parsedProperty->getDefaultValue()); + } + + public function testGetDefaultPropertiesContainsReadonlyDefaults(): void + { + $defaultProperties = $this->getParsedClass(self::STUB_CLASS)->getDefaultProperties(); + + $this->assertSame('create_books_table', $defaultProperties['name']); + $this->assertSame(1, $defaultProperties['version']); + $this->assertSame(0.5, $defaultProperties['ratio']); + $this->assertTrue($defaultProperties['enabled']); + $this->assertArrayHasKey('nullableWithNull', $defaultProperties); + $this->assertNull($defaultProperties['nullableWithNull']); + $this->assertSame(14, $defaultProperties['constExprSum']); + $this->assertSame('migration_books', $defaultProperties['classConstantConcat']); + $this->assertSame(['a', 'b'], $defaultProperties['list']); + $this->assertSame('restricted', $defaultProperties['privateSetWithDefault']); + + $this->assertArrayNotHasKey( + 'noDefault', + $defaultProperties, + 'Readonly property without default must be omitted, like an uninitialized typed property' + ); + } + + /** + * A `readonly class` marks every property readonly, defaults must be reported as well + */ + public function testReadonlyClassPropertyDefaults(): void + { + $parsedClass = $this->getParsedClass(self::READONLY_CLASS); + $this->assertTrue($parsedClass->isReadOnly()); + + $parsedProperty = $parsedClass->getProperty('name'); + $this->assertTrue($parsedProperty->isReadOnly(), 'Property of a readonly class is readonly'); + $this->assertTrue($parsedProperty->hasDefaultValue()); + $this->assertSame('readonly_class_default', $parsedProperty->getDefaultValue()); + + $this->assertSame(['name' => 'readonly_class_default'], $parsedClass->getDefaultProperties()); + } + + public function testInheritedAndTraitReadonlyDefaults(): void + { + $parsedClass = $this->getParsedClass(self::CHILD_CLASS); + $defaultProperties = $parsedClass->getDefaultProperties(); + + $this->assertSame(7, $defaultProperties['own']); + $this->assertSame('trait_default', $defaultProperties['fromTrait'], 'Default from a trait should be collected'); + $this->assertSame('parent_default', $defaultProperties['inherited'], 'Inherited default should be collected'); + $this->assertCount(3, $defaultProperties); + + $this->assertTrue($parsedClass->getProperty('fromTrait')->isReadOnly()); + $this->assertTrue($parsedClass->getProperty('inherited')->isReadOnly()); + } + + /** + * A default value of a promoted parameter belongs to the parameter, not to the property. + * + * Native reflection reports `hasDefaultValue() === false` for promoted properties even when the + * corresponding constructor parameter is optional (https://bugs.php.net/bug.php?id=81386), and the + * readonly modifier does not change that. + */ + public function testPromotedReadonlyParameterWithDefault(): void + { + $parsedClass = $this->getParsedClass(self::PROMOTED_CLASS); + + $parsedProperty = $parsedClass->getProperty('promotedReadonly'); + $this->assertTrue($parsedProperty->isPromoted()); + $this->assertTrue($parsedProperty->isReadOnly()); + $this->assertFalse($parsedProperty->hasDefaultValue(), 'Promoted property has no property-level default'); + $this->assertNull($parsedProperty->getDefaultValue()); + $this->assertNull($parsedProperty->getDefaultValueExpression()); + $this->assertSame([], $parsedClass->getDefaultProperties(), 'Promoted defaults are not class defaults'); + + $constructor = $parsedClass->getConstructor(); + $this->assertNotNull($constructor); + [$firstParameter, $secondParameter] = $constructor->getParameters(); + + $this->assertTrue($firstParameter->isPromoted()); + $this->assertTrue($firstParameter->isDefaultValueAvailable()); + $this->assertSame('promoted_default', $firstParameter->getDefaultValue()); + $this->assertTrue($secondParameter->isDefaultValueAvailable()); + $this->assertSame(42, $secondParameter->getDefaultValue()); + } + + public function testStubClassesAreNeverLoaded(): void + { + $this->assertSame(self::STUB_CLASS, $this->getParsedClass(self::STUB_CLASS)->getName()); + + foreach ([self::STUB_CLASS, self::READONLY_CLASS, self::PROMOTED_CLASS, self::CHILD_CLASS] as $className) { + $this->assertFalse(class_exists($className, false), 'Stub class should not be loaded: ' . $className); + } + } + + /** + * Parity against native reflection, pending an actual runtime that accepts readonly defaults. + * + * TODO: remove the skip once php-src ships the feature in a beta/RC build, the expectations below + * have to be re-verified against the final native semantics at that point. + */ + public function testNativeParityForReadonlyDefaults(): void + { + if (!self::runtimeSupportsReadonlyDefaults()) { + $this->markTestSkipped( + 'The current runtime rejects readonly properties with default values ' + . '(the RFC implementation landed after the php-8.6.0beta1 tag), native parity can not be verified yet' + ); + } + + // @codeCoverageIgnoreStart + require_once $this->stubFileName; + + $parsedClass = $this->getParsedClass(self::STUB_CLASS); + $nativeClass = new \ReflectionClass(self::STUB_CLASS); + + foreach (['name', 'version', 'ratio', 'enabled', 'nullableWithNull', 'constExprSum', 'classConstantConcat', 'list', 'noDefault'] as $propertyName) { + $parsedProperty = $parsedClass->getProperty($propertyName); + $nativeProperty = $nativeClass->getProperty($propertyName); + + $this->assertSame( + $nativeProperty->hasDefaultValue(), + $parsedProperty->hasDefaultValue(), + 'hasDefaultValue() mismatch for $' . $propertyName + ); + $this->assertSame( + $nativeProperty->getDefaultValue(), + $parsedProperty->getDefaultValue(), + 'getDefaultValue() mismatch for $' . $propertyName + ); + } + + $nativeDefaults = $nativeClass->getDefaultProperties(); + $parsedDefaults = $parsedClass->getDefaultProperties(); + // The enum-case default can only be compared once the enum is loaded, which is the case here + $this->assertSame($nativeDefaults, $parsedDefaults); + // @codeCoverageIgnoreEnd + } + + /** + * Detects whether the current runtime compiles a readonly property with a default value. + * + * The rejection is an uncatchable compile-time fatal error, so it can not be probed with eval() + * inside the running process, a short-lived child process is used instead. + */ + private static function runtimeSupportsReadonlyDefaults(): bool + { + static $isSupported = null; + + if ($isSupported !== null) { + return $isSupported; + } + + if (!function_exists('exec') || PHP_BINARY === '') { + return $isSupported = false; + } + + $probeCode = 'class ReadonlyDefaultsProbe { public readonly int $probe = 1; } exit(0);'; + $command = escapeshellarg(PHP_BINARY) . ' -n -r ' . escapeshellarg($probeCode) . ' 2>&1'; + + exec($command, $output, $exitCode); + + return $isSupported = ($exitCode === 0); + } + + /** + * Reflects a stub class without triggering autoloading of the PHP 8.6 source + * + * @param class-string $className + */ + private function getParsedClass(string $className): ReflectionClass + { + $stubFileName = $this->stubFileName; + $locator = new CallableLocator( + static fn(string $classNameToLocate): false|string + => str_starts_with($classNameToLocate, self::STUB_NAMESPACE) && str_ends_with($classNameToLocate, '86') + ? $stubFileName + : false + ); + ReflectionEngine::init($locator); + + return new ReflectionClass($className); + } +} diff --git a/tests/Stub/FileWithReadonlyDefaults86.php b/tests/Stub/FileWithReadonlyDefaults86.php new file mode 100644 index 0000000..78ff74d --- /dev/null +++ b/tests/Stub/FileWithReadonlyDefaults86.php @@ -0,0 +1,89 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * This file contains PHP 8.6 syntax (readonly properties with default values) and can not be + * loaded by any current runtime: the feature was merged into php-src after the 8.6.0beta1 tag, + * so even PHP 8.6.0beta1 still fails with "Readonly property X::$y cannot have default value". + * + * It is intended to be analyzed statically only, never include or autoload it directly. + * + * @see https://wiki.php.net/rfc/readonly_property_defaults + */ + +enum ReadonlyDefaultSuit86: string +{ + case Hearts = 'H'; + case Spades = 'S'; +} + +class ClassWithReadonlyDefaults86 +{ + public const string PREFIX = 'migration_'; + + public readonly string $name = 'create_books_table'; + + protected readonly int $version = 1; + + private readonly float $ratio = 0.5; + + public readonly bool $enabled = true; + + public readonly ?string $nullableWithNull = null; + + public readonly int $constExprSum = 2 + 3 * 4; + + public readonly string $classConstantConcat = self::PREFIX . 'books'; + + public readonly ReadonlyDefaultSuit86 $suit = ReadonlyDefaultSuit86::Spades; + + /** @var array */ + public readonly array $list = ['a', 'b']; + + public readonly string $noDefault; + + public private(set) readonly string $privateSetWithDefault = 'restricted'; +} + +readonly class ReadonlyClassWithDefaults86 +{ + public string $name = 'readonly_class_default'; + + public int $noDefault; +} + +trait ReadonlyDefaultsTrait86 +{ + public readonly string $fromTrait = 'trait_default'; +} + +class ParentWithReadonlyDefaults86 +{ + public readonly string $inherited = 'parent_default'; +} + +class ChildWithReadonlyDefaults86 extends ParentWithReadonlyDefaults86 +{ + use ReadonlyDefaultsTrait86; + + public readonly int $own = 7; +} + +class ClassWithPromotedReadonlyDefaults86 +{ + public function __construct( + public readonly string $promotedReadonly = 'promoted_default', + protected readonly int $promotedReadonlyInt = 42, + ) { + } +}