Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e752f28
Parse @pure-unless-callable-is-impure PHPDoc tag
zonuexe Jul 2, 2026
53727b9
Thread pure-unless-callable-is-impure through the reflection layer
zonuexe Jul 2, 2026
7b0f228
Wire pure-unless-callable-is-impure into the analyser
zonuexe Jul 2, 2026
da3fbca
Mark callback-accepting builtins as pure-unless-callable-is-impure
zonuexe Jul 2, 2026
e5f4c50
Add tests for @pure-unless-callable-is-impure
zonuexe Jul 2, 2026
d354666
Fix pure-unless-callable-is-impure parameter flag propagation
zonuexe Jul 2, 2026
3fe1829
Suppress call impure points when pure-unless-callable-is-impure callb…
zonuexe Jul 2, 2026
bd3febf
Fix CI: avoid named arguments, adjust no-effect fixture
zonuexe Jul 2, 2026
03e7ea4
Cover pure-unless-callable-is-impure verdict for maybe-null and maybe…
zonuexe Jul 3, 2026
dbe4eaf
Simplify pure-unless-callable-is-impure metadata reads
zonuexe Jul 3, 2026
498783b
Document the shape of the function metadata array
zonuexe Jul 3, 2026
8d7b559
Mark array_all() and array_any() as pure-unless-callable-is-impure
zonuexe Jul 3, 2026
9bdc1ea
Honor pure-unless-callable-is-impure metadata for stub-reflected func…
zonuexe Jul 3, 2026
4fd839e
Add lint version comment to the PHP 8.4 pure-unless fixture
zonuexe Jul 3, 2026
9d64ec1
Honor @pure-unless-callable-is-impure on methods
zonuexe Jul 5, 2026
d1a253d
Note the two paths that intentionally don't thread pure-unless yet
zonuexe Jul 5, 2026
0a6650a
Honor @pure-unless-callable-is-impure on constructor calls
zonuexe Jul 5, 2026
ae0e28f
Drop the native mixed property type from the pure-unless fixture
zonuexe Jul 5, 2026
1bc68ff
Restructure NewHandler pure-unless branching to match createFromVariant
zonuexe Jul 5, 2026
92e24b2
Cover a union pure-callable|callable argument on a pure-unless constr…
zonuexe Jul 6, 2026
3ec51f1
Return TrinaryLogic from isPureUnlessCallableIsImpureParameter()
zonuexe Jul 7, 2026
33926fe
Check the body of @pure-unless-callable-is-impure functions for purity
zonuexe Jul 7, 2026
160db8c
Report redundant @pure-unless-callable-is-impure on a pure callable
zonuexe Jul 7, 2026
43f61e8
Return TrinaryLogic from getPureUnlessCallableIsImpureParameters()
zonuexe Jul 7, 2026
b6f0d66
Report impure methods overriding @pure-unless-callable-is-impure methods
zonuexe Jul 7, 2026
33f825a
Add a direct unit test for resolvePureUnlessCallableIsImpureVerdict()
zonuexe Jul 7, 2026
f0cf3d4
Make SimpleImpurePointTest a self-contained unit test
zonuexe Jul 7, 2026
4859c2f
Share the pure-unless-callable verdict-to-certainty decision
zonuexe Jul 7, 2026
07722d2
Revert the direct mutation-testing unit test and verdict helper
zonuexe Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Check the body of @pure-unless-callable-is-impure functions for purity
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.
  • Loading branch information
zonuexe committed Jul 7, 2026
commit 33926fe470fafb6e0bf91d5cd32526139a291745
3 changes: 2 additions & 1 deletion src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,7 @@ public function processStmtNode(
$throwPoints = [];
$impurePoints = [];
$this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback);
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, , $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts,, $phpDocParameterOutTypes] = $this->getPhpDocs($scope, $stmt);
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, , $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts,, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters] = $this->getPhpDocs($scope, $stmt);

