[PHP 8.6] ReflectionProperty::isReadable()/isWritable() emulation - #230
Merged
Conversation
Implements the PHP 8.6 scope-aware accessibility introspection API statically from the AST: - visibility checks for the given scope (null = global scope, class name = class scope), with protected members accessible from both descendant and ancestor scopes and private members only from the exact declaring class, matching zend_check_protected semantics - asymmetric set-visibility (private(set)/protected(set)), including the implicit protected(set) of readonly properties and readonly classes, and explicit public(set) readonly - hook capabilities: virtual properties without a get hook are never readable, without a set hook never writable; abstract/interface hook declarations count as capabilities - scope class names are normalized (leading backslash, case) and resolved without autoloading: already loaded classes are used directly, otherwise the scope is resolved via parser reflection; an unresolvable scope raises Error 'Class "..." not found' like the native implementation - object validation mirrors native reflection: objects are rejected for static properties and for instances of foreign classes before the scope is resolved - only the initialization state of a concrete $object falls back to the native reflection: readonly properties are writable only while uninitialized, and properties without a get hook are readable only when initialized Also fixes isVirtual() to detect backing store usage inside short-form get hooks (e.g. `get => $this->prop + 1;`), which the new stub matrix uncovered. The stub matrix and its hard-coded expectations were generated from the native PHP 8.6.0beta1 implementation; an exhaustive parity test compares every property x scope x object combination against native reflection on PHP >= 8.6. Refs #222 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #222
Implements static (AST-based) emulation of the PHP 8.6 scope-aware property accessibility API in
Go\ParserReflection\ReflectionProperty.Deviation from the issue text (verified against the shipped 8.6.0beta1)
The actually shipped API differs from the signatures quoted in #222:
isWritable, notisWriteable.$scopeis required (nullable, but has no default):isReadable(?string $scope, ?object $object = null): bool— a zero-argument call raisesArgumentCountErrornatively.?object $object, not only the write check.All semantics below were derived empirically from
php 8.6.0beta1, not from the RFC/issue text.Implemented semantics
Scope check (
null= global scope, class name = class scope):publicmembers: accessible from any scope — but a non-null scope class is still always resolved first, and an unknown scope raisesError: Class "..." not found, exactly like native.protected: accessible when the scope is the declaring class, a descendant of it, or an ancestor of it (same rule aszend_check_protected); unrelated/sibling classes are denied.private: accessible only from the exact declaring class (shadowed subclass privates are handled via the real declaring class).Write visibility takes asymmetric visibility into account: explicit
private(set)/protected(set)/public(set), the implicitprotected(set)ofreadonlyproperties (including promoted readonly andreadonlyclasses), and explicitpublic(set) readonly.Hooks: a virtual property without a
gethook is never readable ({ set; }), without asethook never writable ({ get; }); backed hooked properties behave like plain ones; abstract/interface hook declarations ({ get; },{ set; },{ get; set; }) count as capabilities.$objecthandling (native parity):ReflectionException: null is expected as object argument for static properties) and for instances not matching the declaring class (ReflectionException: Given object is not an instance of the class this property was declared in) — both checks fire before scope resolution, in that order;readonlyproperty is writable on an object only while uninitialized on it;InitializationTrait); everything else stays purely static.Deliberate divergences
$scopethrough the autoloader. Here, an already-loaded class/interface/trait is used directly (no autoload), otherwise the scope is resolved statically via parser reflection; only if both fail is the nativeError: Class "..." not foundraised. This preserves the library's contract of not loading reflected code.Go\ParserReflection\ReflectionException(a subclass of\ReflectionException) with the exact native messages.Drive-by fix
The new stub matrix uncovered that
isVirtual()treated a property with a single short-form get hook referencing its own backing store (get => $this->prop + 1;) as virtual, while native reflection reports it as backed. Short hook expressions are now scanned for backing-store access like block-form bodies.Tests
tests/Stub/FileWithPropertyAccessibility86.php(PHP 8.4/8.5 syntax only, parses and loads on all supported runtimes): full matrix of public/protected/private × plain/readonly (incl.public(set) readonly, promoted readonly, readonly class) × hooks (virtual get-only/set-only/both, backed get/set/both, protected hooked, asymmetric hooked) × static × promoted × inherited/shadowed × interface/abstract properties × enumname/value.tests/PropertyAccessibilityTest.php:PHP_VERSION_ID >= 80600): every property × 11 scopes (incl. backslash-prefixed, uppercased, unknown, empty) × 7 objects (uninitialized/initialized/subclass/foreign/enum case) × both methods — ~12,800 combinations, comparing values and exception family+message.Local results
vendor/bin/phpunit— OK, 13750 tests, 15911 assertions (134 skipped, 2 incomplete — pre-existing coverage notes for other epic tickets)php8.6 vendor/bin/phpunit— OK, 13750 tests, 28694 assertions; theReflectionPropertycoverage gap now lists onlygetMangledName(separate ticket)vendor/bin/phpstan analyse src --no-progress— level 10, no errors🤖 Generated with Claude Code
https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
Generated by Claude Code