From 989fe1b2153aa1ec25efeff34a698ee7f17d1239 Mon Sep 17 00:00:00 2001 From: Pawel Date: Wed, 7 Oct 2026 15:29:18 +0200 Subject: [PATCH 1/4] Resolve template types of method calls on a concrete generic static return type A return type like `static` (no template type in its arguments) was turned into a GenericObjectType that kept the ClassReflection of the declaring class, so methods called on the result saw unresolved TKey/TValue (`array`). Co-Authored-By: Claude Sonnet 5.5 --- ...ypeUnresolvedMethodPrototypeReflection.php | 15 +++++- .../generic-static-concrete-arguments.php | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/generic-static-concrete-arguments.php diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php index ba61cd9c1fc..45733233a14 100644 --- a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php @@ -11,6 +11,7 @@ use PHPStan\Reflection\Php\ExtendedDummyParameter; use PHPStan\Reflection\ResolvedMethodReflection; use PHPStan\Turbo\ShadowedByTurboExtension; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\GenericStaticType; use PHPStan\Type\StaticType; use PHPStan\Type\ThisType; @@ -149,7 +150,19 @@ private function transformStaticType(Type $type): Type if (count($calledOnTypeReflections) === 1) { $calledOnTypeReflection = $calledOnTypeReflections[0]; - return $traverse($type->changeBaseClass($calledOnTypeReflection)->getStaticObjectType()); + $staticObjectType = $type->changeBaseClass($calledOnTypeReflection)->getStaticObjectType(); + if ($staticObjectType instanceof GenericObjectType) { + // do not carry over the class reflection of the declaring class, + // it would not know the type arguments of the resolved type + $staticObjectType = new GenericObjectType( + $staticObjectType->getClassName(), + $staticObjectType->getTypes(), + $staticObjectType->getSubtractedType(), + variances: $staticObjectType->getVariances(), + ); + } + + return $traverse($staticObjectType); } return $this->calledOnType; diff --git a/tests/PHPStan/Analyser/nsrt/generic-static-concrete-arguments.php b/tests/PHPStan/Analyser/nsrt/generic-static-concrete-arguments.php new file mode 100644 index 00000000000..720f2246b7c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/generic-static-concrete-arguments.php @@ -0,0 +1,49 @@ + */ + public function concrete() + { + return new static(); + } + + /** @return static */ + public function withTemplate() + { + return new static(); + } + + /** @return array */ + public function all(): array + { + return []; + } + + /** @return TValue */ + public function first() + { + return 1; + } + +} + +/** + * @param Box $box + */ +function test(Box $box): void +{ + assertType('GenericStaticConcreteArguments\Box', $box->concrete()); + assertType('array', $box->concrete()->all()); + assertType('string', $box->concrete()->first()); + assertType('array', $box->withTemplate()->all()); +} From 529120b10f718b29a2272576d3031794870ab0a2 Mon Sep 17 00:00:00 2001 From: Pawel Date: Wed, 7 Oct 2026 16:07:00 +0200 Subject: [PATCH 2/4] Port the generic static return type resolution to the turbo extension Mirrors CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType() from the previous commit. Co-Authored-By: Claude Sonnet 5.5 --- ...ypeUnresolvedMethodPrototypeReflection.cpp | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/turbo-ext/src/CalledOnTypeUnresolvedMethodPrototypeReflection.cpp b/turbo-ext/src/CalledOnTypeUnresolvedMethodPrototypeReflection.cpp index 57c3986c2c1..ce5c9373118 100644 --- a/turbo-ext/src/CalledOnTypeUnresolvedMethodPrototypeReflection.cpp +++ b/turbo-ext/src/CalledOnTypeUnresolvedMethodPrototypeReflection.cpp @@ -217,6 +217,29 @@ class CalledOnTypeUnresolvedMethodPrototypeReflection } zv::Val staticObjectType = pt_type_call(Z_OBJ_P(changed.raw()), PT_LC("getstaticobjecttype"), 0, NULL); if (UNEXPECTED(staticObjectType.isUndef())) return; + /* a GenericObjectType is rebuilt without the class reflection of the + * declaring class, which would not know the resolved type arguments: + * new GenericObjectType($t->getClassName(), $t->getTypes(), $t->getSubtractedType(), variances: $t->getVariances()) */ + bool isGenericObject; + pt_type_instanceof_ce(staticObjectType.raw(), pt_ce_generic_object_type, isGenericObject); + if (isGenericObject) { + zend_object *objectType = Z_OBJ_P(staticObjectType.raw()); + zv::Val className = pt_type_call(objectType, PT_LC("getclassname"), 0, NULL); + if (UNEXPECTED(className.isUndef())) return; + zv::Val objectTypes = pt_type_call(objectType, PT_LC("gettypes"), 0, NULL); + if (UNEXPECTED(objectTypes.isUndef())) return; + zv::Val subtracted = pt_type_call(objectType, PT_LC("getsubtractedtype"), 0, NULL); + if (UNEXPECTED(subtracted.isUndef())) return; + zv::Val objectVariances = pt_type_call(objectType, PT_LC("getvariances"), 0, NULL); + if (UNEXPECTED(objectVariances.isUndef())) return; + if (UNEXPECTED(Z_TYPE_P(className.raw()) != IS_STRING)) { + zend_type_error("phpstan_turbo: getClassName() must return a string"); + return; + } + zval rebuiltRaw; + if (UNEXPECTED(!pt_generic_object_type_new(&rebuiltRaw, Z_STR_P(className.raw()), objectTypes.raw(), subtracted.raw(), NULL, objectVariances.raw()))) return; + staticObjectType = zv::Val::adopt(rebuiltRaw); + } zv::Val traversed = pt_type_call_callable(&argv[1], 1, staticObjectType.raw()); if (UNEXPECTED(traversed.isUndef())) return; traversed.intoReturnValue(return_value); From e89b5763f5e909e9c883e9f11e43cefba0c76e6d Mon Sep 17 00:00:00 2001 From: Pawel Date: Fri, 16 Oct 2026 10:44:14 +0200 Subject: [PATCH 3/4] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 22991812fa2..323b5af7278 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '1cb5faf'; + public const EXPECTED_EXTENSION_VERSION = '529120b'; private static bool $active = false; From b1ede1d05c70976af4d1d962e3e52547ad11dc9b Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 11 Oct 2026 01:10:39 +0200 Subject: [PATCH 4/4] Test the non-parse exception that stops the result cache save with a rule Since the BetterReflection update in 613b1b122, a class that extends itself is no longer a reflection error, so the last part of the result-cache-parse-error e2e test failed. A rule that throws ClassNotFoundException now tests the same branch of the save, in the result-cache-internal-error fixture. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e-tests.yml | 12 ++++----- .../class-not-found.neon | 8 ++++++ .../rule/ClassNotFoundRule.php | 27 +++++++++++++++++++ e2e/result-cache-parse-error/loop/Loop.php | 8 ------ 4 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 e2e/result-cache-internal-error/class-not-found.neon create mode 100644 e2e/result-cache-internal-error/rule/ClassNotFoundRule.php delete mode 100644 e2e/result-cache-parse-error/loop/Loop.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b0f15a8a69d..9ba71fefc0a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1352,12 +1352,6 @@ jobs: OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vvv --error-format raw") echo "$OUTPUT" ../bashunit -a not_contains 'not found' "$OUTPUT" - # A reflection error, here a class that extends itself, still keeps the result cache from - # being saved. - cp loop/Loop.php src/Loop.php - OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vvv --error-format raw") - echo "$OUTPUT" - ../bashunit -a contains 'Result cache was not saved because of non-ignorable exception: Reflection error: Circular reference' "$OUTPUT" - script: | cd e2e/result-cache-internal-error composer dump-autoload @@ -1367,6 +1361,12 @@ jobs: ../bashunit -a contains 'Internal error: The rule crashed.' "$OUTPUT" ../bashunit -a contains 'Result cache was not saved' "$OUTPUT" test ! -f tmp/resultCache.php + # An exception about the analysed code other than a parse error, here a class that is not + # found, also keeps the result cache from being saved. + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -c class-not-found.neon -vvv --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains 'Result cache was not saved because of non-ignorable exception: Class ResultCacheE2EInternalError\Missing was not found' "$OUTPUT" + test ! -f tmp/resultCache.php - script: | cd e2e/result-cache-string-class ../../bin/phpstan analyse diff --git a/e2e/result-cache-internal-error/class-not-found.neon b/e2e/result-cache-internal-error/class-not-found.neon new file mode 100644 index 00000000000..4f32bf4b7e6 --- /dev/null +++ b/e2e/result-cache-internal-error/class-not-found.neon @@ -0,0 +1,8 @@ +parameters: + level: 8 + tmpDir: tmp + paths: + - src + +rules: + - ResultCacheE2EInternalError\ClassNotFoundRule diff --git a/e2e/result-cache-internal-error/rule/ClassNotFoundRule.php b/e2e/result-cache-internal-error/rule/ClassNotFoundRule.php new file mode 100644 index 00000000000..14d529c4e6c --- /dev/null +++ b/e2e/result-cache-internal-error/rule/ClassNotFoundRule.php @@ -0,0 +1,27 @@ + + */ +final class ClassNotFoundRule implements Rule +{ + + public function getNodeType(): string + { + return Function_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + throw new ClassNotFoundException('ResultCacheE2EInternalError\Missing'); + } + +} diff --git a/e2e/result-cache-parse-error/loop/Loop.php b/e2e/result-cache-parse-error/loop/Loop.php deleted file mode 100644 index 048567eb03e..00000000000 --- a/e2e/result-cache-parse-error/loop/Loop.php +++ /dev/null @@ -1,8 +0,0 @@ -