Skip to content

Consume only the nullsafe twin's receiver from stored results instead of its whole subtree - #6782

Merged
ondrejmirtes merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-qe8gvoc
Oct 23, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-qe8gvoc

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Arguments of a nullsafe method call (and the dynamic name of a nullsafe property fetch or method call) inside a loop that needs more than one convergence pass were never walked in later passes. Rules saw none of the nodes inside them, so errors went missing (method.notFound, property.notFound, binaryOp.invalid, …) and property.onlyWritten was reported falsely for properties read only there.

This change limits the nullsafe handlers' "consume stored results" mode to the one node it was introduced for: the twin's receiver.

Changes

  • src/Analyser/NodeScopeResolver.php:
    • processExprNodeConsumingStored() takes the node to consume (Expr $consumedExpr).
    • The private bool $consumeStoredExpressionResults flag becomes ?Expr $consumeStoredExpression.
    • processExprNode() consumes a stored result unconditionally only when $expr === $consumeStoredExpression. Every other node in the twin subtree takes the normal path.
    • isConsumingStoredExpressionResults() was removed.
  • src/Analyser/ExprHandler/NullsafeMethodCallHandler.php, NullsafePropertyFetchHandler.php: pass $expr->var as the consumed node.
  • src/Analyser/ArgumentsHandler.php: closure / arrow-function arguments no longer answer from storage in consume mode. Consume mode never reaches arguments now. That branch was the same bug for a closure passed to a ?-> call in a loop: its body was skipped in later passes.
  • turbo-ext/src/NodeScopeResolver.cpp, ArgumentsHandler.cpp, NullsafeMethodCallHandler.cpp, NullsafePropertyFetchHandler.cpp, support.h, generated/NodeScopeResolver.h: the same changes in the C++ mirror.

Analogous cases I probed:

  • Fixed by this change, with tests: foreach / while / do-while / for, closure and arrow-function arguments, a dynamic ?->{…} property name, a dynamic ?->{…}() method name, nested ?-> in arguments.
  • Also fixed, checked manually: chained ?->, ?-> under ?? / isset, spread and named arguments.

Root cause

The nullsafe handlers walk the receiver once, then walk a plain MethodCall / PropertyFetch twin in consume mode so the receiver isn't walked twice. The flag covered the whole twin subtree, though, and in consume mode processExprNode() returned any stored result without walking the node.

Before #6765 that was harmless, because every pass started from fresh storage. Since #6765, later passes are seeded with the previous pass's results, so the twin's arguments found pass-1 results and were consumed instead of walked. As a result:

  • no node callbacks fired for them;
  • ClassStatementsGatherer recorded no property reads;
  • the final walk adopted that pass's recording.

Consuming only the receiver brings back the intended behaviour and keeps #6765's seeding.

Note: turbo-ext/src changed, so make bump-turbo is needed after this commit lands.

Test

  • AnalyserIntegrationTest::testBug15520 (tests/PHPStan/Analyser/data/bug-15520.php): the issue's "missing errors" playground snippet, verbatim, plus while, do-while, for, closure argument, arrow-function argument, dynamic nullsafe property name, nested ?-> and dynamic nullsafe method name variants. All expected errors are asserted.
  • UnusedPrivatePropertyRuleTest::testBug15520 (tests/PHPStan/Rules/DeadCode/data/bug-15520.php): the issue's property.onlyWritten playground snippet, verbatim, plus while and dynamic-property-name variants.
  • Both tests fail without the fix: 2 of 10 errors reported, and 2 false property.onlyWritten. Both pass with the fix, with and without the turbo extension loaded.

Fixes phpstan/phpstan#15520

@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-qe8gvoc branch 2 times, most recently from 523c7b6 to e05877e Compare October 23, 2026 08:27
… of its whole subtree

- NodeScopeResolver::processExprNodeConsumingStored() now takes the node to
  consume (the twin's receiver). The bool $consumeStoredExpressionResults flag
  becomes ?Expr $consumeStoredExpression, and processExprNode() consumes a
  stored result unconditionally only for that exact node. Everything else in
  the plain MethodCall / PropertyFetch twin (arguments, a dynamic method or
  property name) takes the normal path.
- Since phpstan#6765, later loop convergence passes are seeded with the previous
  pass's stored results. The over-broad consume mode then answered the twin's
  arguments from pass-1 results without walking them. Rules never saw those
  nodes and gatherers never recorded them, so errors went missing and
  property.onlyWritten was reported falsely.
- NullsafeMethodCallHandler / NullsafePropertyFetchHandler pass $expr->var
  as the consumed node.
- ArgumentsHandler no longer consumes stored closure / arrow-function
  argument results in consume mode (removed
  NodeScopeResolver::isConsumingStoredExpressionResults()). Consume mode no
  longer reaches arguments, and that branch was the same bug for closures in
  `?->` arguments.
- Mirrored in turbo-ext (NodeScopeResolver.cpp, ArgumentsHandler.cpp,
  Nullsafe*Handler.cpp, support.h, generated/NodeScopeResolver.h).
- Probed and covered: foreach/while/do-while/for, closure and arrow-function
  arguments, a dynamic nullsafe property name, a dynamic nullsafe method name,
  nested `?->` in arguments. Also checked chained `?->`, `??`/isset around
  `?->`, spread and named arguments; all are fixed by the same change.
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-qe8gvoc branch from e05877e to fd5c6e2 Compare October 23, 2026 10:56
@ondrejmirtes
ondrejmirtes merged commit 119d067 into phpstan:2.3.x Oct 23, 2026
988 of 991 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-qe8gvoc branch October 23, 2026 13:07
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.

Arguments of a nullsafe method call inside a multi-pass loop are skipped since #6765 (missing errors, false property.onlyWritten)

2 participants