From e752f28866b18a49bbd00e640d3b8e1e0227eee8 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 00:36:48 +0900 Subject: [PATCH 01/29] Parse @pure-unless-callable-is-impure PHPDoc tag Resolve the tag into per-parameter flags and merge them across parent PHPDocs. --- src/PhpDoc/PhpDocNodeResolver.php | 13 +++++++++ src/PhpDoc/ResolvedPhpDocBlock.php | 45 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 05167dc6908..3519842b360 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -387,6 +387,19 @@ public function resolveParamImmediatelyInvokedCallable(PhpDocNode $phpDocNode): return $parameters; } + /** + * @return array + */ + public function resolveParamPureUnlessCallableIsImpure(PhpDocNode $phpDocNode): array + { + $parameters = []; + foreach ($phpDocNode->getPureUnlessCallableIsImpureTagValues() as $tag) { + $parameters[$tag->parameterName] = true; + } + + return $parameters; + } + /** * @return array */ diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index be6da4fc46d..c00634c963f 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -96,6 +96,9 @@ final class ResolvedPhpDocBlock /** @var array|false */ private array|false $paramsImmediatelyInvokedCallable = false; + /** @var array|false */ + private array|false $paramsPureUnlessCallableIsImpure = false; + /** @var array|false */ private array|false $paramClosureThisTags = false; @@ -220,6 +223,7 @@ public static function createEmpty(): self $self->paramTags = []; $self->paramOutTags = []; $self->paramsImmediatelyInvokedCallable = []; + $self->paramsPureUnlessCallableIsImpure = []; $self->paramClosureThisTags = []; $self->returnTag = null; $self->throwsTag = null; @@ -276,6 +280,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->paramTags = self::mergeParamTags($this->getParamTags(), $parent, $parameterMapping, $parentClass); $result->paramOutTags = self::mergeParamOutTags($this->getParamOutTags(), $parent, $parameterMapping, $parentClass); $result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $parent, $parameterMapping); + $result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping); $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass); $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass); $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent); @@ -584,6 +589,18 @@ public function getParamsImmediatelyInvokedCallable(): array return $this->paramsImmediatelyInvokedCallable; } + /** + * @return array + */ + public function getParamsPureUnlessCallableIsImpure(): array + { + if ($this->paramsPureUnlessCallableIsImpure === false) { + $this->paramsPureUnlessCallableIsImpure = $this->phpDocNodeResolver->resolveParamPureUnlessCallableIsImpure($this->phpDocNode); + } + + return $this->paramsPureUnlessCallableIsImpure; + } + /** * @return array */ @@ -1085,6 +1102,34 @@ private static function mergeOneParentParamImmediatelyInvokedCallable(array $par return $paramsImmediatelyInvokedCallable; } + /** + * @param array $paramsPureUnlessCallableIsImpure + * @return array + */ + private static function mergeParamsPureUnlessCallableIsImpure(array $paramsPureUnlessCallableIsImpure, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + return self::mergeOneParentParamPureUnlessCallableIsImpure($paramsPureUnlessCallableIsImpure, $parent, $parameterMapping); + } + + /** + * @param array $paramsPureUnlessCallableIsImpure + * @return array + */ + private static function mergeOneParentParamPureUnlessCallableIsImpure(array $paramsPureUnlessCallableIsImpure, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + $parentPureUnlessCallableIsImpure = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamsPureUnlessCallableIsImpure()); + + foreach ($parentPureUnlessCallableIsImpure as $name => $parentIsPureUnlessCallableIsImpure) { + if (array_key_exists($name, $paramsPureUnlessCallableIsImpure)) { + continue; + } + + $paramsPureUnlessCallableIsImpure[$name] = $parentIsPureUnlessCallableIsImpure; + } + + return $paramsPureUnlessCallableIsImpure; + } + /** * @param array $paramsClosureThisTags * @return array From 53727b96b54417ff51a12221245befcb83112441 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 00:36:48 +0900 Subject: [PATCH 02/29] Thread pure-unless-callable-is-impure through the reflection layer Add isPureUnlessCallableIsImpureParameter() on parameter reflections and getPureUnlessCallableIsImpureParameters() on function/method reflections, populated from PHPDoc and function metadata. --- .../Annotations/AnnotationMethodReflection.php | 5 +++++ .../AnnotationsMethodParameterReflection.php | 5 +++++ .../Dummy/ChangedTypeMethodReflection.php | 5 +++++ .../Dummy/DummyConstructorReflection.php | 5 +++++ src/Reflection/Dummy/DummyMethodReflection.php | 5 +++++ src/Reflection/ExtendedMethodReflection.php | 5 +++++ src/Reflection/ExtendedParameterReflection.php | 2 ++ src/Reflection/FunctionReflection.php | 5 +++++ .../ExtendedNativeParameterReflection.php | 6 ++++++ .../Native/NativeFunctionReflection.php | 5 +++++ .../Native/NativeMethodReflection.php | 5 +++++ .../Php/ClosureCallMethodReflection.php | 5 +++++ .../Php/EnumCasesMethodReflection.php | 5 +++++ src/Reflection/Php/ExitFunctionReflection.php | 5 +++++ src/Reflection/Php/ExtendedDummyParameter.php | 6 ++++++ .../Php/PhpClassReflectionExtension.php | 11 ++++++++--- .../PhpFunctionFromParserNodeReflection.php | 14 ++++++++++++++ src/Reflection/Php/PhpFunctionReflection.php | 5 +++++ .../Php/PhpMethodFromParserNodeReflection.php | 2 ++ src/Reflection/Php/PhpMethodReflection.php | 8 ++++++++ .../Php/PhpMethodReflectionFactory.php | 2 ++ .../PhpParameterFromParserNodeReflection.php | 6 ++++++ src/Reflection/Php/PhpParameterReflection.php | 6 ++++++ src/Reflection/ResolvedMethodReflection.php | 5 +++++ .../NativeFunctionReflectionProvider.php | 18 +++++++++++++++--- .../SignatureMap/SignatureMapProvider.php | 4 ++-- .../Type/IntersectionTypeMethodReflection.php | 5 +++++ .../Type/UnionTypeMethodReflection.php | 5 +++++ .../WrappedExtendedMethodReflection.php | 5 +++++ ...RewrittenDeclaringClassMethodReflection.php | 5 +++++ 30 files changed, 167 insertions(+), 8 deletions(-) diff --git a/src/Reflection/Annotations/AnnotationMethodReflection.php b/src/Reflection/Annotations/AnnotationMethodReflection.php index 2f0b233122b..dbd3f650f40 100644 --- a/src/Reflection/Annotations/AnnotationMethodReflection.php +++ b/src/Reflection/Annotations/AnnotationMethodReflection.php @@ -179,6 +179,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php index 93941cf0698..f6d719dcdd3 100644 --- a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -92,4 +92,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return new AllowedConstantsResult([], [], false); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return false; + } + } diff --git a/src/Reflection/Dummy/ChangedTypeMethodReflection.php b/src/Reflection/Dummy/ChangedTypeMethodReflection.php index d525f2661d9..d59bf13fa8b 100644 --- a/src/Reflection/Dummy/ChangedTypeMethodReflection.php +++ b/src/Reflection/Dummy/ChangedTypeMethodReflection.php @@ -168,6 +168,11 @@ public function isPure(): TrinaryLogic return $this->reflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return $this->reflection->getAttributes(); diff --git a/src/Reflection/Dummy/DummyConstructorReflection.php b/src/Reflection/Dummy/DummyConstructorReflection.php index 844a5340e11..d0e91d5f264 100644 --- a/src/Reflection/Dummy/DummyConstructorReflection.php +++ b/src/Reflection/Dummy/DummyConstructorReflection.php @@ -153,6 +153,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createYes(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Dummy/DummyMethodReflection.php b/src/Reflection/Dummy/DummyMethodReflection.php index afe694a7c56..a47fb22d055 100644 --- a/src/Reflection/Dummy/DummyMethodReflection.php +++ b/src/Reflection/Dummy/DummyMethodReflection.php @@ -145,6 +145,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/ExtendedMethodReflection.php b/src/Reflection/ExtendedMethodReflection.php index b9cf6acddf7..56e100b26f3 100644 --- a/src/Reflection/ExtendedMethodReflection.php +++ b/src/Reflection/ExtendedMethodReflection.php @@ -69,6 +69,11 @@ public function isBuiltin(): TrinaryLogic|bool; */ public function isPure(): TrinaryLogic; + /** + * @return array + */ + public function getPureUnlessCallableIsImpureParameters(): array; + /** @return list */ public function getAttributes(): array; diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php index 1ccd1d4b935..07ef8f243e8 100644 --- a/src/Reflection/ExtendedParameterReflection.php +++ b/src/Reflection/ExtendedParameterReflection.php @@ -36,4 +36,6 @@ public function getAllowedConstants(): ?ParameterAllowedConstants; */ public function checkAllowedConstants(array $constants): AllowedConstantsResult; + public function isPureUnlessCallableIsImpureParameter(): bool; + } diff --git a/src/Reflection/FunctionReflection.php b/src/Reflection/FunctionReflection.php index d719fc62e58..4286ac7320a 100644 --- a/src/Reflection/FunctionReflection.php +++ b/src/Reflection/FunctionReflection.php @@ -68,6 +68,11 @@ public function returnsByReference(): TrinaryLogic; */ public function isPure(): TrinaryLogic; + /** + * @return array + */ + public function getPureUnlessCallableIsImpureParameters(): array; + /** @return list */ public function getAttributes(): array; diff --git a/src/Reflection/Native/ExtendedNativeParameterReflection.php b/src/Reflection/Native/ExtendedNativeParameterReflection.php index 5539d9132a1..094d647739d 100644 --- a/src/Reflection/Native/ExtendedNativeParameterReflection.php +++ b/src/Reflection/Native/ExtendedNativeParameterReflection.php @@ -31,6 +31,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, + private bool $pureUnlessCallableIsImpureParameter = false, ) { } @@ -114,4 +115,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/Native/NativeFunctionReflection.php b/src/Reflection/Native/NativeFunctionReflection.php index 50ddbb0e9b9..77e34d1ab84 100644 --- a/src/Reflection/Native/NativeFunctionReflection.php +++ b/src/Reflection/Native/NativeFunctionReflection.php @@ -110,6 +110,11 @@ public function isPure(): TrinaryLogic return $this->hasSideEffects->negate(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + private function isVoid(): bool { foreach ($this->variants as $variant) { diff --git a/src/Reflection/Native/NativeMethodReflection.php b/src/Reflection/Native/NativeMethodReflection.php index 5cd7475e83e..d614bda8811 100644 --- a/src/Reflection/Native/NativeMethodReflection.php +++ b/src/Reflection/Native/NativeMethodReflection.php @@ -187,6 +187,11 @@ public function isPure(): TrinaryLogic return $this->hasSideEffects->negate(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + private function isVoid(): bool { foreach ($this->variants as $variant) { diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index 41c278f08dd..91ec4376506 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -199,6 +199,11 @@ public function isPure(): TrinaryLogic return $this->nativeMethodReflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return $this->nativeMethodReflection->getAttributes(); diff --git a/src/Reflection/Php/EnumCasesMethodReflection.php b/src/Reflection/Php/EnumCasesMethodReflection.php index d731fc29d90..3f829984d8a 100644 --- a/src/Reflection/Php/EnumCasesMethodReflection.php +++ b/src/Reflection/Php/EnumCasesMethodReflection.php @@ -157,6 +157,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createYes(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Php/ExitFunctionReflection.php b/src/Reflection/Php/ExitFunctionReflection.php index 8303f1084f4..bbffd428ce6 100644 --- a/src/Reflection/Php/ExitFunctionReflection.php +++ b/src/Reflection/Php/ExitFunctionReflection.php @@ -139,6 +139,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createNo(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php index 69a19ccbf3a..7ec8a16105e 100644 --- a/src/Reflection/Php/ExtendedDummyParameter.php +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -31,6 +31,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, + private bool $pureUnlessCallableIsImpureParameter = false, ) { parent::__construct($name, $type, $optional, $passedByReference, $variadic, $defaultValue); @@ -85,4 +86,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index c5c7ed2b68a..0efd3519a0e 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -636,7 +636,7 @@ private function createMethod( $isPure = null; if ($this->signatureMapProvider->hasMethodMetadata($declaringClassName, $methodReflection->getName())) { - $isPure = !$this->signatureMapProvider->getMethodMetadata($declaringClassName, $methodReflection->getName())['hasSideEffects']; + $isPure = !($this->signatureMapProvider->getMethodMetadata($declaringClassName, $methodReflection->getName())['hasSideEffects'] ?? true); } $methodSignaturesResult = $this->signatureMapProvider->getMethodSignatures($declaringClassName, $methodReflection->getName(), $methodReflection); @@ -882,11 +882,14 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla ); $isPure = null; + $pureUnlessCallableIsImpureParameters = []; if ($actualDeclaringClass->isBuiltin() || $actualDeclaringClass->isEnum()) { foreach (array_keys($actualDeclaringClass->getAncestors()) as $className) { if ($this->signatureMapProvider->hasMethodMetadata($className, $methodReflection->getName())) { - $hasSideEffects = $this->signatureMapProvider->getMethodMetadata($className, $methodReflection->getName())['hasSideEffects']; + $methodMetadata = $this->signatureMapProvider->getMethodMetadata($className, $methodReflection->getName()); + $hasSideEffects = $methodMetadata['hasSideEffects'] ?? true; $isPure = !$hasSideEffects; + $pureUnlessCallableIsImpureParameters += $methodMetadata['pureUnlessCallableIsImpureParameters'] ?? []; break; } @@ -988,6 +991,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $closureThisParameters, $acceptsNamedArguments, $this->attributeReflectionFactory->fromNativeReflection($methodReflection->getAttributes(), InitializerExprContext::fromClassMethod($actualDeclaringClass->getName(), $declaringTraitName, $methodReflection->getName(), $actualDeclaringClass->getFileName())), + $pureUnlessCallableIsImpureParameters, ); } @@ -1163,7 +1167,7 @@ private function inferAndCachePropertyTypes( $classScope = $classScope->enterNamespace($namespace); } $classScope = $classScope->enterClass($declaringClass); - [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $phpDocPureUnlessCallableIsImpureParameters] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); $methodScope = $classScope->enterClassMethod( $methodNode, $templateTypeMap, @@ -1182,6 +1186,7 @@ private function inferAndCachePropertyTypes( $phpDocParameterOutTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, + phpDocPureUnlessCallableIsImpureParameters: $phpDocPureUnlessCallableIsImpureParameters, ); $propertyTypes = []; diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index ba4e66fa1b9..f3e1e8a8733 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -48,6 +48,7 @@ class PhpFunctionFromParserNodeReflection implements FunctionReflection, Extende * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param list $attributes + * @param array $pureUnlessCallableIsImpureParameters */ public function __construct( FunctionLike $functionLike, @@ -71,6 +72,7 @@ public function __construct( private array $immediatelyInvokedCallableParameters, private array $phpDocClosureThisTypeParameters, private array $attributes, + private array $pureUnlessCallableIsImpureParameters, ) { $this->functionLike = $functionLike; @@ -177,6 +179,12 @@ public function getParameters(): array $closureThisType = null; } + if (isset($this->pureUnlessCallableIsImpureParameters[$parameter->var->name])) { + $pureUnlessCallableIsImpureParameter = $this->pureUnlessCallableIsImpureParameters[$parameter->var->name]; + } else { + $pureUnlessCallableIsImpureParameter = false; + } + $parameters[] = new PhpParameterFromParserNodeReflection( $parameter->var->name, $isOptional, @@ -191,6 +199,7 @@ public function getParameters(): array $immediatelyInvokedCallable, $closureThisType, $this->parameterAttributes[$parameter->var->name] ?? [], + $pureUnlessCallableIsImpureParameter, ); } @@ -334,6 +343,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return $this->attributes; diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 2dcb0c7b870..682fe70f7e2 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -213,6 +213,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function isBuiltin(): bool { return $this->reflection->isInternal(); diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 25aa5fefa8b..290e0cb4b76 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -72,6 +72,7 @@ public function __construct( array $phpDocClosureThisTypeParameters, private bool $isConstructor, array $attributes, + array $pureUnlessCallableIsImpureParameters, ) { if ($this->classMethod instanceof Node\PropertyHook) { @@ -138,6 +139,7 @@ public function __construct( $immediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $attributes, + $pureUnlessCallableIsImpureParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index d24532340d4..cb65ff4fd14 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -90,6 +90,7 @@ public function __construct( private array $immediatelyInvokedCallableParameters, private array $phpDocClosureThisTypeParameters, private array $attributes, + private array $pureUnlessCallableIsImpureParameters, ) { } @@ -405,6 +406,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->pureUnlessCallableIsImpureParameters; + } + public function changePropertyGetHookPhpDocType(Type $phpDocType): self { return new self( @@ -433,6 +439,7 @@ public function changePropertyGetHookPhpDocType(Type $phpDocType): self $this->immediatelyInvokedCallableParameters, $this->phpDocClosureThisTypeParameters, $this->attributes, + $this->pureUnlessCallableIsImpureParameters, ); } @@ -467,6 +474,7 @@ public function changePropertySetHookPhpDocType(string $parameterName, Type $php $this->immediatelyInvokedCallableParameters, $this->phpDocClosureThisTypeParameters, $this->attributes, + $this->pureUnlessCallableIsImpureParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflectionFactory.php b/src/Reflection/Php/PhpMethodReflectionFactory.php index 6ac366fc1b4..c2978529d86 100644 --- a/src/Reflection/Php/PhpMethodReflectionFactory.php +++ b/src/Reflection/Php/PhpMethodReflectionFactory.php @@ -20,6 +20,7 @@ interface PhpMethodReflectionFactory * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param list $attributes + * @param array $pureUnlessCallableIsImpureParameters */ public function create( ClassReflection $declaringClass, @@ -43,6 +44,7 @@ public function create( array $phpDocClosureThisTypeParameters, bool $acceptsNamedArguments, array $attributes, + array $pureUnlessCallableIsImpureParameters, ): PhpMethodReflection; } diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php index 7061d7f63e9..78939b3c82b 100644 --- a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -33,6 +33,7 @@ public function __construct( private TrinaryLogic $immediatelyInvokedCallable, private ?Type $closureThisType, private array $attributes, + private bool $pureUnlessCallableIsImpureParameter, ) { } @@ -125,4 +126,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return new AllowedConstantsResult([], [], false); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php index 17b55295159..2838a8dc9d9 100644 --- a/src/Reflection/Php/PhpParameterReflection.php +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -37,6 +37,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, + private bool $pureUnlessCallableIsImpureParameter = false, ) { } @@ -160,4 +161,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/ResolvedMethodReflection.php b/src/Reflection/ResolvedMethodReflection.php index 151b3372894..4edd20227b1 100644 --- a/src/Reflection/ResolvedMethodReflection.php +++ b/src/Reflection/ResolvedMethodReflection.php @@ -168,6 +168,11 @@ public function isPure(): TrinaryLogic return $this->reflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->reflection->getPureUnlessCallableIsImpureParameters(); + } + public function getAsserts(): Assertions { return $this->asserts ??= $this->reflection->getAsserts()->mapTypes(fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index efb3b508db3..f6ccf9ad009 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -110,13 +110,24 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef } $allowedConstantsMapProvider = $this->allowedConstantsMapProvider; + $pureUnlessCallableIsImpureParameters = []; + if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { + $functionMetadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); + if (isset($functionMetadata['pureUnlessCallableIsImpureParameters'])) { + $pureUnlessCallableIsImpureParameters = $functionMetadata['pureUnlessCallableIsImpureParameters']; + } + } else { + $functionMetadata = null; + } + $variantsByType = ['positional' => []]; foreach ($functionSignaturesResult as $signatureType => $functionSignatures) { foreach ($functionSignatures ?? [] as $functionSignature) { $variantsByType[$signatureType][] = new ExtendedFunctionVariant( TemplateTypeMap::createEmpty(), null, - array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider): ExtendedNativeParameterReflection { + array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider, $pureUnlessCallableIsImpureParameters): ExtendedNativeParameterReflection { + $name = $parameterSignature->getName(); $type = $parameterSignature->getType(); $phpDocType = null; @@ -148,6 +159,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $closureThisType, [], $allowedConstantsMapProvider->getForFunctionParameter($lowerCasedFunctionName, $parameterSignature->getName()), + isset($pureUnlessCallableIsImpureParameters[$name]) && $pureUnlessCallableIsImpureParameters[$name], ); }, $functionSignature->getParameters()), $functionSignature->isVariadic(), @@ -158,8 +170,8 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef } } - if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { - $hasSideEffects = TrinaryLogic::createFromBoolean($this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName)['hasSideEffects']); + if (isset($functionMetadata['hasSideEffects'])) { + $hasSideEffects = TrinaryLogic::createFromBoolean($functionMetadata['hasSideEffects']); } else { $hasSideEffects = TrinaryLogic::createMaybe(); } diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php index 3999d919b8f..8afcf9b1b1a 100644 --- a/src/Reflection/SignatureMap/SignatureMapProvider.php +++ b/src/Reflection/SignatureMap/SignatureMapProvider.php @@ -26,12 +26,12 @@ public function hasMethodMetadata(string $className, string $methodName): bool; public function hasFunctionMetadata(string $name): bool; /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array} */ public function getMethodMetadata(string $className, string $methodName): array; /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array} */ public function getFunctionMetadata(string $functionName): array; diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index 7aab93fa82d..6c3d00011d4 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -214,6 +214,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::lazyMaxMin($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isPure()); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getDocComment(): ?string { return null; diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 89557b4e2c0..8e53f8fb956 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -171,6 +171,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isPure()); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getDocComment(): ?string { return null; diff --git a/src/Reflection/WrappedExtendedMethodReflection.php b/src/Reflection/WrappedExtendedMethodReflection.php index 7711a799580..2d35ce62d0e 100644 --- a/src/Reflection/WrappedExtendedMethodReflection.php +++ b/src/Reflection/WrappedExtendedMethodReflection.php @@ -145,6 +145,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAsserts(): Assertions { return Assertions::createEmpty(); diff --git a/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php b/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php index 43fc7ffaedc..f49c197a554 100644 --- a/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php +++ b/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php @@ -101,6 +101,11 @@ public function isPure(): TrinaryLogic return $this->methodReflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->methodReflection->getPureUnlessCallableIsImpureParameters(); + } + public function getAttributes(): array { return $this->methodReflection->getAttributes(); From 7b0f228853f3e3af3ead722c6b02c793af74ee79 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 00:36:48 +0900 Subject: [PATCH 03/29] Wire pure-unless-callable-is-impure into the analyser Return the parameter flags from getPhpDocs(), thread them through enterClassMethod(), and consult them when collecting impure points for calls. --- src/Analyser/MutatingScope.php | 7 +++++++ src/Analyser/NodeScopeResolver.php | 28 +++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 73ce0796955..bc65d25f41a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1555,6 +1555,7 @@ public function enterTrait(ClassReflection $traitReflection): self * @param Type[] $parameterOutTypes * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters + * @param array $phpDocPureUnlessCallableIsImpureParameters */ public function enterClassMethod( Node\Stmt\ClassMethod $classMethod, @@ -1576,6 +1577,7 @@ public function enterClassMethod( array $phpDocClosureThisTypeParameters = [], bool $isConstructor = false, ?ResolvedPhpDocBlock $resolvedPhpDocBlock = null, + array $phpDocPureUnlessCallableIsImpureParameters = [], ): self { if (!$this->isInClass()) { @@ -1611,6 +1613,7 @@ public function enterClassMethod( array_map(fn (Type $type): Type => $this->transformStaticType(TemplateTypeHelper::toArgument($type)), $phpDocClosureThisTypeParameters), $isConstructor, $this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)), + $phpDocPureUnlessCallableIsImpureParameters, ), !$classMethod->isStatic(), ); @@ -1700,6 +1703,7 @@ public function enterPropertyHook( [], false, $this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)), + [], ), true, ); @@ -1776,6 +1780,7 @@ private function getParameterAttributes(ClassMethod|Function_|PropertyHook $func * @param Type[] $parameterOutTypes * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters + * @param array $pureUnlessCallableIsImpureParameters */ public function enterFunction( Node\Stmt\Function_ $function, @@ -1793,6 +1798,7 @@ public function enterFunction( array $parameterOutTypes = [], array $immediatelyInvokedCallableParameters = [], array $phpDocClosureThisTypeParameters = [], + array $pureUnlessCallableIsImpureParameters = [], ): self { return $this->enterFunctionLike( @@ -1818,6 +1824,7 @@ public function enterFunction( $immediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)), + $pureUnlessCallableIsImpureParameters, ), false, ); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ff75a84ab3f..4513ed0545b 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -874,7 +874,7 @@ public function processStmtNode( $throwPoints = []; $impurePoints = []; $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback); - [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes] = $this->getPhpDocs($scope, $stmt); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters] = $this->getPhpDocs($scope, $stmt); foreach ($stmt->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -3658,6 +3658,26 @@ public function processArgs( } $parameter = $lastParameter; } + + if ($parameter instanceof ExtendedParameterReflection + && $parameter->isPureUnlessCallableIsImpureParameter() + && $parameterType !== null + && $parameterType->isTrue()->yes() + ) { + if (count($parameterType->getCallableParametersAcceptors($scope)) > 0 && $calleeReflection !== null) { + $parameterCallable = $parameterType->getCallableParametersAcceptors($scope)[0]; + $certain = $parameterCallable->isPure()->yes(); + if ($certain) { + $impurePoints[] = new ImpurePoint( + $scope, + $callLike, + 'functionCall', + sprintf('call to function %s()', $calleeReflection->getName()), + $certain, + ); + } + } + } } $lookForUnset = false; @@ -4940,7 +4960,7 @@ private function processNodesForCalledMethod($node, ExpressionResultStorage $sto } /** - * @return array{TemplateTypeMap, array, array, array, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock} + * @return array{TemplateTypeMap, array, array, array, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock, array} */ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $node): array { @@ -4970,6 +4990,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $resolvedPhpDoc = null; $functionName = null; $phpDocParameterOutTypes = []; + $phpDocPureUnlessCallableIsImpureParameters = []; if ($node instanceof Node\Stmt\ClassMethod) { if (!$scope->isInClass()) { @@ -5103,6 +5124,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; $varTags = $resolvedPhpDoc->getVarTags(); + $phpDocPureUnlessCallableIsImpureParameters = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); } if ($acceptsNamedArguments && $scope->isInClass()) { @@ -5126,7 +5148,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n } } - return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc]; + return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters]; } private function transformStaticType(ClassReflection $declaringClass, Type $type): Type From da3fbcaf27cc280b36cae1a44270777486b3b9e3 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 00:36:49 +0900 Subject: [PATCH 04/29] Mark callback-accepting builtins as pure-unless-callable-is-impure array_filter/array_map/array_reduce, the array_u*/array_*_u* family, call_user_func(_array), forward_static_call(_array) and preg_replace_callback(_array) are pure unless their callback argument is impure. --- bin/functionMetadata_original.php | 29 ++++++++++++++++--------- bin/generate-function-metadata.php | 34 +++++++++++++++++++++++++++--- resources/functionMetadata.php | 31 +++++++++++++++++---------- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 78ac65db296..e33901de378 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -28,20 +28,23 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false], - 'array_diff_ukey' => ['hasSideEffects' => false], + 'array_diff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_diff_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], 'array_intersect_key' => ['hasSideEffects' => false], - 'array_intersect_uassoc' => ['hasSideEffects' => false], - 'array_intersect_ukey' => ['hasSideEffects' => false], + 'array_intersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_intersect_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], 'array_key_first' => ['hasSideEffects' => false], 'array_key_last' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], + 'array_map' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -49,18 +52,19 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], 'array_shift' => ['hasSideEffects' => true], 'array_slice' => ['hasSideEffects' => false], 'array_sum' => ['hasSideEffects' => false], - 'array_udiff' => ['hasSideEffects' => false], - 'array_udiff_assoc' => ['hasSideEffects' => false], - 'array_udiff_uassoc' => ['hasSideEffects' => false], - 'array_uintersect' => ['hasSideEffects' => false], - 'array_uintersect_assoc' => ['hasSideEffects' => false], - 'array_uintersect_uassoc' => ['hasSideEffects' => false], + 'array_udiff' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true]], + 'array_udiff_assoc' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], + 'array_udiff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true, 'key_comp_func' => true]], + 'array_uintersect' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_assoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true, 'key_compare_func' => true]], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], @@ -81,6 +85,8 @@ 'bcround' => ['hasSideEffects' => false], 'bcfloor' => ['hasSideEffects' => false], 'bcceil' => ['hasSideEffects' => false], + 'call_user_func' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'call_user_func_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], // continue functionMap.php, line 424 'chgrp' => ['hasSideEffects' => true], 'chmod' => ['hasSideEffects' => true], @@ -97,6 +103,8 @@ 'file_put_contents' => ['hasSideEffects' => true], 'flock' => ['hasSideEffects' => true], 'fopen' => ['hasSideEffects' => true], + 'forward_static_call' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'forward_static_call_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], 'fpassthru' => ['hasSideEffects' => true], 'fputcsv' => ['hasSideEffects' => true], 'fputs' => ['hasSideEffects' => true], @@ -237,6 +245,7 @@ 'output_reset_rewrite_vars' => ['hasSideEffects' => true], 'pclose' => ['hasSideEffects' => true], 'popen' => ['hasSideEffects' => true], + 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'readfile' => ['hasSideEffects' => true], 'rename' => ['hasSideEffects' => true], 'rewind' => ['hasSideEffects' => true], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index d161d374e46..776e5987abf 100755 --- a/bin/generate-function-metadata.php +++ b/bin/generate-function-metadata.php @@ -123,9 +123,17 @@ public function enterNode(Node $node) $metadata = require __DIR__ . '/functionMetadata_original.php'; foreach ($visitor->functions as $functionName) { if (array_key_exists($functionName, $metadata)) { - if ($metadata[$functionName]['hasSideEffects']) { + if (isset($metadata[$functionName]['hasSideEffects']) && $metadata[$functionName]['hasSideEffects']) { throw new ShouldNotHappenException($functionName); } + + if (isset($metadata[$functionName]['pureUnlessCallableIsImpureParameters'])) { + $metadata[$functionName] = [ + 'pureUnlessCallableIsImpureParameters' => $metadata[$functionName]['pureUnlessCallableIsImpureParameters'], + ]; + + continue; + } } $metadata[$functionName] = ['hasSideEffects' => false]; } @@ -184,12 +192,32 @@ public function enterNode(Node $node) ]; php; $content = ''; + $escape = static fn (mixed $value): string => var_export($value, true); + $encodeHasSideEffects = static fn (array $meta) => [$escape('hasSideEffects'), $escape($meta['hasSideEffects'])]; + $encodePureUnlessCallableIsImpureParameters = static fn (array $meta) => [ + $escape('pureUnlessCallableIsImpureParameters'), + sprintf( + '[%s]', + implode( + ' ,', + array_map( + static fn ($key, $param) => sprintf('%s => %s', $escape($key), $escape($param)), + array_keys($meta['pureUnlessCallableIsImpureParameters']), + $meta['pureUnlessCallableIsImpureParameters'], + ), + ), + ), + ]; + foreach ($metadata as $name => $meta) { $content .= sprintf( "\t%s => [%s => %s],\n", var_export($name, true), - var_export('hasSideEffects', true), - var_export($meta['hasSideEffects'], true), + ...match (true) { + isset($meta['hasSideEffects']) => $encodeHasSideEffects($meta), + isset($meta['pureUnlessCallableIsImpureParameters']) => $encodePureUnlessCallableIsImpureParameters($meta), + default => throw new ShouldNotHappenException($escape($meta)), + }, ); } diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 7a5515b967d..0b7f35f93f3 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -733,23 +733,26 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false], - 'array_diff_ukey' => ['hasSideEffects' => false], + 'array_diff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_diff_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_first' => ['hasSideEffects' => false], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], 'array_intersect_key' => ['hasSideEffects' => false], - 'array_intersect_uassoc' => ['hasSideEffects' => false], - 'array_intersect_ukey' => ['hasSideEffects' => false], + 'array_intersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_intersect_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], 'array_is_list' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_key_first' => ['hasSideEffects' => false], 'array_key_last' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], 'array_last' => ['hasSideEffects' => false], + 'array_map' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -757,6 +760,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], @@ -764,12 +768,12 @@ 'array_shift' => ['hasSideEffects' => true], 'array_slice' => ['hasSideEffects' => false], 'array_sum' => ['hasSideEffects' => false], - 'array_udiff' => ['hasSideEffects' => false], - 'array_udiff_assoc' => ['hasSideEffects' => false], - 'array_udiff_uassoc' => ['hasSideEffects' => false], - 'array_uintersect' => ['hasSideEffects' => false], - 'array_uintersect_assoc' => ['hasSideEffects' => false], - 'array_uintersect_uassoc' => ['hasSideEffects' => false], + 'array_udiff' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true]], + 'array_udiff_assoc' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], + 'array_udiff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true ,'key_comp_func' => true]], + 'array_uintersect' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_assoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true ,'key_compare_func' => true]], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], @@ -803,6 +807,8 @@ 'bzerror' => ['hasSideEffects' => false], 'bzerrstr' => ['hasSideEffects' => false], 'bzopen' => ['hasSideEffects' => false], + 'call_user_func' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'call_user_func_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], 'ceil' => ['hasSideEffects' => false], 'checkdate' => ['hasSideEffects' => false], 'checkdnsrr' => ['hasSideEffects' => false], @@ -954,6 +960,8 @@ 'fmod' => ['hasSideEffects' => false], 'fnmatch' => ['hasSideEffects' => true], 'fopen' => ['hasSideEffects' => true], + 'forward_static_call' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'forward_static_call_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], 'fpassthru' => ['hasSideEffects' => true], 'fputcsv' => ['hasSideEffects' => true], 'fputs' => ['hasSideEffects' => true], @@ -1616,6 +1624,7 @@ 'preg_last_error' => ['hasSideEffects' => true], 'preg_last_error_msg' => ['hasSideEffects' => true], 'preg_quote' => ['hasSideEffects' => false], + 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'preg_split' => ['hasSideEffects' => false], 'property_exists' => ['hasSideEffects' => false], 'quoted_printable_decode' => ['hasSideEffects' => false], @@ -1757,4 +1766,4 @@ 'zlib_encode' => ['hasSideEffects' => false], 'zlib_get_coding_type' => ['hasSideEffects' => false], -]; \ No newline at end of file +]; From e5f4c5067c3598bb51767ab118c302e41b69d5b8 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 00:36:49 +0900 Subject: [PATCH 05/29] Add tests for @pure-unless-callable-is-impure Type-inference fixture plus function-metadata expectations. --- .../Analyser/TestClosureTypeRuleTest.php | 4 +-- .../Analyser/nsrt/closure-passed-to-type.php | 1 + .../param-pure-unless-callable-is-impure.php | 28 +++++++++++++++++++ .../SignatureMap/FunctionMetadataTest.php | 8 ++++-- 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/param-pure-unless-callable-is-impure.php diff --git a/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php b/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php index aebfdc606f4..a095b96ac40 100644 --- a/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php +++ b/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php @@ -21,11 +21,11 @@ public function testRule(): void $this->analyse([__DIR__ . '/nsrt/closure-passed-to-type.php'], [ [ 'Closure type: Closure(mixed): (1|2|3)', - 25, + 26, ], [ 'Closure type: Closure(mixed): (1|2|3)', - 35, + 36, ], ]); } diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php index a34ded15919..f3fa9795d2b 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php @@ -13,6 +13,7 @@ class Foo * @param array $items * @param callable(T): U $cb * @return array + * @pure-unless-callable-impure $cb */ public function doFoo(array $items, callable $cb) { diff --git a/tests/PHPStan/Analyser/nsrt/param-pure-unless-callable-is-impure.php b/tests/PHPStan/Analyser/nsrt/param-pure-unless-callable-is-impure.php new file mode 100644 index 00000000000..4ddd3212822 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/param-pure-unless-callable-is-impure.php @@ -0,0 +1,28 @@ + $a + * @return array + * @pure-unless-callable-is-impure $f + */ +function map(Closure $f, iterable $a): array +{ + $result = []; + foreach ($a as $i => $v) { + $retult[$i] = $f($v); + } + + return $result; +} + +map('printf', []); +map('sprintf', []); + +assertType('array', map('printf', [])); diff --git a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php index 24ef8431eee..365e3d49757 100644 --- a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php +++ b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php @@ -5,6 +5,7 @@ use Nette\Schema\Expect; use Nette\Schema\Processor; use PHPStan\Testing\PHPStanTestCase; +use function count; class FunctionMetadataTest extends PHPStanTestCase { @@ -17,8 +18,11 @@ public function testSchema(): void $processor = new Processor(); $processor->process(Expect::arrayOf( Expect::structure([ - 'hasSideEffects' => Expect::bool()->required(), - ])->required(), + 'hasSideEffects' => Expect::bool(), + 'pureUnlessCallableIsImpureParameters' => Expect::arrayOf(Expect::bool(), Expect::string()), + ]) + ->assert(static fn ($v) => count((array) $v) > 0, 'Metadata entries must not be empty.') + ->required(), )->required(), $data); } From d3546662ce98f02174650d61457ba2d796f159ef Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 01:37:59 +0900 Subject: [PATCH 06/29] Fix pure-unless-callable-is-impure parameter flag propagation The flag was silently dropped wherever parameters are re-wrapped (combineAcceptors, overrideParameterType, resolved template variants, unresolved method prototypes) and the BetterReflection function path never read the PHPDoc tag. Also honor the @phpstan- prefixed alias, strip the leading $ from the tag's parameter name, read the tag from stub PHPDocs, and annotate array_reduce's stub. --- src/PhpDoc/PhpDocNodeResolver.php | 7 +++++-- .../BetterReflection/BetterReflectionProvider.php | 3 +++ src/Reflection/FunctionReflectionFactory.php | 2 ++ src/Reflection/ParametersAcceptorSelector.php | 6 ++++++ src/Reflection/Php/PhpFunctionReflection.php | 5 ++++- src/Reflection/Php/PhpMethodReflection.php | 1 + src/Reflection/ResolvedFunctionVariantWithOriginal.php | 1 + .../SignatureMap/NativeFunctionReflectionProvider.php | 6 +++++- .../Type/CallbackUnresolvedMethodPrototypeReflection.php | 1 + .../CalledOnTypeUnresolvedMethodPrototypeReflection.php | 1 + stubs/arrayFunctions.stub | 2 ++ 11 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 3519842b360..2304edc1490 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -393,8 +393,11 @@ public function resolveParamImmediatelyInvokedCallable(PhpDocNode $phpDocNode): public function resolveParamPureUnlessCallableIsImpure(PhpDocNode $phpDocNode): array { $parameters = []; - foreach ($phpDocNode->getPureUnlessCallableIsImpureTagValues() as $tag) { - $parameters[$tag->parameterName] = true; + foreach (['@pure-unless-callable-is-impure', '@phpstan-pure-unless-callable-is-impure'] as $tagName) { + foreach ($phpDocNode->getPureUnlessCallableIsImpureTagValues($tagName) as $tag) { + $parameterName = substr($tag->parameterName, 1); + $parameters[$parameterName] = true; + } } return $parameters; diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 49197e64e3b..b55744220ee 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -292,6 +292,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterOutTags = []; $phpDocParameterImmediatelyInvokedCallable = []; $phpDocParameterClosureThisTypeTags = []; + $phpDocParameterPureUnlessCallableIsImpure = []; $resolvedPhpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($reflectionFunction->getName(), array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $reflectionFunction->getParameters())); if ($resolvedPhpDoc === null && $reflectionFunction->getFileName() !== false && $reflectionFunction->getDocComment() !== false) { @@ -318,6 +319,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterOutTags = $resolvedPhpDoc->getParamOutTags(); $phpDocParameterImmediatelyInvokedCallable = $resolvedPhpDoc->getParamsImmediatelyInvokedCallable(); $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); + $phpDocParameterPureUnlessCallableIsImpure = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); } return $this->functionReflectionFactory->create( @@ -338,6 +340,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterImmediatelyInvokedCallable, array_map(static fn (ParamClosureThisTag $tag): Type => $tag->getType(), $phpDocParameterClosureThisTypeTags), $this->attributeReflectionFactory->fromNativeReflection($reflectionFunction->getAttributes(), InitializerExprContext::fromFunction($reflectionFunction->getName(), $reflectionFunction->getFileName() !== false ? $reflectionFunction->getFileName() : null)), + $phpDocParameterPureUnlessCallableIsImpure, ); } diff --git a/src/Reflection/FunctionReflectionFactory.php b/src/Reflection/FunctionReflectionFactory.php index 993bf34b3b4..58224858714 100644 --- a/src/Reflection/FunctionReflectionFactory.php +++ b/src/Reflection/FunctionReflectionFactory.php @@ -16,6 +16,7 @@ interface FunctionReflectionFactory * @param array $phpDocParameterImmediatelyInvokedCallable * @param array $phpDocParameterClosureThisTypes * @param list $attributes + * @param array $phpDocParameterPureUnlessCallableIsImpure */ public function create( ReflectionFunction $reflection, @@ -35,6 +36,7 @@ public function create( array $phpDocParameterImmediatelyInvokedCallable, array $phpDocParameterClosureThisTypes, array $attributes, + array $phpDocParameterPureUnlessCallableIsImpure, ): PhpFunctionReflection; } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index d918b512f22..e34989a39dd 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -768,6 +768,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $parameter instanceof ExtendedParameterReflection ? $parameter->getClosureThisType() : null, $parameter instanceof ExtendedParameterReflection ? $parameter->getAttributes() : [], $parameter instanceof ExtendedParameterReflection ? $parameter->getAllowedConstants() : null, + $parameter instanceof ExtendedParameterReflection && $parameter->isPureUnlessCallableIsImpureParameter(), ); continue; } @@ -822,6 +823,9 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc } } + $pureUnlessCallableIsImpureParameter = $parameters[$i]->isPureUnlessCallableIsImpureParameter() + || ($parameter instanceof ExtendedParameterReflection && $parameter->isPureUnlessCallableIsImpureParameter()); + $parameters[$i] = new ExtendedDummyParameter( $parameters[$i]->getName() !== $parameter->getName() ? sprintf('%s|%s', $parameters[$i]->getName(), $parameter->getName()) : $parameter->getName(), $type, @@ -836,6 +840,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $closureThisType, $attributes, $allowedConstants, + $pureUnlessCallableIsImpureParameter, ); if ($isVariadic) { @@ -1265,6 +1270,7 @@ private static function overrideParameterType(ParameterReflection $original, Typ $wrapped->getClosureThisType(), $wrapped->getAttributes(), $wrapped->getAllowedConstants(), + $wrapped->isPureUnlessCallableIsImpureParameter(), ); } diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 682fe70f7e2..0d7fac1ceef 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -40,6 +40,7 @@ final class PhpFunctionReflection implements FunctionReflection * @param array $phpDocParameterImmediatelyInvokedCallable * @param array $phpDocParameterClosureThisTypes * @param list $attributes + * @param array $phpDocParameterPureUnlessCallableIsImpure */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, @@ -62,6 +63,7 @@ public function __construct( private array $phpDocParameterImmediatelyInvokedCallable, private array $phpDocParameterClosureThisTypes, private array $attributes, + private array $phpDocParameterPureUnlessCallableIsImpure, ) { } @@ -130,6 +132,7 @@ private function getParameters(): array $this->phpDocParameterClosureThisTypes[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForFunctionParameter(strtolower($this->reflection->getName()), $reflection->getName()), + $this->phpDocParameterPureUnlessCallableIsImpure[$reflection->getName()] ?? false, ); }, $this->reflection->getParameters()); } @@ -215,7 +218,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return []; + return $this->phpDocParameterPureUnlessCallableIsImpure; } public function isBuiltin(): bool diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index cb65ff4fd14..edc3a645fcd 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -63,6 +63,7 @@ final class PhpMethodReflection implements ExtendedMethodReflection * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param list $attributes + * @param array $pureUnlessCallableIsImpureParameters */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 9d451a57148..0151110214b 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -122,6 +122,7 @@ function (ExtendedParameterReflection $param): ExtendedParameterReflection { $closureThisType, $param->getAttributes(), $param->getAllowedConstants(), + $param->isPureUnlessCallableIsImpureParameter(), ); }, $this->parametersAcceptor->getParameters(), diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index f6ccf9ad009..4fec33a07a9 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -133,6 +133,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $phpDocType = null; $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); $closureThisType = null; + $pureUnlessCallableIsImpureParameter = isset($pureUnlessCallableIsImpureParameters[$name]) && $pureUnlessCallableIsImpureParameters[$name]; if ($phpDoc !== null) { if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamTags())) { $phpDocType = $phpDoc->getParamTags()[$parameterSignature->getName()]->getType(); @@ -143,6 +144,9 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamClosureThisTags())) { $closureThisType = $phpDoc->getParamClosureThisTags()[$parameterSignature->getName()]->getType(); } + if (($phpDoc->getParamsPureUnlessCallableIsImpure()[$parameterSignature->getName()] ?? false) === true) { + $pureUnlessCallableIsImpureParameter = true; + } } return new ExtendedNativeParameterReflection( @@ -159,7 +163,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $closureThisType, [], $allowedConstantsMapProvider->getForFunctionParameter($lowerCasedFunctionName, $parameterSignature->getName()), - isset($pureUnlessCallableIsImpureParameters[$name]) && $pureUnlessCallableIsImpureParameters[$name], + $pureUnlessCallableIsImpureParameter, ); }, $functionSignature->getParameters()), $functionSignature->isVariadic(), diff --git a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php index 026d3c36fb2..72b5def04fe 100644 --- a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php @@ -119,6 +119,7 @@ function (ExtendedParameterReflection $parameter): ExtendedParameterReflection { $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, $parameter->getAttributes(), $parameter->getAllowedConstants(), + $parameter->isPureUnlessCallableIsImpureParameter(), ); }, $acceptor->getParameters(), diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php index 8198ea1f954..8e6099d8886 100644 --- a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php @@ -106,6 +106,7 @@ private function transformMethodWithStaticType(ClassReflection $declaringClass, $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, $parameter->getAttributes(), $parameter->getAllowedConstants(), + $parameter->isPureUnlessCallableIsImpureParameter(), ), $acceptor->getParameters(), ), diff --git a/stubs/arrayFunctions.stub b/stubs/arrayFunctions.stub index 3297e473168..aff4251d517 100644 --- a/stubs/arrayFunctions.stub +++ b/stubs/arrayFunctions.stub @@ -9,6 +9,8 @@ * @param TReturn $three * * @return TReturn + * + * @pure-unless-callable-is-impure $two */ function array_reduce( array $one, From 3fe1829887ea2d1418134574696042dbc0f2df5e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 01:37:59 +0900 Subject: [PATCH 07/29] Suppress call impure points when pure-unless-callable-is-impure callbacks are pure SimpleImpurePoint::createFromVariant() now evaluates the purity of arguments passed to flagged parameters: provably pure (or omitted) callbacks produce no impure point, provably impure ones make the point certain, unknown callables keep the current possibly-impure behavior. This fixes the false positive for array_map() with a pure callback in @phpstan-pure functions and enables the no-effect report for standalone statements. Closes https://github.com/phpstan/phpstan/issues/11100 Closes https://github.com/phpstan/phpstan/issues/11101 --- src/Analyser/NodeScopeResolver.php | 21 +---- .../Callables/SimpleImpurePoint.php | 86 ++++++++++++++++++ ...ionStatementWithoutSideEffectsRuleTest.php | 22 +++++ .../Rules/Functions/data/bug-11101.php | 23 +++++ .../Rules/Pure/PureFunctionRuleTest.php | 22 +++++ .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 6 ++ tests/PHPStan/Rules/Pure/data/bug-11100.php | 18 ++++ .../data/pure-unless-callable-is-impure.php | 89 +++++++++++++++++++ 8 files changed, 267 insertions(+), 20 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11101.php create mode 100644 tests/PHPStan/Rules/Pure/data/bug-11100.php create mode 100644 tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 4513ed0545b..88350a21df2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -910,6 +910,7 @@ public function processStmtNode( $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $isConstructor, + phpDocPureUnlessCallableIsImpureParameters: $pureUnlessCallableIsImpureParameters, ); if (!$scope->isInClass()) { @@ -3658,26 +3659,6 @@ public function processArgs( } $parameter = $lastParameter; } - - if ($parameter instanceof ExtendedParameterReflection - && $parameter->isPureUnlessCallableIsImpureParameter() - && $parameterType !== null - && $parameterType->isTrue()->yes() - ) { - if (count($parameterType->getCallableParametersAcceptors($scope)) > 0 && $calleeReflection !== null) { - $parameterCallable = $parameterType->getCallableParametersAcceptors($scope)[0]; - $certain = $parameterCallable->isPure()->yes(); - if ($certain) { - $impurePoints[] = new ImpurePoint( - $scope, - $callLike, - 'functionCall', - sprintf('call to function %s()', $calleeReflection->getName()), - $certain, - ); - } - } - } } $lookForUnset = false; diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 6274d75ed12..ce81663e37c 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -6,9 +6,12 @@ use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\Scope; use PHPStan\Reflection\ExtendedMethodReflection; +use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\TrinaryLogic; use PHPStan\Type\Type; +use function count; use function sprintf; /** @@ -59,6 +62,18 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec $certain = $certain || $variant->getReturnType()->isVoid()->yes(); } + if (!$certain && $scope !== null && $variant !== null) { + $verdict = self::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, $args); + if ($verdict !== null) { + if ($verdict->yes()) { + return null; + } + if ($verdict->no()) { + $certain = true; + } + } + } + if ($function instanceof FunctionReflection) { if (isset(self::SIDE_EFFECT_FLIP_PARAMETERS[$function->getName()]) && $scope !== null) { [ @@ -116,6 +131,77 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec return null; } + /** + * Combined purity verdict of all arguments passed to parameters flagged + * with @pure-unless-callable-is-impure. Returns null when the variant has + * no such parameters (so the caller keeps its current behavior). + * + * @param Arg[] $args + */ + private static function resolvePureUnlessCallableIsImpureVerdict(ParametersAcceptor $variant, Scope $scope, array $args): ?TrinaryLogic + { + $parameters = $variant->getParameters(); + $verdict = null; + + foreach ($parameters as $parameterIndex => $parameter) { + if (!$parameter instanceof ExtendedParameterReflection) { + continue; + } + if (!$parameter->isPureUnlessCallableIsImpureParameter()) { + continue; + } + + $verdict ??= TrinaryLogic::createYes(); + + $matchedArg = null; + $hasNamedParameter = false; + foreach ($args as $i => $arg) { + if ($arg->name !== null) { + $hasNamedParameter = true; + if ($arg->name->name === $parameter->getName()) { + $matchedArg = $arg; + break; + } + + continue; + } + + if (!$hasNamedParameter && $i === $parameterIndex) { + $matchedArg = $arg; + break; + } + } + + if ($matchedArg === null) { + // Optional callback omitted (e.g. array_filter($arr)) - pure. + continue; + } + + $argType = $scope->getType($matchedArg->value); + if ($argType->isNull()->yes()) { + // Explicit null callback (e.g. array_filter($arr, null)) - pure. + continue; + } + + if (!$argType->isCallable()->yes()) { + $verdict = $verdict->and(TrinaryLogic::createMaybe()); + continue; + } + + $acceptors = $argType->getCallableParametersAcceptors($scope); + if (count($acceptors) === 0) { + $verdict = $verdict->and(TrinaryLogic::createMaybe()); + continue; + } + + foreach ($acceptors as $acceptor) { + $verdict = $verdict->and($acceptor->isPure()); + } + } + + return $verdict; + } + /** @return ImpurePointIdentifier */ public function getIdentifier(): string { diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index 4c40e6d7fcc..1e02b374418 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -91,6 +91,28 @@ public function testBug12224(): void $this->analyse([__DIR__ . '/data/bug-12224.php'], []); } + public function testBug11101(): void + { + $this->analyse([__DIR__ . '/data/bug-11101.php'], [ + [ + 'Call to function array_filter() on a separate line has no effect.', + 12, + ], + [ + 'Call to function array_map() on a separate line has no effect.', + 13, + ], + [ + 'Call to function array_reduce() on a separate line has no effect.', + 14, + ], + [ + 'Call to function array_filter() on a separate line has no effect.', + 15, + ], + ]); + } + public function testBug4455(): void { require_once __DIR__ . '/data/bug-4455.php'; diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101.php b/tests/PHPStan/Rules/Functions/data/bug-11101.php new file mode 100644 index 00000000000..0c1143e5b0d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11101.php @@ -0,0 +1,23 @@ + $array + * @param callable(int): bool $opaque + * @param pure-callable(int): int $pureCb + */ +function doFoo(array $array, callable $opaque, callable $pureCb): void +{ + array_filter($array, 'is_string'); + array_map('is_string', $array); + array_reduce($array, fn ($c, $i) => $c + $i, 0); + array_filter($array); + + array_map(static function (int $x): int { + echo $x; + return $x; + }, $array); + array_map($opaque, $array); + usort($array, $pureCb); +} diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index cb9afa117a6..c49a319746f 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -230,4 +230,26 @@ public function testBug6574(): void $this->analyse([__DIR__ . '/data/bug-6574.php'], []); } + public function testPureUnlessCallableIsImpure(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-callable-is-impure.php'], [ + [ + 'Impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithImpureCallback().', + 22, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunction\pureWithImpureCallback().', + 23, + ], + [ + 'Possibly impure call to a callable in pure function PureUnlessCallableIsImpureFunction\pureWithOpaqueCallback().', + 36, + ], + [ + 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithOpaqueCallback().', + 36, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 8287809407a..a31dcfe0bd0 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -383,6 +383,12 @@ public function testBug14504(): void $this->analyse([__DIR__ . '/data/bug-14504-method.php'], []); } + public function testBug11100(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-11100.php'], []); + } + public function testBug14511(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Pure/data/bug-11100.php b/tests/PHPStan/Rules/Pure/data/bug-11100.php new file mode 100644 index 00000000000..13793140ef8 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-11100.php @@ -0,0 +1,18 @@ + $numbers + * @return array + * @phpstan-pure + */ + public function double(array $numbers): array + { + return array_map(static fn (int $x): int => $x * 2, $numbers); + } + +} diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php new file mode 100644 index 00000000000..64a6e87811e --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -0,0 +1,89 @@ + $arr + * @return array + * @phpstan-pure + */ +function pureWithPureCallback(array $arr): array +{ + return array_map(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureWithImpureCallback(array $arr): array +{ + return array_map(static function (int $x): int { + echo $x; + return $x * 2; + }, $arr); +} + +/** + * @param array $arr + * @param callable(int): int $cb + * @return array + * @phpstan-pure + */ +function pureWithOpaqueCallback(array $arr, callable $cb): array +{ + return array_map($cb, $arr); +} + +/** + * @param callable(int): int $f + * @param array $arr + * @return array + * @pure-unless-callable-is-impure $f + */ +function myMap(callable $f, array $arr): array +{ + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $f($v); + } + + return $result; +} + +/** + * @param callable(int): int $f + * @param array $arr + * @return array + * @phpstan-pure-unless-callable-is-impure $f + */ +function myMapPhpstanAlias(callable $f, array $arr): array +{ + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $f($v); + } + + return $result; +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingUserlandWithPureCallback(array $arr): array +{ + return myMap(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingUserlandAliasWithPureCallback(array $arr): array +{ + return myMapPhpstanAlias(static fn (int $x): int => $x * 2, $arr); +} From bd3febf2d8c839b14a37bbf32f407957815864de Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 02:04:28 +0900 Subject: [PATCH 08/29] Fix CI: avoid named arguments, adjust no-effect fixture Named arguments are not supported by the PHP 7.4 downgrade, so pass the enterClassMethod() parameters positionally. The file-without-errors.php fixture contained a standalone array_filter() call that is now correctly reported as having no effect - wrap it in echo count(). --- src/Analyser/NodeScopeResolver.php | 3 ++- src/Reflection/Php/PhpClassReflectionExtension.php | 4 +++- tests/PHPStan/Command/data/file-without-errors.php | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 88350a21df2..c55713c3237 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -910,7 +910,8 @@ public function processStmtNode( $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $isConstructor, - phpDocPureUnlessCallableIsImpureParameters: $pureUnlessCallableIsImpureParameters, + null, + $pureUnlessCallableIsImpureParameters, ); if (!$scope->isInClass()) { diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 0efd3519a0e..79403d301d0 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -1186,7 +1186,9 @@ private function inferAndCachePropertyTypes( $phpDocParameterOutTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, - phpDocPureUnlessCallableIsImpureParameters: $phpDocPureUnlessCallableIsImpureParameters, + false, + null, + $phpDocPureUnlessCallableIsImpureParameters, ); $propertyTypes = []; diff --git a/tests/PHPStan/Command/data/file-without-errors.php b/tests/PHPStan/Command/data/file-without-errors.php index 08929907d3c..48196273364 100644 --- a/tests/PHPStan/Command/data/file-without-errors.php +++ b/tests/PHPStan/Command/data/file-without-errors.php @@ -1,3 +1,3 @@ Date: Fri, 3 Jul 2026 11:04:24 +0900 Subject: [PATCH 09/29] Cover pure-unless-callable-is-impure verdict for maybe-null and maybe-callable arguments Kills the two escaped Infection mutants in SimpleImpurePoint: a (callable|null) callback exercises the isNull() branch and a (pure-callable|int) callback exercises the isCallable() branch - both keep the call possibly impure. --- .../Rules/Pure/PureFunctionRuleTest.php | 8 +++++++ .../data/pure-unless-callable-is-impure.php | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index c49a319746f..b581251d825 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -249,6 +249,14 @@ public function testPureUnlessCallableIsImpure(): void 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithOpaqueCallback().', 36, ], + [ + 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithMaybeNullCallback().', + 100, + ], + [ + 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithMaybeCallablePureCallback().', + 112, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index 64a6e87811e..45dc56b9771 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -87,3 +87,27 @@ function pureCallingUserlandAliasWithPureCallback(array $arr): array { return myMapPhpstanAlias(static fn (int $x): int => $x * 2, $arr); } + +/** + * @param array $arr + * @param (callable(int): int)|null $cb + * @return array + * @phpstan-pure + */ +function pureWithMaybeNullCallback(array $arr, ?callable $cb): array +{ + // $cb might be null (pure) or an unknown callable, so the call stays possibly impure. + return array_map($cb, $arr); +} + +/** + * @param array $arr + * @param (pure-callable(int): int)|int $cb + * @return array + * @phpstan-pure + */ +function pureWithMaybeCallablePureCallback(array $arr, $cb): array +{ + // $cb is only maybe-callable, so the call stays possibly impure. + return array_map($cb, $arr); +} From dbe4eaf5b20d3f8a61cbb77d6ab4db7c9cb02b48 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 15:00:43 +0900 Subject: [PATCH 10/29] Simplify pure-unless-callable-is-impure metadata reads Address review nits: use null coalescing for the array parameter flags, a ternary for the hasSideEffects TrinaryLogic (keeping the absent -> Maybe branch), and split the negated method hasSideEffects read into named steps matching the ancestors loop below. --- src/Reflection/Php/PhpClassReflectionExtension.php | 4 +++- .../Php/PhpFunctionFromParserNodeReflection.php | 6 +----- .../SignatureMap/NativeFunctionReflectionProvider.php | 10 ++++------ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 79403d301d0..81a144fe731 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -636,7 +636,9 @@ private function createMethod( $isPure = null; if ($this->signatureMapProvider->hasMethodMetadata($declaringClassName, $methodReflection->getName())) { - $isPure = !($this->signatureMapProvider->getMethodMetadata($declaringClassName, $methodReflection->getName())['hasSideEffects'] ?? true); + $methodMetadata = $this->signatureMapProvider->getMethodMetadata($declaringClassName, $methodReflection->getName()); + $hasSideEffects = $methodMetadata['hasSideEffects'] ?? true; + $isPure = !$hasSideEffects; } $methodSignaturesResult = $this->signatureMapProvider->getMethodSignatures($declaringClassName, $methodReflection->getName(), $methodReflection); diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index f3e1e8a8733..dd601bdf635 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -179,11 +179,7 @@ public function getParameters(): array $closureThisType = null; } - if (isset($this->pureUnlessCallableIsImpureParameters[$parameter->var->name])) { - $pureUnlessCallableIsImpureParameter = $this->pureUnlessCallableIsImpureParameters[$parameter->var->name]; - } else { - $pureUnlessCallableIsImpureParameter = false; - } + $pureUnlessCallableIsImpureParameter = $this->pureUnlessCallableIsImpureParameters[$parameter->var->name] ?? false; $parameters[] = new PhpParameterFromParserNodeReflection( $parameter->var->name, diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 4fec33a07a9..a13012b14ba 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -133,7 +133,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $phpDocType = null; $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); $closureThisType = null; - $pureUnlessCallableIsImpureParameter = isset($pureUnlessCallableIsImpureParameters[$name]) && $pureUnlessCallableIsImpureParameters[$name]; + $pureUnlessCallableIsImpureParameter = $pureUnlessCallableIsImpureParameters[$name] ?? false; if ($phpDoc !== null) { if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamTags())) { $phpDocType = $phpDoc->getParamTags()[$parameterSignature->getName()]->getType(); @@ -174,11 +174,9 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef } } - if (isset($functionMetadata['hasSideEffects'])) { - $hasSideEffects = TrinaryLogic::createFromBoolean($functionMetadata['hasSideEffects']); - } else { - $hasSideEffects = TrinaryLogic::createMaybe(); - } + $hasSideEffects = isset($functionMetadata['hasSideEffects']) + ? TrinaryLogic::createFromBoolean($functionMetadata['hasSideEffects']) + : TrinaryLogic::createMaybe(); $functionReflection = new NativeFunctionReflection( $realFunctionName, From 498783b4c053c95d081d4512ff955d8ad14bcf0b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 15:05:57 +0900 Subject: [PATCH 11/29] Document the shape of the function metadata array The value shape gained a second form (pureUnlessCallableIsImpureParameters), so describe both forms in the file-level comments of the source, the generator template and the generated file, add an inline @var before the array, and fix the generator's now-stale internal @var. --- bin/functionMetadata_original.php | 16 ++++++++++++++++ bin/generate-function-metadata.php | 10 +++++++++- resources/functionMetadata.php | 8 ++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index e33901de378..37096be7d8a 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -1,5 +1,21 @@ bool] + * false: the call is pure. true: the call has side effects. + * - ['pureUnlessCallableIsImpureParameters' => array] + * the call is pure unless one of the listed callable parameters + * (keyed by parameter name) receives an impure callable, e.g. array_map() + * whose only side effects come from its 'callback' argument. + */ + +/** @var array}> */ return [ 'abs' => ['hasSideEffects' => false], 'acos' => ['hasSideEffects' => false], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index 776e5987abf..f5ef1c5101c 100755 --- a/bin/generate-function-metadata.php +++ b/bin/generate-function-metadata.php @@ -119,7 +119,7 @@ public function enterNode(Node $node) ); } - /** @var array $metadata */ + /** @var array}> $metadata */ $metadata = require __DIR__ . '/functionMetadata_original.php'; foreach ($visitor->functions as $functionName) { if (array_key_exists($functionName, $metadata)) { @@ -185,8 +185,16 @@ public function enterNode(Node $node) * 2) Contribute the functions that have 'hasSideEffects' => true as a modification to bin/functionMetadata_original.php. * 3) Contribute the #[Pure] functions without side effects to https://github.com/JetBrains/phpstorm-stubs * 4) Once the PR from 3) is merged, please update the package here and run ./bin/generate-function-metadata.php. + * + * The array is keyed by lowercase function name or "Class::method". Each entry is + * exactly one of: + * - ['hasSideEffects' => bool] - false: pure, true: has side effects. + * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless + * one of the listed callable parameters (keyed by parameter name) receives an + * impure callable, e.g. array_map()'s 'callback'. */ +/** @var array}> */ return [ %s ]; diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 0b7f35f93f3..7fa82357ab2 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -12,8 +12,16 @@ * 2) Contribute the functions that have 'hasSideEffects' => true as a modification to bin/functionMetadata_original.php. * 3) Contribute the #[Pure] functions without side effects to https://github.com/JetBrains/phpstorm-stubs * 4) Once the PR from 3) is merged, please update the package here and run ./bin/generate-function-metadata.php. + * + * The array is keyed by lowercase function name or "Class::method". Each entry is + * exactly one of: + * - ['hasSideEffects' => bool] - false: pure, true: has side effects. + * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless + * one of the listed callable parameters (keyed by parameter name) receives an + * impure callable, e.g. array_map()'s 'callback'. */ +/** @var array}> */ return [ 'BackedEnum::from' => ['hasSideEffects' => false], 'BackedEnum::tryFrom' => ['hasSideEffects' => false], From 8d7b559ded6a8cb9861e0197d7be1d8cb4e416f6 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 15:26:54 +0900 Subject: [PATCH 12/29] Mark array_all() and array_any() as pure-unless-callable-is-impure PHP 8.4's array_all() and array_any() are pure unless their callback is impure, like array_filter()/array_map(). --- bin/functionMetadata_original.php | 2 ++ resources/functionMetadata.php | 2 ++ .../Rules/Pure/PureFunctionRuleTest.php | 15 +++++++++ .../pure-unless-callable-is-impure-php84.php | 33 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 37096be7d8a..ec4692a7924 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -36,6 +36,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_any' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 7fa82357ab2..e45c2f3801f 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -733,6 +733,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_any' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index b581251d825..d477a35f53e 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -260,4 +260,19 @@ public function testPureUnlessCallableIsImpure(): void ]); } + #[RequiresPhp('>= 8.4.0')] + public function testPureUnlessCallableIsImpurePhp84(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-callable-is-impure-php84.php'], [ + [ + 'Impure call to function array_any() in pure function PureUnlessCallableIsImpureFunctionPhp84\anyWithImpureCallback().', + 29, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunctionPhp84\anyWithImpureCallback().', + 30, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php new file mode 100644 index 00000000000..4e37f6eae8e --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php @@ -0,0 +1,33 @@ + $arr + * @phpstan-pure + */ +function anyWithPureCallback(array $arr): bool +{ + return array_any($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function allWithPureCallback(array $arr): bool +{ + return array_all($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function anyWithImpureCallback(array $arr): bool +{ + return array_any($arr, static function (int $x): bool { + echo $x; + return $x > 0; + }); +} From 9bdc1ea3682fd2e01b8a15c614d3391f90dfbbda Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 15:47:08 +0900 Subject: [PATCH 13/29] Honor pure-unless-callable-is-impure metadata for stub-reflected functions Generic builtins like array_find()/array_find_key() are reflected via BetterReflection (PhpFunctionReflection), whose getCustomFunction() only read the @pure-unless-callable-is-impure PHPDoc tag and ignored functionMetadata.php - unlike the native-signature path. Merge the metadata's pureUnlessCallableIsImpureParameters in there too (PHPDoc tag wins). This makes array_find() actually honor its existing metadata entry and lets array_find_key() be added. --- bin/functionMetadata_original.php | 1 + resources/functionMetadata.php | 1 + .../BetterReflectionProvider.php | 14 +++++++ .../Rules/Pure/PureFunctionRuleTest.php | 16 +++++++ .../pure-unless-callable-is-impure-php84.php | 42 +++++++++++++++++++ 5 files changed, 74 insertions(+) diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index ec4692a7924..8f4eb456e53 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -52,6 +52,7 @@ 'array_fill_keys' => ['hasSideEffects' => false], 'array_filter' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_find' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find_key' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index e45c2f3801f..af546d10642 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -749,6 +749,7 @@ 'array_fill_keys' => ['hasSideEffects' => false], 'array_filter' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_find' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find_key' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_first' => ['hasSideEffects' => false], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index b55744220ee..68bee839746 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -49,6 +49,7 @@ use PHPStan\Reflection\Php\PhpFunctionReflection; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\SignatureMap\NativeFunctionReflectionProvider; +use PHPStan\Reflection\SignatureMap\SignatureMapProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\FileTypeMapper; @@ -92,6 +93,7 @@ public function __construct( private DeprecationProvider $deprecationProvider, private PhpVersion $phpVersion, private NativeFunctionReflectionProvider $nativeFunctionReflectionProvider, + private SignatureMapProvider $signatureMapProvider, private StubPhpDocProvider $stubPhpDocProvider, private FunctionReflectionFactory $functionReflectionFactory, private RelativePathHelper $relativePathHelper, @@ -322,6 +324,18 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterPureUnlessCallableIsImpure = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); } + $lowerCasedFunctionName = strtolower($reflectionFunction->getName()); + if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { + $functionMetadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); + foreach ($functionMetadata['pureUnlessCallableIsImpureParameters'] ?? [] as $parameterName => $isPureUnlessCallableIsImpure) { + if (($phpDocParameterPureUnlessCallableIsImpure[$parameterName] ?? false) === true) { + continue; + } + + $phpDocParameterPureUnlessCallableIsImpure[$parameterName] = $isPureUnlessCallableIsImpure; + } + } + return $this->functionReflectionFactory->create( $reflectionFunction, $templateTypeMap, diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index d477a35f53e..d4d4d87864c 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -272,6 +272,22 @@ public function testPureUnlessCallableIsImpurePhp84(): void 'Impure echo in pure function PureUnlessCallableIsImpureFunctionPhp84\anyWithImpureCallback().', 30, ], + [ + 'Impure call to function array_find() in pure function PureUnlessCallableIsImpureFunctionPhp84\findWithImpureCallback().', + 59, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunctionPhp84\findWithImpureCallback().', + 60, + ], + [ + 'Impure call to function array_find_key() in pure function PureUnlessCallableIsImpureFunctionPhp84\findKeyWithImpureCallback().', + 71, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunctionPhp84\findKeyWithImpureCallback().', + 72, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php index 4e37f6eae8e..1964e4eeb14 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php @@ -31,3 +31,45 @@ function anyWithImpureCallback(array $arr): bool return $x > 0; }); } + +/** + * @param array $arr + * @phpstan-pure + */ +function findWithPureCallback(array $arr): ?int +{ + return array_find($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function findKeyWithPureCallback(array $arr): int|string|null +{ + return array_find_key($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function findWithImpureCallback(array $arr): ?int +{ + return array_find($arr, static function (int $x): bool { + echo $x; + return $x > 0; + }); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function findKeyWithImpureCallback(array $arr): int|string|null +{ + return array_find_key($arr, static function (int $x): bool { + echo $x; + return $x > 0; + }); +} From 4fd839e30dc6bbb1aca57665af63283c55d22ad4 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Fri, 3 Jul 2026 16:49:16 +0900 Subject: [PATCH 14/29] Add lint version comment to the PHP 8.4 pure-unless fixture RequiredPhpVersionCommentTest requires fixtures using version-specific features to declare a '// lint >= X.Y' comment so they are skipped on older PHP in CI. --- .../Rules/Pure/data/pure-unless-callable-is-impure-php84.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php index 1964e4eeb14..b9d33d09761 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php @@ -1,4 +1,4 @@ -= 8.4 namespace PureUnlessCallableIsImpureFunctionPhp84; From 9d64ec136b7befc26ed5ea37502926bb8e4f38f8 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 5 Jul 2026 16:52:33 +0900 Subject: [PATCH 15/29] Honor @pure-unless-callable-is-impure on methods Method support was entirely non-functional: PhpClassReflectionExtension only read the pure-unless map from built-in method metadata (never from a userland method's own PHPDoc tag), and PhpMethodReflection::getParameters() never passed the flag on to its PhpParameterReflection (the function path already did). Read the tag from the resolved PHPDoc and thread the flag through, and make the method-reflection wrappers (changed-type, closure-call, intersection, union, parser-node function) delegate getPureUnlessCallableIsImpureParameters() instead of returning []. --- .../Dummy/ChangedTypeMethodReflection.php | 2 +- .../Php/ClosureCallMethodReflection.php | 2 +- .../Php/PhpClassReflectionExtension.php | 3 + .../PhpFunctionFromParserNodeReflection.php | 2 +- src/Reflection/Php/PhpMethodReflection.php | 1 + .../Type/IntersectionTypeMethodReflection.php | 2 +- .../Type/UnionTypeMethodReflection.php | 2 +- .../Rules/Pure/PureFunctionRuleTest.php | 12 +++- .../data/pure-unless-callable-is-impure.php | 55 +++++++++++++++++++ 9 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/Reflection/Dummy/ChangedTypeMethodReflection.php b/src/Reflection/Dummy/ChangedTypeMethodReflection.php index d59bf13fa8b..20e540d0858 100644 --- a/src/Reflection/Dummy/ChangedTypeMethodReflection.php +++ b/src/Reflection/Dummy/ChangedTypeMethodReflection.php @@ -170,7 +170,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return []; + return $this->reflection->getPureUnlessCallableIsImpureParameters(); } public function getAttributes(): array diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index 91ec4376506..3ec00f46729 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -201,7 +201,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return []; + return $this->nativeMethodReflection->getPureUnlessCallableIsImpureParameters(); } public function getAttributes(): array diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 81a144fe731..ef20828586f 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -914,6 +914,9 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $templateTypeMap = $resolvedPhpDoc->getTemplateTypeMap(); $immediatelyInvokedCallableParameters = array_map(static fn (bool $immediate) => TrinaryLogic::createFromBoolean($immediate), $resolvedPhpDoc->getParamsImmediatelyInvokedCallable()); $closureThisParameters = array_map(static fn ($tag) => $tag->getType(), $resolvedPhpDoc->getParamClosureThisTags()); + foreach ($resolvedPhpDoc->getParamsPureUnlessCallableIsImpure() as $paramName => $isPureUnlessCallableIsImpure) { + $pureUnlessCallableIsImpureParameters[$paramName] = $isPureUnlessCallableIsImpure; + } $phpDocReturnType = $this->getPhpDocReturnType($phpDocBlockClassReflection, $resolvedPhpDoc, $nativeReturnType); $phpDocThrowType = $resolvedPhpDoc->getThrowsTag() !== null ? $resolvedPhpDoc->getThrowsTag()->getType() : null; foreach ($resolvedPhpDoc->getParamTags() as $paramName => $paramTag) { diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index dd601bdf635..26623a19a2d 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -341,7 +341,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return []; + return $this->pureUnlessCallableIsImpureParameters; } public function getAttributes(): array diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index edc3a645fcd..3ee602c37dd 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -231,6 +231,7 @@ private function getParameters(): array $this->phpDocClosureThisTypeParameters[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForMethodParameter($this->declaringClass->getName(), $this->reflection->getName(), $reflection->getName()), + $this->pureUnlessCallableIsImpureParameters[$reflection->getName()] ?? false, ), $this->reflection->getParameters()); } diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index 6c3d00011d4..154126e0229 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -216,7 +216,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return []; + return $this->getMethodWithMostParameters()->getPureUnlessCallableIsImpureParameters(); } public function getDocComment(): ?string diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 8e53f8fb956..584376eadab 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -173,7 +173,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return []; + return $this->methods[0]->getPureUnlessCallableIsImpureParameters(); } public function getDocComment(): ?string diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index d4d4d87864c..2baa6568521 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -249,13 +249,21 @@ public function testPureUnlessCallableIsImpure(): void 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithOpaqueCallback().', 36, ], + [ + 'Impure call to method PureUnlessCallableIsImpureFunction\Mapper::map() in pure function PureUnlessCallableIsImpureFunction\pureCallingMethodWithImpureCallback().', + 129, + ], + [ + 'Possibly impure call to method PureUnlessCallableIsImpureFunction\Mapper::map() in pure function PureUnlessCallableIsImpureFunction\pureCallingMethodWithOpaqueCallback().', + 143, + ], [ 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithMaybeNullCallback().', - 100, + 155, ], [ 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithMaybeCallablePureCallback().', - 112, + 167, ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index 45dc56b9771..62f75bde59e 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -88,6 +88,61 @@ function pureCallingUserlandAliasWithPureCallback(array $arr): array return myMapPhpstanAlias(static fn (int $x): int => $x * 2, $arr); } +class Mapper +{ + + /** + * @param callable(int): int $f + * @param array $arr + * @return array + * @pure-unless-callable-is-impure $f + */ + public function map(callable $f, array $arr): array + { + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $f($v); + } + + return $result; + } + +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingMethodWithPureCallback(Mapper $mapper, array $arr): array +{ + return $mapper->map(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingMethodWithImpureCallback(Mapper $mapper, array $arr): array +{ + return $mapper->map(static function (int $x): int { + echo $x; + return $x * 2; + }, $arr); +} + +/** + * @param array $arr + * @param callable(int): int $cb + * @return array + * @phpstan-pure + */ +function pureCallingMethodWithOpaqueCallback(Mapper $mapper, array $arr, callable $cb): array +{ + return $mapper->map($cb, $arr); +} + /** * @param array $arr * @param (callable(int): int)|null $cb From d1a253d8040cdaf4b1b7477b765628cee06aacec Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 5 Jul 2026 17:08:54 +0900 Subject: [PATCH 16/29] Note the two paths that intentionally don't thread pure-unless yet createNativeMethodVariant() (no built-in method carries the metadata) and Closure::call()'s parameters (a closure cannot carry the tag) leave the flag at its default; document why so a future maintainer knows it is deliberate, not an oversight. --- src/Reflection/Php/ClosureCallMethodReflection.php | 2 ++ src/Reflection/Php/PhpClassReflectionExtension.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index 3ec00f46729..a605c7cab35 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -99,6 +99,8 @@ public function getVariants(): array null, [], null, + // pure-unless-callable-is-impure is not threaded here: a closure's own + // parameters cannot carry the tag. ), $parameters), $this->closureType->isVariadic(), $this->closureType->getReturnType(), diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index ef20828586f..df72513078c 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -1065,6 +1065,8 @@ private function createNativeMethodVariant( $closureThisType, [], $this->allowedConstantsMapProvider->getForMethodParameter($declaringClassName, $methodName, $parameterSignature->getName()), + // pure-unless-callable-is-impure is not threaded here because no built-in method + // carries it (there are no Class::method entries in functionMetadata.php). ); } From 0a6650aea2bbc2ca275c3dce154c4fe5f5218c10 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 5 Jul 2026 21:05:07 +0900 Subject: [PATCH 17/29] Honor @pure-unless-callable-is-impure on constructor calls A 'new X(...)' where X's constructor is tagged only produced a fixed possibly-impure instantiation point, ignoring the callback's purity. NewHandler now applies the same pure-unless verdict used for function and method calls (extracted as a public static helper on SimpleImpurePoint): a pure callback drops the point, an impure one makes it certain. Constructors return void, so createFromVariant could not be reused directly (its void-return heuristic forces certain=true and skips the verdict). --- src/Analyser/ExprHandler/NewHandler.php | 21 +++++--- .../Callables/SimpleImpurePoint.php | 5 +- .../Rules/Pure/PureFunctionRuleTest.php | 8 ++++ .../data/pure-unless-callable-is-impure.php | 48 +++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 75dcb6246e0..3f6ef208a2e 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -32,6 +32,7 @@ use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Parser\NewAssignedToPropertyVisitor; +use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\Dummy\DummyConstructorReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; @@ -258,13 +259,19 @@ private function processConstructorReflection(string $className, New_ $expr, Mut if ($constructorReflection !== null) { if (!$constructorReflection->hasSideEffects()->no()) { $certain = $constructorReflection->isPure()->no(); - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'new', - sprintf('instantiation of class %s', $constructorReflection->getDeclaringClass()->getDisplayName()), - $certain, - ); + $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs()); + if ($verdict === null || !$verdict->yes()) { + if ($verdict !== null && $verdict->no()) { + $certain = true; + } + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'new', + sprintf('instantiation of class %s', $constructorReflection->getDeclaringClass()->getDisplayName()), + $certain, + ); + } } } elseif ($classReflection === null) { $impurePoints[] = new ImpurePoint( diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index ce81663e37c..7f9828e116e 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -134,11 +134,12 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec /** * Combined purity verdict of all arguments passed to parameters flagged * with @pure-unless-callable-is-impure. Returns null when the variant has - * no such parameters (so the caller keeps its current behavior). + * no such parameters (so the caller keeps its current behavior). Shared with + * NewHandler, which applies it to constructor calls. * * @param Arg[] $args */ - private static function resolvePureUnlessCallableIsImpureVerdict(ParametersAcceptor $variant, Scope $scope, array $args): ?TrinaryLogic + public static function resolvePureUnlessCallableIsImpureVerdict(ParametersAcceptor $variant, Scope $scope, array $args): ?TrinaryLogic { $parameters = $variant->getParameters(); $verdict = null; diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 2baa6568521..0e2b52fb20b 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -265,6 +265,14 @@ public function testPureUnlessCallableIsImpure(): void 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithMaybeCallablePureCallback().', 167, ], + [ + 'Impure instantiation of class PureUnlessCallableIsImpureFunction\Baz in pure function PureUnlessCallableIsImpureFunction\pureInstantiatingWithImpureCallback().', + 200, + ], + [ + 'Possibly impure instantiation of class PureUnlessCallableIsImpureFunction\Baz in pure function PureUnlessCallableIsImpureFunction\pureInstantiatingWithOpaqueCallback().', + 213, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index 62f75bde59e..8db5f266b4f 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -166,3 +166,51 @@ function pureWithMaybeCallablePureCallback(array $arr, $cb): array // $cb is only maybe-callable, so the call stays possibly impure. return array_map($cb, $arr); } + +class Baz +{ + + protected mixed $x; + + /** + * @pure-unless-callable-is-impure $f + */ + public function __construct(callable $f) + { + $this->x = $f(1); + } + +} + +/** + * @phpstan-pure + */ +function pureInstantiatingWithPureCallback(): int +{ + new Baz(static fn (int $x): int => $x * 2); + + return 1; +} + +/** + * @phpstan-pure + */ +function pureInstantiatingWithImpureCallback(): int +{ + new Baz(static function (int $x): int { + echo $x; + return $x * 2; + }); + + return 1; +} + +/** + * @phpstan-pure + */ +function pureInstantiatingWithOpaqueCallback(callable $cb): int +{ + new Baz($cb); + + return 1; +} From ae0e28ff1645a201d6cb7fc4b0267388079d3e39 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 5 Jul 2026 22:41:22 +0900 Subject: [PATCH 18/29] Drop the native mixed property type from the pure-unless fixture RequiredPhpVersionCommentTest flags the non-php84 fixture because a native 'mixed' property type requires PHP 8.0 but the file carries no '// lint >=' comment. The property type is irrelevant to the test, so make it untyped to keep the fixture runnable on all supported PHP versions. --- .../PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index 8db5f266b4f..4371259d644 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -170,7 +170,7 @@ function pureWithMaybeCallablePureCallback(array $arr, $cb): array class Baz { - protected mixed $x; + protected $x; /** * @pure-unless-callable-is-impure $f From 1bc68ff09fa3fc4dd4e648be29d76ac6599b6855 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 5 Jul 2026 23:23:06 +0900 Subject: [PATCH 19/29] Restructure NewHandler pure-unless branching to match createFromVariant The previous compound condition ($verdict === null || !$verdict->yes()) produced mutants the constructor test did not kill in CI's Infection run. Use the same separate yes()/no() checks with an early return for the pure case that SimpleImpurePoint::createFromVariant already uses (and that Infection covers), so the branch is fully mutation-tested. --- src/Analyser/ExprHandler/NewHandler.php | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 3f6ef208a2e..111fe38a682 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -260,18 +260,19 @@ private function processConstructorReflection(string $className, New_ $expr, Mut if (!$constructorReflection->hasSideEffects()->no()) { $certain = $constructorReflection->isPure()->no(); $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs()); - if ($verdict === null || !$verdict->yes()) { - if ($verdict !== null && $verdict->no()) { - $certain = true; - } - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'new', - sprintf('instantiation of class %s', $constructorReflection->getDeclaringClass()->getDisplayName()), - $certain, - ); + if ($verdict !== null && $verdict->yes()) { + return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; } + if ($verdict !== null && $verdict->no()) { + $certain = true; + } + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'new', + sprintf('instantiation of class %s', $constructorReflection->getDeclaringClass()->getDisplayName()), + $certain, + ); } } elseif ($classReflection === null) { $impurePoints[] = new ImpurePoint( From 92e24b2b491ee64c8a6317874e773ce3c1ae386b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 6 Jul 2026 22:01:11 +0900 Subject: [PATCH 20/29] Cover a union pure-callable|callable argument on a pure-unless constructor --- .../Rules/Pure/PureFunctionRuleTest.php | 4 +++ .../data/pure-unless-callable-is-impure.php | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 0e2b52fb20b..1c3ebfa07f9 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -273,6 +273,10 @@ public function testPureUnlessCallableIsImpure(): void 'Possibly impure instantiation of class PureUnlessCallableIsImpureFunction\Baz in pure function PureUnlessCallableIsImpureFunction\pureInstantiatingWithOpaqueCallback().', 213, ], + [ + 'Possibly impure instantiation of class PureUnlessCallableIsImpureFunction\Baz in pure function PureUnlessCallableIsImpureFunction\pureInstantiatingWithUnionCallback().', + 238, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index 4371259d644..5b6ad0f4b9d 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -214,3 +214,28 @@ function pureInstantiatingWithOpaqueCallback(callable $cb): int return 1; } + +/** + * @param pure-callable(int): int $pureCb + * @phpstan-pure + */ +function pureInstantiatingWithPureCallableParam(callable $pureCb): int +{ + // $pureCb is known pure, so the instantiation is pure. + new Baz($pureCb); + + return 1; +} + +/** + * @param pure-callable(int): int $pureCb + * @param callable(int): int $cb + * @phpstan-pure + */ +function pureInstantiatingWithUnionCallback(callable $pureCb, callable $cb, bool $flag): int +{ + // The union pure-callable|callable might be the impure branch, so it stays possibly impure. + new Baz($flag ? $pureCb : $cb); + + return 1; +} From 3ec51f139f7f44f883ac7a9f9600f425f198f24a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 14:49:17 +0900 Subject: [PATCH 21/29] Return TrinaryLogic from isPureUnlessCallableIsImpureParameter() 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(). --- .../Annotations/AnnotationsMethodParameterReflection.php | 4 ++-- src/Reflection/Callables/SimpleImpurePoint.php | 2 +- src/Reflection/ExtendedParameterReflection.php | 2 +- src/Reflection/GenericParametersAcceptorResolver.php | 1 + src/Reflection/Native/ExtendedNativeParameterReflection.php | 4 ++-- src/Reflection/ParametersAcceptorSelector.php | 5 +++-- src/Reflection/Php/ClosureCallMethodReflection.php | 1 + src/Reflection/Php/ExitFunctionReflection.php | 1 + src/Reflection/Php/ExtendedDummyParameter.php | 4 ++-- src/Reflection/Php/PhpClassReflectionExtension.php | 1 + src/Reflection/Php/PhpFunctionFromParserNodeReflection.php | 2 +- src/Reflection/Php/PhpFunctionReflection.php | 2 +- src/Reflection/Php/PhpMethodReflection.php | 2 +- src/Reflection/Php/PhpParameterFromParserNodeReflection.php | 4 ++-- src/Reflection/Php/PhpParameterReflection.php | 4 ++-- .../SignatureMap/NativeFunctionReflectionProvider.php | 4 ++-- src/Reflection/WrappedExtendedMethodReflection.php | 1 + tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php | 2 ++ 18 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php index f6d719dcdd3..60849ddc98e 100644 --- a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -92,9 +92,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return new AllowedConstantsResult([], [], false); } - public function isPureUnlessCallableIsImpureParameter(): bool + public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic { - return false; + return TrinaryLogic::createNo(); } } diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 7f9828e116e..95067a61380 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -148,7 +148,7 @@ public static function resolvePureUnlessCallableIsImpureVerdict(ParametersAccept if (!$parameter instanceof ExtendedParameterReflection) { continue; } - if (!$parameter->isPureUnlessCallableIsImpureParameter()) { + if ($parameter->isPureUnlessCallableIsImpureParameter()->no()) { continue; } diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php index 07ef8f243e8..3f2df446330 100644 --- a/src/Reflection/ExtendedParameterReflection.php +++ b/src/Reflection/ExtendedParameterReflection.php @@ -36,6 +36,6 @@ public function getAllowedConstants(): ?ParameterAllowedConstants; */ public function checkAllowedConstants(array $constants): AllowedConstantsResult; - public function isPureUnlessCallableIsImpureParameter(): bool; + public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic; } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index d38396e9520..c1b719387eb 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -132,6 +132,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc null, [], null, + TrinaryLogic::createNo(), ), $parameters), $parametersAcceptor->isVariadic(), $returnType, diff --git a/src/Reflection/Native/ExtendedNativeParameterReflection.php b/src/Reflection/Native/ExtendedNativeParameterReflection.php index 094d647739d..d4957240fd1 100644 --- a/src/Reflection/Native/ExtendedNativeParameterReflection.php +++ b/src/Reflection/Native/ExtendedNativeParameterReflection.php @@ -31,7 +31,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, - private bool $pureUnlessCallableIsImpureParameter = false, + private TrinaryLogic $pureUnlessCallableIsImpureParameter, ) { } @@ -115,7 +115,7 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } - public function isPureUnlessCallableIsImpureParameter(): bool + public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic { return $this->pureUnlessCallableIsImpureParameter; } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index e34989a39dd..93f5230039f 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -768,7 +768,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $parameter instanceof ExtendedParameterReflection ? $parameter->getClosureThisType() : null, $parameter instanceof ExtendedParameterReflection ? $parameter->getAttributes() : [], $parameter instanceof ExtendedParameterReflection ? $parameter->getAllowedConstants() : null, - $parameter instanceof ExtendedParameterReflection && $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo(), ); continue; } @@ -824,7 +824,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc } $pureUnlessCallableIsImpureParameter = $parameters[$i]->isPureUnlessCallableIsImpureParameter() - || ($parameter instanceof ExtendedParameterReflection && $parameter->isPureUnlessCallableIsImpureParameter()); + ->or($parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo()); $parameters[$i] = new ExtendedDummyParameter( $parameters[$i]->getName() !== $parameter->getName() ? sprintf('%s|%s', $parameters[$i]->getName(), $parameter->getName()) : $parameter->getName(), @@ -942,6 +942,7 @@ private static function wrapParameter(ParameterReflection $parameter): ExtendedP null, [], null, + TrinaryLogic::createNo(), ); } diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index a605c7cab35..d42361970e6 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -101,6 +101,7 @@ public function getVariants(): array null, // pure-unless-callable-is-impure is not threaded here: a closure's own // parameters cannot carry the tag. + TrinaryLogic::createNo(), ), $parameters), $this->closureType->isVariadic(), $this->closureType->getReturnType(), diff --git a/src/Reflection/Php/ExitFunctionReflection.php b/src/Reflection/Php/ExitFunctionReflection.php index bbffd428ce6..9b771e11bd0 100644 --- a/src/Reflection/Php/ExitFunctionReflection.php +++ b/src/Reflection/Php/ExitFunctionReflection.php @@ -60,6 +60,7 @@ public function getVariants(): array null, [], null, + TrinaryLogic::createNo(), ), ], false, diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php index 7ec8a16105e..adf1315738f 100644 --- a/src/Reflection/Php/ExtendedDummyParameter.php +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -31,7 +31,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, - private bool $pureUnlessCallableIsImpureParameter = false, + private TrinaryLogic $pureUnlessCallableIsImpureParameter, ) { parent::__construct($name, $type, $optional, $passedByReference, $variadic, $defaultValue); @@ -86,7 +86,7 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } - public function isPureUnlessCallableIsImpureParameter(): bool + public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic { return $this->pureUnlessCallableIsImpureParameter; } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index df72513078c..16a725cf433 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -1067,6 +1067,7 @@ private function createNativeMethodVariant( $this->allowedConstantsMapProvider->getForMethodParameter($declaringClassName, $methodName, $parameterSignature->getName()), // pure-unless-callable-is-impure is not threaded here because no built-in method // carries it (there are no Class::method entries in functionMetadata.php). + TrinaryLogic::createNo(), ); } diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index 26623a19a2d..0101dba55e2 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -179,7 +179,7 @@ public function getParameters(): array $closureThisType = null; } - $pureUnlessCallableIsImpureParameter = $this->pureUnlessCallableIsImpureParameters[$parameter->var->name] ?? false; + $pureUnlessCallableIsImpureParameter = TrinaryLogic::createFromBoolean($this->pureUnlessCallableIsImpureParameters[$parameter->var->name] ?? false); $parameters[] = new PhpParameterFromParserNodeReflection( $parameter->var->name, diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 0d7fac1ceef..fe40b1b2d79 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -132,7 +132,7 @@ private function getParameters(): array $this->phpDocParameterClosureThisTypes[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForFunctionParameter(strtolower($this->reflection->getName()), $reflection->getName()), - $this->phpDocParameterPureUnlessCallableIsImpure[$reflection->getName()] ?? false, + TrinaryLogic::createFromBoolean($this->phpDocParameterPureUnlessCallableIsImpure[$reflection->getName()] ?? false), ); }, $this->reflection->getParameters()); } diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index 3ee602c37dd..d2c8c39591b 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -231,7 +231,7 @@ private function getParameters(): array $this->phpDocClosureThisTypeParameters[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForMethodParameter($this->declaringClass->getName(), $this->reflection->getName(), $reflection->getName()), - $this->pureUnlessCallableIsImpureParameters[$reflection->getName()] ?? false, + TrinaryLogic::createFromBoolean($this->pureUnlessCallableIsImpureParameters[$reflection->getName()] ?? false), ), $this->reflection->getParameters()); } diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php index 78939b3c82b..754f6622c2a 100644 --- a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -33,7 +33,7 @@ public function __construct( private TrinaryLogic $immediatelyInvokedCallable, private ?Type $closureThisType, private array $attributes, - private bool $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessCallableIsImpureParameter, ) { } @@ -126,7 +126,7 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return new AllowedConstantsResult([], [], false); } - public function isPureUnlessCallableIsImpureParameter(): bool + public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic { return $this->pureUnlessCallableIsImpureParameter; } diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php index 2838a8dc9d9..75365f543ef 100644 --- a/src/Reflection/Php/PhpParameterReflection.php +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -37,7 +37,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, - private bool $pureUnlessCallableIsImpureParameter = false, + private TrinaryLogic $pureUnlessCallableIsImpureParameter, ) { } @@ -161,7 +161,7 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } - public function isPureUnlessCallableIsImpureParameter(): bool + public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic { return $this->pureUnlessCallableIsImpureParameter; } diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index a13012b14ba..6b40422c1d4 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -133,7 +133,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $phpDocType = null; $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); $closureThisType = null; - $pureUnlessCallableIsImpureParameter = $pureUnlessCallableIsImpureParameters[$name] ?? false; + $pureUnlessCallableIsImpureParameter = TrinaryLogic::createFromBoolean($pureUnlessCallableIsImpureParameters[$name] ?? false); if ($phpDoc !== null) { if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamTags())) { $phpDocType = $phpDoc->getParamTags()[$parameterSignature->getName()]->getType(); @@ -145,7 +145,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $closureThisType = $phpDoc->getParamClosureThisTags()[$parameterSignature->getName()]->getType(); } if (($phpDoc->getParamsPureUnlessCallableIsImpure()[$parameterSignature->getName()] ?? false) === true) { - $pureUnlessCallableIsImpureParameter = true; + $pureUnlessCallableIsImpureParameter = TrinaryLogic::createYes(); } } diff --git a/src/Reflection/WrappedExtendedMethodReflection.php b/src/Reflection/WrappedExtendedMethodReflection.php index 2d35ce62d0e..79874e29186 100644 --- a/src/Reflection/WrappedExtendedMethodReflection.php +++ b/src/Reflection/WrappedExtendedMethodReflection.php @@ -78,6 +78,7 @@ public function getVariants(): array null, [], null, + TrinaryLogic::createNo(), ), $variant->getParameters()), $variant->isVariadic(), $variant->getReturnType(), diff --git a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php index b00993697a6..91a7c18d8c2 100644 --- a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php +++ b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php @@ -93,6 +93,7 @@ public static function dataSelectFromTypes(): Generator $parameter->getClosureThisType(), $parameter->getAttributes(), $parameter->getAllowedConstants(), + $parameter->isPureUnlessCallableIsImpureParameter(), ), $datePeriodConstructorVariants[0]->getParameters()), false, new VoidType(), @@ -125,6 +126,7 @@ public static function dataSelectFromTypes(): Generator $parameter->getClosureThisType(), $parameter->getAttributes(), $parameter->getAllowedConstants(), + $parameter->isPureUnlessCallableIsImpureParameter(), ), $datePeriodConstructorVariants[1]->getParameters()), false, new VoidType(), From 33926fe470fafb6e0bf91d5cd32526139a291745 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 14:57:00 +0900 Subject: [PATCH 22/29] 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. --- src/Analyser/NodeScopeResolver.php | 3 +- src/Rules/Pure/FunctionPurityCheck.php | 88 +++++++++++++++---- .../Rules/Pure/PureFunctionRuleTest.php | 4 + .../data/pure-unless-callable-is-impure.php | 19 ++++ 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c55713c3237..b979b61b7a5 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -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); @@ -809,6 +809,7 @@ public function processStmtNode( $phpDocParameterOutTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, + $pureUnlessCallableIsImpureParameters, ); $functionReflection = $functionScope->getFunction(); if (!$functionReflection instanceof PhpFunctionFromParserNodeReflection) { diff --git a/src/Rules/Pure/FunctionPurityCheck.php b/src/Rules/Pure/FunctionPurityCheck.php index 1e5f7a32d61..c70f11eecd0 100644 --- a/src/Rules/Pure/FunctionPurityCheck.php +++ b/src/Rules/Pure/FunctionPurityCheck.php @@ -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; @@ -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; @@ -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()) { @@ -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 @@ -153,4 +157,58 @@ public function check( return $errors; } + /** + * @param ImpurePoint[] $impurePoints + * @param array $pureUnlessCallableParamNames + * @return list + */ + 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 $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); + } + } diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 1c3ebfa07f9..5975e585a02 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -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, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index 5b6ad0f4b9d..efc48abadb3 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -239,3 +239,22 @@ function pureInstantiatingWithUnionCallback(callable $pureCb, callable $cb, bool return 1; } + +/** + * @param callable(int): int $f + * @param array $arr + * @return array + * @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; +} From 160db8cb74440c27609cb7ad5c78c1716ad9a8ad Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 15:01:21 +0900 Subject: [PATCH 23/29] Report redundant @pure-unless-callable-is-impure on a pure callable 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. --- src/Rules/Pure/FunctionPurityCheck.php | 29 +++++++++++++++++++ src/Rules/Pure/PureFunctionRule.php | 1 + src/Rules/Pure/PureMethodRule.php | 1 + .../Rules/Pure/PureFunctionRuleTest.php | 4 +++ .../data/pure-unless-callable-is-impure.php | 17 +++++++++++ 5 files changed, 52 insertions(+) diff --git a/src/Rules/Pure/FunctionPurityCheck.php b/src/Rules/Pure/FunctionPurityCheck.php index c70f11eecd0..95daae65c1d 100644 --- a/src/Rules/Pure/FunctionPurityCheck.php +++ b/src/Rules/Pure/FunctionPurityCheck.php @@ -7,6 +7,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\Scope; use PHPStan\Analyser\ThrowPoint; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ExtendedMethodReflection; @@ -38,6 +39,7 @@ final class FunctionPurityCheck * @return list */ public function check( + Scope $scope, string $functionDescription, string $identifier, FunctionReflection|ExtendedMethodReflection $functionReflection, @@ -59,6 +61,33 @@ public function check( } $pureUnlessCallableParamNames[$parameter->getName()] = true; + + $acceptors = $parameter->getType()->getCallableParametersAcceptors($scope); + if (count($acceptors) === 0) { + continue; + } + + $allPure = true; + foreach ($acceptors as $acceptor) { + if ($acceptor->isPure()->yes()) { + continue; + } + + $allPure = false; + break; + } + + if (!$allPure) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s is marked @pure-unless-callable-is-impure for parameter $%s, but $%s is already a pure callable, so %s can be marked @phpstan-pure instead.', + $functionDescription, + $parameter->getName(), + $parameter->getName(), + lcfirst($functionDescription), + ))->identifier(sprintf('pure%s.redundantUnlessCallable', $identifier))->build(); } if ($isPure->yes()) { diff --git a/src/Rules/Pure/PureFunctionRule.php b/src/Rules/Pure/PureFunctionRule.php index 56ec8aa2698..bfa65d09618 100644 --- a/src/Rules/Pure/PureFunctionRule.php +++ b/src/Rules/Pure/PureFunctionRule.php @@ -30,6 +30,7 @@ public function processNode(Node $node, Scope $scope): array $function = $node->getFunctionReflection(); return $this->check->check( + $scope, sprintf('Function %s()', $function->getName()), 'Function', $function, diff --git a/src/Rules/Pure/PureMethodRule.php b/src/Rules/Pure/PureMethodRule.php index 9bb11a7e6fe..f105cc723b2 100644 --- a/src/Rules/Pure/PureMethodRule.php +++ b/src/Rules/Pure/PureMethodRule.php @@ -30,6 +30,7 @@ public function processNode(Node $node, Scope $scope): array $method = $node->getMethodReflection(); return $this->check->check( + $scope, sprintf('Method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()), 'Method', $method, diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 5975e585a02..01404a0c040 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -281,6 +281,10 @@ public function testPureUnlessCallableIsImpure(): void 'Impure echo in pure function PureUnlessCallableIsImpureFunction\pureUnlessCallableWithImpureStatementOutsideCallback().', 253, ], + [ + 'Function PureUnlessCallableIsImpureFunction\redundantPureUnlessCallableWithPureCallable() is marked @pure-unless-callable-is-impure for parameter $f, but $f is already a pure callable, so function PureUnlessCallableIsImpureFunction\redundantPureUnlessCallableWithPureCallable() can be marked @phpstan-pure instead.', + 268, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index efc48abadb3..74ae6202372 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -258,3 +258,20 @@ function pureUnlessCallableWithImpureStatementOutsideCallback(callable $f, array return $result; } + +/** + * @param pure-callable(int): int $f + * @param array $arr + * @return array + * @pure-unless-callable-is-impure $f + */ +function redundantPureUnlessCallableWithPureCallable(callable $f, array $arr): array +{ + // $f is already a pure-callable, so @pure-unless-callable-is-impure is redundant. + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $f($v); + } + + return $result; +} From 43f61e8167f97b97e666c2342afea214945cac58 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 16:15:11 +0900 Subject: [PATCH 24/29] Return TrinaryLogic from getPureUnlessCallableIsImpureParameters() Per review (ondrejmirtes): the method/function-level map must return array 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. --- src/Reflection/ExtendedMethodReflection.php | 2 +- src/Reflection/FunctionReflection.php | 2 +- src/Reflection/ParametersAcceptorSelector.php | 5 +- .../PhpFunctionFromParserNodeReflection.php | 6 +- src/Reflection/Php/PhpFunctionReflection.php | 5 +- src/Reflection/Php/PhpMethodReflection.php | 5 +- .../Type/IntersectionTypeMethodReflection.php | 2 +- ...edPureUnlessCallableIsImpureParameters.php | 56 ++++++++ .../Type/UnionTypeMethodReflection.php | 2 +- src/Rules/Pure/FunctionPurityCheck.php | 3 +- .../Rules/Pure/PureFunctionRuleTest.php | 8 ++ .../data/pure-unless-callable-is-impure.php | 123 ++++++++++++++++++ 12 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 src/Reflection/Type/MergedPureUnlessCallableIsImpureParameters.php diff --git a/src/Reflection/ExtendedMethodReflection.php b/src/Reflection/ExtendedMethodReflection.php index 56e100b26f3..18129b6a919 100644 --- a/src/Reflection/ExtendedMethodReflection.php +++ b/src/Reflection/ExtendedMethodReflection.php @@ -70,7 +70,7 @@ public function isBuiltin(): TrinaryLogic|bool; public function isPure(): TrinaryLogic; /** - * @return array + * @return array */ public function getPureUnlessCallableIsImpureParameters(): array; diff --git a/src/Reflection/FunctionReflection.php b/src/Reflection/FunctionReflection.php index 4286ac7320a..f9c7cefa520 100644 --- a/src/Reflection/FunctionReflection.php +++ b/src/Reflection/FunctionReflection.php @@ -69,7 +69,7 @@ public function returnsByReference(): TrinaryLogic; public function isPure(): TrinaryLogic; /** - * @return array + * @return array */ public function getPureUnlessCallableIsImpureParameters(): array; diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 93f5230039f..f0e50750602 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -823,8 +823,9 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc } } - $pureUnlessCallableIsImpureParameter = $parameters[$i]->isPureUnlessCallableIsImpureParameter() - ->or($parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo()); + $leftPureUnless = $parameters[$i]->isPureUnlessCallableIsImpureParameter(); + $rightPureUnless = $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo(); + $pureUnlessCallableIsImpureParameter = $leftPureUnless->equals($rightPureUnless) ? $leftPureUnless : TrinaryLogic::createMaybe(); $parameters[$i] = new ExtendedDummyParameter( $parameters[$i]->getName() !== $parameter->getName() ? sprintf('%s|%s', $parameters[$i]->getName(), $parameter->getName()) : $parameter->getName(), diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index 0101dba55e2..cecedced1d7 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -21,6 +21,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypehintHelper; +use function array_map; use function array_reverse; use function is_array; use function is_string; @@ -339,9 +340,12 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + /** + * @return array + */ public function getPureUnlessCallableIsImpureParameters(): array { - return $this->pureUnlessCallableIsImpureParameters; + return array_map(static fn (bool $value): TrinaryLogic => TrinaryLogic::createFromBoolean($value), $this->pureUnlessCallableIsImpureParameters); } public function getAttributes(): array diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index fe40b1b2d79..15afca5bd98 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -216,9 +216,12 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + /** + * @return array + */ public function getPureUnlessCallableIsImpureParameters(): array { - return $this->phpDocParameterPureUnlessCallableIsImpure; + return array_map(static fn (bool $value): TrinaryLogic => TrinaryLogic::createFromBoolean($value), $this->phpDocParameterPureUnlessCallableIsImpure); } public function isBuiltin(): bool diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index d2c8c39591b..e75049d69ef 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -408,9 +408,12 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + /** + * @return array + */ public function getPureUnlessCallableIsImpureParameters(): array { - return $this->pureUnlessCallableIsImpureParameters; + return array_map(static fn (bool $value): TrinaryLogic => TrinaryLogic::createFromBoolean($value), $this->pureUnlessCallableIsImpureParameters); } public function changePropertyGetHookPhpDocType(Type $phpDocType): self diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index 154126e0229..56faffd9a3c 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -216,7 +216,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return $this->getMethodWithMostParameters()->getPureUnlessCallableIsImpureParameters(); + return MergedPureUnlessCallableIsImpureParameters::merge($this->methods); } public function getDocComment(): ?string diff --git a/src/Reflection/Type/MergedPureUnlessCallableIsImpureParameters.php b/src/Reflection/Type/MergedPureUnlessCallableIsImpureParameters.php new file mode 100644 index 00000000000..b5ecfd9ba1b --- /dev/null +++ b/src/Reflection/Type/MergedPureUnlessCallableIsImpureParameters.php @@ -0,0 +1,56 @@ + + */ + public static function merge(array $methods): array + { + $parameterNames = []; + $maps = []; + foreach ($methods as $method) { + $map = $method->getPureUnlessCallableIsImpureParameters(); + $maps[] = $map; + foreach (array_keys($map) as $name) { + $parameterNames[$name] = true; + } + } + + $merged = []; + foreach (array_keys($parameterNames) as $name) { + $value = null; + foreach ($maps as $map) { + $current = $map[$name] ?? TrinaryLogic::createNo(); + if ($value === null) { + $value = $current; + continue; + } + $value = $value->equals($current) ? $value : TrinaryLogic::createMaybe(); + } + + if ($value === null) { + continue; + } + + $merged[$name] = $value; + } + + return $merged; + } + +} diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 584376eadab..6953dafaad1 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -173,7 +173,7 @@ public function isPure(): TrinaryLogic public function getPureUnlessCallableIsImpureParameters(): array { - return $this->methods[0]->getPureUnlessCallableIsImpureParameters(); + return MergedPureUnlessCallableIsImpureParameters::merge($this->methods); } public function getDocComment(): ?string diff --git a/src/Rules/Pure/FunctionPurityCheck.php b/src/Rules/Pure/FunctionPurityCheck.php index 95daae65c1d..bbe0c7bfddb 100644 --- a/src/Rules/Pure/FunctionPurityCheck.php +++ b/src/Rules/Pure/FunctionPurityCheck.php @@ -54,9 +54,10 @@ public function check( $errors = []; $isPure = $functionReflection->isPure(); + $pureUnlessCallableParameters = $functionReflection->getPureUnlessCallableIsImpureParameters(); $pureUnlessCallableParamNames = []; foreach ($parameters as $parameter) { - if (!$parameter->isPureUnlessCallableIsImpureParameter()->yes()) { + if (!array_key_exists($parameter->getName(), $pureUnlessCallableParameters)) { continue; } diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 01404a0c040..173ab215baf 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -285,6 +285,14 @@ public function testPureUnlessCallableIsImpure(): void 'Function PureUnlessCallableIsImpureFunction\redundantPureUnlessCallableWithPureCallable() is marked @pure-unless-callable-is-impure for parameter $f, but $f is already a pure callable, so function PureUnlessCallableIsImpureFunction\redundantPureUnlessCallableWithPureCallable() can be marked @phpstan-pure instead.', 268, ], + [ + 'Possibly impure call to method PureUnlessCallableIsImpureFunction\InheritedMapperChild::map() in pure function PureUnlessCallableIsImpureFunction\pureCallingInheritedMethodWithOpaqueCallback().', + 374, + ], + [ + 'Possibly impure call to method PureUnlessCallableIsImpureFunction\InheritedMapperRenamedChild::map() in pure function PureUnlessCallableIsImpureFunction\pureCallingRenamedInheritedMethodWithOpaqueCallback().', + 399, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php index 74ae6202372..87e6f0315f4 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -275,3 +275,126 @@ function redundantPureUnlessCallableWithPureCallable(callable $f, array $arr): a return $result; } + +interface PureUnlessCallableA +{ + + /** + * @pure-unless-callable-is-impure $f + */ + public function m(callable $f): int; + +} + +interface PureUnlessCallableB +{ + + public function m(callable $f): int; + +} + +/** + * @param PureUnlessCallableA|PureUnlessCallableB $obj + * @phpstan-pure + */ +function pureUnionMethodWithPureCallback($obj): int +{ + // The @pure-unless-callable-is-impure flag on $f is Yes in A and absent in B, + // so combineAcceptors merges it to Maybe. A pure callback keeps the call pure, + // so no error is reported. (If the Maybe flag were treated like No, this call + // would be wrongly reported as possibly impure.) + return $obj->m(static fn (int $x): int => $x * 2); +} + +abstract class InheritedMapperParent +{ + + /** + * @param callable(int): int $cb + * @param array $arr + * @return array + * @pure-unless-callable-is-impure $cb + */ + abstract public function map(callable $cb, array $arr): array; + +} + +class InheritedMapperChild extends InheritedMapperParent +{ + + public function map(callable $cb, array $arr): array + { + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $cb($v); + } + + return $result; + } + +} + +class InheritedMapperRenamedChild extends InheritedMapperParent +{ + + public function map(callable $fn, array $arr): array + { + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $fn($v); + } + + return $result; + } + +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingInheritedMethodWithPureCallback(InheritedMapperChild $mapper, array $arr): array +{ + // The child does not re-declare @pure-unless-callable-is-impure; it is inherited + // from the parent. A pure callback keeps the call pure, so no error is reported. + return $mapper->map(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @param callable(int): int $cb + * @return array + * @phpstan-pure + */ +function pureCallingInheritedMethodWithOpaqueCallback(InheritedMapperChild $mapper, array $arr, callable $cb): array +{ + // The inherited @pure-unless-callable-is-impure applies to the child, so an opaque + // callable makes the call possibly impure. + return $mapper->map($cb, $arr); +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingRenamedInheritedMethodWithPureCallback(InheritedMapperRenamedChild $mapper, array $arr): array +{ + // The parent flags $cb; the child renames it to $fn. The inherited flag still + // applies to the renamed parameter, so a pure callback keeps the call pure. + return $mapper->map(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @param callable(int): int $cb + * @return array + * @phpstan-pure + */ +function pureCallingRenamedInheritedMethodWithOpaqueCallback(InheritedMapperRenamedChild $mapper, array $arr, callable $cb): array +{ + // The inherited flag applies to the renamed parameter, so an opaque callable + // makes the call possibly impure. + return $mapper->map($cb, $arr); +} From b6f0d667e61a4179713cdbf11b9b12c44db8a0b4 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 16:15:26 +0900 Subject: [PATCH 25/29] Report impure methods overriding @pure-unless-callable-is-impure methods 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. --- src/Rules/Methods/MethodSignatureRule.php | 12 ++++++ .../Rules/Methods/MethodSignatureRuleTest.php | 13 +++++++ .../method-signature-pure-unless-callable.php | 38 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/method-signature-pure-unless-callable.php diff --git a/src/Rules/Methods/MethodSignatureRule.php b/src/Rules/Methods/MethodSignatureRule.php index 3c476d14b87..51b5a076eee 100644 --- a/src/Rules/Methods/MethodSignatureRule.php +++ b/src/Rules/Methods/MethodSignatureRule.php @@ -75,6 +75,18 @@ public function processNode(Node $node, Scope $scope): array $parentMethodDeclaringClass->getDisplayName(), $parentMethod->getName(), ))->identifier('method.impure')->build(); + } elseif ( + $this->reportMethodPurityOverride + && $method->isPure()->no() + && count($parentMethod->getPureUnlessCallableIsImpureParameters()) > 0 + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Impure method %s::%s() overrides method %s::%s() marked @pure-unless-callable-is-impure.', + $method->getDeclaringClass()->getDisplayName(), + $method->getName(), + $parentMethodDeclaringClass->getDisplayName(), + $parentMethod->getName(), + ))->identifier('method.impureOverridePureUnlessCallable')->build(); } $parentVariants = $parentMethod->getVariants(); diff --git a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php index d06e1339742..109b85fd3c9 100644 --- a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php @@ -646,4 +646,17 @@ public function testBug10942(): void $this->analyse([__DIR__ . '/data/bug-10942.php'], []); } + public function testPureUnlessCallableIsImpureOverride(): void + { + $this->reportMaybes = true; + $this->reportStatic = true; + $this->reportMethodPurityOverride = true; + $this->analyse([__DIR__ . '/data/method-signature-pure-unless-callable.php'], [ + [ + 'Impure method MethodSignaturePureUnlessCallable\ImpureChild::run() overrides method MethodSignaturePureUnlessCallable\PureUnlessParent::run() marked @pure-unless-callable-is-impure.', + 21, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/method-signature-pure-unless-callable.php b/tests/PHPStan/Rules/Methods/data/method-signature-pure-unless-callable.php new file mode 100644 index 00000000000..465bdfb396a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/method-signature-pure-unless-callable.php @@ -0,0 +1,38 @@ + Date: Tue, 7 Jul 2026 21:36:32 +0900 Subject: [PATCH 26/29] Add a direct unit test for resolvePureUnlessCallableIsImpureVerdict() 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. --- .../Callables/SimpleImpurePointTest.php | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php diff --git a/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php b/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php new file mode 100644 index 00000000000..dd68ccba54c --- /dev/null +++ b/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php @@ -0,0 +1,79 @@ + [ + TrinaryLogic::createNo(), + null, + ]; + + yield 'flag Yes - callback omitted, verdict is Yes' => [ + TrinaryLogic::createYes(), + TrinaryLogic::createYes(), + ]; + + // A Maybe flag (e.g. from a union method where only some members carry + // @pure-unless-callable-is-impure) must be processed like Yes, not skipped. + yield 'flag Maybe - callback omitted, verdict is Yes' => [ + TrinaryLogic::createMaybe(), + TrinaryLogic::createYes(), + ]; + } + + #[DataProvider('dataResolvePureUnlessCallableIsImpureVerdict')] + public function testResolvePureUnlessCallableIsImpureVerdict(TrinaryLogic $parameterFlag, ?TrinaryLogic $expectedVerdict): void + { + $parameter = new ExtendedDummyParameter( + 'cb', + new MixedType(), + true, + PassedByReference::createNo(), + false, + null, + new MixedType(), + new MixedType(), + null, + TrinaryLogic::createMaybe(), + null, + [], + null, + $parameterFlag, + ); + $variant = new FunctionVariant( + TemplateTypeMap::createEmpty(), + null, + [$parameter], + false, + new MixedType(), + ); + + $scopeFactory = self::getContainer()->getByType(ScopeFactory::class); + $scope = $scopeFactory->create(ScopeContext::create('dummy.php')); + + $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, []); + + if ($expectedVerdict === null) { + $this->assertNull($verdict); + } else { + $this->assertNotNull($verdict); + $this->assertTrue($expectedVerdict->equals($verdict)); + } + } + +} From f0cf3d496dc297d0c852db27f68ace7255198528 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 21:56:25 +0900 Subject: [PATCH 27/29] Make SimpleImpurePointTest a self-contained unit test 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. --- .../Reflection/Callables/SimpleImpurePointTest.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php b/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php index dd68ccba54c..d359965394f 100644 --- a/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php +++ b/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php @@ -2,18 +2,17 @@ namespace PHPStan\Reflection\Callables; -use PHPStan\Analyser\ScopeContext; -use PHPStan\Analyser\ScopeFactory; +use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionVariant; use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\ExtendedDummyParameter; -use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\MixedType; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; -class SimpleImpurePointTest extends PHPStanTestCase +class SimpleImpurePointTest extends TestCase { public static function dataResolvePureUnlessCallableIsImpureVerdict(): iterable @@ -63,8 +62,8 @@ public function testResolvePureUnlessCallableIsImpureVerdict(TrinaryLogic $param new MixedType(), ); - $scopeFactory = self::getContainer()->getByType(ScopeFactory::class); - $scope = $scopeFactory->create(ScopeContext::create('dummy.php')); + // The callback is omitted (no args), so the scope is never consulted. + $scope = $this->createMock(Scope::class); $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, []); From 4859c2f99b9a89f6bf6fbdeeeec5df3747c4d711 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 22:24:05 +0900 Subject: [PATCH 28/29] Share the pure-unless-callable verdict-to-certainty decision 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. --- src/Analyser/ExprHandler/NewHandler.php | 7 ++-- .../Callables/SimpleImpurePoint.php | 34 +++++++++++++++---- .../Callables/SimpleImpurePointTest.php | 18 ++++++++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 111fe38a682..1759f12b5b4 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -258,14 +258,11 @@ private function processConstructorReflection(string $className, New_ $expr, Mut if ($constructorReflection !== null) { if (!$constructorReflection->hasSideEffects()->no()) { - $certain = $constructorReflection->isPure()->no(); $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs()); - if ($verdict !== null && $verdict->yes()) { + $certain = SimpleImpurePoint::applyPureUnlessCallableIsImpureVerdict($verdict, $constructorReflection->isPure()->no()); + if ($certain === null) { return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; } - if ($verdict !== null && $verdict->no()) { - $certain = true; - } $impurePoints[] = new ImpurePoint( $scope, $expr, diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 95067a61380..09ee211c98e 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -64,14 +64,11 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec if (!$certain && $scope !== null && $variant !== null) { $verdict = self::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, $args); - if ($verdict !== null) { - if ($verdict->yes()) { - return null; - } - if ($verdict->no()) { - $certain = true; - } + $certainty = self::applyPureUnlessCallableIsImpureVerdict($verdict, $certain); + if ($certainty === null) { + return null; } + $certain = $certainty; } if ($function instanceof FunctionReflection) { @@ -203,6 +200,29 @@ public static function resolvePureUnlessCallableIsImpureVerdict(ParametersAccept return $verdict; } + /** + * Applies a @pure-unless-callable-is-impure verdict to a base certainty. + * Returns null when the call is pure (verdict Yes) and no impure point should + * be created, true when it is certainly impure (verdict No), and the base + * certainty otherwise (verdict Maybe, or no flagged parameters). Shared by + * createFromVariant() and NewHandler so the yes()/no() branching lives in one + * place. + */ + public static function applyPureUnlessCallableIsImpureVerdict(?TrinaryLogic $verdict, bool $baseCertain): ?bool + { + if ($verdict === null) { + return $baseCertain; + } + if ($verdict->yes()) { + return null; + } + if ($verdict->no()) { + return true; + } + + return $baseCertain; + } + /** @return ImpurePointIdentifier */ public function getIdentifier(): string { diff --git a/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php b/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php index d359965394f..e963446e9ad 100644 --- a/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php +++ b/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php @@ -75,4 +75,22 @@ public function testResolvePureUnlessCallableIsImpureVerdict(TrinaryLogic $param } } + public static function dataApplyPureUnlessCallableIsImpureVerdict(): iterable + { + yield 'no verdict keeps base certainty (false)' => [null, false, false]; + yield 'no verdict keeps base certainty (true)' => [null, true, true]; + yield 'verdict Yes suppresses the impure point' => [TrinaryLogic::createYes(), false, null]; + yield 'verdict No is certainly impure' => [TrinaryLogic::createNo(), false, true]; + // A Maybe verdict keeps the base certainty; this is the case that tells + // yes()/no() apart from their negated counterparts. + yield 'verdict Maybe keeps base certainty (false)' => [TrinaryLogic::createMaybe(), false, false]; + yield 'verdict Maybe keeps base certainty (true)' => [TrinaryLogic::createMaybe(), true, true]; + } + + #[DataProvider('dataApplyPureUnlessCallableIsImpureVerdict')] + public function testApplyPureUnlessCallableIsImpureVerdict(?TrinaryLogic $verdict, bool $baseCertain, ?bool $expected): void + { + $this->assertSame($expected, SimpleImpurePoint::applyPureUnlessCallableIsImpureVerdict($verdict, $baseCertain)); + } + } From 07722d2f76ddf074bda125fcf3b70918f2f741a9 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 7 Jul 2026 23:11:40 +0900 Subject: [PATCH 29/29] Revert the direct mutation-testing unit test and verdict helper Reverts 33f825ab7, f0cf3d496 and 4859c2f99. 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. --- src/Analyser/ExprHandler/NewHandler.php | 7 +- .../Callables/SimpleImpurePoint.php | 34 ++----- .../Callables/SimpleImpurePointTest.php | 96 ------------------- 3 files changed, 12 insertions(+), 125 deletions(-) delete mode 100644 tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 1759f12b5b4..111fe38a682 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -258,11 +258,14 @@ private function processConstructorReflection(string $className, New_ $expr, Mut if ($constructorReflection !== null) { if (!$constructorReflection->hasSideEffects()->no()) { + $certain = $constructorReflection->isPure()->no(); $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs()); - $certain = SimpleImpurePoint::applyPureUnlessCallableIsImpureVerdict($verdict, $constructorReflection->isPure()->no()); - if ($certain === null) { + if ($verdict !== null && $verdict->yes()) { return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; } + if ($verdict !== null && $verdict->no()) { + $certain = true; + } $impurePoints[] = new ImpurePoint( $scope, $expr, diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 09ee211c98e..95067a61380 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -64,11 +64,14 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec if (!$certain && $scope !== null && $variant !== null) { $verdict = self::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, $args); - $certainty = self::applyPureUnlessCallableIsImpureVerdict($verdict, $certain); - if ($certainty === null) { - return null; + if ($verdict !== null) { + if ($verdict->yes()) { + return null; + } + if ($verdict->no()) { + $certain = true; + } } - $certain = $certainty; } if ($function instanceof FunctionReflection) { @@ -200,29 +203,6 @@ public static function resolvePureUnlessCallableIsImpureVerdict(ParametersAccept return $verdict; } - /** - * Applies a @pure-unless-callable-is-impure verdict to a base certainty. - * Returns null when the call is pure (verdict Yes) and no impure point should - * be created, true when it is certainly impure (verdict No), and the base - * certainty otherwise (verdict Maybe, or no flagged parameters). Shared by - * createFromVariant() and NewHandler so the yes()/no() branching lives in one - * place. - */ - public static function applyPureUnlessCallableIsImpureVerdict(?TrinaryLogic $verdict, bool $baseCertain): ?bool - { - if ($verdict === null) { - return $baseCertain; - } - if ($verdict->yes()) { - return null; - } - if ($verdict->no()) { - return true; - } - - return $baseCertain; - } - /** @return ImpurePointIdentifier */ public function getIdentifier(): string { diff --git a/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php b/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php deleted file mode 100644 index e963446e9ad..00000000000 --- a/tests/PHPStan/Reflection/Callables/SimpleImpurePointTest.php +++ /dev/null @@ -1,96 +0,0 @@ - [ - TrinaryLogic::createNo(), - null, - ]; - - yield 'flag Yes - callback omitted, verdict is Yes' => [ - TrinaryLogic::createYes(), - TrinaryLogic::createYes(), - ]; - - // A Maybe flag (e.g. from a union method where only some members carry - // @pure-unless-callable-is-impure) must be processed like Yes, not skipped. - yield 'flag Maybe - callback omitted, verdict is Yes' => [ - TrinaryLogic::createMaybe(), - TrinaryLogic::createYes(), - ]; - } - - #[DataProvider('dataResolvePureUnlessCallableIsImpureVerdict')] - public function testResolvePureUnlessCallableIsImpureVerdict(TrinaryLogic $parameterFlag, ?TrinaryLogic $expectedVerdict): void - { - $parameter = new ExtendedDummyParameter( - 'cb', - new MixedType(), - true, - PassedByReference::createNo(), - false, - null, - new MixedType(), - new MixedType(), - null, - TrinaryLogic::createMaybe(), - null, - [], - null, - $parameterFlag, - ); - $variant = new FunctionVariant( - TemplateTypeMap::createEmpty(), - null, - [$parameter], - false, - new MixedType(), - ); - - // The callback is omitted (no args), so the scope is never consulted. - $scope = $this->createMock(Scope::class); - - $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, []); - - if ($expectedVerdict === null) { - $this->assertNull($verdict); - } else { - $this->assertNotNull($verdict); - $this->assertTrue($expectedVerdict->equals($verdict)); - } - } - - public static function dataApplyPureUnlessCallableIsImpureVerdict(): iterable - { - yield 'no verdict keeps base certainty (false)' => [null, false, false]; - yield 'no verdict keeps base certainty (true)' => [null, true, true]; - yield 'verdict Yes suppresses the impure point' => [TrinaryLogic::createYes(), false, null]; - yield 'verdict No is certainly impure' => [TrinaryLogic::createNo(), false, true]; - // A Maybe verdict keeps the base certainty; this is the case that tells - // yes()/no() apart from their negated counterparts. - yield 'verdict Maybe keeps base certainty (false)' => [TrinaryLogic::createMaybe(), false, false]; - yield 'verdict Maybe keeps base certainty (true)' => [TrinaryLogic::createMaybe(), true, true]; - } - - #[DataProvider('dataApplyPureUnlessCallableIsImpureVerdict')] - public function testApplyPureUnlessCallableIsImpureVerdict(?TrinaryLogic $verdict, bool $baseCertain, ?bool $expected): void - { - $this->assertSame($expected, SimpleImpurePoint::applyPureUnlessCallableIsImpureVerdict($verdict, $baseCertain)); - } - -}