From d56fab10420da63a32689c59d3fec7831c16b1b4 Mon Sep 17 00:00:00 2001 From: Thiago Marsola <36829328+thimarsola@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:01:27 -0300 Subject: [PATCH] fix: avoid internal error on higher-order expectations over magic properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HigherOrderExpectationTypeExtension::resolvePropertyType()` only bailed out when `hasProperty()` returned `no`. `Type::hasProperty()` is trinary, and `ObjectType::hasProperty()` returns `maybe` for classes that allow dynamic properties — which includes every class declaring `__get()`, `__set()` or `__isset()`. On `maybe` the guard let execution through and `ClassReflection::getProperty()` threw `MissingPropertyFromReflectionException`, which nothing caught, so PHPStan aborted the whole file with an internal error instead of reporting a regular, baselineable error. `maybe` cannot be used to decide on its own: it is also returned for types whose property is perfectly resolvable, such as `Post|null`. Bailing out on anything that is not `yes` would break those cases. The reflection lookup is therefore the only reliable check, so it is guarded with a `try`/`catch`, the same way PHPStan's own `AccessPropertiesCheck` and `ObjectShapeType` handle it. The unresolvable case falls back to `MixedType` rather than `null` so that the higher-order chain keeps its shape. Returning `null` degrades the expression to `mixed`, which turns a single internal error into a series of `method.nonObject` and `property.nonObject` errors for every subsequent link in the chain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvRLUyRV47CJr9NfpxWg2M --- .../HigherOrderExpectationTypeExtension.php | 7 +++++- tests/Type/Fixtures/MagicPropertyObject.php | 13 +++++++++++ tests/Type/data/higher-order-expectations.php | 23 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/Type/Fixtures/MagicPropertyObject.php diff --git a/src/Type/Pest/HigherOrderExpectationTypeExtension.php b/src/Type/Pest/HigherOrderExpectationTypeExtension.php index 0bde973..0c42f8b 100644 --- a/src/Type/Pest/HigherOrderExpectationTypeExtension.php +++ b/src/Type/Pest/HigherOrderExpectationTypeExtension.php @@ -13,6 +13,7 @@ use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; +use PHPStan\Reflection\MissingPropertyFromReflectionException; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\ExpressionTypeResolverExtension; use PHPStan\Type\Generic\GenericObjectType; @@ -187,7 +188,11 @@ private function resolvePropertyType(Type $objectType, string $propertyName, Sco return null; } - return $objectType->getProperty($propertyName, $scope)->getReadableType(); + try { + return $objectType->getProperty($propertyName, $scope)->getReadableType(); + } catch (MissingPropertyFromReflectionException) { + return new MixedType; + } } private function isNativeExpectationProperty(string $propertyName): bool diff --git a/tests/Type/Fixtures/MagicPropertyObject.php b/tests/Type/Fixtures/MagicPropertyObject.php new file mode 100644 index 0000000..53ca891 --- /dev/null +++ b/tests/Type/Fixtures/MagicPropertyObject.php @@ -0,0 +1,13 @@ +author; assertType('Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\Author>', $result); } + +function testMagicPropertyDoesNotCrash(): void +{ + $object = new MagicPropertyObject; + $result = expect($object)->name; + assertType('Pest\Expectations\HigherOrderExpectation, mixed>', $result); +} + +function testMagicPropertyChainDoesNotCrash(): void +{ + $object = new MagicPropertyObject; + $result = expect($object)->name->toBe('Nuno'); + assertType('Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\MagicPropertyObject>', $result); +} + +function testUnionWithoutClassPropertyDoesNotCrash(): void +{ + /** @var array|object|null $payload */ + $payload = null; + $result = expect($payload)->currentUser; + assertType('Pest\Expectations\HigherOrderExpectation|object|null>, mixed>', $result); +}