Skip to content

Keep the template-argument constraints of a static variable's default and resolve the markers in inferred static variable types - #6812

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-y1ra59r
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-y1ra59r

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

On bleeding edge (unresolvedTemplateArguments + staticVariablesFromUsages), a static local variable initialised with a generic new expression kept its "unresolved" template-argument marker. Rules then saw it, so return $cache; reported should return Cache<string> but returns Cache<unresolved(string)>. Without the static keyword, there was no error.

This PR makes the static statement keep the constraints of its default expression, and resolves the markers left in the inferred static variable types once the template arguments are resolved.

Changes

  • src/Analyser/StmtHandler/StaticVariableHandler.php: add the default expression's template-argument constraints to the statement's scope with addTemplateArgumentConstraints(). This is the same pattern CoalesceHandler uses for its conditionally evaluated right side.
  • src/Analyser/Generics/TemplateArgumentFrame.php: on a resolved frame, withStaticVariableTypes() now maps each inferred type through a new private resolveMarkers(). Each non-closure-signature UnresolvedTemplateArgumentType becomes the frame's resolution, falling back to the initial type and then to the unconstrained default/bound. This is the same order GenericTypeTemplateTraverser uses at a site.
  • turbo-ext/src/StaticVariableHandler.cpp and turbo-ext/src/TemplateArgumentFrame.cpp: native ports of both changes.

Analogous cases probed:

  • Fixed by this change (each one failed before):
    • a marker created in the body (static $c = null; if ($c === null) { $c = new Cache(...); })
    • a generic function call as the default
    • a run of two static variables
    • a send to a typed parameter instead of a return
    • a plain function
    • a closure body (before: mixed)
  • Already correct: a closure stored in a static variable stays Closure(mixed): mixed and leaks no marker, so it is unchanged.

Root cause

There were two gaps, and both have to be fixed for the reported case:

  1. StaticVariableHandler discarded the scope returned by processing the default expression. The withSite/withSend facts that the new Cache(...) site recorded there never reached the body's constraints, so the resolver had nothing to resolve.
  2. The types of static variables are collected during the observation walks, so they contain markers. They were installed into the resolved frame as they were, and the second pass assigned them to the variable at the static statement. Unlike a new expression, nothing on that path ever substituted the resolutions. This also broke the case where the site is in the body, because the type at the static statement is joined from the body's observed types.

Test

tests/PHPStan/Analyser/nsrt/bug-15416.php contains the issue's reproducer verbatim, plus the analogous cases listed above. It fails before the fix and passes after. The test also passes with only the native port active (PHP twins reverted, extension loaded).

Verification:

  • make tests passes, both without and with phpstan_turbo loaded.
  • make phpstan passes.
  • turbo smoke.php, side-by-side.php, signature-parity.php and walk-trace.php all pass; the walk traces are identical.
  • make name-collision aborts on a parse error in tests/PHPStan/Build/data/final-class-rule-pipe.php. This also happens on a clean tree; the new test namespace is unique.
  • make lint-turbo was not run: clang-tidy is pinned to 21 and only 18 is installed.

Fixes phpstan/phpstan#15416

🤖 Generated with Claude Code

…lt 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spurious type error for static local variable

1 participant