From 30ab75537a0909a04f397ae5352419852b2ae2bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 09:56:14 +0000 Subject: [PATCH 1/4] 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/4] 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, + ) { + } +} From 3f6619afb50632c6a38a76034d8ccab6a8f287b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:06:21 +0000 Subject: [PATCH 3/4] test: verify readonly property defaults against PHP 8.6.0RC2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8.6.0RC2 (the first pre-release line shipping the Readonly Property Defaults RFC — the feature missed the beta1 tag; there is no beta2, the line went beta1 -> RC1/RC2) accepts readonly properties with default values, so the runtime feature probe now activates the native parity test. Verification against the RC surfaced two runtime changes: - PHP 8.6 deprecates ReflectionProperty::getDefaultValue() on a property without a default value; the parity comparison is limited to properties that have one, keeping the suite deprecation-clean. - Native ReflectionClassConstant::__toString() renders boolean values as "true"/"false" since 8.6 (string cast before); the parsed rendering now follows the runtime. The guarding version check is a documented PHPStan ignore because the installed PHPStan caps its PHP version model at 8.5 and folds the branch to "always false". Full matrix verified on PHP 8.6.0RC2: all 13 readonly-defaults tests pass with the parity test active, full suite green with --fail-on-deprecation, PHPStan level 10 clean, PHP 8.5 suite unchanged. Refs #223 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn --- phpstan.neon | 7 +++++++ src/ReflectionClassConstant.php | 12 +++++++++++- tests/ReadonlyPropertyDefaultsTest.php | 21 ++++++++++++--------- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 9208ceb..38fa105 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,6 +3,13 @@ parameters: paths: - src ignoreErrors: + # The installed PHPStan release knows no PHP 8.6 yet (phpVersion.max is capped at 80599), so it + # folds the runtime check guarding the 8.6-specific boolean rendering to "always false". The + # code path is exercised by the experimental PHP 8.6 CI job. + - identifier: greaterOrEqual.alwaysFalse + path: src/ReflectionClassConstant.php + - identifier: booleanAnd.alwaysFalse + path: src/ReflectionClassConstant.php # Both classes are final, so "might have hooks in a subclass" is a false positive - identifier: unset.possiblyHookedProperty path: src/ReflectionFunction.php diff --git a/src/ReflectionClassConstant.php b/src/ReflectionClassConstant.php index 3e707be..54b2e40 100644 --- a/src/ReflectionClassConstant.php +++ b/src/ReflectionClassConstant.php @@ -324,12 +324,22 @@ public function __toString(): string $docComment = $this->getDocComment(); $prefix = $docComment !== false ? $docComment . "\n" : ''; + // Since PHP 8.6 the native reflection prints boolean values as "true"/"false" instead of + // the string cast "1"/"" used before + if (is_bool($value) && PHP_VERSION_ID >= 80600) { + $displayValue = $value ? 'true' : 'false'; + } elseif (is_object($value)) { + $displayValue = 'Object'; + } else { + $displayValue = is_scalar($value) || $value === null ? (string) $value : ''; + } + return $prefix . sprintf( "Constant [ %s %s %s ] { %s }\n", implode(' ', Reflection::getModifierNames($this->getModifiers())), ReflectionType::convertToDisplayType($valueType), $this->getName(), - is_object($value) ? 'Object' : (is_scalar($value) || $value === null ? $value : '') + $displayValue ); } diff --git a/tests/ReadonlyPropertyDefaultsTest.php b/tests/ReadonlyPropertyDefaultsTest.php index 184f693..74010c9 100644 --- a/tests/ReadonlyPropertyDefaultsTest.php +++ b/tests/ReadonlyPropertyDefaultsTest.php @@ -263,10 +263,9 @@ public function testStubClassesAreNeverLoaded(): void } /** - * 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. + * Parity against native reflection, verified against PHP 8.6.0RC2 (the first pre-release line + * shipping readonly property defaults — the feature missed the beta1 tag). The runtime probe + * keeps the test skipped on runtimes without the feature, e.g. PHP 8.5. */ public function testNativeParityForReadonlyDefaults(): void { @@ -292,11 +291,15 @@ public function testNativeParityForReadonlyDefaults(): void $parsedProperty->hasDefaultValue(), 'hasDefaultValue() mismatch for $' . $propertyName ); - $this->assertSame( - $nativeProperty->getDefaultValue(), - $parsedProperty->getDefaultValue(), - 'getDefaultValue() mismatch for $' . $propertyName - ); + // PHP 8.6 deprecates calling getDefaultValue() on a property without a default value, + // so the value comparison is limited to properties that actually have one + if ($nativeProperty->hasDefaultValue()) { + $this->assertSame( + $nativeProperty->getDefaultValue(), + $parsedProperty->getDefaultValue(), + 'getDefaultValue() mismatch for $' . $propertyName + ); + } } $nativeDefaults = $nativeClass->getDefaultProperties(); From 8c331e7abf1d015db779ad3ef2b2fb97da00ee4d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:07:25 +0000 Subject: [PATCH 4/4] build: tolerate PHPStan releases that already know PHP 8.6 CI resolves a newer PHPStan than the local one; a release whose version model includes 8.6 reports nothing on the guarded rendering branch, so the two ignore entries must not fail as unmatched there. Refs #223 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn --- phpstan.neon | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 38fa105..1666d2b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,13 +3,16 @@ parameters: paths: - src ignoreErrors: - # The installed PHPStan release knows no PHP 8.6 yet (phpVersion.max is capped at 80599), so it - # folds the runtime check guarding the 8.6-specific boolean rendering to "always false". The - # code path is exercised by the experimental PHP 8.6 CI job. + # A PHPStan release that knows no PHP 8.6 (phpVersion.max capped at 80599) folds the runtime + # check guarding the 8.6-specific boolean rendering to "always false"; newer releases know 8.6 + # and report nothing, hence reportUnmatched is disabled for these two entries. The code path is + # exercised by the experimental PHP 8.6 CI job. - identifier: greaterOrEqual.alwaysFalse path: src/ReflectionClassConstant.php + reportUnmatched: false - identifier: booleanAnd.alwaysFalse path: src/ReflectionClassConstant.php + reportUnmatched: false # Both classes are final, so "might have hooks in a subclass" is a false positive - identifier: unset.possiblyHookedProperty path: src/ReflectionFunction.php