Invalidate maybe-impure function return values after impure method/static calls - #5667
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
find is marked as impure
/**
* @return array<int, self>
* @phpstan-impure
*/
public static function find(): array {
return self::$storage;
}
So this fix is wrong ; the real issue is the fact that
count(MyRecord::find())
is a memoized expression even if it contains an impure call.
count($expr) can only be memorize if it does not contains impure $expr.
Maybe the same issue exist for other rememberedExpr ?
|
Pushed. Here's a summary of the changes: Root cause identified: The previous approach (post-hoc invalidation via Fix: Added Key behaviors:
Removed: |
VincentLanglet
left a comment
There was a problem hiding this comment.
This produce a regression on phpstan-beberlei-assert tests which does not report the second call
Assert::uuid($a);
Assert::uuid($a);
anymore ; any idea why ?
ac9419d to
eb6ad5a
Compare
| private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool | ||
| { | ||
| $nodeFinder = new NodeFinder(); | ||
| $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool { |
There was a problem hiding this comment.
do we have the same bug on impure ArrowFunctions and impure Closures?
if so, add tests and expand the callable to cover it
There was a problem hiding this comment.
Pushed. Here's a summary of the changes:
Investigation: The reviewer asked whether impure ArrowFunctions and Closures are handled by expressionContainsNonPureCall. The answer is yes, the current code handles them correctly through two mechanisms:
- NodeFinder descends into closure/arrow function bodies — impure FuncCall/MethodCall/StaticCall nodes inside closure bodies are found and flagged (e.g.,
(fn() => MyRecord::find())()) - The FuncCall dynamic callable path checks
ClosureType::isPure()viagetCallableParametersAcceptors(), which catches impurity from non-call sources likeecho
Tests added (7 new test cases):
- Arrow function IIFE with impure static call in body
- Closure IIFE with impure static call in body
strlenwrapping impure arrow function IIFE- Impure closure called via variable
- Closure IIFE with
echo+ impure calls in body - Pure closure IIFE stays narrowed (positive case)
- Pure function wrapping impure function with coalesce (covers #12734 pattern)
All 12071 tests pass and PHPStan self-analysis reports no errors.
eb6ad5a to
452a3c8
Compare
|
Pushed successfully. Here's a summary of what I did: Root cause: The Fix (two changes):
Verified: All 12071 tests pass (79697 assertions), PHPStan self-analysis clean, original bug-13416 fix still works, and the beberlei-assert regression (string, integer, uuid, isJsonString double-call detection) is fixed. |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add non regression test for the fix made about FAUX_FUNCTION_ markers
|
Pushed. Added a non-regression test in |
159e29a to
1d1aa46
Compare
1d1aa46 to
fac421e
Compare
…atic calls When a maybe-impure function call's return value was narrowed via assert() (e.g. assert(count(MyRecord::find()) === 1)), the narrowed type persisted even after an impure method call like $msg2->insert(). This happened because invalidateExpression() only invalidated expressions containing the specific callee variable, not unrelated maybe-impure expressions whose results could have been affected by the side effects. Add invalidateAllMaybeImpureFunctionReturnValues() to MutatingScope which walks stored expression types and removes any that contain maybe-impure function/method/static calls. Call it from MethodCallHandler and StaticCallHandler when processing calls with definite side effects (hasSideEffects()->yes()), skipping $this-> calls which already invalidate via invalidateExpression(). Closes phpstan/phpstan#13416
fac421e to
3ce9635
Compare
|
This pull request has been marked as ready for review. |
|
will this PR also fix phpstan/phpstan#15223 ? |
I don't think so. |
expressionContainsNonPureCall() runs for every expression the TypeSpecifier stores, so allocating a NodeFinder, a NodeTraverser, a FirstFindingVisitor and a closure per call dominated the scan: on the PHPStan bench suite the purity scan cost up to +14% (and-chain-truthy-blowup, bug-14207-and), even though 39% of the scanned expressions contain no call at all. Replace it with the same hand-rolled depth-first search ScopeOps uses for invalidation, and move the per-call-node purity checks into callIsNotPure() so the three copies of the rememberPossiblyImpureFunctionValues policy become one isNotPure() helper. Behaviour is unchanged: the walk visits the same nodes in the same pre-order and answers the same for each of them. Bench suite against the pre-PR baseline: and-chain-truthy-blowup +13.81% -> +4.05%, bug-14207-and +14.27% -> +1.58%, big-constant-int-union +8.11% -> -0.23%, impure-call-columns +7.41% -> +2.77%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whether an expression contains a call is a property of the AST node, not of the scope it is specified in, and 39% of the specified expressions - plain variables, property fetches, constant fetches - contain none. Those can answer the purity scan without walking anything on every later visit, the same way ScopeOps remembers 'containsSuperGlobal'. The flag is only remembered when the walk found no call of any kind (Expr\CallLike, so a New_ or a nullsafe call keeps the expression out of the cache as well), which makes the remembered answer independent of what callIsNotPure() would say in another scope. Bench suite against the pre-PR baseline, on top of the previous commit: and-chain-truthy-blowup +4.05% -> +1.32%, hash-key-lookup +0.96% -> -0.01%, or-chain-falsey-blowup +1.29% -> +0.12%, bug-13352 +0.70% -> -0.04%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On 2.3.x the call handlers' createTypesCallbacks gate only the call itself, so #5667's nested-call purity check in TypeSpecifier::createForExpr() did not reach narrowing like `strlen($record->getName()) === 3`. The impure gate in DefaultNarrowingHelper now reads the impure points of the subject's whole subtree off its stored result, and createSubjectTypes() applies it to call subjects before their handler callback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
Summary
assert(count(MyRecord::find()) === 1), calling an impure method like$msg2->insert()now correctly invalidates the narrowed count, socount(MyRecord::find())returnsint<0, max>instead of staying narrowed to1MutatingScope::invalidateAllMaybeImpureFunctionReturnValues()which removes stored expression types that contain maybe-impure function/method/static callsMethodCallHandlerandStaticCallHandlerfor calls withhasSideEffects()->yes(), skipping$this->calls to avoid over-invalidationDetails
The root cause was that
specifyTypesForCountFuncCall()narrows the argument expression (e.g.,MyRecord::find()->array{0: MyRecord}) and stores it in scope. Sincefind()hashasSideEffects()->maybe()andrememberPossiblyImpureFunctionValuesis enabled, the narrowing persists. When$msg2->insert()runs,invalidateExpression()only invalidates expressions containing$msg2, soMyRecord::find()was never cleared.The fix adds a new method that walks all stored expression types with
NodeFinder, checking each for function calls, method calls, or static calls that are not proven pure (hasSideEffects()->no()). Any expression containing such a call is removed from the scope.Test plan
tests/PHPStan/Analyser/nsrt/bug-13416.phpwith test cases covering:strlen()of impure call invalidated by method callCloses phpstan/phpstan#13416
Closes phpstan/phpstan#12734