foreach ($stmt->params as $param) {
$this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback);
Expand Down Expand Up @@ -809,6 +809,7 @@ public function processStmtNode(
$phpDocParameterOutTypes,
$phpDocImmediatelyInvokedCallableParameters,
$phpDocClosureThisTypeParameters,
$pureUnlessCallableIsImpureParameters,
);
$functionReflection = $functionScope->getFunction();
if (!$functionReflection instanceof PhpFunctionFromParserNodeReflection) {
Expand Down
88 changes: 73 additions & 15 deletions src/Rules/Pure/FunctionPurityCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace PHPStan\Rules\Pure;

use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt;
use PHPStan\Analyser\ImpurePoint;
Expand All @@ -16,8 +17,11 @@
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Type;
use function array_filter;
use function array_key_exists;
use function array_merge;
use function count;
use function in_array;
use function is_string;
use function lcfirst;
use function sprintf;

Expand Down Expand Up @@ -48,6 +52,15 @@ public function check(
$errors = [];
$isPure = $functionReflection->isPure();

$pureUnlessCallableParamNames = [];
foreach ($parameters as $parameter) {
if (!$parameter->isPureUnlessCallableIsImpureParameter()->yes()) {
continue;
}

$pureUnlessCallableParamNames[$parameter->getName()] = true;
}

if ($isPure->yes()) {
foreach ($parameters as $parameter) {
if (!$parameter->passedByReference()->createsNewVariable()) {
Expand All @@ -74,21 +87,12 @@ public function check(
))->identifier(sprintf('pure%s.void', $identifier))->build();
}

foreach ($impurePoints as $impurePoint) {
$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s in pure %s.',
$impurePoint->isCertain() ? 'Impure' : 'Possibly impure',
$impurePoint->getDescription(),
lcfirst($functionDescription),
))
->line($impurePoint->getNode()->getStartLine())
->identifier(sprintf(
'%s.%s',
$impurePoint->isCertain() ? 'impure' : 'possiblyImpure',
$impurePoint->getIdentifier(),
))
->build();
}
$errors = array_merge($errors, $this->reportImpurePoints($impurePoints, $pureUnlessCallableParamNames, $functionDescription));
} elseif ($pureUnlessCallableParamNames !== []) {
// A function declared @pure-unless-callable-is-impure is pure except
// for the flagged callables, so its body is checked for purity while
// the flagged callables' own invocations are exempt.
$errors = array_merge($errors, $this->reportImpurePoints($impurePoints, $pureUnlessCallableParamNames, $functionDescription));
} elseif ($isPure->no()) {
if (
count($throwPoints) === 0
Expand Down Expand Up @@ -153,4 +157,58 @@ public function check(
return $errors;
}

/**
* @param ImpurePoint[] $impurePoints
* @param array<string, true> $pureUnlessCallableParamNames
* @return list<IdentifierRuleError>
*/
private function reportImpurePoints(array $impurePoints, array $pureUnlessCallableParamNames, string $functionDescription): array
{
$errors = [];
foreach ($impurePoints as $impurePoint) {
if ($this->isPureUnlessCallableInvocation($impurePoint, $pureUnlessCallableParamNames)) {
continue;
}

$errors[] = RuleErrorBuilder::message(sprintf(
'%s %s in pure %s.',
$impurePoint->isCertain() ? 'Impure' : 'Possibly impure',
$impurePoint->getDescription(),
lcfirst($functionDescription),
))
->line($impurePoint->getNode()->getStartLine())
->identifier(sprintf(
'%s.%s',
$impurePoint->isCertain() ? 'impure' : 'possiblyImpure',
$impurePoint->getIdentifier(),
))
->build();
}

return $errors;
}

/**
* @param array<string, true> $pureUnlessCallableParamNames
*/
private function isPureUnlessCallableInvocation(ImpurePoint $impurePoint, array $pureUnlessCallableParamNames): bool
{
if ($pureUnlessCallableParamNames === []) {
return false;
}

$node = $impurePoint->getNode();
if (!$node instanceof FuncCall) {
return false;
}
if (!$node->name instanceof Variable) {
return false;
}
if (!is_string($node->name->name)) {
return false;
}

return array_key_exists($node->name->name, $pureUnlessCallableParamNames);
}

}
4 changes: 4 additions & 0 deletions tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ public function testPureUnlessCallableIsImpure(): void
'Possibly impure instantiation of class PureUnlessCallableIsImpureFunction\Baz in pure function PureUnlessCallableIsImpureFunction\pureInstantiatingWithUnionCallback().',
238,
],
[
'Impure echo in pure function PureUnlessCallableIsImpureFunction\pureUnlessCallableWithImpureStatementOutsideCallback().',
253,
],
]);
}

Expand Down
19 changes: 19 additions & 0 deletions tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,22 @@ function pureInstantiatingWithUnionCallback(callable $pureCb, callable $cb, bool

return 1;
}

/**
* @param callable(int): int $f
* @param array<int> $arr
* @return array<int>
* @pure-unless-callable-is-impure $f
*/
function pureUnlessCallableWithImpureStatementOutsideCallback(callable $f, array $arr): array
{
// The function is pure except for $f, so a side effect outside $f is reported,
// while the $f() invocation itself is exempt.
echo 'side effect';
$result = [];
foreach ($arr as $i => $v) {
$result[$i] = $f($v);
}

return $result;
}