From 9bfe49c73dd81a14001b8d9ac95175883bdaa67e Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:48:20 +0000 Subject: [PATCH] Consult `ExpressionTypeResolverExtension`s in `MutatingScope::resolveScopeStateType()` - The state-type read (getStateType()) used by the non-nullability ensure of `?->`, `??`, isset()/empty() and by narrowing skipped expression type resolver extensions, unlike resolveType(). A receiver typed only by an extension (e.g. a universal object crate property) was read as mixed, so `$crate->holder->day` was never ensured non-null and the plain twin of `$crate->holder->day?->toDateString()` was checked against `Day|null`. - Non-variable expressions now ask the extensions first (on the native- promoted scope for the native flavour), mirroring resolveType(). - Ported the same change to the turbo-ext mirror (MutatingScope.cpp). - The same root cause also broke, and this fixes: chained `?->` over an extension-typed receiver, `?->` property fetch on an offset of it, `$crate->holder->day->date ?? null` (was mixed), and `!== null` narrowing of `$crate->holder->day` (was mixed~null). --- src/Analyser/MutatingScope.php | 12 ++++ .../ExpressionTypeResolverExtensionTest.php | 1 + ...ug15421ExpressionTypeResolverExtension.php | 28 ++++++++ tests/PHPStan/Analyser/data/bug-15421.php | 44 ++++++++++++ .../expression-type-resolver-extension.neon | 9 ++- ...uleExpressionTypeResolverExtensionTest.php | 67 +++++++++++++++++++ ...uleExpressionTypeResolverExtensionTest.php | 55 +++++++++++++++ turbo-ext/src/MutatingScope.cpp | 26 +++++++ 8 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/data/Bug15421ExpressionTypeResolverExtension.php create mode 100644 tests/PHPStan/Analyser/data/bug-15421.php create mode 100644 tests/PHPStan/Rules/Methods/CallMethodsRuleExpressionTypeResolverExtensionTest.php create mode 100644 tests/PHPStan/Rules/Properties/AccessPropertiesRuleExpressionTypeResolverExtensionTest.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 85867200a20..83c59b75f89 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3423,6 +3423,18 @@ private function getScopeStateNativeType(Expr $expr): Type */ private function resolveScopeStateType(Expr $expr, bool $native): Type { + if (!$expr instanceof Variable) { + // mirror resolveType(): an extension's answer wins over both the + // tracked holder and the reflection-derived read below + $askScope = $native ? $this->doNotTreatPhpDocTypesAsCertain() : $this; + foreach ($this->services->expressionTypeResolverExtensions->getAll() as $extension) { + $type = $extension->getType($expr, $askScope); + if ($type !== null) { + return $type; + } + } + } + if (!$expr instanceof Variable && $this->hasExpressionType($expr)->yes()) { // mirror resolveType()'s tracked-holder lookup without pricing the // node - the tracked type IS scope state (the extension hook is diff --git a/tests/PHPStan/Analyser/ExpressionTypeResolverExtensionTest.php b/tests/PHPStan/Analyser/ExpressionTypeResolverExtensionTest.php index 6c3d707942a..659486277bc 100644 --- a/tests/PHPStan/Analyser/ExpressionTypeResolverExtensionTest.php +++ b/tests/PHPStan/Analyser/ExpressionTypeResolverExtensionTest.php @@ -11,6 +11,7 @@ class ExpressionTypeResolverExtensionTest extends TypeInferenceTestCase public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/expression-type-resolver-extension.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/bug-15421.php'); } /** diff --git a/tests/PHPStan/Analyser/data/Bug15421ExpressionTypeResolverExtension.php b/tests/PHPStan/Analyser/data/Bug15421ExpressionTypeResolverExtension.php new file mode 100644 index 00000000000..4ece4c6c581 --- /dev/null +++ b/tests/PHPStan/Analyser/data/Bug15421ExpressionTypeResolverExtension.php @@ -0,0 +1,28 @@ +name instanceof Identifier + && $expr->name->name === 'holder' + && (new ObjectType('Bug15421\Crate'))->isSuperTypeOf($scope->getType($expr->var))->yes()) { + return new ObjectType('Bug15421\Holder'); + } + + return null; + } + +} diff --git a/tests/PHPStan/Analyser/data/bug-15421.php b/tests/PHPStan/Analyser/data/bug-15421.php new file mode 100644 index 00000000000..792bb3e20fb --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-15421.php @@ -0,0 +1,44 @@ +day; } +} +final class Crate {} + +function test(Crate $crate): void +{ + $a = $crate->holder->day?->toDateString(); // reported on 2.3.0 + $h = $crate->holder; + $b = $h->day?->toDateString(); // not reported +} + +function analogous(Crate $crate): void +{ + assertType('Bug15421\Holder', $crate->holder); + assertType('Bug15421\Day|null', $crate->holder->day); + assertType('string|null', $crate->holder->day?->toDateString()); + assertType('string|null', $crate->holder->day?->date); + assertType('Bug15421\Day|null', $crate->holder->day?->next()?->next()); + assertType('string|null', $crate->holder->getDay()?->toDateString()); + assertType('string|null', $crate->holder->getDay()?->date); + assertType('string|null', $crate->holder->day->date ?? null); + assertType('string|null', $crate->holder->days['day']?->date); + assertType('bool', isset($crate->holder->day->date)); + assertType('bool', empty($crate->holder->day->date)); + if ($crate->holder->day !== null) { + assertType('Bug15421\Day', $crate->holder->day); + assertType('string', $crate->holder->day->toDateString()); + } +} diff --git a/tests/PHPStan/Analyser/expression-type-resolver-extension.neon b/tests/PHPStan/Analyser/expression-type-resolver-extension.neon index de0f92640b6..02969d5fcee 100644 --- a/tests/PHPStan/Analyser/expression-type-resolver-extension.neon +++ b/tests/PHPStan/Analyser/expression-type-resolver-extension.neon @@ -1,7 +1,14 @@ # config for ExpressionTypeResolverExtensionTest +parameters: + universalObjectCratesClasses: + - Bug15421\Crate + services: - class: ExpressionTypeResolverExtension\MethodCallReturnsBoolExpressionTypeResolverExtension tags: - phpstan.broker.expressionTypeResolverExtension - + - + class: ExpressionTypeResolverExtension\Bug15421ExpressionTypeResolverExtension + tags: + - phpstan.broker.expressionTypeResolverExtension diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleExpressionTypeResolverExtensionTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleExpressionTypeResolverExtensionTest.php new file mode 100644 index 00000000000..f3e7b73d15e --- /dev/null +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleExpressionTypeResolverExtensionTest.php @@ -0,0 +1,67 @@ + + */ +class CallMethodsRuleExpressionTypeResolverExtensionTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + $ruleLevelHelper = new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ); + return new CallMethodsRule( + new MethodCallCheck( + $reflectionProvider, + $ruleLevelHelper, + checkFunctionNameCase: true, + reportMagicMethods: true, + ), + new FunctionCallParametersCheck( + $ruleLevelHelper, + new NullsafeCheck(), + new UnresolvableTypeHelper(), + new PropertyReflectionFinder(), + $reflectionProvider, + checkArgumentTypes: true, + checkArgumentsPassedByReference: true, + checkExtraArguments: true, + checkMissingTypehints: true, + ), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), + ); + } + + public function testBug15421(): void + { + $this->analyse([__DIR__ . '/../../Analyser/data/bug-15421.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../Analyser/expression-type-resolver-extension.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleExpressionTypeResolverExtensionTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleExpressionTypeResolverExtensionTest.php new file mode 100644 index 00000000000..9f5983b4d94 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleExpressionTypeResolverExtensionTest.php @@ -0,0 +1,55 @@ + + */ +class AccessPropertiesRuleExpressionTypeResolverExtensionTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + $ruleLevelHelper = new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ); + return new AccessPropertiesRule( + new AccessPropertiesCheck( + $reflectionProvider, + $ruleLevelHelper, + new PhpVersion(PHP_VERSION_ID), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), + reportMagicProperties: true, + checkDynamicProperties: false, + ), + ); + } + + public function testBug15421(): void + { + $this->analyse([__DIR__ . '/../../Analyser/data/bug-15421.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../Analyser/expression-type-resolver-extension.neon', + ]; + } + +} diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index 1138b46ed9a..7582bbb886d 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -10549,6 +10549,32 @@ class MutatingScope ZVAL_OBJ(&exprZv, expr); bool isVariable; if (UNEXPECTED(!isInstance(zv::Ref(&exprZv), PT_CLASS_VARIABLE, isVariable))) return zv::Val(); + if (!isVariable) { + /* mirror resolveType(): an extension's answer wins over both the + * tracked holder and the reflection-derived read below */ + zv::Val askScope = native ? thisDoNotTreatPhpDocTypesAsCertain() : self_(); + if (UNEXPECTED(askScope.isUndef())) return zv::Val(); + zv::Ref extensions = service(PT_MSS_EXPRESSION_TYPE_RESOLVER_EXTENSIONS); + if (UNEXPECTED(!extensions.isObject())) return uninitializedProperty("expressionTypeResolverExtensions"); + zv::Val all = pt_extensions_collection_get_all(extensions.asObject()); + if (UNEXPECTED(all.isUndef())) return zv::Val(); + if (EXPECTED(Z_TYPE_P(all.raw()) == IS_ARRAY)) { + zv::Args args{expr, askScope.raw()}; + for (auto entry : zv::ArrRef(all.raw())) { + zv::Ref extension = entry.value().deref(); + if (UNEXPECTED(!extension.isObject())) { + zend_throw_error(NULL, "Call to a member function getType() on %s", zend_zval_value_name(extension.raw())); + return zv::Val(); + } + zv::Val type = pt_type_call(extension.asObject(), PT_LC("gettype"), 2, args); + if (UNEXPECTED(type.isUndef())) return zv::Val(); + if (!type.isNull()) return type; + } + } else { + zend_error(E_WARNING, "foreach() argument must be of type array|object, %s given", zend_zval_value_name(all.raw())); + if (UNEXPECTED(EG(exception))) return zv::Val(); + } + } if (!isVariable) { zv::Val has = thisHasExpressionType(&exprZv); if (UNEXPECTED(has.isUndef())) return zv::Val();