From d749e38866d18d1a56e36c49b80926dab3665389 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 02:13:28 +0900 Subject: [PATCH 1/9] Parse @pure-unless-parameter-passed PHPDoc tag Resolve the tag into per-parameter flags and merge them across parent PHPDocs. Until phpstan/phpdoc-parser#259 is merged the parser does not understand the tag natively, so parse it from the generic tag value and whitelist the @phpstan- alias in InvalidPHPStanDocTagRule. --- src/PhpDoc/PhpDocNodeResolver.php | 43 ++++++++++++++++++ src/PhpDoc/ResolvedPhpDocBlock.php | 45 +++++++++++++++++++ src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 3 ++ 3 files changed, 91 insertions(+) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 2304edc1490..9dff372d533 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -28,6 +28,7 @@ use PHPStan\PhpDoc\Tag\UsesTag; use PHPStan\PhpDoc\Tag\VarTag; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNullNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; @@ -47,8 +48,10 @@ use function array_reverse; use function count; use function in_array; +use function ltrim; use function method_exists; use function str_starts_with; +use function strpos; use function substr; #[AutowiredService] @@ -403,6 +406,46 @@ public function resolveParamPureUnlessCallableIsImpure(PhpDocNode $phpDocNode): return $parameters; } + /** + * @return array + */ + public function resolveParamPureUnlessParameterPassed(PhpDocNode $phpDocNode): array + { + $parameters = []; + // TODO: replace this generic-tag parsing with + // $phpDocNode->getPureUnlessParameterIsPassedTagValues() once + // phpstan/phpdoc-parser#259 is merged and the parser understands the tag. + foreach (['@pure-unless-parameter-passed', '@phpstan-pure-unless-parameter-passed'] as $tagName) { + foreach ($phpDocNode->getTags() as $tag) { + if ($tag->name !== $tagName) { + continue; + } + if (!$tag->value instanceof GenericTagValueNode) { + continue; + } + + $value = ltrim($tag->value->value); + if ($value === '' || $value[0] !== '$') { + continue; + } + + $parameterName = substr($value, 1); + $spacePosition = strpos($parameterName, ' '); + if ($spacePosition !== false) { + $parameterName = substr($parameterName, 0, $spacePosition); + } + + if ($parameterName === '') { + continue; + } + + $parameters[$parameterName] = true; + } + } + + return $parameters; + } + /** * @return array */ diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index c00634c963f..ba4e6d7bf63 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -99,6 +99,9 @@ final class ResolvedPhpDocBlock /** @var array|false */ private array|false $paramsPureUnlessCallableIsImpure = false; + /** @var array|false */ + private array|false $paramsPureUnlessParameterPassed = false; + /** @var array|false */ private array|false $paramClosureThisTags = false; @@ -224,6 +227,7 @@ public static function createEmpty(): self $self->paramOutTags = []; $self->paramsImmediatelyInvokedCallable = []; $self->paramsPureUnlessCallableIsImpure = []; + $self->paramsPureUnlessParameterPassed = []; $self->paramClosureThisTags = []; $self->returnTag = null; $self->throwsTag = null; @@ -281,6 +285,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $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->paramsPureUnlessParameterPassed = self::mergeParamsPureUnlessParameterPassed($this->getParamsPureUnlessParameterPassed(), $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); @@ -601,6 +606,18 @@ public function getParamsPureUnlessCallableIsImpure(): array return $this->paramsPureUnlessCallableIsImpure; } + /** + * @return array + */ + public function getParamsPureUnlessParameterPassed(): array + { + if ($this->paramsPureUnlessParameterPassed === false) { + $this->paramsPureUnlessParameterPassed = $this->phpDocNodeResolver->resolveParamPureUnlessParameterPassed($this->phpDocNode); + } + + return $this->paramsPureUnlessParameterPassed; + } + /** * @return array */ @@ -1130,6 +1147,34 @@ private static function mergeOneParentParamPureUnlessCallableIsImpure(array $par return $paramsPureUnlessCallableIsImpure; } + /** + * @param array $paramsPureUnlessParameterPassed + * @return array + */ + private static function mergeParamsPureUnlessParameterPassed(array $paramsPureUnlessParameterPassed, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + return self::mergeOneParentParamPureUnlessParameterPassed($paramsPureUnlessParameterPassed, $parent, $parameterMapping); + } + + /** + * @param array $paramsPureUnlessParameterPassed + * @return array + */ + private static function mergeOneParentParamPureUnlessParameterPassed(array $paramsPureUnlessParameterPassed, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + $parentPureUnlessParameterPassed = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamsPureUnlessParameterPassed()); + + foreach ($parentPureUnlessParameterPassed as $name => $parentIsPureUnlessParameterPassed) { + if (array_key_exists($name, $paramsPureUnlessParameterPassed)) { + continue; + } + + $paramsPureUnlessParameterPassed[$name] = $parentIsPureUnlessParameterPassed; + } + + return $paramsPureUnlessParameterPassed; + } + /** * @param array $paramsClosureThisTags * @return array diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index fcd5702fafe..109d19efb81 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -64,6 +64,9 @@ final class InvalidPHPStanDocTagRule implements Rule '@phpstan-param-immediately-invoked-callable', '@phpstan-param-later-invoked-callable', '@phpstan-param-closure-this', + // TODO: drop this once phpstan/phpdoc-parser#259 is merged and the parser + // recognizes the tag natively (like @phpstan-pure-unless-callable-is-impure). + '@phpstan-pure-unless-parameter-passed', '@phpstan-all-methods-pure', '@phpstan-all-methods-impure', ]; From 49fc1e9dcfb37a4e4bc9891edb681e711d9cb709 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 02:13:39 +0900 Subject: [PATCH 2/9] Thread @pure-unless-parameter-passed through the reflection layer Add isPureUnlessParameterPassedParameter(): TrinaryLogic on parameter reflections, populated from PHPDoc and function metadata, mirroring the already-merged @pure-unless-callable-is-impure threading (same TrinaryLogic typing from the start, so combineAcceptors() merges it with equals-or-Maybe instead of ->or(), giving correct behavior on union method variants). --- .../AnnotationsMethodParameterReflection.php | 5 +++++ .../BetterReflection/BetterReflectionProvider.php | 10 ++++++++++ src/Reflection/ExtendedParameterReflection.php | 2 ++ src/Reflection/FunctionReflectionFactory.php | 2 ++ .../GenericParametersAcceptorResolver.php | 1 + .../Native/ExtendedNativeParameterReflection.php | 6 ++++++ src/Reflection/ParametersAcceptorSelector.php | 8 ++++++++ .../Php/ClosureCallMethodReflection.php | 5 +++-- src/Reflection/Php/ExitFunctionReflection.php | 1 + src/Reflection/Php/ExtendedDummyParameter.php | 6 ++++++ .../Php/PhpClassReflectionExtension.php | 15 ++++++++++++--- .../Php/PhpFunctionFromParserNodeReflection.php | 4 ++++ src/Reflection/Php/PhpFunctionReflection.php | 3 +++ .../Php/PhpMethodFromParserNodeReflection.php | 2 ++ src/Reflection/Php/PhpMethodReflection.php | 5 +++++ src/Reflection/Php/PhpMethodReflectionFactory.php | 2 ++ .../Php/PhpParameterFromParserNodeReflection.php | 6 ++++++ src/Reflection/Php/PhpParameterReflection.php | 6 ++++++ .../ResolvedFunctionVariantWithOriginal.php | 1 + .../NativeFunctionReflectionProvider.php | 11 ++++++++++- .../SignatureMap/SignatureMapProvider.php | 4 ++-- ...allbackUnresolvedMethodPrototypeReflection.php | 1 + ...dOnTypeUnresolvedMethodPrototypeReflection.php | 1 + .../WrappedExtendedMethodReflection.php | 1 + .../Reflection/ParametersAcceptorSelectorTest.php | 2 ++ 25 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php index 60849ddc98e..7becf4b758a 100644 --- a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -97,4 +97,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + } diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 68bee839746..eb527182051 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -295,6 +295,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterImmediatelyInvokedCallable = []; $phpDocParameterClosureThisTypeTags = []; $phpDocParameterPureUnlessCallableIsImpure = []; + $phpDocParameterPureUnlessParameterPassed = []; $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) { @@ -322,6 +323,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterImmediatelyInvokedCallable = $resolvedPhpDoc->getParamsImmediatelyInvokedCallable(); $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); $phpDocParameterPureUnlessCallableIsImpure = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); + $phpDocParameterPureUnlessParameterPassed = $resolvedPhpDoc->getParamsPureUnlessParameterPassed(); } $lowerCasedFunctionName = strtolower($reflectionFunction->getName()); @@ -334,6 +336,13 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterPureUnlessCallableIsImpure[$parameterName] = $isPureUnlessCallableIsImpure; } + foreach ($functionMetadata['pureUnlessParameterPassedParameters'] ?? [] as $parameterName => $isPureUnlessParameterPassed) { + if (($phpDocParameterPureUnlessParameterPassed[$parameterName] ?? false) === true) { + continue; + } + + $phpDocParameterPureUnlessParameterPassed[$parameterName] = $isPureUnlessParameterPassed; + } } return $this->functionReflectionFactory->create( @@ -355,6 +364,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection 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, + $phpDocParameterPureUnlessParameterPassed, ); } diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php index 3f2df446330..8ce8e77ab86 100644 --- a/src/Reflection/ExtendedParameterReflection.php +++ b/src/Reflection/ExtendedParameterReflection.php @@ -38,4 +38,6 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult; public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic; + public function isPureUnlessParameterPassedParameter(): TrinaryLogic; + } diff --git a/src/Reflection/FunctionReflectionFactory.php b/src/Reflection/FunctionReflectionFactory.php index 58224858714..3ff2adcbbbe 100644 --- a/src/Reflection/FunctionReflectionFactory.php +++ b/src/Reflection/FunctionReflectionFactory.php @@ -17,6 +17,7 @@ interface FunctionReflectionFactory * @param array $phpDocParameterClosureThisTypes * @param list $attributes * @param array $phpDocParameterPureUnlessCallableIsImpure + * @param array $phpDocParameterPureUnlessParameterPassed */ public function create( ReflectionFunction $reflection, @@ -37,6 +38,7 @@ public function create( array $phpDocParameterClosureThisTypes, array $attributes, array $phpDocParameterPureUnlessCallableIsImpure, + array $phpDocParameterPureUnlessParameterPassed, ): PhpFunctionReflection; } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index c1b719387eb..f2c70606870 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -133,6 +133,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ), $parameters), $parametersAcceptor->isVariadic(), $returnType, diff --git a/src/Reflection/Native/ExtendedNativeParameterReflection.php b/src/Reflection/Native/ExtendedNativeParameterReflection.php index d4957240fd1..0c08b58b709 100644 --- a/src/Reflection/Native/ExtendedNativeParameterReflection.php +++ b/src/Reflection/Native/ExtendedNativeParameterReflection.php @@ -32,6 +32,7 @@ public function __construct( private array $attributes, private ?ParameterAllowedConstants $allowedConstants, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { } @@ -120,4 +121,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index f0e50750602..bdfc3744702 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -769,6 +769,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $parameter instanceof ExtendedParameterReflection ? $parameter->getAttributes() : [], $parameter instanceof ExtendedParameterReflection ? $parameter->getAllowedConstants() : null, $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo(), + $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessParameterPassedParameter() : TrinaryLogic::createNo(), ); continue; } @@ -827,6 +828,10 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $rightPureUnless = $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo(); $pureUnlessCallableIsImpureParameter = $leftPureUnless->equals($rightPureUnless) ? $leftPureUnless : TrinaryLogic::createMaybe(); + $leftPureUnlessParameterPassed = $parameters[$i]->isPureUnlessParameterPassedParameter(); + $rightPureUnlessParameterPassed = $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessParameterPassedParameter() : TrinaryLogic::createNo(); + $pureUnlessParameterPassedParameter = $leftPureUnlessParameterPassed->equals($rightPureUnlessParameterPassed) ? $leftPureUnlessParameterPassed : TrinaryLogic::createMaybe(); + $parameters[$i] = new ExtendedDummyParameter( $parameters[$i]->getName() !== $parameter->getName() ? sprintf('%s|%s', $parameters[$i]->getName(), $parameter->getName()) : $parameter->getName(), $type, @@ -842,6 +847,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $attributes, $allowedConstants, $pureUnlessCallableIsImpureParameter, + $pureUnlessParameterPassedParameter, ); if ($isVariadic) { @@ -944,6 +950,7 @@ private static function wrapParameter(ParameterReflection $parameter): ExtendedP [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ); } @@ -1273,6 +1280,7 @@ private static function overrideParameterType(ParameterReflection $original, Typ $wrapped->getAttributes(), $wrapped->getAllowedConstants(), $wrapped->isPureUnlessCallableIsImpureParameter(), + $wrapped->isPureUnlessParameterPassedParameter(), ); } diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index d42361970e6..e031576c097 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -99,8 +99,9 @@ public function getVariants(): array null, [], null, - // pure-unless-callable-is-impure is not threaded here: a closure's own - // parameters cannot carry the tag. + // pure-unless-callable-is-impure and pure-unless-parameter-passed are not + // threaded here: a closure's own parameters cannot carry either tag. + TrinaryLogic::createNo(), TrinaryLogic::createNo(), ), $parameters), $this->closureType->isVariadic(), diff --git a/src/Reflection/Php/ExitFunctionReflection.php b/src/Reflection/Php/ExitFunctionReflection.php index 9b771e11bd0..a3609465e4a 100644 --- a/src/Reflection/Php/ExitFunctionReflection.php +++ b/src/Reflection/Php/ExitFunctionReflection.php @@ -61,6 +61,7 @@ public function getVariants(): array [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ), ], false, diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php index adf1315738f..30434679271 100644 --- a/src/Reflection/Php/ExtendedDummyParameter.php +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -32,6 +32,7 @@ public function __construct( private array $attributes, private ?ParameterAllowedConstants $allowedConstants, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { parent::__construct($name, $type, $optional, $passedByReference, $variadic, $defaultValue); @@ -91,4 +92,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 16a725cf433..042604e219c 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -885,6 +885,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $isPure = null; $pureUnlessCallableIsImpureParameters = []; + $pureUnlessParameterPassedParameters = []; if ($actualDeclaringClass->isBuiltin() || $actualDeclaringClass->isEnum()) { foreach (array_keys($actualDeclaringClass->getAncestors()) as $className) { if ($this->signatureMapProvider->hasMethodMetadata($className, $methodReflection->getName())) { @@ -892,6 +893,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $hasSideEffects = $methodMetadata['hasSideEffects'] ?? true; $isPure = !$hasSideEffects; $pureUnlessCallableIsImpureParameters += $methodMetadata['pureUnlessCallableIsImpureParameters'] ?? []; + $pureUnlessParameterPassedParameters += $methodMetadata['pureUnlessParameterPassedParameters'] ?? []; break; } @@ -917,6 +919,9 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla foreach ($resolvedPhpDoc->getParamsPureUnlessCallableIsImpure() as $paramName => $isPureUnlessCallableIsImpure) { $pureUnlessCallableIsImpureParameters[$paramName] = $isPureUnlessCallableIsImpure; } + foreach ($resolvedPhpDoc->getParamsPureUnlessParameterPassed() as $paramName => $isPureUnlessParameterPassed) { + $pureUnlessParameterPassedParameters[$paramName] = $isPureUnlessParameterPassed; + } $phpDocReturnType = $this->getPhpDocReturnType($phpDocBlockClassReflection, $resolvedPhpDoc, $nativeReturnType); $phpDocThrowType = $resolvedPhpDoc->getThrowsTag() !== null ? $resolvedPhpDoc->getThrowsTag()->getType() : null; foreach ($resolvedPhpDoc->getParamTags() as $paramName => $paramTag) { @@ -997,6 +1002,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $acceptsNamedArguments, $this->attributeReflectionFactory->fromNativeReflection($methodReflection->getAttributes(), InitializerExprContext::fromClassMethod($actualDeclaringClass->getName(), $declaringTraitName, $methodReflection->getName(), $actualDeclaringClass->getFileName())), $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ); } @@ -1065,8 +1071,10 @@ 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). + // pure-unless-callable-is-impure and pure-unless-parameter-passed are not + // threaded here because no built-in method carries either tag (there are no + // Class::method entries in functionMetadata.php). + TrinaryLogic::createNo(), TrinaryLogic::createNo(), ); } @@ -1175,7 +1183,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, , , , $phpDocPureUnlessCallableIsImpureParameters] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $phpDocPureUnlessCallableIsImpureParameters, $phpDocPureUnlessParameterPassedParameters] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); $methodScope = $classScope->enterClassMethod( $methodNode, $templateTypeMap, @@ -1197,6 +1205,7 @@ private function inferAndCachePropertyTypes( false, null, $phpDocPureUnlessCallableIsImpureParameters, + $phpDocPureUnlessParameterPassedParameters, ); $propertyTypes = []; diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index cecedced1d7..8aa5f0f7978 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -50,6 +50,7 @@ class PhpFunctionFromParserNodeReflection implements FunctionReflection, Extende * @param array $phpDocClosureThisTypeParameters * @param list $attributes * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function __construct( FunctionLike $functionLike, @@ -74,6 +75,7 @@ public function __construct( private array $phpDocClosureThisTypeParameters, private array $attributes, private array $pureUnlessCallableIsImpureParameters, + private array $pureUnlessParameterPassedParameters, ) { $this->functionLike = $functionLike; @@ -181,6 +183,7 @@ public function getParameters(): array } $pureUnlessCallableIsImpureParameter = TrinaryLogic::createFromBoolean($this->pureUnlessCallableIsImpureParameters[$parameter->var->name] ?? false); + $pureUnlessParameterPassedParameter = TrinaryLogic::createFromBoolean($this->pureUnlessParameterPassedParameters[$parameter->var->name] ?? false); $parameters[] = new PhpParameterFromParserNodeReflection( $parameter->var->name, @@ -197,6 +200,7 @@ public function getParameters(): array $closureThisType, $this->parameterAttributes[$parameter->var->name] ?? [], $pureUnlessCallableIsImpureParameter, + $pureUnlessParameterPassedParameter, ); } diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 15afca5bd98..f1fb979dd45 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -41,6 +41,7 @@ final class PhpFunctionReflection implements FunctionReflection * @param array $phpDocParameterClosureThisTypes * @param list $attributes * @param array $phpDocParameterPureUnlessCallableIsImpure + * @param array $phpDocParameterPureUnlessParameterPassed */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, @@ -64,6 +65,7 @@ public function __construct( private array $phpDocParameterClosureThisTypes, private array $attributes, private array $phpDocParameterPureUnlessCallableIsImpure, + private array $phpDocParameterPureUnlessParameterPassed, ) { } @@ -133,6 +135,7 @@ private function getParameters(): array $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForFunctionParameter(strtolower($this->reflection->getName()), $reflection->getName()), TrinaryLogic::createFromBoolean($this->phpDocParameterPureUnlessCallableIsImpure[$reflection->getName()] ?? false), + TrinaryLogic::createFromBoolean($this->phpDocParameterPureUnlessParameterPassed[$reflection->getName()] ?? false), ); }, $this->reflection->getParameters()); } diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 290e0cb4b76..60763d4e01b 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -73,6 +73,7 @@ public function __construct( private bool $isConstructor, array $attributes, array $pureUnlessCallableIsImpureParameters, + array $pureUnlessParameterPassedParameters, ) { if ($this->classMethod instanceof Node\PropertyHook) { @@ -140,6 +141,7 @@ public function __construct( $phpDocClosureThisTypeParameters, $attributes, $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index e75049d69ef..8ae2f887fca 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -64,6 +64,7 @@ final class PhpMethodReflection implements ExtendedMethodReflection * @param array $phpDocClosureThisTypeParameters * @param list $attributes * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, @@ -92,6 +93,7 @@ public function __construct( private array $phpDocClosureThisTypeParameters, private array $attributes, private array $pureUnlessCallableIsImpureParameters, + private array $pureUnlessParameterPassedParameters, ) { } @@ -232,6 +234,7 @@ private function getParameters(): array $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForMethodParameter($this->declaringClass->getName(), $this->reflection->getName(), $reflection->getName()), TrinaryLogic::createFromBoolean($this->pureUnlessCallableIsImpureParameters[$reflection->getName()] ?? false), + TrinaryLogic::createFromBoolean($this->pureUnlessParameterPassedParameters[$reflection->getName()] ?? false), ), $this->reflection->getParameters()); } @@ -445,6 +448,7 @@ public function changePropertyGetHookPhpDocType(Type $phpDocType): self $this->phpDocClosureThisTypeParameters, $this->attributes, $this->pureUnlessCallableIsImpureParameters, + $this->pureUnlessParameterPassedParameters, ); } @@ -480,6 +484,7 @@ public function changePropertySetHookPhpDocType(string $parameterName, Type $php $this->phpDocClosureThisTypeParameters, $this->attributes, $this->pureUnlessCallableIsImpureParameters, + $this->pureUnlessParameterPassedParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflectionFactory.php b/src/Reflection/Php/PhpMethodReflectionFactory.php index c2978529d86..583d2cb4d28 100644 --- a/src/Reflection/Php/PhpMethodReflectionFactory.php +++ b/src/Reflection/Php/PhpMethodReflectionFactory.php @@ -21,6 +21,7 @@ interface PhpMethodReflectionFactory * @param array $phpDocClosureThisTypeParameters * @param list $attributes * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function create( ClassReflection $declaringClass, @@ -45,6 +46,7 @@ public function create( bool $acceptsNamedArguments, array $attributes, array $pureUnlessCallableIsImpureParameters, + array $pureUnlessParameterPassedParameters, ): PhpMethodReflection; } diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php index 754f6622c2a..9535b0c7f7c 100644 --- a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -34,6 +34,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { } @@ -131,4 +132,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php index 75365f543ef..afc2b48d8db 100644 --- a/src/Reflection/Php/PhpParameterReflection.php +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -38,6 +38,7 @@ public function __construct( private array $attributes, private ?ParameterAllowedConstants $allowedConstants, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { } @@ -166,4 +167,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 0151110214b..cf8cd6bf4a8 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -123,6 +123,7 @@ function (ExtendedParameterReflection $param): ExtendedParameterReflection { $param->getAttributes(), $param->getAllowedConstants(), $param->isPureUnlessCallableIsImpureParameter(), + $param->isPureUnlessParameterPassedParameter(), ); }, $this->parametersAcceptor->getParameters(), diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 6b40422c1d4..6a868d39971 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -111,11 +111,15 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $allowedConstantsMapProvider = $this->allowedConstantsMapProvider; $pureUnlessCallableIsImpureParameters = []; + $pureUnlessParameterPassedParameters = []; if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { $functionMetadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); if (isset($functionMetadata['pureUnlessCallableIsImpureParameters'])) { $pureUnlessCallableIsImpureParameters = $functionMetadata['pureUnlessCallableIsImpureParameters']; } + if (isset($functionMetadata['pureUnlessParameterPassedParameters'])) { + $pureUnlessParameterPassedParameters = $functionMetadata['pureUnlessParameterPassedParameters']; + } } else { $functionMetadata = null; } @@ -126,7 +130,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $variantsByType[$signatureType][] = new ExtendedFunctionVariant( TemplateTypeMap::createEmpty(), null, - array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider, $pureUnlessCallableIsImpureParameters): ExtendedNativeParameterReflection { + array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider, $pureUnlessCallableIsImpureParameters, $pureUnlessParameterPassedParameters): ExtendedNativeParameterReflection { $name = $parameterSignature->getName(); $type = $parameterSignature->getType(); @@ -134,6 +138,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); $closureThisType = null; $pureUnlessCallableIsImpureParameter = TrinaryLogic::createFromBoolean($pureUnlessCallableIsImpureParameters[$name] ?? false); + $pureUnlessParameterPassedParameter = TrinaryLogic::createFromBoolean($pureUnlessParameterPassedParameters[$name] ?? false); if ($phpDoc !== null) { if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamTags())) { $phpDocType = $phpDoc->getParamTags()[$parameterSignature->getName()]->getType(); @@ -147,6 +152,9 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef if (($phpDoc->getParamsPureUnlessCallableIsImpure()[$parameterSignature->getName()] ?? false) === true) { $pureUnlessCallableIsImpureParameter = TrinaryLogic::createYes(); } + if (($phpDoc->getParamsPureUnlessParameterPassed()[$parameterSignature->getName()] ?? false) === true) { + $pureUnlessParameterPassedParameter = TrinaryLogic::createYes(); + } } return new ExtendedNativeParameterReflection( @@ -164,6 +172,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef [], $allowedConstantsMapProvider->getForFunctionParameter($lowerCasedFunctionName, $parameterSignature->getName()), $pureUnlessCallableIsImpureParameter, + $pureUnlessParameterPassedParameter, ); }, $functionSignature->getParameters()), $functionSignature->isVariadic(), diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php index 8afcf9b1b1a..2ccb642ccee 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, pureUnlessCallableIsImpureParameters?: array} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array, pureUnlessParameterPassedParameters?: array} */ public function getMethodMetadata(string $className, string $methodName): array; /** - * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array, pureUnlessParameterPassedParameters?: array} */ public function getFunctionMetadata(string $functionName): array; diff --git a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php index 72b5def04fe..9c5812b423e 100644 --- a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php @@ -120,6 +120,7 @@ function (ExtendedParameterReflection $parameter): ExtendedParameterReflection { $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ); }, $acceptor->getParameters(), diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php index 8e6099d8886..6b2817dd063 100644 --- a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php @@ -107,6 +107,7 @@ private function transformMethodWithStaticType(ClassReflection $declaringClass, $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ), $acceptor->getParameters(), ), diff --git a/src/Reflection/WrappedExtendedMethodReflection.php b/src/Reflection/WrappedExtendedMethodReflection.php index 79874e29186..7cdf0437bba 100644 --- a/src/Reflection/WrappedExtendedMethodReflection.php +++ b/src/Reflection/WrappedExtendedMethodReflection.php @@ -79,6 +79,7 @@ public function getVariants(): array [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ), $variant->getParameters()), $variant->isVariadic(), $variant->getReturnType(), diff --git a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php index 91a7c18d8c2..efd36e19a0a 100644 --- a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php +++ b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php @@ -94,6 +94,7 @@ public static function dataSelectFromTypes(): Generator $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ), $datePeriodConstructorVariants[0]->getParameters()), false, new VoidType(), @@ -127,6 +128,7 @@ public static function dataSelectFromTypes(): Generator $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ), $datePeriodConstructorVariants[1]->getParameters()), false, new VoidType(), From b7ebbb531a60c7a0b9f0609135012e1c9c2ba8a5 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 02:13:47 +0900 Subject: [PATCH 3/9] Wire @pure-unless-parameter-passed into the analyser Thread the raw parameter flags through enterClassMethod()/enterFunction() and getPhpDocs(), and consult them in SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict(): a call to a function flagged with @pure-unless-parameter-passed stays pure as long as none of those by-ref parameters received an argument. --- src/Analyser/MutatingScope.php | 7 +++ src/Analyser/NodeScopeResolver.php | 9 ++- .../Callables/SimpleImpurePoint.php | 62 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index bc65d25f41a..9b469079bdc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1556,6 +1556,7 @@ public function enterTrait(ClassReflection $traitReflection): self * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param array $phpDocPureUnlessCallableIsImpureParameters + * @param array $phpDocPureUnlessParameterPassedParameters */ public function enterClassMethod( Node\Stmt\ClassMethod $classMethod, @@ -1578,6 +1579,7 @@ public function enterClassMethod( bool $isConstructor = false, ?ResolvedPhpDocBlock $resolvedPhpDocBlock = null, array $phpDocPureUnlessCallableIsImpureParameters = [], + array $phpDocPureUnlessParameterPassedParameters = [], ): self { if (!$this->isInClass()) { @@ -1614,6 +1616,7 @@ public function enterClassMethod( $isConstructor, $this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)), $phpDocPureUnlessCallableIsImpureParameters, + $phpDocPureUnlessParameterPassedParameters, ), !$classMethod->isStatic(), ); @@ -1704,6 +1707,7 @@ public function enterPropertyHook( false, $this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)), [], + [], ), true, ); @@ -1781,6 +1785,7 @@ private function getParameterAttributes(ClassMethod|Function_|PropertyHook $func * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function enterFunction( Node\Stmt\Function_ $function, @@ -1799,6 +1804,7 @@ public function enterFunction( array $immediatelyInvokedCallableParameters = [], array $phpDocClosureThisTypeParameters = [], array $pureUnlessCallableIsImpureParameters = [], + array $pureUnlessParameterPassedParameters = [], ): self { return $this->enterFunctionLike( @@ -1825,6 +1831,7 @@ public function enterFunction( $phpDocClosureThisTypeParameters, $this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)), $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ), false, ); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b979b61b7a5..73e97172d68 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -875,7 +875,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, , , , $pureUnlessCallableIsImpureParameters] = $this->getPhpDocs($scope, $stmt); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters, $pureUnlessParameterPassedParameters] = $this->getPhpDocs($scope, $stmt); foreach ($stmt->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -913,6 +913,7 @@ public function processStmtNode( $isConstructor, null, $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ); if (!$scope->isInClass()) { @@ -4943,7 +4944,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, array} + * @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, array} */ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $node): array { @@ -4974,6 +4975,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $functionName = null; $phpDocParameterOutTypes = []; $phpDocPureUnlessCallableIsImpureParameters = []; + $phpDocPureUnlessParameterPassedParameters = []; if ($node instanceof Node\Stmt\ClassMethod) { if (!$scope->isInClass()) { @@ -5108,6 +5110,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; $varTags = $resolvedPhpDoc->getVarTags(); $phpDocPureUnlessCallableIsImpureParameters = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); + $phpDocPureUnlessParameterPassedParameters = $resolvedPhpDoc->getParamsPureUnlessParameterPassed(); } if ($acceptsNamedArguments && $scope->isInClass()) { @@ -5131,7 +5134,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, $phpDocPureUnlessCallableIsImpureParameters]; + return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters, $phpDocPureUnlessParameterPassedParameters]; } private function transformStaticType(ClassReflection $declaringClass, Type $type): Type diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 95067a61380..abee88ee3c1 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -74,6 +74,15 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec } } + if (!$certain && $scope !== null && $variant !== null) { + $passedVerdict = self::resolvePureUnlessParameterPassedVerdict($variant, $args); + if ($passedVerdict !== null && $passedVerdict->yes()) { + // None of the @pure-unless-parameter-passed by-ref parameters + // received an argument, so the call is pure. + return null; + } + } + if ($function instanceof FunctionReflection) { if (isset(self::SIDE_EFFECT_FLIP_PARAMETERS[$function->getName()]) && $scope !== null) { [ @@ -203,6 +212,59 @@ public static function resolvePureUnlessCallableIsImpureVerdict(ParametersAccept return $verdict; } + /** + * Purity verdict for parameters flagged with @pure-unless-parameter-passed: + * the call stays pure as long as none of those (by-ref out) parameters + * received an argument. Returns Yes when no flagged parameter was passed, + * No when at least one was, and null when the variant has no such parameters + * (so the caller keeps its current behavior). + * + * @param Arg[] $args + */ + public static function resolvePureUnlessParameterPassedVerdict(ParametersAcceptor $variant, array $args): ?TrinaryLogic + { + $parameters = $variant->getParameters(); + $verdict = null; + + foreach ($parameters as $parameterIndex => $parameter) { + if (!$parameter instanceof ExtendedParameterReflection) { + continue; + } + if ($parameter->isPureUnlessParameterPassedParameter()->no()) { + 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) { + continue; + } + + $verdict = $verdict->and(TrinaryLogic::createNo()); + } + + return $verdict; + } + /** @return ImpurePointIdentifier */ public function getIdentifier(): string { From 6a1869c07fe6c8eb89aea660f89d4b4c18f409b9 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 02:13:57 +0900 Subject: [PATCH 4/9] Mark by-ref out-parameter builtins as @pure-unless-parameter-passed str_replace, str_ireplace, preg_replace, preg_match, preg_match_all and similar_text are pure unless their optional by-ref out parameter (count / matches / percent) receives an argument. Add the pureUnlessParameterPassedParameters metadata shape to the generator, the metadata schema test, and the metadata files. --- bin/functionMetadata_original.php | 12 +++++++- bin/generate-function-metadata.php | 30 +++++++++++++++++-- resources/functionMetadata.php | 11 ++++++- .../SignatureMap/FunctionMetadataTest.php | 1 + 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 8f4eb456e53..d12fd4ddfa7 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -13,9 +13,13 @@ * 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. + * - ['pureUnlessParameterPassedParameters' => array] + * the call is pure unless one of the listed (by-ref out) parameters + * (keyed by parameter name) receives an argument, e.g. str_replace() + * whose only side effect is writing to its optional 'count' argument. */ -/** @var array}> */ +/** @var array}|array{pureUnlessParameterPassedParameters: array}> */ return [ 'abs' => ['hasSideEffects' => false], 'acos' => ['hasSideEffects' => false], @@ -264,7 +268,11 @@ 'output_reset_rewrite_vars' => ['hasSideEffects' => true], 'pclose' => ['hasSideEffects' => true], 'popen' => ['hasSideEffects' => true], + 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], + 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], + 'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]], 'readfile' => ['hasSideEffects' => true], 'rename' => ['hasSideEffects' => true], 'rewind' => ['hasSideEffects' => true], @@ -272,6 +280,8 @@ 'sprintf' => ['hasSideEffects' => false], 'str_decrement' => ['hasSideEffects' => false], 'str_increment' => ['hasSideEffects' => false], + 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], + 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'symlink' => ['hasSideEffects' => true], 'time' => ['hasSideEffects' => true], 'tempnam' => ['hasSideEffects' => true], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index f5ef1c5101c..f3a4a66f664 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, pureUnlessParameterPassedParameters?: array}> $metadata */ $metadata = require __DIR__ . '/functionMetadata_original.php'; foreach ($visitor->functions as $functionName) { if (array_key_exists($functionName, $metadata)) { @@ -134,6 +134,14 @@ public function enterNode(Node $node) continue; } + + if (isset($metadata[$functionName]['pureUnlessParameterPassedParameters'])) { + $metadata[$functionName] = [ + 'pureUnlessParameterPassedParameters' => $metadata[$functionName]['pureUnlessParameterPassedParameters'], + ]; + + continue; + } } $metadata[$functionName] = ['hasSideEffects' => false]; } @@ -192,9 +200,12 @@ public function enterNode(Node $node) * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless * one of the listed callable parameters (keyed by parameter name) receives an * impure callable, e.g. array_map()'s 'callback'. + * - ['pureUnlessParameterPassedParameters' => array] - pure unless + * one of the listed (by-ref out) parameters (keyed by parameter name) receives + * an argument, e.g. str_replace()'s 'replace_count'. */ -/** @var array}> */ +/** @var array}|array{pureUnlessParameterPassedParameters: array}> */ return [ %s ]; @@ -216,6 +227,20 @@ public function enterNode(Node $node) ), ), ]; + $encodePureUnlessParameterPassedParameters = static fn (array $meta) => [ + $escape('pureUnlessParameterPassedParameters'), + sprintf( + '[%s]', + implode( + ' ,', + array_map( + static fn ($key, $param) => sprintf('%s => %s', $escape($key), $escape($param)), + array_keys($meta['pureUnlessParameterPassedParameters']), + $meta['pureUnlessParameterPassedParameters'], + ), + ), + ), + ]; foreach ($metadata as $name => $meta) { $content .= sprintf( @@ -224,6 +249,7 @@ public function enterNode(Node $node) ...match (true) { isset($meta['hasSideEffects']) => $encodeHasSideEffects($meta), isset($meta['pureUnlessCallableIsImpureParameters']) => $encodePureUnlessCallableIsImpureParameters($meta), + isset($meta['pureUnlessParameterPassedParameters']) => $encodePureUnlessParameterPassedParameters($meta), default => throw new ShouldNotHappenException($escape($meta)), }, ); diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index af546d10642..c74075cb60c 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -19,9 +19,12 @@ * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless * one of the listed callable parameters (keyed by parameter name) receives an * impure callable, e.g. array_map()'s 'callback'. + * - ['pureUnlessParameterPassedParameters' => array] - pure unless + * one of the listed (by-ref out) parameters (keyed by parameter name) receives + * an argument, e.g. str_replace()'s 'count'. */ -/** @var array}> */ +/** @var array}|array{pureUnlessParameterPassedParameters: array}> */ return [ 'BackedEnum::from' => ['hasSideEffects' => false], 'BackedEnum::tryFrom' => ['hasSideEffects' => false], @@ -1634,7 +1637,10 @@ 'preg_grep' => ['hasSideEffects' => false], 'preg_last_error' => ['hasSideEffects' => true], 'preg_last_error_msg' => ['hasSideEffects' => true], + 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], + 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], 'preg_quote' => ['hasSideEffects' => false], + 'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'preg_split' => ['hasSideEffects' => false], 'property_exists' => ['hasSideEffects' => false], @@ -1666,6 +1672,7 @@ 'rtrim' => ['hasSideEffects' => false], 'sha1' => ['hasSideEffects' => false], 'sha1_file' => ['hasSideEffects' => true], + 'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]], 'sin' => ['hasSideEffects' => false], 'sinh' => ['hasSideEffects' => false], 'sizeof' => ['hasSideEffects' => false], @@ -1680,8 +1687,10 @@ 'str_ends_with' => ['hasSideEffects' => false], 'str_getcsv' => ['hasSideEffects' => false], 'str_increment' => ['hasSideEffects' => false], + 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'str_pad' => ['hasSideEffects' => false], 'str_repeat' => ['hasSideEffects' => false], + 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'str_rot13' => ['hasSideEffects' => false], 'str_split' => ['hasSideEffects' => false], 'str_starts_with' => ['hasSideEffects' => false], diff --git a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php index 365e3d49757..789c1a387cd 100644 --- a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php +++ b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php @@ -20,6 +20,7 @@ public function testSchema(): void Expect::structure([ 'hasSideEffects' => Expect::bool(), 'pureUnlessCallableIsImpureParameters' => Expect::arrayOf(Expect::bool(), Expect::string()), + 'pureUnlessParameterPassedParameters' => Expect::arrayOf(Expect::bool(), Expect::string()), ]) ->assert(static fn ($v) => count((array) $v) > 0, 'Metadata entries must not be empty.') ->required(), From 9dda5611000293d97bd75c42d00abb923d90ffaa Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 02:13:57 +0900 Subject: [PATCH 5/9] Add tests for @pure-unless-parameter-passed --- .../Rules/Pure/PureFunctionRuleTest.php | 28 +++++++++ .../pure-unless-parameter-passed-builtin.php | 41 ++++++++++++ .../data/pure-unless-parameter-passed.php | 63 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php create mode 100644 tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 1a4d3c3fbba..c8d902e42e2 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -378,4 +378,32 @@ public function testPureUnlessCallableIsImpurePhp84(): void ]); } + public function testPureUnlessParameterPassed(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-parameter-passed.php'], [ + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\purePassingByRef().', + 44, + ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().', + 62, + ], + ]); + } + + public function testPureUnlessParameterPassedBuiltin(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-parameter-passed-builtin.php'], [ + [ + 'Possibly impure call to function str_replace() in pure function PureUnlessParameterPassedBuiltin\pureStrReplaceWithCount().', + 22, + ], + [ + 'Possibly impure call to function preg_match() in pure function PureUnlessParameterPassedBuiltin\purePregMatchWithMatches().', + 40, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php new file mode 100644 index 00000000000..a8f607ef1d5 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php @@ -0,0 +1,41 @@ + Date: Wed, 8 Jul 2026 02:46:03 +0900 Subject: [PATCH 6/9] Cover the pre-PHP 8 parameter names for the by-ref out-parameter builtins str_replace/str_ireplace's count and preg_match/preg_match_all's matches resolve to different parameter names (replace_count/subpatterns) when the target PHP version is below 8.0: NativeFunctionReflectionProvider falls back from php-8-stubs to the legacy functionMap.php names in that case. List both names in the metadata so the by-ref-omitted suppression works regardless of the analysed PHP version. Found via the old-PHPUnit (7.4) CI job, which runs the test suite against phpVersion 7.4 by default. --- bin/functionMetadata_original.php | 12 ++++++++---- resources/functionMetadata.php | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index d12fd4ddfa7..aea0c2827dc 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -268,8 +268,10 @@ 'output_reset_rewrite_vars' => ['hasSideEffects' => true], 'pclose' => ['hasSideEffects' => true], 'popen' => ['hasSideEffects' => true], - 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], - 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], + // 'matches'/'subpatterns': PHP 8+ uses the php-8-stubs parameter name, PHP <8 falls + // back to the legacy functionMap.php name. + 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], + 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], 'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]], @@ -280,8 +282,10 @@ 'sprintf' => ['hasSideEffects' => false], 'str_decrement' => ['hasSideEffects' => false], 'str_increment' => ['hasSideEffects' => false], - 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], - 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], + // 'count'/'replace_count': PHP 8+ uses the php-8-stubs parameter name, PHP <8 falls + // back to the legacy functionMap.php name. + 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], + 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], 'symlink' => ['hasSideEffects' => true], 'time' => ['hasSideEffects' => true], 'tempnam' => ['hasSideEffects' => true], diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index c74075cb60c..1204e71091a 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -1637,8 +1637,8 @@ 'preg_grep' => ['hasSideEffects' => false], 'preg_last_error' => ['hasSideEffects' => true], 'preg_last_error_msg' => ['hasSideEffects' => true], - 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], - 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true]], + 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], + 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], 'preg_quote' => ['hasSideEffects' => false], 'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], @@ -1687,10 +1687,10 @@ 'str_ends_with' => ['hasSideEffects' => false], 'str_getcsv' => ['hasSideEffects' => false], 'str_increment' => ['hasSideEffects' => false], - 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], + 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], 'str_pad' => ['hasSideEffects' => false], 'str_repeat' => ['hasSideEffects' => false], - 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], + 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], 'str_rot13' => ['hasSideEffects' => false], 'str_split' => ['hasSideEffects' => false], 'str_starts_with' => ['hasSideEffects' => false], From cd93236e44c0d015175675db258ee32555c6275a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 18:25:47 +0900 Subject: [PATCH 7/9] Honor @pure-unless-parameter-passed on constructor calls, unpacked and named arguments NewHandler now applies the parameter-passed verdict to 'new' the same way the sibling callable verdict is applied (constructors return void, so createFromVariant cannot be reused). The verdict also treats argument unpacking conservatively (an unpacked argument might cover the flagged by-ref parameter, so the call stays possibly impure) and respects the flag's own TrinaryLogic certainty from union-variant composition (an uncertain flag downgrades a passed argument to Maybe instead of No). --- src/Analyser/ExprHandler/NewHandler.php | 11 ++ .../Callables/SimpleImpurePoint.php | 22 +++- .../Rules/Pure/PureFunctionRuleTest.php | 16 +++ .../data/pure-unless-parameter-passed.php | 107 +++++++++++++++++- 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 111fe38a682..93bda59a5a2 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -266,6 +266,17 @@ private function processConstructorReflection(string $className, New_ $expr, Mut if ($verdict !== null && $verdict->no()) { $certain = true; } + + if (!$certain) { + $passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs()); + if ($passedVerdict !== null && $passedVerdict->yes()) { + return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; + } + if ($passedVerdict !== null && $passedVerdict->no()) { + $certain = true; + } + } + $impurePoints[] = new ImpurePoint( $scope, $expr, diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index abee88ee3c1..628ecf8ed40 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -237,8 +237,14 @@ public static function resolvePureUnlessParameterPassedVerdict(ParametersAccepto $verdict ??= TrinaryLogic::createYes(); $matchedArg = null; + $hasUnpackedArg = false; $hasNamedParameter = false; foreach ($args as $i => $arg) { + if ($arg->unpack) { + $hasUnpackedArg = true; + continue; + } + if ($arg->name !== null) { $hasNamedParameter = true; if ($arg->name->name === $parameter->getName()) { @@ -256,10 +262,24 @@ public static function resolvePureUnlessParameterPassedVerdict(ParametersAccepto } if ($matchedArg === null) { + if ($hasUnpackedArg) { + // An unpacked argument list (...$args) might supply the flagged + // by-ref parameter, so we cannot be sure the call stays pure. + $verdict = $verdict->and(TrinaryLogic::createMaybe()); + } + + continue; + } + + if ($parameter->isPureUnlessParameterPassedParameter()->yes()) { + $verdict = $verdict->and(TrinaryLogic::createNo()); continue; } - $verdict = $verdict->and(TrinaryLogic::createNo()); + // The flag itself is uncertain (e.g. only one variant of a union type + // declares @pure-unless-parameter-passed), so passing an argument here + // only makes the call possibly impure, not certainly impure. + $verdict = $verdict->and(TrinaryLogic::createMaybe()); } return $verdict; diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index c8d902e42e2..91105c022cf 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -389,6 +389,22 @@ public function testPureUnlessParameterPassed(): void 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().', 62, ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureUnpackingArgs().', + 72, + ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureNamedArgForFlaggedParameter().', + 91, + ], + [ + 'Impure instantiation of class PureUnlessParameterPassedFunction\MyReplacerConstructor in pure function PureUnlessParameterPassedFunction\pureConstructorPassingByRef().', + 127, + ], + [ + 'Possibly impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedA::m() in pure function PureUnlessParameterPassedFunction\pureUnionMethodPassingCount().', + 167, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php index 0bc6b618220..5ae18df8d29 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php @@ -1,4 +1,4 @@ -= 8.0 namespace PureUnlessParameterPassedFunction; @@ -61,3 +61,108 @@ function purePassingByRefAlias(string $s): string return myReplacePhpstanAlias($s, $count); } + +/** + * @phpstan-pure + */ +function pureUnpackingArgs(string $s): string +{ + $args = [$s]; + // An unpacked argument list might supply $count, so this stays possibly impure. + return myReplace(...$args); +} + +/** + * @phpstan-pure + */ +function pureNamedArgForOtherParameter(string $s): string +{ + // The named argument targets $subject, not the flagged $count, so this stays pure. + return myReplace(subject: $s); +} + +/** + * @phpstan-pure + */ +function pureNamedArgForFlaggedParameter(string $s): string +{ + $count = 0; + // The named argument explicitly targets the flagged $count parameter. + return myReplace(subject: $s, count: $count); +} + +class MyReplacerConstructor +{ + + public string $s; + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function __construct(string $s, int &$count = 0) + { + $this->s = $s; + $count = 1; + } + +} + +/** + * @phpstan-pure + */ +function pureConstructorNotPassingByRef(string $s): MyReplacerConstructor +{ + // $count is omitted, so instantiation stays pure. + return new MyReplacerConstructor($s); +} + +/** + * @phpstan-pure + */ +function pureConstructorPassingByRef(string $s): MyReplacerConstructor +{ + $count = 0; + // $count is passed, so instantiation is impure (the flag is certain). + return new MyReplacerConstructor($s, $count); +} + +interface PureUnlessParameterPassedA +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function m(string $s, int &$count = 0): string; + +} + +interface PureUnlessParameterPassedB +{ + + public function m(string $s, int &$count = 0): string; + +} + +/** + * @param PureUnlessParameterPassedA|PureUnlessParameterPassedB $obj + * @phpstan-pure + */ +function pureUnionMethodOmittingCount($obj, string $s): string +{ + // The flag is Yes in A and absent (No) in B, so combineAcceptors() merges it to Maybe. + // $count is omitted here, so the call stays pure regardless of the flag's certainty. + return $obj->m($s); +} + +/** + * @param PureUnlessParameterPassedA|PureUnlessParameterPassedB $obj + * @phpstan-pure + */ +function pureUnionMethodPassingCount($obj, string $s): string +{ + $count = 0; + // $count is passed against the Maybe-flagged parameter, so this is possibly impure. + return $obj->m($s, $count); +} From 5c7135b54b07d2c7274c348b388f39776806bd2d Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 18:29:55 +0900 Subject: [PATCH 8/9] Parse @pure-unless-parameter-passed via phpdoc-parser 2.3.3 phpstan/phpdoc-parser#259 is merged and released, so replace the generic-tag stopgap with PhpDocNode::getPureUnlessParameterIsPassedTagValues() and bump the dependency. --- src/PhpDoc/PhpDocNodeResolver.php | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 9dff372d533..048031ae7fb 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -28,7 +28,6 @@ use PHPStan\PhpDoc\Tag\UsesTag; use PHPStan\PhpDoc\Tag\VarTag; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNullNode; -use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; @@ -48,10 +47,8 @@ use function array_reverse; use function count; use function in_array; -use function ltrim; use function method_exists; use function str_starts_with; -use function strpos; use function substr; #[AutowiredService] @@ -412,33 +409,9 @@ public function resolveParamPureUnlessCallableIsImpure(PhpDocNode $phpDocNode): public function resolveParamPureUnlessParameterPassed(PhpDocNode $phpDocNode): array { $parameters = []; - // TODO: replace this generic-tag parsing with - // $phpDocNode->getPureUnlessParameterIsPassedTagValues() once - // phpstan/phpdoc-parser#259 is merged and the parser understands the tag. foreach (['@pure-unless-parameter-passed', '@phpstan-pure-unless-parameter-passed'] as $tagName) { - foreach ($phpDocNode->getTags() as $tag) { - if ($tag->name !== $tagName) { - continue; - } - if (!$tag->value instanceof GenericTagValueNode) { - continue; - } - - $value = ltrim($tag->value->value); - if ($value === '' || $value[0] !== '$') { - continue; - } - - $parameterName = substr($value, 1); - $spacePosition = strpos($parameterName, ' '); - if ($spacePosition !== false) { - $parameterName = substr($parameterName, 0, $spacePosition); - } - - if ($parameterName === '') { - continue; - } - + foreach ($phpDocNode->getPureUnlessParameterIsPassedTagValues($tagName) as $tag) { + $parameterName = substr($tag->parameterName, 1); $parameters[$parameterName] = true; } } From 52e31bdd4bee057d71f80fb1fb737c809220e5e0 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 8 Jul 2026 19:20:39 +0900 Subject: [PATCH 9/9] Report certain impurity when a @pure-unless-parameter-passed argument is passed createFromVariant suppressed the impure point when the flagged parameter was omitted, but never promoted the verdict to certain when it was passed, unlike its @pure-unless-callable-is-impure sibling right above it and unlike NewHandler's own constructor handling. Passing the by-ref out-parameter is a definite side effect, not a possible one. Also covers intersection types, method inheritance (incl. a renamed parameter), and first-class callables. --- .../Callables/SimpleImpurePoint.php | 13 +- .../Rules/Pure/PureFunctionRuleTest.php | 35 +++- .../pure-unless-parameter-passed-builtin.php | 4 +- .../data/pure-unless-parameter-passed.php | 180 +++++++++++++++++- 4 files changed, 219 insertions(+), 13 deletions(-) diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 628ecf8ed40..e8f23bf7998 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -76,10 +76,15 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec if (!$certain && $scope !== null && $variant !== null) { $passedVerdict = self::resolvePureUnlessParameterPassedVerdict($variant, $args); - if ($passedVerdict !== null && $passedVerdict->yes()) { - // None of the @pure-unless-parameter-passed by-ref parameters - // received an argument, so the call is pure. - return null; + if ($passedVerdict !== null) { + if ($passedVerdict->yes()) { + // None of the @pure-unless-parameter-passed by-ref parameters + // received an argument, so the call is pure. + return null; + } + if ($passedVerdict->no()) { + $certain = true; + } } } diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 91105c022cf..1708cb67e2d 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -378,15 +378,16 @@ public function testPureUnlessCallableIsImpurePhp84(): void ]); } + #[RequiresPhp('>= 8.1.0')] public function testPureUnlessParameterPassed(): void { $this->analyse([__DIR__ . '/data/pure-unless-parameter-passed.php'], [ [ - 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\purePassingByRef().', + 'Impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\purePassingByRef().', 44, ], [ - 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().', + 'Impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().', 62, ], [ @@ -394,7 +395,7 @@ public function testPureUnlessParameterPassed(): void 72, ], [ - 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureNamedArgForFlaggedParameter().', + 'Impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureNamedArgForFlaggedParameter().', 91, ], [ @@ -405,6 +406,30 @@ public function testPureUnlessParameterPassed(): void 'Possibly impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedA::m() in pure function PureUnlessParameterPassedFunction\pureUnionMethodPassingCount().', 167, ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedIntersectionA::m() in pure function PureUnlessParameterPassedFunction\pureIntersectionMethodPassingCount().', + 206, + ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\Replacer::replace() in pure function PureUnlessParameterPassedFunction\pureCallingMethodPassingByRef().', + 241, + ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\InheritedReplacerChild::replace() in pure function PureUnlessParameterPassedFunction\pureCallingInheritedMethodPassingByRef().', + 297, + ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\InheritedReplacerRenamedChild::replace() in pure function PureUnlessParameterPassedFunction\pureCallingRenamedInheritedMethodPassingByRef().', + 318, + ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureCallingFirstClassCallableOmittingCount().', + 331, + ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureCallingFirstClassCallablePassingCount().', + 343, + ], ]); } @@ -412,11 +437,11 @@ public function testPureUnlessParameterPassedBuiltin(): void { $this->analyse([__DIR__ . '/data/pure-unless-parameter-passed-builtin.php'], [ [ - 'Possibly impure call to function str_replace() in pure function PureUnlessParameterPassedBuiltin\pureStrReplaceWithCount().', + 'Impure call to function str_replace() in pure function PureUnlessParameterPassedBuiltin\pureStrReplaceWithCount().', 22, ], [ - 'Possibly impure call to function preg_match() in pure function PureUnlessParameterPassedBuiltin\purePregMatchWithMatches().', + 'Impure call to function preg_match() in pure function PureUnlessParameterPassedBuiltin\purePregMatchWithMatches().', 40, ], ]); diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php index a8f607ef1d5..e53b989e951 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php @@ -16,7 +16,7 @@ function pureStrReplaceWithoutCount(string $s): string */ function pureStrReplaceWithCount(string $s): string { - // The by-ref $count is passed, so str_replace() is possibly impure. + // The by-ref $count is passed, so str_replace() is impure (the flag is certain). $count = 0; return str_replace('a', 'b', $s, $count); @@ -36,6 +36,6 @@ function purePregMatchWithoutMatches(string $s): int */ function purePregMatchWithMatches(string $s): int { - // The by-ref $matches is passed, so preg_match() is possibly impure. + // The by-ref $matches is passed, so preg_match() is impure (the flag is certain). return (int) preg_match('/a/', $s, $matches); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php index 5ae18df8d29..2844305adb6 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed.php @@ -1,4 +1,4 @@ -= 8.0 += 8.1 namespace PureUnlessParameterPassedFunction; @@ -38,7 +38,7 @@ function pureNotPassingByRef(string $s): string */ function purePassingByRef(string $s): string { - // $count is passed, so myReplace() is possibly impure. + // $count is passed, so myReplace() is impure (the flag is certain). $count = 0; return myReplace($s, $count); @@ -166,3 +166,179 @@ function pureUnionMethodPassingCount($obj, string $s): string // $count is passed against the Maybe-flagged parameter, so this is possibly impure. return $obj->m($s, $count); } + +interface PureUnlessParameterPassedIntersectionA +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function m(string $s, int &$count = 0): string; + +} + +interface PureUnlessParameterPassedIntersectionB +{ + + public function n(): string; + +} + +/** + * @param PureUnlessParameterPassedIntersectionA&PureUnlessParameterPassedIntersectionB $obj + * @phpstan-pure + */ +function pureIntersectionMethodOmittingCount($obj, string $s): string +{ + // $count is omitted here, so the call stays pure. + return $obj->m($s); +} + +/** + * @param PureUnlessParameterPassedIntersectionA&PureUnlessParameterPassedIntersectionB $obj + * @phpstan-pure + */ +function pureIntersectionMethodPassingCount($obj, string $s): string +{ + $count = 0; + // $count is passed, so this is impure (the flag is certain). + return $obj->m($s, $count); +} + +class Replacer +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function replace(string $subject, int &$count = 0): string + { + $count = 1; + + return $subject; + } + +} + +/** + * @phpstan-pure + */ +function pureCallingMethodNotPassingByRef(Replacer $replacer, string $s): string +{ + // $count is omitted, so the call stays pure. + return $replacer->replace($s); +} + +/** + * @phpstan-pure + */ +function pureCallingMethodPassingByRef(Replacer $replacer, string $s): string +{ + $count = 0; + // $count is passed, so the call is impure (the flag is certain). + return $replacer->replace($s, $count); +} + +abstract class InheritedReplacerParent +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + abstract public function replace(string $subject, int &$count = 0): string; + +} + +class InheritedReplacerChild extends InheritedReplacerParent +{ + + public function replace(string $subject, int &$count = 0): string + { + $count = 1; + + return $subject; + } + +} + +class InheritedReplacerRenamedChild extends InheritedReplacerParent +{ + + public function replace(string $subject, int &$cnt = 0): string + { + $cnt = 1; + + return $subject; + } + +} + +/** + * @phpstan-pure + */ +function pureCallingInheritedMethodNotPassingByRef(InheritedReplacerChild $replacer, string $s): string +{ + // The child does not re-declare @pure-unless-parameter-passed; it is inherited + // from the parent. $count is omitted here, so the call stays pure. + return $replacer->replace($s); +} + +/** + * @phpstan-pure + */ +function pureCallingInheritedMethodPassingByRef(InheritedReplacerChild $replacer, string $s): string +{ + $count = 0; + // The inherited @pure-unless-parameter-passed applies to the child, so passing + // $count makes the call impure (the flag is certain). + return $replacer->replace($s, $count); +} + +/** + * @phpstan-pure + */ +function pureCallingRenamedInheritedMethodNotPassingByRef(InheritedReplacerRenamedChild $replacer, string $s): string +{ + // The parent flags $count; the child renames it to $cnt. The inherited flag + // still applies to the renamed parameter. It is omitted here, so this stays pure. + return $replacer->replace($s); +} + +/** + * @phpstan-pure + */ +function pureCallingRenamedInheritedMethodPassingByRef(InheritedReplacerRenamedChild $replacer, string $s): string +{ + $cnt = 0; + // The inherited flag applies to the renamed parameter, so passing it makes + // the call impure (the flag is certain). + return $replacer->replace($s, $cnt); +} + +/** + * @phpstan-pure + */ +function pureCallingFirstClassCallableOmittingCount(string $s): string +{ + $f = myReplace(...); + // A first-class callable's purity is evaluated from its ParametersAcceptor + // alone, without scope/args, so @pure-unless-parameter-passed cannot gate on + // whether $count is actually passed at this call site; it stays possibly + // impure even though $count is omitted here. + return $f($s); +} + +/** + * @phpstan-pure + */ +function pureCallingFirstClassCallablePassingCount(string $s): string +{ + $count = 0; + $f = myReplace(...); + // The first-class callable is called with the flagged $count passed, so this + // stays possibly impure. + return $f($s, $count); +}