Skip to content

Implement @pure-unless-callable-is-impure#3482

Merged
ondrejmirtes merged 29 commits into
phpstan:2.2.xfrom
zonuexe:feature/pure-unless-callable-is-impure
Jul 7, 2026
Merged

Implement @pure-unless-callable-is-impure#3482
ondrejmirtes merged 29 commits into
phpstan:2.2.xfrom
zonuexe:feature/pure-unless-callable-is-impure

Conversation

@zonuexe

@zonuexe zonuexe commented Sep 25, 2024

Copy link
Copy Markdown
Contributor

Implements the @pure-unless-callable-is-impure PHPDoc tag (parser support: phpstan/phpdoc-parser#253, merged): a parameter-level annotation declaring that the function/method is pure unless the callable passed to that parameter is impure. Rebuilt on top of 2.2.x.

How it works

  • The tag (and its @phpstan- prefixed alias) is resolved into per-parameter flags, threaded through the reflection layer (ExtendedParameterReflection::isPureUnlessCallableIsImpureParameter()), and populated for builtins via functionMetadata (array_filter, array_map, array_reduce, the array_*u* comparator family, call_user_func(_array), forward_static_call(_array), preg_replace_callback(_array), …) and for array_reduce via its stub.
  • SimpleImpurePoint::createFromVariant() evaluates the purity of arguments passed to flagged parameters:
    • provably pure callback (or omitted / null) → no impure point — the call is treated as pure;
    • provably impure callback → the impure point becomes certain;
    • unknown callable (opaque callable type) → unchanged possibly-impure behavior.
  • Since the no-effect detection (NoopExpressionNode) is driven by "the statement has zero impure points", standalone array_map($pureCb, $arr); statements are now reported by CallToFunctionStatementWithoutSideEffectsRule with no rule changes.
/** @phpstan-pure */
function doubled(array $arr): array
{
    // no longer false positive "Possibly impure"
    return array_map(static fn (int $x): int => $x * 2, $arr);
}

// now: "Call to function array_map() on a separate line has no effect."
array_map('is_string', $arr);
// still allowed as a statement (impure callback)
array_map(static function ($x) { echo $x; }, $arr);
  • Add function map
  • Add tests (fail-first: PureFunctionRule/PureMethodRule regressions, CallToFunctionStatementWithoutSideEffectsRule cases incl. impure/opaque/by-ref non-regressions)
  • Add rules (no new rule needed — the impure-point suppression feeds both the pure-context check and the existing no-effect rule)

refs #3106, #5912

Closes phpstan/phpstan#11101
Closes phpstan/phpstan#11100

@zonuexe zonuexe marked this pull request as draft September 25, 2024 17:46
Comment thread src/PhpDoc/PhpDocNodeResolver.php Outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will be replaced by phpstan/phpdoc-parser#253

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

WIP. = false will be removed.

@ondrejmirtes

Copy link
Copy Markdown
Member

Just updated phpdoc-parser here so you can use that.

@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch 4 times, most recently from eddd22a to f207c72 Compare September 30, 2024 18:09
@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from f207c72 to 8c756cc Compare November 3, 2024 17:57
@staabm

staabm commented Nov 18, 2024

Copy link
Copy Markdown
Contributor

@zonuexe any plans to move this forward?

@zonuexe

zonuexe commented Nov 19, 2024

Copy link
Copy Markdown
Contributor Author

@staabm Hi, I know this branch is important but it was blocked due to my motivation. I'm going to rebase it on 2.0.x and do the necessary work. If I miss it tonight, I won't be able to start the rest of the work until next week, so it would be quicker for you to take over then.

@staabm

staabm commented Nov 19, 2024

Copy link
Copy Markdown
Contributor

I don't want to put pressure on you. It was not clear to me whether you stopped working on it or not.

I am totally fine if this takes a bit more time - thank you

@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from 8c756cc to 389c7f0 Compare November 19, 2024 16:58
@zonuexe

zonuexe commented Nov 19, 2024

Copy link
Copy Markdown
Contributor Author

No problem, this is a very interesting subject for me too, so I'm happy to work on it because I realized that there are other people who are interested in it. More importantly, I'm always helped by your hard work.

@zonuexe zonuexe changed the base branch from 1.12.x to 2.0.x November 19, 2024 17:11
@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch 4 times, most recently from 2146ae0 to 1cc1fad Compare November 19, 2024 18:32
@VincentLanglet

Copy link
Copy Markdown
Contributor

Friendly ping @zonuexe, still interested in this PR ? Do you have time to finish it ?
Do you look for help ? What is missing ?

@zonuexe

zonuexe commented Jul 20, 2025

Copy link
Copy Markdown
Contributor Author

@VincentLanglet
Thank you for the suggestion, sorry for making you wait so long.
I've been working on PHPStan 2.1.18 a few times over the last few weeks, and just finished a PHPStan workshop at a local PHP conference, so I'll get back to that tomorrow.

@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from adb02ca to abc8cd8 Compare August 1, 2025 12:45
@zonuexe zonuexe changed the base branch from 2.0.x to 2.1.x August 1, 2025 12:46
@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from abc8cd8 to c4999be Compare August 1, 2025 13:09
@VincentLanglet

Copy link
Copy Markdown
Contributor

Friendly ping @zonuexe

I don't want to put pressure on you. It's just not clear to me whether you stopped working on it or not.
What is missing ?

It will unblock the phpstan/phpdoc-parser#259 pr :)

@VincentLanglet

Copy link
Copy Markdown
Contributor

Maybe I'm wrong, but I start to think this annotation is useless
https://phpstan.org/r/d3af976b-17ef-4ca6-87cb-4a1325622cee

PHPStan already handle cb with immediately/later invoked callable, even for a pure function/method.

So we just need to declare array_map and so as pure.

@devbanana

Copy link
Copy Markdown
Contributor

i'm happy to help contribute towards a solution if it's decided which direction is best.

@VincentLanglet

Copy link
Copy Markdown
Contributor

Hi @devbanana feel free to work on this.

Laybe @zonuexe could explain what he had in mind ; or @ondrejmirtes will have some guidelines

@staabm staabm requested a review from VincentLanglet July 6, 2026 15:42
*/
public function checkAllowedConstants(array $constants): AllowedConstantsResult;

public function isPureUnlessCallableIsImpureParameter(): bool;

@staabm staabm Jul 6, 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.

should return trinary.

quoting ondrey:

I feel like on ExtendedParametersAcceptor, the method isPureUnlessCallableIsImpureParameter should return TrinaryLogic.
Because you need to correctly set it on unions and intersection methods

* @param callable(int): int $f
* @param array<int> $arr
* @return array<int>
* @pure-unless-callable-is-impure $f

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.

we need a test which shows that a function which is declared @pure-unless-callable-is-impure $f errors about impure side-effects when the impure statement/expression is outside the callable $f

* @param callable(int): int $f
* @param array<int> $arr
* @return array<int>
* @phpstan-pure-unless-callable-is-impure $f

@staabm staabm Jul 7, 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.

If @pure-unless-callable-is-impure  is paired with pure-callable or pure-Closure , that should be a new kind of error (that the PHPdoc tag could be replaced with @phpstan-pure essentially

@zonuexe zonuexe marked this pull request as draft July 7, 2026 05:28
zonuexe added 3 commits July 7, 2026 14:49
Per review: the parameter-level flag must be a TrinaryLogic so it composes
correctly on union and intersection method variants (combineAcceptors uses
->or(), mirroring isImmediatelyInvokedCallable). Origins convert the bool
from PHPDoc/metadata with TrinaryLogic::createFromBoolean(); the consumption
in SimpleImpurePoint skips only parameters whose flag is ->no().
A function/method declared @pure-unless-callable-is-impure $f is pure except
for the flagged callable, so its body is now checked by FunctionPurityCheck:
impure statements outside the callable (e.g. echo) are reported, while the
flagged callable's own invocation is exempt. Thread the parameter flags through
enterFunction() so the check sees them on plain functions too.
Per review: when @pure-unless-callable-is-impure $f is paired with a pure
callable type (pure-callable / pure-Closure) for $f, the tag is redundant
because the callback is always pure, so the function/method can be marked
@phpstan-pure instead. FunctionPurityCheck now reports this.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One more thing - in MethodSignatureRule, we need to enforce covariance for these methods. Check around $this->reportMethodPurityOverride.

If a method has @phpstan-pure or @phpstan-pure-unless-callable-is-impure, the child method cannot be @phpstan-impure.

Also are there same tests around @phpstan-pure-unless-callable-is-impure being inherited implicitly? (The child method does not need to mention the parameter but if parent method has it, it should also apply to child. Even when the parameter is renamed.)

public function isPure(): TrinaryLogic;

/**
* @return array<string, bool>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should still be TrinaryLogic here. When you have UnionMethodReflection or IntersectionMethodReflection (and the methods in question have different PHPDoc tags involved, you need TrinaryLogic to properly model the behaviour). Also add some tests.

zonuexe added 2 commits July 7, 2026 16:15
Per review (ondrejmirtes): the method/function-level map must return
array<string, TrinaryLogic> so UnionTypeMethodReflection and
IntersectionTypeMethodReflection can model Maybe when constituent methods
disagree on the tag (new MergedPureUnlessCallableIsImpureParameters helper).
combineAcceptors() now merges the parameter-level flag with equals-or-Maybe
instead of ->or(), so a union receiver yields a Maybe flag. FunctionPurityCheck
drives its flagged-parameter set off the map getter. Adds union and inherited
(incl. renamed-parameter) regression tests.
Per review (ondrejmirtes): MethodSignatureRule now enforces purity covariance
for the tag - a child method marked @phpstan-impure cannot override a parent
method carrying @pure-unless-callable-is-impure (identifier
method.impureOverridePureUnlessCallable), mirroring the existing pure-override
check. Gated by reportMethodPurityOverride.
@zonuexe

zonuexe commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@ondrejmirtes @staabm Both review points are addressed.

1. TrinaryLogic on the map getter. getPureUnlessCallableIsImpureParameters() now returns array<string, TrinaryLogic> across the interfaces and all implementers. UnionTypeMethodReflection / IntersectionTypeMethodReflection merge their members per parameter (new MergedPureUnlessCallableIsImpureParameters helper) so a parameter tagged in some members but not others becomes Maybe, and combineAcceptors() merges the parameter-level flag with equals-or-Maybe instead of ->or(), so a union receiver yields a real Maybe at the call site. Added union and implicit-inheritance tests (child does not re-declare the tag), including the renamed-parameter case.

2. Purity covariance. MethodSignatureRule now reports a @phpstan-impure child overriding a parent that carries @pure-unless-callable-is-impure (method.impureOverridePureUnlessCallable), gated by reportMethodPurityOverride.

Note: the Mutation Testing job is still red on a few mutants around the verdict/override branches. The tests seem to kill them locally, so I will leave it to you two to decide how to proceed.

@zonuexe zonuexe marked this pull request as ready for review July 7, 2026 07:52
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@ondrejmirtes

Copy link
Copy Markdown
Member

Might be valuable to fix the escaped mutant. It means that either the code logic needs to change, or a test has to be added to fail when the code is changed this way.

@zonuexe

zonuexe commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@ondrejmirtes @staabm
On the Mutation Testing job: I built a local Infection setup with the same custom TrinaryLogicMutator and ran it (scoped to the affected files/suites, single-threaded) against the exact same mutation points. All of the mutants currently shown as escaped in CI come back as killed locally, the only mutant that actually escaped in my run was on an unrelated, pre-existing line. So this looks like something specific to the full CI run rather than these lines being under-tested.

@ondrejmirtes

Copy link
Copy Markdown
Member

@zonuexe I don't think you need to set up Infection locally to verify this mutant. The question is: if you modify the file:

-			if ($parameter->isPureUnlessCallableIsImpureParameter()->no()) {
+			if (!$parameter->isPureUnlessCallableIsImpureParameter()->yes()) {

Do you get a failing test locally or not? If no tests fail the mutant is valid and then it's easy to fix the code/add the test.

zonuexe added 4 commits July 7, 2026 21:36
Kills the TrinaryLogicMutator mutant on the parameter-flag check
(->no() -> !->yes()) deterministically, without going through the
analyser: a Maybe flag must be processed like Yes, so with the callback
omitted the verdict is Yes, while the mutant skips the parameter and
yields null.
Extend plain TestCase and pass a mocked Scope instead of booting the DI
container and creating a real analyser Scope. The verdict method never
consults the scope when no arguments are passed, so this keeps the test
fully isolated (avoids interfering with other tests' shared state) while
still killing the parameter-flag mutant.
Extract the verdict->yes()/->no() branching from createFromVariant() and
NewHandler into SimpleImpurePoint::applyPureUnlessCallableIsImpureVerdict(),
covered by a direct unit test. This removes the duplicated logic and lets the
unit test deterministically kill the mutants on those branches instead of
relying on coverage attribution from the rule tests.
Reverts 33f825a, f0cf3d4 and 4859c2f. Local Infection with the exact
custom TrinaryLogicMutator kills every mutant on the changed lines (MSI 100%),
so the remaining CI Mutation Testing failure is a coverage-attribution issue in
the parallel run rather than a missing test - the added unit test and the
verdict-to-certainty helper did not change that, so they are dropped to keep the
PR focused on the reviewed changes.
@zonuexe

zonuexe commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

I ran the exact custom TrinaryLogicMutator locally to check this properly.

Using the same mutator with --git-diff-lines over the changed files, and coverage generated from the Rules/Pure, Rules/Methods and Reflection/Callables suites, every mutant on the changed lines is killed by the test framework: 8 mutants generated, 8 killed, Covered Code MSI 100%, nothing escaped.

In CI the escaped mutant is a moving target: across the last three runs it was SimpleImpurePoint:151, then NewHandler:263/266, then NewHandler:262 - each killed by PureFunctionRuleTest locally (verified by hand-applying the ->yes() <-> !->no() mutation). So the tests do cover and kill these mutants; the remaining CI failure looks like coverage attribution in the parallel Infection run rather than a missing test.

I briefly tried a direct unit test plus factoring the verdict branching into one helper, but since that does not change the CI attribution I reverted it to keep the PR focused on the reviewed changes.

@ondrejmirtes ondrejmirtes merged commit 4dd8467 into phpstan:2.2.x Jul 7, 2026
668 of 672 checks passed
@zonuexe zonuexe deleted the feature/pure-unless-callable-is-impure branch July 7, 2026 16:42
@staabm

staabm commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Thank you @zonuexe

@zonuexe

zonuexe commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

I am always helped by the great work of the PHPStan team!

@ondrejmirtes

Copy link
Copy Markdown
Member

Yeah, thank you!

And it's documented already. I love these times!

@staabm

staabm commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

As a followup it would be great to look into phpstan 1st party extension packages and also often used packages in the php ecosystem, which could utilize the new phpdoc

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

6 participants