Skip to content

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

Open
phpstan-bot wants to merge 10 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-vf3uivq
Open

Invalidate maybe-impure function return values after impure method/static calls#5667
phpstan-bot wants to merge 10 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-vf3uivq

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

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

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

…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
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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 ?

Instead of invalidating stored expression types after impure calls,
prevent them from being stored in the first place. TypeSpecifier::createForExpr()
now walks the expression tree and returns empty SpecifiedTypes when any
sub-expression is a non-pure function/method/static call.

This fixes cases like strlen(impure()) === 3 where the outer call (strlen)
is pure but the inner argument is impure — the previous approach only
checked the top-level expression.

Closes phpstan/phpstan#13416

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@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 Outdated
The individual FuncCall, MethodCall, and StaticCall early returns in
createForExpr() are fully covered by the expressionContainsNonPureCall()
check that walks the entire expression tree. Remove the redundant
top-level checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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
Refactor expressionContainsNonPureCall to return TrinaryLogic instead
of bool, separating the purity determination from the
rememberPossiblyImpureFunctionValues policy decision. Extract
callNodeHasSideEffects helper that returns the raw TrinaryLogic purity
of each call node.

Replace NodeFinder::findFirst (which requires a bool callback) with
NodeFinder::find to collect all call nodes, then aggregate their
purity via TrinaryLogic::or with early exit on yes.

Unknown methods and dynamic call names return maybe (we genuinely
don't know their purity). Unknown named functions keep returning yes
for backward compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool
{
$nodeFinder = new NodeFinder();
$found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool {
Copy link
Copy Markdown
Contributor

@staabm staabm May 15, 2026

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 and others added 4 commits May 15, 2026 09:25
Verify that expressionContainsNonPureCall correctly handles:
- Impure arrow function IIFEs
- Impure closure IIFEs
- strlen wrapping impure arrow function IIFEs
- Impure closures called via variable
- Closures with echo (non-call impurity) wrapping impure calls
- Pure closure IIFEs staying narrowed (positive case)
- Pure function wrapping impure function with coalesce

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hecks

The expressionContainsNonPureCall() method walks all sub-expressions
looking for impure calls. Unknown functions (not found in the reflection
provider) were treated as non-pure, which broke extensions using
FAUX_FUNCTION_ markers in rootExpr (e.g. phpstan-beberlei-assert's
isJsonString). The old code only checked the top-level expression, so
unknown functions in sub-expressions were never flagged.

Fix: return false for unknown functions in the tree walk (sub-expression
check), but keep a separate top-level guard for unknown FuncCall
expressions in createForExpr for backwards compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@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.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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

Verifies that an unknown function (like FAUX_FUNCTION used by
StrContainingTypeSpecifyingExtension and third-party extensions)
appearing as a sub-expression does not prevent type narrowing in
createForExpr. The test calls TypeSpecifier::create() with
count(FAUX_FUNCTION($foo)) and asserts the expression is narrowed,
which would fail if expressionContainsNonPureCall incorrectly
treated unknown functions as non-pure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@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.

return new Expr\Instanceof_(new Variable($variableName), new Name($className));
}

public function testUnknownFunctionSubExpressionDoesNotPreventNarrowing(): void
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.

could this be tested via StrContainingTypeSpecifyingExtension with a str-containing function instead?

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.

Done. Replaced the contrived TypeSpecifierTest::testUnknownFunctionSubExpressionDoesNotPreventNarrowing unit test (which manually built count(FAUX_FUNCTION($foo)) AST nodes) with an NSRT integration test in non-empty-string-str-containing-fns.php that exercises str_contains with a non-empty-string needle — which triggers the FAUX_FUNCTION rootExpr path in StrContainingTypeSpecifyingExtension. The test verifies narrowing works correctly across two successive str_contains calls. All 12071 tests pass, PHPStan self-analysis clean, CS clean.

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.

the newly added test does not fail when reverting aadf81a

Replace the contrived TypeSpecifierTest that manually built
count(FAUX_FUNCTION($foo)) AST nodes with an NSRT integration test
via StrContainingTypeSpecifyingExtension. The new test exercises
str_contains with a non-empty needle (which triggers FAUX_FUNCTION
in the rootExpr) and verifies narrowing works correctly across
repeated calls.

Co-Authored-By: Claude Opus 4.6 <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.

3 participants