Skip to content

Invalidate maybe-impure function return values after impure method/static calls - #5667

Merged
VincentLanglet merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-vf3uivq
Sep 21, 2026
Merged

VincentLanglet merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-vf3uivq

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented May 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • After assert(count(MyRecord::find()) === 1), calling an impure method like $msg2->insert() now correctly invalidates the narrowed count, so count(MyRecord::find()) returns int<0, max> instead of staying narrowed to 1
  • Added MutatingScope::invalidateAllMaybeImpureFunctionReturnValues() which removes stored expression types that contain maybe-impure function/method/static calls
  • Hooked invalidation into MethodCallHandler and StaticCallHandler for calls with hasSideEffects()->yes(), skipping $this-> calls to avoid over-invalidation

Details

The root cause was that specifyTypesForCountFuncCall() narrows the argument expression (e.g., MyRecord::find() -> array{0: MyRecord}) and stores it in scope. Since find() has hasSideEffects()->maybe() and rememberPossiblyImpureFunctionValues is enabled, the narrowing persists. When $msg2->insert() runs, invalidateExpression() only invalidates expressions containing $msg2, so MyRecord::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

  • Added tests/PHPStan/Analyser/nsrt/bug-13416.php with test cases covering:
    • Static call invalidated by method call
    • Method call invalidated by method call
    • strlen() of impure call invalidated by method call
    • Count NOT invalidated by pure function (rand)
    • Same scenarios inside a class context
  • Full test suite passes (12071 tests, 79697 assertions)
  • PHPStan self-analysis introduces no new errors
  • CS-fix reports no violations

Closes phpstan/phpstan#13416
Closes phpstan/phpstan#12734

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes:

Root cause identified: The previous approach (post-hoc invalidation via invalidateAllMaybeImpureFunctionReturnValues) was wrong as the reviewer pointed out. The real issue was that TypeSpecifier::createForExpr() only checked the top-level expression for impurity, missing impure sub-expressions. For example, strlen(impure_func()) === 3 passed the check because strlen is pure, even though the argument impure_func() is impure.

Fix: Added expressionContainsNonPureCall() in TypeSpecifier that walks the expression tree via NodeFinder and checks all sub-expressions for impurity. This is called in createForExpr() after the existing top-level checks, preventing expressions with impure sub-calls from being stored in scope in the first place.

Key behaviors:

  • @phpstan-impure calls: narrowing not applied (both direct and wrapped in pure functions)
  • Maybe-impure calls with rememberPossiblyImpureFunctionValues=true: narrowing still applied (by design)
  • Maybe-impure calls with rememberPossiblyImpureFunctionValues=false: narrowing not applied
  • AlwaysRememberedExpr: excluded from the check (bypass by design)
  • Variable callables: purity checked via callable parameter acceptors (matching existing createForExpr logic)

Removed: invalidateAllMaybeImpureFunctionReturnValues() and expressionContainsMaybeImpureCall() from MutatingScope, along with their call sites in MethodCallHandler and StaticCallHandler.

Comment thread src/Analyser/TypeSpecifier.php

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

Comment thread src/Analyser/TypeSpecifier.php
@staabm
staabm force-pushed the create-pull-request/patch-vf3uivq branch from ac9419d to eb6ad5a Compare May 15, 2026 09:03
Comment thread src/Analyser/TypeSpecifier.php Outdated
private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool
{
$nodeFinder = new NodeFinder();
$found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool {

@staabm staabm May 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have the same bug on impure ArrowFunctions and impure Closures?
if so, add tests and expand the callable to cover it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. NodeFinder descends into closure/arrow function bodies — impure FuncCall/MethodCall/StaticCall nodes inside closure bodies are found and flagged (e.g., (fn() => MyRecord::find())())
  2. The FuncCall dynamic callable path checks ClosureType::isPure() via getCallableParametersAcceptors(), which catches impurity from non-call sources like echo

Tests added (7 new test cases):

  • Arrow function IIFE with impure static call in body
  • Closure IIFE with impure static call in body
  • strlen wrapping 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.

@phpstan-bot
phpstan-bot force-pushed the create-pull-request/patch-vf3uivq branch from eb6ad5a to 452a3c8 Compare May 15, 2026 09:14
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed successfully. Here's a summary of what I did:

Root cause: The expressionContainsNonPureCall() method used NodeFinder to walk all sub-expressions looking for impure calls. Unknown functions (not found in the reflection provider) were treated as non-pure (return true), which broke third-party extensions that use FAUX_FUNCTION_ markers in their rootExpr expressions (e.g., phpstan-beberlei-assert's isJsonString assertion). The old code only checked the top-level expression, so unknown functions in sub-expressions were never flagged.

Fix (two changes):

  1. In expressionContainsNonPureCall(): changed return true to return false for unknown functions in the sub-expression tree walk. We can't determine purity of unknown functions, and being overly conservative blocks legitimate patterns.
  2. In createForExpr(): added a separate early-return guard for top-level unknown FuncCall expressions (restoring the backwards-compatibility behavior that was in the old code before the refactor).

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 VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add non regression test for the fix made about FAUX_FUNCTION_ markers

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Added a non-regression test in TypeSpecifierTest::testUnknownFunctionSubExpressionDoesNotPreventNarrowing() that verifies the FAUX_FUNCTION fix. The test calls TypeSpecifier::create() with count(FAUX_FUNCTION($foo)) — a known pure function wrapping an unknown function — and asserts that type narrowing succeeds. I verified the test fails when the fix is reverted (unknown functions treated as non-pure), all 12072 tests pass, and PHPStan self-analysis reports no errors.

Comment thread tests/PHPStan/Analyser/TypeSpecifierTest.php Outdated
@VincentLanglet VincentLanglet self-assigned this May 28, 2026
@VincentLanglet
VincentLanglet marked this pull request as draft July 15, 2026 13:16
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-vf3uivq branch from 159e29a to 1d1aa46 Compare September 21, 2026 13:02
@VincentLanglet
VincentLanglet changed the base branch from 2.1.x to 2.2.x September 21, 2026 13:03
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-vf3uivq branch from 1d1aa46 to fac421e Compare September 21, 2026 13:07
…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
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-vf3uivq branch from fac421e to 3ce9635 Compare September 21, 2026 13:11
@VincentLanglet
VincentLanglet marked this pull request as ready for review September 21, 2026 14:03
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

This pull request has been marked as ready for review.

Comment thread src/Analyser/TypeSpecifier.php Outdated
@staabm

staabm commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

will this PR also fix phpstan/phpstan#15223 ?

@VincentLanglet

Copy link
Copy Markdown
Contributor

will this PR also fix phpstan/phpstan#15223 ?

I don't think so.
I have multiple PR which makes believe that the issue is solved, dunno why we have so many false positive from the bot.
I will still check after the merge and add regression test if needed

phpstan-bot and others added 2 commits September 21, 2026 15:11
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>
@VincentLanglet
VincentLanglet merged commit 21aaf7b into phpstan:2.2.x Sep 21, 2026
840 of 893 checks passed
@VincentLanglet
VincentLanglet deleted the create-pull-request/patch-vf3uivq branch September 21, 2026 17:32
ondrejmirtes added a commit that referenced this pull request Sep 22, 2026
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
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.

3 participants