From 9a0bcab5ce1dda95ec454242ad6e4ef3b8d1b403 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:41:23 +0000 Subject: [PATCH] Keep the template-argument constraints of a `static` variable's default and resolve the markers in inferred `static` variable types - StaticVariableHandler: carry the template-argument constraints of the scope the default expression returned onto the statement's scope, the way CoalesceHandler carries those of its conditionally evaluated right side. Before this, the `new` / generic call sites in `static $x = new Foo(...)` recorded no facts, so their template arguments were never resolved. - TemplateArgumentFrame::withStaticVariableTypes(): on a resolved frame, replace the UnresolvedTemplateArgumentType markers in the inferred types (collected by the observation walks) with the frame's resolution, the initial type, or the unconstrained default/bound - the same order GenericTypeTemplateTraverser uses at the site. Closure-signature markers are left alone. - Ported both changes to the turbo-ext mirrors (StaticVariableHandler.cpp, TemplateArgumentFrame.cpp). - Analogous cases covered by the regression test: a site created in the body (`static $c = null; $c = new ...`), a generic function call as the default, a run of two `static` variables, a send to a typed parameter instead of a return, a function, and a closure body. A closure stored in a `static` variable was probed too: it stays `Closure(mixed): mixed` with no marker leak, so it is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Generics/TemplateArgumentFrame.php | 25 ++++- .../StmtHandler/StaticVariableHandler.php | 2 + tests/PHPStan/Analyser/nsrt/bug-15416.php | 98 ++++++++++++++++++ turbo-ext/src/StaticVariableHandler.cpp | 13 +++ turbo-ext/src/TemplateArgumentFrame.cpp | 99 +++++++++++++++++++ 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15416.php diff --git a/src/Analyser/Generics/TemplateArgumentFrame.php b/src/Analyser/Generics/TemplateArgumentFrame.php index 87481248043..23b2ea73ed3 100644 --- a/src/Analyser/Generics/TemplateArgumentFrame.php +++ b/src/Analyser/Generics/TemplateArgumentFrame.php @@ -10,6 +10,7 @@ use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Turbo\ShadowedByTurboExtension; use PHPStan\Type\Generic\TemplateType; +use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; use function array_keys; @@ -112,7 +113,10 @@ public function getStaticVariableConditionalExpressions(Node\Stmt\Static_ $stmt) /** * The frame with the inferred types of the body's `static` variables; the - * statements holding them are walked again in the second pass. + * statements holding them are walked again in the second pass. The walks + * inferring the types observed, so on a resolved frame the markers of the + * types are replaced by what the frame resolved them to - as the second + * pass replaces them at their sites. * * @param array $staticVariableTypes * @param array $statementIndexes @@ -120,6 +124,12 @@ public function getStaticVariableConditionalExpressions(Node\Stmt\Static_ $stmt) */ public function withStaticVariableTypes(array $staticVariableTypes, array $statementIndexes, array $conditionalExpressions = []): self { + if ($this->resolutions !== null) { + foreach ($staticVariableTypes as $id => [$var, $type, $nativeType]) { + $staticVariableTypes[$id] = [$var, $this->resolveMarkers($type), $this->resolveMarkers($nativeType)]; + } + } + return new self( $this->parent, $this->resolutions, @@ -350,6 +360,19 @@ public function getResolutionCacheKeySuffix(): string return ''; } + private function resolveMarkers(Type $type): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof UnresolvedTemplateArgumentType && !$type->isClosureSignature()) { + $site = $type->getSite(); + + return $traverse($this->resolve($site, $type->getTemplateName()) ?? $type->getInitialType() ?? $this->resolveOrUnconstrained($site, $type->getTemplate())); + } + + return $traverse($type); + }); + } + private static function key(Expr $site, string $templateName): string { return spl_object_id($site) . '#' . $templateName; diff --git a/src/Analyser/StmtHandler/StaticVariableHandler.php b/src/Analyser/StmtHandler/StaticVariableHandler.php index cd06719e02b..03622fc011e 100644 --- a/src/Analyser/StmtHandler/StaticVariableHandler.php +++ b/src/Analyser/StmtHandler/StaticVariableHandler.php @@ -78,6 +78,8 @@ public function processStmt( $dependencies[] = $defaultExprResult->getDependencies(); $variableFlows[] = $defaultExprResult->getVariableFlow(); $impurePoints = array_merge($impurePoints, $defaultExprResult->getImpurePoints()); + // the default is evaluated by the first call only, but the sites it creates are the variable's + $scope = $scope->addTemplateArgumentConstraints($defaultExprResult->getScope()->getTemplateArgumentConstraints()); } $scope = $scope->enterExpressionAssign($var->var); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15416.php b/tests/PHPStan/Analyser/nsrt/bug-15416.php new file mode 100644 index 00000000000..0dab2a1e990 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15416.php @@ -0,0 +1,98 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15416; + +use function PHPStan\Testing\assertType; + +class Repro +{ + /** @return Cache */ + public static function getCache(): Cache + { + static $cache = new Cache(Cache::strings()); + assertType('Bug15416\Cache', $cache); + return $cache; + } + + /** @return Cache */ + public static function getLazyCache(): Cache + { + static $cache = null; + if ($cache === null) { + $cache = new Cache(Cache::strings()); + } + assertType('Bug15416\Cache', $cache); + return $cache; + } + + /** @return Cache */ + public static function getWrappedCache(): Cache + { + static $cache = wrap(Cache::strings()); + assertType('Bug15416\Cache', $cache); + return $cache; + } + + /** @return array{Cache, Cache} */ + public static function getCaches(): array + { + static $strings = new Cache(Cache::strings()); + static $ints = new Cache([1, 2]); + assertType('Bug15416\Cache', $strings); + assertType('Bug15416\Cache', $ints); + return [$strings, $ints]; + } + + public static function sendCache(): void + { + static $cache = new Cache(Cache::strings()); + assertType('Bug15416\Cache', $cache); + self::takeCache($cache); + } + + /** @param Cache $cache */ + public static function takeCache(Cache $cache): void + { + } + + public static function closure(): void + { + $f = /** @return Cache */ function (): Cache { + static $cache = new Cache(Cache::strings()); + assertType('Bug15416\Cache', $cache); + return $cache; + }; + } +} + +/** + * @template T + */ +class Cache +{ + /** @param list $x */ + public function __construct(public array $x) { } + + /** @return list */ + public static function strings(): array { return ['a']; } +} + +/** + * @template T + * @param list $x + * @return Cache + */ +function wrap(array $x): Cache +{ + return new Cache($x); +} + +/** @return Cache */ +function getCache(): Cache +{ + static $cache = new Cache(Cache::strings()); + assertType('Bug15416\Cache', $cache); + return $cache; +} diff --git a/turbo-ext/src/StaticVariableHandler.cpp b/turbo-ext/src/StaticVariableHandler.cpp index 8b65428edaf..659f9a0bae7 100644 --- a/turbo-ext/src/StaticVariableHandler.cpp +++ b/turbo-ext/src/StaticVariableHandler.cpp @@ -188,6 +188,19 @@ class StaticVariableHandler if (UNEXPECTED(variableFlow.isUndef())) return false; variableFlows.push(std::move(variableFlow)); if (UNEXPECTED(!mergeImpurePoints(impurePoints, defaultExprResult.raw()))) return false; + /* $scope = $scope->addTemplateArgumentConstraints($defaultExprResult->getScope()->getTemplateArgumentConstraints()); */ + zv::Val resultScopeHold; + zval *resultScope = pt_expression_result_scope(defaultExprResult.raw(), resultScopeHold); + if (UNEXPECTED(resultScope == NULL)) return false; + if (UNEXPECTED(Z_TYPE_P(resultScope) != IS_OBJECT)) { + zend_throw_error(NULL, "Call to a member function getTemplateArgumentConstraints() on %s", zend_zval_value_name(resultScope)); + return false; + } + zv::Val constraints = pt_mutating_scope_get_template_argument_constraints(Z_OBJ_P(resultScope)); + if (UNEXPECTED(constraints.isUndef())) return false; + zv::Val constrainedScope = pt_mutating_scope_add_template_argument_constraints(Z_OBJ_P(scope.raw()), constraints.raw()); + if (UNEXPECTED(constrainedScope.isUndef())) return false; + scope = std::move(constrainedScope); } var = ptsh::readNodeProperty(pt_svh_static_var_var_site, staticVar, PT_LC("var")); diff --git a/turbo-ext/src/TemplateArgumentFrame.cpp b/turbo-ext/src/TemplateArgumentFrame.cpp index 001fd35d37d..31fdac77d78 100644 --- a/turbo-ext/src/TemplateArgumentFrame.cpp +++ b/turbo-ext/src/TemplateArgumentFrame.cpp @@ -49,6 +49,7 @@ zv::Val call0(zval *object, const char *lcname, size_t len, const char *method) void resolveUnconstrainedCallback(zval *site, zval *captured, uint32_t argc, zval *argv, zval *return_value); void resolveCallback(zval *frame, zval *state1, uint32_t argc, zval *argv, zval *return_value); +void resolveMarkersCallback(zval *frame, zval *state1, uint32_t argc, zval *argv, zval *return_value); } // namespace @@ -203,6 +204,12 @@ class TemplateArgumentFrame if (UNEXPECTED(resolutions == NULL)) return zv::Val(); zval *indexes = pt_typed_slot(self, slots::siteStatementIndexes, self->ce, "siteStatementIndexes"); if (UNEXPECTED(indexes == NULL)) return zv::Val(); + zv::Val resolvedStaticVariableTypes; + if (Z_TYPE_P(resolutions) != IS_NULL) { + resolvedStaticVariableTypes = resolveStaticVariableTypeMarkers(staticVariableTypes); + if (UNEXPECTED(resolvedStaticVariableTypes.isUndef())) return zv::Val(); + staticVariableTypes = resolvedStaticVariableTypes.raw(); + } zv::Val mergedIndexes = arrayUnion(Z_ARRVAL_P(indexes), Z_ARRVAL_P(statementIndexes)); zval *body = OBJ_PROP_NUM(self, slots::closureSignatureBody); return create( @@ -219,6 +226,50 @@ class TemplateArgumentFrame ); } + /* withStaticVariableTypes()'s loop on a resolved frame: every entry + * [$var, $type, $nativeType] with both types through resolveMarkers(), + * under its key; UNDEF = pending exception */ + zv::Val resolveStaticVariableTypeMarkers(zval *staticVariableTypes) const + { + zv::Arr result = zv::Arr::create(zend_hash_num_elements(Z_ARRVAL_P(staticVariableTypes))); + for (auto entry : zv::ArrRef(staticVariableTypes)) { + zval *types = entry.value().deref().raw(); + zval *var = Z_TYPE_P(types) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL_P(types), 0) : NULL; + zval *type = Z_TYPE_P(types) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL_P(types), 1) : NULL; + zval *nativeType = Z_TYPE_P(types) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL_P(types), 2) : NULL; + if (UNEXPECTED(var == NULL || type == NULL || nativeType == NULL)) { + zend_throw_error(NULL, "TemplateArgumentFrame::withStaticVariableTypes(): an entry must be [$var, $type, $nativeType]"); + return zv::Val(); + } + zv::Val resolvedType = resolveMarkers(type); + if (UNEXPECTED(resolvedType.isUndef())) return zv::Val(); + zv::Val resolvedNativeType = resolveMarkers(nativeType); + if (UNEXPECTED(resolvedNativeType.isUndef())) return zv::Val(); + zv::Arr triple = zv::Arr::create(3); + triple.push(zv::Ref(var)); + triple.push(std::move(resolvedType)); + triple.push(std::move(resolvedNativeType)); + zend_string *key = entry.stringKeyOrNull(); + if (key != NULL) { + result.set(key, zv::Val(std::move(triple))); + } else { + zval tripleZv = zv::Val(std::move(triple)).take(); + zend_hash_index_update(result.table(), entry.indexKey(), &tripleZv); + } + } + return zv::Val(std::move(result)); + } + + /* Mirrors resolveMarkers(); UNDEF = pending exception */ + zv::Val resolveMarkers(zval *type) const + { + zval selfZv; + ZVAL_OBJ(&selfZv, self); + zv::Val callback = pt_type_native_callback(resolveMarkersCallback, &selfZv, NULL); + if (UNEXPECTED(callback.isUndef())) return zv::Val(); + return pt_type_traverser_map_of(type, callback.raw()); + } + /* Mirrors getByRefSiteMode(): the entry of this frame's byRefSites for * spl_object_id($site) holding that very node, then the parent's; the * interned 'local' / 'escaped', or PHP null. UNDEF = pending exception */ @@ -556,6 +607,54 @@ void resolveCallback(zval *frame, zval *state1, uint32_t argc, zval *argv, zval resolved.intoReturnValue(return_value); } +/* function (Type $type, callable $traverse): Type of resolveMarkers() — the + * frame in the first state slot */ +void resolveMarkersCallback(zval *frame, zval *state1, uint32_t argc, zval *argv, zval *return_value) +{ + (void) state1; + if (UNEXPECTED(argc < 2 || Z_TYPE(argv[0]) != IS_OBJECT)) { + zend_type_error("TemplateArgumentFrame traversal: expected (Type $type, callable $traverse)"); + return; + } + zval *type = &argv[0]; + zval *traverse = &argv[1]; + if (instanceof_function(Z_OBJCE_P(type), pt_ce_unresolved_template_argument_type)) { + bool closureSignature = false; + if (UNEXPECTED(!pt_unresolved_template_argument_type_is_closure_signature(type, closureSignature))) return; + if (!closureSignature) { + /* $traverse($this->resolve($site, $type->getTemplateName()) ?? $type->getInitialType() ?? $this->resolveOrUnconstrained($site, $type->getTemplate())) */ + zval *site = pt_unresolved_template_argument_type_site(type); + if (UNEXPECTED(site == NULL)) return; + zv::Val siteHold = zv::Val::copyOf(zv::Ref(site)); + zv::Val name = pt_unresolved_template_argument_type_get_template_name(type); + if (UNEXPECTED(name.isUndef())) return; + if (UNEXPECTED(Z_TYPE_P(name.raw()) != IS_STRING)) { + zend_type_error("%s::resolve(): Argument #2 ($templateName) must be of type string, %s given", ZSTR_VAL(Z_OBJCE_P(frame)->name), zend_zval_value_name(name.raw())); + return; + } + zv::Val replacement = TemplateArgumentFrame(Z_OBJ_P(frame)).resolve(siteHold.raw(), Z_STR_P(name.raw())); + if (UNEXPECTED(replacement.isUndef())) return; + if (Z_TYPE_P(replacement.raw()) == IS_NULL) { + replacement = pt_type_call(Z_OBJ_P(type), PT_LC("getinitialtype"), 0, NULL); + if (UNEXPECTED(replacement.isUndef())) return; + } + if (Z_TYPE_P(replacement.raw()) == IS_NULL) { + zv::Val templateType = pt_type_call(Z_OBJ_P(type), PT_LC("gettemplate"), 0, NULL); + if (UNEXPECTED(templateType.isUndef())) return; + replacement = TemplateArgumentFrame(Z_OBJ_P(frame)).resolveOrUnconstrained(siteHold.raw(), templateType.raw()); + if (UNEXPECTED(replacement.isUndef())) return; + } + zv::Val traversed = pt_type_call_callable(traverse, 1, replacement.raw()); + if (UNEXPECTED(traversed.isUndef())) return; + traversed.intoReturnValue(return_value); + return; + } + } + zv::Val traversed = pt_type_call_callable(traverse, 1, type); + if (UNEXPECTED(traversed.isUndef())) return; + traversed.intoReturnValue(return_value); +} + /* static function (Type $type, callable $traverse) use ($site, $scope, $resolve): Type * — $site in the first state slot, [$scope, $resolve] in the second */ void resolveUnconstrainedCallback(zval *site, zval *captured, uint32_t argc, zval *argv, zval *return_value)