Rebuild GatheringNodeCallback layers in VirtualAssignNodeCallback instead of wrapping them - #6172
Open
phpstan-bot wants to merge 1 commit into
Open
Conversation
… instead of wrapping them * `VirtualAssignNodeCallback` used to wrap the whole callback chain, which hid any `GatheringNodeCallback` from `FiberNodeScopeResolver::callNodeCallback()`. The gatherer then ran inside the fiber and could be parked past the point where its result is read. * Added `VirtualAssignNodeCallback::create()`, which recursively rebuilds the chain so gathering layers stay on the outside and keep running at the emission position; `NodeScopeResolver::processVirtualAssign()` now uses it. * This restores `hasAssign` on `NoopExpressionNode` for every construct routed through `processVirtualAssign()` - `$this->prop++`/`--`, `++$this->prop`/`--`, `$this->arr['k']++`, by-ref function-call writes, unset-offset and foreach virtual assigns - not just the reported `match` arm case. * Analogous false positives fixed by the same change: `Unused result of "&&"/"||" operator` when the operand only increments a property, `Expression "[$this->a++]" ... does not do anything`, and nested `match` arms. * Probed and found already correct: `LogicalAnd`/`LogicalOr`/`LogicalXor` and ternary statements (reported before the `hasAssign` check on purpose), plain `=`/`+=` property assignments and list destructuring (they emit through the unwrapped callback), and `NoopNodeCallback` (discards every node, so it has nothing to gather).
ondrejmirtes
requested changes
Aug 2, 2026
ondrejmirtes
left a comment
Member
There was a problem hiding this comment.
Test should be in NoopRuleTest rather than AnalyserIntegrationTest.
Collaborator
Author
|
The suite is still running; I'll report as soon as it lands. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
match ($x) { A => $this->counterA++, B => $this->counterB++ };used as a statement was reported asExpression "match ($x) {…" on a separate line does not do anything.even though every arm mutates a property.NoopRuleskips the report when the statement contained an assignment —NodeScopeResolvertracks that with a$hasAssigngatherer that watches forPropertyAssignNode/VariableAssignNode. Inc/dec goes throughNodeScopeResolver::processVirtualAssign(), which does emit aPropertyAssignNode, but the node arrived at the gatherer after$hasAssignhad already been read.Changes
src/Analyser/VirtualAssignNodeCallback.php— added acreate()factory that recursively rebuilds the callback chain: when the wrapped callback is aGatheringNodeCallback, a newGatheringNodeCallbackis returned whose gatherer and inner callback are each filtered separately, instead of one filter wrapped around the whole chain. The constructor is now private.src/Analyser/NodeScopeResolver.php—processVirtualAssign()builds its callback withVirtualAssignNodeCallback::create().Analogous cases fixed by the same change (all verified failing before the fix, passing after, and all covered by the regression test):
matcharms with$this->prop--(post-decrement)matcharms with$this->arr['k']++/$this->arr['k']--(array-dim virtual assign)matcharms$cond && ($this->a++ > 0);→Unused result of "&&" operator.$cond || ($this->b-- > 0);→Unused result of "||" operator.[$this->a++];→Expression "[$this->a++]" on a separate line does not do anything.Probed and found already correct, so no test kept for them:
and/or/xorstatements and ternary statements — these are reported before thehasAssign()check inNoopRuleby design (precedence tips), so they are unaffected.$this->a = 1;and$this->a += 1;insidematcharms —AssignHandler/AssignOpHandleremit through the unwrapped node callback, so the gathering layer was never hidden.AssignHandler::prepareTarget()withAssignTargetWalkMode::virtualAssign()) — that path passes$nodeCallbackstraight through.NoopNodeCallback, the only otherShallowNodeCallback— it discards every node, so it has nothing to gather and cannot hide a gatherer.Root cause
FiberNodeScopeResolver::callNodeCallback()peelsGatheringNodeCallbacklayers off the top of the chain and runs their gatherers synchronously, precisely because "their arrays are read as soon as the enclosing body walk returns". Only the rule-facing remainder may be deferred into a fiber.VirtualAssignNodeCallbackbroke that invariant: it wrapped the entire chain — gatherers included — in an opaqueShallowNodeCallback.callNodeCallback()therefore saw no gathering layer, put the whole chain into a fiber, and if the rule handlingPropertyAssignNodeasked for a type of an expression that had not been walked yet (TypesAssignedToPropertiesRulecalls$scope->getType($node->getAssignedExpr())), the fiber parked. It was only resumed at the end of the function body byprocessPendingFibers()— long afterNodeScopeResolverhad read$hasAssignand emittedNoopExpressionNode($stmt->expr, false).The pattern is "a node-callback wrapper that hides gathering layers from the fiber resolver".
VirtualAssignNodeCallbackwas the only wrapper with that shape; the fix makes it preserve the chain structure rather than nest inside it. Because it is the single entry point for every virtual assign, all callers ofprocessVirtualAssign()(inc/dec handlers,unset()of an offset,foreachtargets, by-ref function-call writes such aspreg_match/array_shift) are fixed at once.Note that the symptom was rule-set dependent: with only
NoopRuleregistered nothing parks and the bug does not appear, which is why the regression test runs the full rule set.Test
tests/PHPStan/Analyser/data/bug-15038.php+AnalyserIntegrationTest::testBug15038(). The data file starts with the reporter's playground snippet verbatim, then adds aMoreVirtualAssignsclass covering the analogous constructs listed above. The test runs the complete level-8 rule set (so a rule actually parks a fiber, as in the reported scenario) and asserts no errors.Before the fix the test fails with 8 errors (lines 15, 41, 57, 65, 75, 76, 81); after the fix it passes.
make tests,make phpstanandmake cs-fixare green, and the affected tests also pass withPHPSTAN_FNSR=0(fibers disabled).Fixes phpstan/phpstan#15038