From a09ab3ac9c163ec7909c08b5575b14ef8b1f7238 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Fri, 1 May 2026 17:41:26 +0000 Subject: [PATCH 1/7] Mark `array_filter()`, `array_map()`, `array_reduce()` and PHP 8.4 `array_find`/`array_any`/`array_all` as pure in function metadata - Add `array_filter`, `array_map`, `array_reduce` to `bin/functionMetadata_original.php` with `hasSideEffects => false` - Add PHP 8.4 callback-accepting array functions (`array_all`, `array_any`, `array_find`, `array_find_key`) to the metadata - Fix `BetterReflectionProvider::getCustomFunction()` to also check function metadata when PHPDoc purity is unknown, so functions only available through stubs (not in the signature map) also benefit from metadata entries - Add `NativeFunctionReflectionProvider::getFunctionPurityFromMetadata()` to expose metadata lookup - Regenerate `resources/functionMetadata.php` - Update `tests/PHPStan/Command/data/file-without-errors.php` to assign `array_filter()` result (previously a no-op) --- bin/functionMetadata_original.php | 7 ++++ resources/functionMetadata.php | 7 ++++ .../BetterReflectionProvider.php | 4 ++ .../NativeFunctionReflectionProvider.php | 10 +++++ .../Command/data/file-without-errors.php | 2 +- ...ionStatementWithoutSideEffectsRuleTest.php | 41 +++++++++++++++++++ .../Rules/Functions/data/bug-11101-php84.php | 19 +++++++++ .../Rules/Functions/data/bug-11101.php | 20 +++++++++ 8 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11101-php84.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11101.php diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 78ac65db296..f22b1789352 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -20,6 +20,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['hasSideEffects' => false], + 'array_any' => ['hasSideEffects' => false], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -32,6 +34,9 @@ 'array_diff_ukey' => ['hasSideEffects' => false], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['hasSideEffects' => false], + 'array_find' => ['hasSideEffects' => false], + 'array_find_key' => ['hasSideEffects' => false], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], @@ -42,6 +47,7 @@ 'array_key_last' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], + 'array_map' => ['hasSideEffects' => false], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -49,6 +55,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['hasSideEffects' => false], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 4e50a9f80f3..98133f25ef7 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -725,6 +725,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['hasSideEffects' => false], + 'array_any' => ['hasSideEffects' => false], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -737,6 +739,9 @@ 'array_diff_ukey' => ['hasSideEffects' => false], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['hasSideEffects' => false], + 'array_find' => ['hasSideEffects' => false], + 'array_find_key' => ['hasSideEffects' => false], 'array_first' => ['hasSideEffects' => false], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], @@ -750,6 +755,7 @@ 'array_key_last' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], 'array_last' => ['hasSideEffects' => false], + 'array_map' => ['hasSideEffects' => false], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -757,6 +763,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['hasSideEffects' => false], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index b514bfa1a93..e01467c7327 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -322,6 +322,10 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); } + if ($isPure === null) { + $isPure = $this->nativeFunctionReflectionProvider->getFunctionPurityFromMetadata($functionName); + } + return $this->functionReflectionFactory->create( $reflectionFunction, $templateTypeMap, diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index a2f12f6daad..deb77203f7e 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -178,6 +178,16 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef return $functionReflection; } + public function getFunctionPurityFromMetadata(string $functionName): ?bool + { + $lowerCasedFunctionName = strtolower($functionName); + if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { + return !$this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName)['hasSideEffects']; + } + + return null; + } + private function getReturnTypeFromPhpDoc(ResolvedPhpDocBlock $phpDoc): ?Type { $returnTag = $phpDoc->getReturnTag(); diff --git a/tests/PHPStan/Command/data/file-without-errors.php b/tests/PHPStan/Command/data/file-without-errors.php index 08929907d3c..c6d0a42c3ac 100644 --- a/tests/PHPStan/Command/data/file-without-errors.php +++ b/tests/PHPStan/Command/data/file-without-errors.php @@ -1,3 +1,3 @@ analyse([__DIR__ . '/data/bug-11101.php'], [ + [ + 'Call to function array_filter() on a separate line has no effect.', + 13, + ], + [ + 'Call to function array_map() on a separate line has no effect.', + 14, + ], + [ + 'Call to function array_reduce() on a separate line has no effect.', + 15, + ], + ]); + } + + #[RequiresPhp('>= 8.4.0')] + public function testBug11101Php84(): void + { + $this->analyse([__DIR__ . '/data/bug-11101-php84.php'], [ + [ + 'Call to function array_find() on a separate line has no effect.', + 13, + ], + [ + 'Call to function array_find_key() on a separate line has no effect.', + 14, + ], + [ + 'Call to function array_any() on a separate line has no effect.', + 15, + ], + [ + 'Call to function array_all() on a separate line has no effect.', + 16, + ], + ]); + } + #[RequiresPhp('>= 8.5.0')] public function testPipeOperator(): void { diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php b/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php new file mode 100644 index 00000000000..271178a8290 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11101-php84.php @@ -0,0 +1,19 @@ += 8.4 + +namespace Bug11101Php84; + +class Foo +{ + + /** + * @param array $array + */ + public function doFoo(array $array): void + { + array_find($array, fn ($v) => $v > 5); + array_find_key($array, fn ($v) => $v > 5); + array_any($array, fn ($v) => $v > 5); + array_all($array, fn ($v) => $v > 5); + } + +} 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..d91e161fa75 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11101.php @@ -0,0 +1,20 @@ + $array + */ + public function doFoo(array $array): void + { + array_filter($array, 'is_string'); + array_map('is_string', $array); + array_reduce($array, function ($carry, $item) { + return $carry + $item; + }, 0); + } + +} From bc9200facf7c99944efd6333484832ff95579af1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 18:29:31 +0000 Subject: [PATCH 2/7] Implement @phpstan-pure-unless-callable-is-impure tag and mark callback-accepting array functions with it Instead of marking array_filter(), array_map(), array_reduce() and other callback-accepting array functions as unconditionally pure (hasSideEffects => false), implement the @phpstan-pure-unless-callable-is-impure PHPDoc tag to properly express that these functions are pure only when their callable argument is also pure. The phpdoc-parser already supports parsing the tag. This commit wires up the resolution in PHPStan core: - PhpDocNodeResolver::resolvePureUnlessCallableIsImpure() resolves the tag - ResolvedPhpDocBlock stores, merges, and exposes the parameter names - ResolvedPhpDocBlock::isPure() returns true when the tag is present - Function metadata type extended with optional pureUnlessCallableIsImpure key - All callback-accepting array functions in metadata now carry the flag The behavior is correct because PHPStan already propagates impure points from callback arguments through the processArgs mechanism: if the callback is impure, its impure points are merged into the overall expression, preventing the NoopExpressionNode from being emitted. Co-Authored-By: Claude Opus 4.6 --- bin/functionMetadata_original.php | 34 ++++++------- bin/generate-function-metadata.php | 11 ++-- resources/functionMetadata.php | 34 ++++++------- src/PhpDoc/PhpDocNodeResolver.php | 16 ++++++ src/PhpDoc/ResolvedPhpDocBlock.php | 51 +++++++++++++++++++ .../FunctionSignatureMapProvider.php | 10 ++-- .../SignatureMap/Php8SignatureMapProvider.php | 4 +- .../SignatureMap/SignatureMapProvider.php | 4 +- .../SignatureMap/FunctionMetadataTest.php | 1 + ...ionStatementWithoutSideEffectsRuleTest.php | 11 ++++ ...e-unless-callable-is-impure-definition.php | 14 +++++ .../data/pure-unless-callable-is-impure.php | 16 ++++++ 12 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php create mode 100644 tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index f22b1789352..c27a8f994f7 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -20,8 +20,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], - 'array_all' => ['hasSideEffects' => false], - 'array_any' => ['hasSideEffects' => false], + 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -30,24 +30,24 @@ '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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], - 'array_filter' => ['hasSideEffects' => false], - 'array_find' => ['hasSideEffects' => false], - 'array_find_key' => ['hasSideEffects' => false], + 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => 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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_key_first' => ['hasSideEffects' => false], 'array_key_last' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], - 'array_map' => ['hasSideEffects' => false], + 'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -55,19 +55,19 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], - 'array_reduce' => ['hasSideEffects' => false], + 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => 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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index d161d374e46..84078393319 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,11 +185,14 @@ public function enterNode(Node $node) php; $content = ''; foreach ($metadata as $name => $meta) { + $pairs = sprintf('%s => %s', var_export('hasSideEffects', true), var_export($meta['hasSideEffects'], true)); + if (isset($meta['pureUnlessCallableIsImpure']) && $meta['pureUnlessCallableIsImpure']) { + $pairs .= sprintf(', %s => %s', var_export('pureUnlessCallableIsImpure', true), var_export(true, true)); + } $content .= sprintf( - "\t%s => [%s => %s],\n", + "\t%s => [%s],\n", var_export($name, true), - var_export('hasSideEffects', true), - var_export($meta['hasSideEffects'], true), + $pairs, ); } diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 98133f25ef7..69db8642c5a 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -725,8 +725,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], - 'array_all' => ['hasSideEffects' => false], - 'array_any' => ['hasSideEffects' => false], + 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -735,27 +735,27 @@ '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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], - 'array_filter' => ['hasSideEffects' => false], - 'array_find' => ['hasSideEffects' => false], - 'array_find_key' => ['hasSideEffects' => false], + 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => 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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => 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' => ['hasSideEffects' => false], + 'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -763,7 +763,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], - 'array_reduce' => ['hasSideEffects' => false], + 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], @@ -771,12 +771,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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 1e88dce58aa..d647ba0c7d4 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -673,6 +673,22 @@ public function resolveIsImpure(PhpDocNode $phpDocNode): bool return false; } + /** + * @return array + */ + public function resolvePureUnlessCallableIsImpure(PhpDocNode $phpDocNode): array + { + $parameters = []; + foreach (['@pure-unless-callable-is-impure', '@phpstan-pure-unless-callable-is-impure'] as $tagName) { + foreach ($phpDocNode->getPureUnlessCallableIsImpureTagValues($tagName) as $tagValue) { + $parameterName = substr($tagValue->parameterName, 1); + $parameters[$parameterName] = true; + } + } + + return $parameters; + } + public function resolveAllMethodsPure(PhpDocNode $phpDocNode): bool { return count($phpDocNode->getTagsByName('@phpstan-all-methods-pure')) > 0; diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index be6da4fc46d..3c683129bcf 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -139,6 +139,9 @@ final class ResolvedPhpDocBlock /** @var bool|'notLoaded'|null */ private bool|string|null $isPure = 'notLoaded'; + /** @var array|false */ + private array|false $pureUnlessCallableIsImpureParameterNames = false; + private ?bool $areAllMethodsPure = null; private ?bool $areAllMethodsImpure = null; @@ -237,6 +240,7 @@ public static function createEmpty(): self $self->isInternal = false; $self->isFinal = false; $self->isPure = null; + $self->pureUnlessCallableIsImpureParameterNames = []; $self->areAllMethodsPure = false; $self->areAllMethodsImpure = false; $self->isReadOnly = false; @@ -293,6 +297,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->isInternal = $this->isInternal(); $result->isFinal = $this->isFinal(); $result->isPure = self::mergePureTags($this->isPure(), $parent); + $result->pureUnlessCallableIsImpureParameterNames = self::mergePureUnlessCallableIsImpureParameterNames($this->getPureUnlessCallableIsImpureParameterNames(), $parent, $parameterMapping); $result->areAllMethodsPure = $this->areAllMethodsPure(); $result->areAllMethodsImpure = $this->areAllMethodsImpure(); $result->isReadOnly = $this->isReadOnly(); @@ -352,6 +357,15 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self $newParamsImmediatelyInvokedCallable[$parameterNameMapping[$key]] = $immediatelyInvokedCallable; } + $newPureUnlessCallableIsImpureParameterNames = []; + foreach ($this->getPureUnlessCallableIsImpureParameterNames() as $key => $value) { + if (!array_key_exists($key, $parameterNameMapping)) { + continue; + } + + $newPureUnlessCallableIsImpureParameterNames[$parameterNameMapping[$key]] = $value; + } + $paramClosureThisTags = $this->getParamClosureThisTags(); $newParamClosureThisTags = []; foreach ($paramClosureThisTags as $key => $paramClosureThisTag) { @@ -399,6 +413,7 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self $self->paramTags = $newParamTags; $self->paramOutTags = $newParamOutTags; $self->paramsImmediatelyInvokedCallable = $newParamsImmediatelyInvokedCallable; + $self->pureUnlessCallableIsImpureParameterNames = $newPureUnlessCallableIsImpureParameterNames; $self->paramClosureThisTags = $newParamClosureThisTags; $self->returnTag = $returnTag; $self->throwsTag = $this->throwsTag; @@ -584,6 +599,18 @@ public function getParamsImmediatelyInvokedCallable(): array return $this->paramsImmediatelyInvokedCallable; } + /** + * @return array + */ + public function getPureUnlessCallableIsImpureParameterNames(): array + { + if ($this->pureUnlessCallableIsImpureParameterNames === false) { + $this->pureUnlessCallableIsImpureParameterNames = $this->phpDocNodeResolver->resolvePureUnlessCallableIsImpure($this->phpDocNode); + } + + return $this->pureUnlessCallableIsImpureParameterNames; + } + /** * @return array */ @@ -818,6 +845,11 @@ public function isPure(): ?bool return $this->isPure; } + if (count($this->getPureUnlessCallableIsImpureParameterNames()) > 0) { + $this->isPure = true; + return $this->isPure; + } + $this->isPure = null; } @@ -1085,6 +1117,25 @@ private static function mergeOneParentParamImmediatelyInvokedCallable(array $par return $paramsImmediatelyInvokedCallable; } + /** + * @param array $parameterNames + * @return array + */ + private static function mergePureUnlessCallableIsImpureParameterNames(array $parameterNames, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + $parentParameterNames = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getPureUnlessCallableIsImpureParameterNames()); + + foreach ($parentParameterNames as $name => $value) { + if (array_key_exists($name, $parameterNames)) { + continue; + } + + $parameterNames[$name] = $value; + } + + return $parameterNames; + } + /** * @param array $paramsClosureThisTags * @return array diff --git a/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php index ac8b0674225..bf43f72bade 100644 --- a/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php +++ b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php @@ -28,7 +28,7 @@ final class FunctionSignatureMapProvider implements SignatureMapProvider /** @var array */ private static array $signatureMaps = []; - /** @var array|null */ + /** @var array|null */ private static ?array $functionMetadata = null; public function __construct( @@ -135,7 +135,7 @@ public function hasFunctionMetadata(string $name): bool } /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} */ public function getMethodMetadata(string $className, string $methodName): array { @@ -143,7 +143,7 @@ public function getMethodMetadata(string $className, string $methodName): array } /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} */ public function getFunctionMetadata(string $functionName): array { @@ -157,12 +157,12 @@ public function getFunctionMetadata(string $functionName): array } /** - * @return array + * @return array */ private static function getFunctionMetadataMap(): array { if (self::$functionMetadata === null) { - /** @var array $metadata */ + /** @var array $metadata */ $metadata = require __DIR__ . '/../../../resources/functionMetadata.php'; self::$functionMetadata = array_change_key_case($metadata, CASE_LOWER); } diff --git a/src/Reflection/SignatureMap/Php8SignatureMapProvider.php b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php index 19884d70378..c06754ff88a 100644 --- a/src/Reflection/SignatureMap/Php8SignatureMapProvider.php +++ b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php @@ -385,7 +385,7 @@ public function hasFunctionMetadata(string $name): bool } /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} */ public function getMethodMetadata(string $className, string $methodName): array { @@ -393,7 +393,7 @@ public function getMethodMetadata(string $className, string $methodName): array } /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} */ public function getFunctionMetadata(string $functionName): array { diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php index 3999d919b8f..7f25bde4a86 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, pureUnlessCallableIsImpure?: bool} */ public function getMethodMetadata(string $className, string $methodName): array; /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} */ public function getFunctionMetadata(string $functionName): array; diff --git a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php index 24ef8431eee..9c139a1fccc 100644 --- a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php +++ b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php @@ -18,6 +18,7 @@ public function testSchema(): void $processor->process(Expect::arrayOf( Expect::structure([ 'hasSideEffects' => Expect::bool()->required(), + 'pureUnlessCallableIsImpure' => Expect::bool(), ])->required(), )->required(), $data); } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index 4d0820ae4d8..409c2d6ad07 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -160,6 +160,17 @@ public function testBug11101Php84(): void ]); } + public function testPureUnlessCallableIsImpure(): void + { + require_once __DIR__ . '/data/pure-unless-callable-is-impure-definition.php'; + $this->analyse([__DIR__ . '/data/pure-unless-callable-is-impure.php'], [ + [ + 'Call to function PureUnlessCallableIsImpure\myFilter() on a separate line has no effect.', + 13, + ], + ]); + } + #[RequiresPhp('>= 8.5.0')] public function testPipeOperator(): void { diff --git a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php new file mode 100644 index 00000000000..4bf302a7e7b --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php @@ -0,0 +1,14 @@ + $array + * @param callable(int): bool $callback + * @return array + */ +function myFilter(array $array, callable $callback): array +{ + return array_filter($array, $callback); +} diff --git a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php new file mode 100644 index 00000000000..14830cd7e93 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php @@ -0,0 +1,16 @@ + $array + */ + public function doFoo(array $array): void + { + myFilter($array, fn ($v) => $v > 5); + } + +} From e56279412bb50fb7026fe89f9e9edd5079fd14b1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 19:00:06 +0000 Subject: [PATCH 3/7] Do not treat @phpstan-pure-unless-callable-is-impure as unconditionally pure The previous implementation set isPure=true on ResolvedPhpDocBlock when @phpstan-pure-unless-callable-is-impure was present. This was semantically wrong because it caused FunctionPurityCheck to validate the function body as purely pure, which would incorrectly flag callback invocations as impure points inside the function. Instead, introduce a separate $isPureUnlessCallableIsImpure flag on PhpFunctionReflection that: - Makes hasSideEffects() return No (so the function itself generates no impure point at call sites - the "no effect" rule still fires for pure callbacks) - Keeps isPure() returning Maybe (so FunctionPurityCheck does not apply pure body checks to the function) The callback arguments' impure points are still correctly propagated through processArgs() - if an impure callback is passed, the overall expression has impure points and NoopExpressionNode is not emitted. Also adds test cases verifying no false positives when impure callbacks are passed to array_filter/array_map/array_reduce and to user-defined functions with @phpstan-pure-unless-callable-is-impure. Co-Authored-By: Claude Opus 4.6 --- src/PhpDoc/ResolvedPhpDocBlock.php | 5 ----- .../BetterReflectionProvider.php | 11 ++++++++++- src/Reflection/FunctionReflectionFactory.php | 1 + src/Reflection/Php/PhpFunctionReflection.php | 4 ++++ .../NativeFunctionReflectionProvider.php | 17 ++++++++++++++++- .../PHPStan/Rules/Functions/data/bug-11101.php | 17 +++++++++++++++++ .../data/pure-unless-callable-is-impure.php | 15 +++++++++++++++ 7 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 3c683129bcf..ce751a02f89 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -845,11 +845,6 @@ public function isPure(): ?bool return $this->isPure; } - if (count($this->getPureUnlessCallableIsImpureParameterNames()) > 0) { - $this->isPure = true; - return $this->isPure; - } - $this->isPure = null; } diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index e01467c7327..607d1fdfd26 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -60,6 +60,7 @@ use function array_key_first; use function array_map; use function base64_decode; +use function count; use function in_array; use function sprintf; use function strtolower; @@ -288,6 +289,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $isInternal = false; $isPure = null; + $isPureUnlessCallableIsImpure = false; $asserts = Assertions::createEmpty(); $acceptsNamedArguments = true; $phpDocComment = null; @@ -312,6 +314,9 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection } $isInternal = $resolvedPhpDoc->isInternal(); $isPure = $resolvedPhpDoc->isPure(); + if ($isPure === null && count($resolvedPhpDoc->getPureUnlessCallableIsImpureParameterNames()) > 0) { + $isPureUnlessCallableIsImpure = true; + } $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); if ($resolvedPhpDoc->hasPhpDocString()) { $phpDocComment = $resolvedPhpDoc->getPhpDocString(); @@ -322,8 +327,11 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); } - if ($isPure === null) { + if ($isPure === null && !$isPureUnlessCallableIsImpure) { $isPure = $this->nativeFunctionReflectionProvider->getFunctionPurityFromMetadata($functionName); + if ($isPure === null) { + $isPureUnlessCallableIsImpure = $this->nativeFunctionReflectionProvider->getFunctionPureUnlessCallableIsImpureFromMetadata($functionName); + } } return $this->functionReflectionFactory->create( @@ -337,6 +345,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $isInternal, $reflectionFunction->getFileName() !== false ? $reflectionFunction->getFileName() : null, $isPure, + $isPureUnlessCallableIsImpure, $asserts, $acceptsNamedArguments, $phpDocComment, diff --git a/src/Reflection/FunctionReflectionFactory.php b/src/Reflection/FunctionReflectionFactory.php index 993bf34b3b4..2ad59e37617 100644 --- a/src/Reflection/FunctionReflectionFactory.php +++ b/src/Reflection/FunctionReflectionFactory.php @@ -28,6 +28,7 @@ public function create( bool $isInternal, ?string $filename, ?bool $isPure, + bool $isPureUnlessCallableIsImpure, Assertions $asserts, bool $acceptsNamedArguments, ?string $phpDocComment, diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 7dbd7ca3c47..5e5746c835f 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -53,6 +53,7 @@ public function __construct( private bool $isInternal, private ?string $filename, private ?bool $isPure, + private bool $isPureUnlessCallableIsImpure, private Assertions $asserts, private bool $acceptsNamedArguments, private ?string $phpDocComment, @@ -197,6 +198,9 @@ public function hasSideEffects(): TrinaryLogic if ($this->isPure !== null) { return TrinaryLogic::createFromBoolean(!$this->isPure); } + if ($this->isPureUnlessCallableIsImpure) { + return TrinaryLogic::createNo(); + } return TrinaryLogic::createMaybe(); } diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index deb77203f7e..74096012fc7 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -182,12 +182,27 @@ public function getFunctionPurityFromMetadata(string $functionName): ?bool { $lowerCasedFunctionName = strtolower($functionName); if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { - return !$this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName)['hasSideEffects']; + $metadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); + if (isset($metadata['pureUnlessCallableIsImpure']) && $metadata['pureUnlessCallableIsImpure']) { + return null; + } + return !$metadata['hasSideEffects']; } return null; } + public function getFunctionPureUnlessCallableIsImpureFromMetadata(string $functionName): bool + { + $lowerCasedFunctionName = strtolower($functionName); + if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { + $metadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); + return isset($metadata['pureUnlessCallableIsImpure']) && $metadata['pureUnlessCallableIsImpure']; + } + + return false; + } + private function getReturnTypeFromPhpDoc(ResolvedPhpDocBlock $phpDoc): ?Type { $returnTag = $phpDoc->getReturnTag(); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101.php b/tests/PHPStan/Rules/Functions/data/bug-11101.php index d91e161fa75..fe2a5f5fbb3 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11101.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11101.php @@ -17,4 +17,21 @@ public function doFoo(array $array): void }, 0); } + /** + * @param array $array + */ + public function doBar(array $array, callable $callback): void + { + // These should NOT be reported because the callback might be impure + array_filter($array, $callback); + array_map($callback, $array); + array_reduce($array, $callback, 0); + + // Impure closure should not be reported + array_filter($array, function ($v) { + echo $v; + return $v > 0; + }); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php index 14830cd7e93..a01521df507 100644 --- a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php +++ b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php @@ -13,4 +13,19 @@ public function doFoo(array $array): void myFilter($array, fn ($v) => $v > 5); } + /** + * @param array $array + */ + public function doBar(array $array, callable $callback): void + { + // Should NOT be reported - unknown callable might be impure + myFilter($array, $callback); + + // Should NOT be reported - closure is impure + myFilter($array, function ($v) { + echo $v; + return $v > 0; + }); + } + } From 0953cfc092707776273bbc543162d350c1924247 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 21:03:48 +0000 Subject: [PATCH 4/7] Store pureUnlessCallableIsImpure as array of parameter names instead of boolean For functions with multiple callback parameters (e.g. array_udiff_uassoc with data_comp_func and key_comp_func), the metadata now specifies exactly which parameters are the callable ones that affect purity. Co-Authored-By: Claude Opus 4.6 --- bin/functionMetadata_original.php | 34 +++++++++---------- bin/generate-function-metadata.php | 7 ++-- resources/functionMetadata.php | 34 +++++++++---------- .../FunctionSignatureMapProvider.php | 10 +++--- .../NativeFunctionReflectionProvider.php | 5 +-- .../SignatureMap/Php8SignatureMapProvider.php | 4 +-- .../SignatureMap/SignatureMapProvider.php | 4 +-- .../SignatureMap/FunctionMetadataTest.php | 2 +- 8 files changed, 51 insertions(+), 49 deletions(-) diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index c27a8f994f7..c31966eddf4 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -20,8 +20,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], - 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -30,24 +30,24 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func']], + 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], - 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], 'array_intersect_key' => ['hasSideEffects' => false], - 'array_intersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_intersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_compare_func']], + 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_compare_func']], 'array_key_first' => ['hasSideEffects' => false], 'array_key_last' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], - 'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -55,19 +55,19 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], - 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], '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, 'pureUnlessCallableIsImpure' => true], - 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_udiff' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func']], + 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], + 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func', 'key_comp_func']], + 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], + 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], + 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func', 'key_compare_func']], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index 84078393319..c6b6470fee7 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)) { @@ -186,8 +186,9 @@ public function enterNode(Node $node) $content = ''; foreach ($metadata as $name => $meta) { $pairs = sprintf('%s => %s', var_export('hasSideEffects', true), var_export($meta['hasSideEffects'], true)); - if (isset($meta['pureUnlessCallableIsImpure']) && $meta['pureUnlessCallableIsImpure']) { - $pairs .= sprintf(', %s => %s', var_export('pureUnlessCallableIsImpure', true), var_export(true, true)); + if (isset($meta['pureUnlessCallableIsImpure']) && count($meta['pureUnlessCallableIsImpure']) > 0) { + $items = implode(', ', array_map(static fn (string $s) => var_export($s, true), $meta['pureUnlessCallableIsImpure'])); + $pairs .= sprintf(', %s => [%s]', var_export('pureUnlessCallableIsImpure', true), $items); } $content .= sprintf( "\t%s => [%s],\n", diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 69db8642c5a..5a7404a79eb 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -725,8 +725,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], - 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -735,27 +735,27 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func']], + 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], - 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], '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, 'pureUnlessCallableIsImpure' => true], - 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_intersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_compare_func']], + 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_compare_func']], '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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -763,7 +763,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], - 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], @@ -771,12 +771,12 @@ 'array_shift' => ['hasSideEffects' => true], 'array_slice' => ['hasSideEffects' => false], 'array_sum' => ['hasSideEffects' => false], - 'array_udiff' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], - 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true], + 'array_udiff' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func']], + 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], + 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func', 'key_comp_func']], + 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], + 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], + 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func', 'key_compare_func']], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], diff --git a/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php index bf43f72bade..40aa0e352a6 100644 --- a/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php +++ b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php @@ -28,7 +28,7 @@ final class FunctionSignatureMapProvider implements SignatureMapProvider /** @var array */ private static array $signatureMaps = []; - /** @var array|null */ + /** @var array}>|null */ private static ?array $functionMetadata = null; public function __construct( @@ -135,7 +135,7 @@ public function hasFunctionMetadata(string $name): bool } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} */ public function getMethodMetadata(string $className, string $methodName): array { @@ -143,7 +143,7 @@ public function getMethodMetadata(string $className, string $methodName): array } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} */ public function getFunctionMetadata(string $functionName): array { @@ -157,12 +157,12 @@ public function getFunctionMetadata(string $functionName): array } /** - * @return array + * @return array}> */ private static function getFunctionMetadataMap(): array { if (self::$functionMetadata === null) { - /** @var array $metadata */ + /** @var array}> $metadata */ $metadata = require __DIR__ . '/../../../resources/functionMetadata.php'; self::$functionMetadata = array_change_key_case($metadata, CASE_LOWER); } diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 74096012fc7..f17361b2cf6 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -24,6 +24,7 @@ use PHPStan\Type\TypehintHelper; use function array_key_exists; use function array_map; +use function count; use function str_contains; use function strtolower; @@ -183,7 +184,7 @@ public function getFunctionPurityFromMetadata(string $functionName): ?bool $lowerCasedFunctionName = strtolower($functionName); if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { $metadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); - if (isset($metadata['pureUnlessCallableIsImpure']) && $metadata['pureUnlessCallableIsImpure']) { + if (isset($metadata['pureUnlessCallableIsImpure']) && count($metadata['pureUnlessCallableIsImpure']) > 0) { return null; } return !$metadata['hasSideEffects']; @@ -197,7 +198,7 @@ public function getFunctionPureUnlessCallableIsImpureFromMetadata(string $functi $lowerCasedFunctionName = strtolower($functionName); if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { $metadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); - return isset($metadata['pureUnlessCallableIsImpure']) && $metadata['pureUnlessCallableIsImpure']; + return isset($metadata['pureUnlessCallableIsImpure']) && count($metadata['pureUnlessCallableIsImpure']) > 0; } return false; diff --git a/src/Reflection/SignatureMap/Php8SignatureMapProvider.php b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php index c06754ff88a..24e21bbb71e 100644 --- a/src/Reflection/SignatureMap/Php8SignatureMapProvider.php +++ b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php @@ -385,7 +385,7 @@ public function hasFunctionMetadata(string $name): bool } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} */ public function getMethodMetadata(string $className, string $methodName): array { @@ -393,7 +393,7 @@ public function getMethodMetadata(string $className, string $methodName): array } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} */ public function getFunctionMetadata(string $functionName): array { diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php index 7f25bde4a86..898b485cbde 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, pureUnlessCallableIsImpure?: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} */ public function getMethodMetadata(string $className, string $methodName): array; /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: bool} + * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} */ public function getFunctionMetadata(string $functionName): array; diff --git a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php index 9c139a1fccc..3694c18abd1 100644 --- a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php +++ b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php @@ -18,7 +18,7 @@ public function testSchema(): void $processor->process(Expect::arrayOf( Expect::structure([ 'hasSideEffects' => Expect::bool()->required(), - 'pureUnlessCallableIsImpure' => Expect::bool(), + 'pureUnlessCallableIsImpure' => Expect::listOf(Expect::string()), ])->required(), )->required(), $data); } From ce47eba556f375228e08958bf9ae2007186f983c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 21:38:03 +0000 Subject: [PATCH 5/7] Remove pureUnlessCallableIsImpure from function metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `pureUnlessCallableIsImpure` key in function metadata is not needed for built-in functions. Just `hasSideEffects => false` is sufficient because callback impure points are already correctly propagated via `callCallbackImmediately()` in NodeScopeResolver — when a callable parameter has `isImmediatelyInvokedCallable() = Maybe` (the default for native functions), it falls back to checking if the parameter type is callable, which returns true. So impure closures passed to array_filter etc. already prevent NoopExpressionNode from being created, avoiding false positives. The `@phpstan-pure-unless-callable-is-impure` PHPDoc tag support is kept for user-defined functions, where it provides semantics that cannot be achieved with `@phpstan-pure` alone: it sets `hasSideEffects() = No` (so the "no effect" rule fires) without setting `isPure() = Yes` (so FunctionPurityCheck doesn't validate the body, which would flag callback invocations as impure points). Co-Authored-By: Claude Opus 4.6 --- bin/functionMetadata_original.php | 34 +++++++++---------- bin/generate-function-metadata.php | 12 +++---- resources/functionMetadata.php | 34 +++++++++---------- .../BetterReflectionProvider.php | 3 -- .../FunctionSignatureMapProvider.php | 10 +++--- .../NativeFunctionReflectionProvider.php | 18 +--------- .../SignatureMap/Php8SignatureMapProvider.php | 4 +-- .../SignatureMap/SignatureMapProvider.php | 4 +-- .../SignatureMap/FunctionMetadataTest.php | 1 - 9 files changed, 48 insertions(+), 72 deletions(-) diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index c31966eddf4..f22b1789352 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -20,8 +20,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], - 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], - 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_all' => ['hasSideEffects' => false], + 'array_any' => ['hasSideEffects' => false], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -30,24 +30,24 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func']], - 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], + 'array_diff_uassoc' => ['hasSideEffects' => false], + 'array_diff_ukey' => ['hasSideEffects' => false], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], - 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], - 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], - 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_filter' => ['hasSideEffects' => false], + 'array_find' => ['hasSideEffects' => false], + 'array_find_key' => ['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, 'pureUnlessCallableIsImpure' => ['key_compare_func']], - 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_compare_func']], + 'array_intersect_uassoc' => ['hasSideEffects' => false], + 'array_intersect_ukey' => ['hasSideEffects' => false], 'array_key_first' => ['hasSideEffects' => false], 'array_key_last' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], - 'array_map' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_map' => ['hasSideEffects' => false], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -55,19 +55,19 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], - 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_reduce' => ['hasSideEffects' => false], '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, 'pureUnlessCallableIsImpure' => ['data_comp_func']], - 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], - 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func', 'key_comp_func']], - 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], - 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], - 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func', 'key_compare_func']], + '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_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index c6b6470fee7..d161d374e46 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,15 +185,11 @@ public function enterNode(Node $node) php; $content = ''; foreach ($metadata as $name => $meta) { - $pairs = sprintf('%s => %s', var_export('hasSideEffects', true), var_export($meta['hasSideEffects'], true)); - if (isset($meta['pureUnlessCallableIsImpure']) && count($meta['pureUnlessCallableIsImpure']) > 0) { - $items = implode(', ', array_map(static fn (string $s) => var_export($s, true), $meta['pureUnlessCallableIsImpure'])); - $pairs .= sprintf(', %s => [%s]', var_export('pureUnlessCallableIsImpure', true), $items); - } $content .= sprintf( - "\t%s => [%s],\n", + "\t%s => [%s => %s],\n", var_export($name, true), - $pairs, + var_export('hasSideEffects', true), + var_export($meta['hasSideEffects'], true), ); } diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 5a7404a79eb..98133f25ef7 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -725,8 +725,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], - 'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], - 'array_any' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_all' => ['hasSideEffects' => false], + 'array_any' => ['hasSideEffects' => false], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -735,27 +735,27 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func']], - 'array_diff_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], + 'array_diff_uassoc' => ['hasSideEffects' => false], + 'array_diff_ukey' => ['hasSideEffects' => false], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], - 'array_filter' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], - 'array_find' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], - 'array_find_key' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_filter' => ['hasSideEffects' => false], + 'array_find' => ['hasSideEffects' => false], + 'array_find_key' => ['hasSideEffects' => false], '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, 'pureUnlessCallableIsImpure' => ['key_compare_func']], - 'array_intersect_ukey' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_compare_func']], + 'array_intersect_uassoc' => ['hasSideEffects' => false], + 'array_intersect_ukey' => ['hasSideEffects' => false], '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' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_map' => ['hasSideEffects' => false], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -763,7 +763,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], - 'array_reduce' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['callback']], + 'array_reduce' => ['hasSideEffects' => false], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], @@ -771,12 +771,12 @@ 'array_shift' => ['hasSideEffects' => true], 'array_slice' => ['hasSideEffects' => false], 'array_sum' => ['hasSideEffects' => false], - 'array_udiff' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func']], - 'array_udiff_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['key_comp_func']], - 'array_udiff_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_comp_func', 'key_comp_func']], - 'array_uintersect' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], - 'array_uintersect_assoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func']], - 'array_uintersect_uassoc' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => ['data_compare_func', 'key_compare_func']], + '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_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 607d1fdfd26..b032933543f 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -329,9 +329,6 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection if ($isPure === null && !$isPureUnlessCallableIsImpure) { $isPure = $this->nativeFunctionReflectionProvider->getFunctionPurityFromMetadata($functionName); - if ($isPure === null) { - $isPureUnlessCallableIsImpure = $this->nativeFunctionReflectionProvider->getFunctionPureUnlessCallableIsImpureFromMetadata($functionName); - } } return $this->functionReflectionFactory->create( diff --git a/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php index 40aa0e352a6..ac8b0674225 100644 --- a/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php +++ b/src/Reflection/SignatureMap/FunctionSignatureMapProvider.php @@ -28,7 +28,7 @@ final class FunctionSignatureMapProvider implements SignatureMapProvider /** @var array */ private static array $signatureMaps = []; - /** @var array}>|null */ + /** @var array|null */ private static ?array $functionMetadata = null; public function __construct( @@ -135,7 +135,7 @@ public function hasFunctionMetadata(string $name): bool } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} + * @return array{hasSideEffects: bool} */ public function getMethodMetadata(string $className, string $methodName): array { @@ -143,7 +143,7 @@ public function getMethodMetadata(string $className, string $methodName): array } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} + * @return array{hasSideEffects: bool} */ public function getFunctionMetadata(string $functionName): array { @@ -157,12 +157,12 @@ public function getFunctionMetadata(string $functionName): array } /** - * @return array}> + * @return array */ private static function getFunctionMetadataMap(): array { if (self::$functionMetadata === null) { - /** @var array}> $metadata */ + /** @var array $metadata */ $metadata = require __DIR__ . '/../../../resources/functionMetadata.php'; self::$functionMetadata = array_change_key_case($metadata, CASE_LOWER); } diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index f17361b2cf6..deb77203f7e 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -24,7 +24,6 @@ use PHPStan\Type\TypehintHelper; use function array_key_exists; use function array_map; -use function count; use function str_contains; use function strtolower; @@ -183,27 +182,12 @@ public function getFunctionPurityFromMetadata(string $functionName): ?bool { $lowerCasedFunctionName = strtolower($functionName); if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { - $metadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); - if (isset($metadata['pureUnlessCallableIsImpure']) && count($metadata['pureUnlessCallableIsImpure']) > 0) { - return null; - } - return !$metadata['hasSideEffects']; + return !$this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName)['hasSideEffects']; } return null; } - public function getFunctionPureUnlessCallableIsImpureFromMetadata(string $functionName): bool - { - $lowerCasedFunctionName = strtolower($functionName); - if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { - $metadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); - return isset($metadata['pureUnlessCallableIsImpure']) && count($metadata['pureUnlessCallableIsImpure']) > 0; - } - - return false; - } - private function getReturnTypeFromPhpDoc(ResolvedPhpDocBlock $phpDoc): ?Type { $returnTag = $phpDoc->getReturnTag(); diff --git a/src/Reflection/SignatureMap/Php8SignatureMapProvider.php b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php index 24e21bbb71e..19884d70378 100644 --- a/src/Reflection/SignatureMap/Php8SignatureMapProvider.php +++ b/src/Reflection/SignatureMap/Php8SignatureMapProvider.php @@ -385,7 +385,7 @@ public function hasFunctionMetadata(string $name): bool } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} + * @return array{hasSideEffects: bool} */ public function getMethodMetadata(string $className, string $methodName): array { @@ -393,7 +393,7 @@ public function getMethodMetadata(string $className, string $methodName): array } /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} + * @return array{hasSideEffects: bool} */ public function getFunctionMetadata(string $functionName): array { diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php index 898b485cbde..3999d919b8f 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, pureUnlessCallableIsImpure?: list} + * @return array{hasSideEffects: bool} */ public function getMethodMetadata(string $className, string $methodName): array; /** - * @return array{hasSideEffects: bool, pureUnlessCallableIsImpure?: list} + * @return array{hasSideEffects: bool} */ public function getFunctionMetadata(string $functionName): array; diff --git a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php index 3694c18abd1..24ef8431eee 100644 --- a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php +++ b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php @@ -18,7 +18,6 @@ public function testSchema(): void $processor->process(Expect::arrayOf( Expect::structure([ 'hasSideEffects' => Expect::bool()->required(), - 'pureUnlessCallableIsImpure' => Expect::listOf(Expect::string()), ])->required(), )->required(), $data); } From 0d86fb8a0f1bbc29f534b36734ab840aedf66b54 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 22:00:11 +0000 Subject: [PATCH 6/7] Remove pureUnlessCallableIsImpure implementation, rely on existing @pure + @param-immediately-invoked-callable The @phpstan-pure-unless-callable-is-impure tag is not needed because the existing combination of @phpstan-pure with @param-immediately-invoked-callable and @param-later-invoked-callable already provides the correct semantics: - @pure + @param-immediately-invoked-callable: function is pure, callback impure points are propagated to caller (so impure callbacks prevent the "no effect" report) - @pure + @param-later-invoked-callable: function is pure, callback impure points are not propagated (function call is always reported as no effect) For built-in functions like array_filter, hasSideEffects => false in metadata is sufficient because NodeScopeResolver's callCallbackImmediately() already handles callback impurity propagation by default when the parameter type is callable. Added tests for custom @pure functions with both @param-immediately-invoked-callable and @param-later-invoked-callable covering pure, impure, and unknown callbacks. Co-Authored-By: Claude Opus 4.6 --- src/PhpDoc/PhpDocNodeResolver.php | 16 ----- src/PhpDoc/ResolvedPhpDocBlock.php | 46 ------------- .../BetterReflectionProvider.php | 8 +-- src/Reflection/FunctionReflectionFactory.php | 1 - src/Reflection/Php/PhpFunctionReflection.php | 4 -- ...ionStatementWithoutSideEffectsRuleTest.php | 21 ++++-- .../Functions/data/bug-11101-custom-pure.php | 68 +++++++++++++++++++ ...e-unless-callable-is-impure-definition.php | 14 ---- .../data/pure-unless-callable-is-impure.php | 31 --------- 9 files changed, 85 insertions(+), 124 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11101-custom-pure.php delete mode 100644 tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php delete mode 100644 tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index d647ba0c7d4..1e88dce58aa 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -673,22 +673,6 @@ public function resolveIsImpure(PhpDocNode $phpDocNode): bool return false; } - /** - * @return array - */ - public function resolvePureUnlessCallableIsImpure(PhpDocNode $phpDocNode): array - { - $parameters = []; - foreach (['@pure-unless-callable-is-impure', '@phpstan-pure-unless-callable-is-impure'] as $tagName) { - foreach ($phpDocNode->getPureUnlessCallableIsImpureTagValues($tagName) as $tagValue) { - $parameterName = substr($tagValue->parameterName, 1); - $parameters[$parameterName] = true; - } - } - - return $parameters; - } - public function resolveAllMethodsPure(PhpDocNode $phpDocNode): bool { return count($phpDocNode->getTagsByName('@phpstan-all-methods-pure')) > 0; diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index ce751a02f89..be6da4fc46d 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -139,9 +139,6 @@ final class ResolvedPhpDocBlock /** @var bool|'notLoaded'|null */ private bool|string|null $isPure = 'notLoaded'; - /** @var array|false */ - private array|false $pureUnlessCallableIsImpureParameterNames = false; - private ?bool $areAllMethodsPure = null; private ?bool $areAllMethodsImpure = null; @@ -240,7 +237,6 @@ public static function createEmpty(): self $self->isInternal = false; $self->isFinal = false; $self->isPure = null; - $self->pureUnlessCallableIsImpureParameterNames = []; $self->areAllMethodsPure = false; $self->areAllMethodsImpure = false; $self->isReadOnly = false; @@ -297,7 +293,6 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->isInternal = $this->isInternal(); $result->isFinal = $this->isFinal(); $result->isPure = self::mergePureTags($this->isPure(), $parent); - $result->pureUnlessCallableIsImpureParameterNames = self::mergePureUnlessCallableIsImpureParameterNames($this->getPureUnlessCallableIsImpureParameterNames(), $parent, $parameterMapping); $result->areAllMethodsPure = $this->areAllMethodsPure(); $result->areAllMethodsImpure = $this->areAllMethodsImpure(); $result->isReadOnly = $this->isReadOnly(); @@ -357,15 +352,6 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self $newParamsImmediatelyInvokedCallable[$parameterNameMapping[$key]] = $immediatelyInvokedCallable; } - $newPureUnlessCallableIsImpureParameterNames = []; - foreach ($this->getPureUnlessCallableIsImpureParameterNames() as $key => $value) { - if (!array_key_exists($key, $parameterNameMapping)) { - continue; - } - - $newPureUnlessCallableIsImpureParameterNames[$parameterNameMapping[$key]] = $value; - } - $paramClosureThisTags = $this->getParamClosureThisTags(); $newParamClosureThisTags = []; foreach ($paramClosureThisTags as $key => $paramClosureThisTag) { @@ -413,7 +399,6 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self $self->paramTags = $newParamTags; $self->paramOutTags = $newParamOutTags; $self->paramsImmediatelyInvokedCallable = $newParamsImmediatelyInvokedCallable; - $self->pureUnlessCallableIsImpureParameterNames = $newPureUnlessCallableIsImpureParameterNames; $self->paramClosureThisTags = $newParamClosureThisTags; $self->returnTag = $returnTag; $self->throwsTag = $this->throwsTag; @@ -599,18 +584,6 @@ public function getParamsImmediatelyInvokedCallable(): array return $this->paramsImmediatelyInvokedCallable; } - /** - * @return array - */ - public function getPureUnlessCallableIsImpureParameterNames(): array - { - if ($this->pureUnlessCallableIsImpureParameterNames === false) { - $this->pureUnlessCallableIsImpureParameterNames = $this->phpDocNodeResolver->resolvePureUnlessCallableIsImpure($this->phpDocNode); - } - - return $this->pureUnlessCallableIsImpureParameterNames; - } - /** * @return array */ @@ -1112,25 +1085,6 @@ private static function mergeOneParentParamImmediatelyInvokedCallable(array $par return $paramsImmediatelyInvokedCallable; } - /** - * @param array $parameterNames - * @return array - */ - private static function mergePureUnlessCallableIsImpureParameterNames(array $parameterNames, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array - { - $parentParameterNames = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getPureUnlessCallableIsImpureParameterNames()); - - foreach ($parentParameterNames as $name => $value) { - if (array_key_exists($name, $parameterNames)) { - continue; - } - - $parameterNames[$name] = $value; - } - - return $parameterNames; - } - /** * @param array $paramsClosureThisTags * @return array diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index b032933543f..e01467c7327 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -60,7 +60,6 @@ use function array_key_first; use function array_map; use function base64_decode; -use function count; use function in_array; use function sprintf; use function strtolower; @@ -289,7 +288,6 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $isInternal = false; $isPure = null; - $isPureUnlessCallableIsImpure = false; $asserts = Assertions::createEmpty(); $acceptsNamedArguments = true; $phpDocComment = null; @@ -314,9 +312,6 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection } $isInternal = $resolvedPhpDoc->isInternal(); $isPure = $resolvedPhpDoc->isPure(); - if ($isPure === null && count($resolvedPhpDoc->getPureUnlessCallableIsImpureParameterNames()) > 0) { - $isPureUnlessCallableIsImpure = true; - } $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); if ($resolvedPhpDoc->hasPhpDocString()) { $phpDocComment = $resolvedPhpDoc->getPhpDocString(); @@ -327,7 +322,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); } - if ($isPure === null && !$isPureUnlessCallableIsImpure) { + if ($isPure === null) { $isPure = $this->nativeFunctionReflectionProvider->getFunctionPurityFromMetadata($functionName); } @@ -342,7 +337,6 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $isInternal, $reflectionFunction->getFileName() !== false ? $reflectionFunction->getFileName() : null, $isPure, - $isPureUnlessCallableIsImpure, $asserts, $acceptsNamedArguments, $phpDocComment, diff --git a/src/Reflection/FunctionReflectionFactory.php b/src/Reflection/FunctionReflectionFactory.php index 2ad59e37617..993bf34b3b4 100644 --- a/src/Reflection/FunctionReflectionFactory.php +++ b/src/Reflection/FunctionReflectionFactory.php @@ -28,7 +28,6 @@ public function create( bool $isInternal, ?string $filename, ?bool $isPure, - bool $isPureUnlessCallableIsImpure, Assertions $asserts, bool $acceptsNamedArguments, ?string $phpDocComment, diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 5e5746c835f..7dbd7ca3c47 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -53,7 +53,6 @@ public function __construct( private bool $isInternal, private ?string $filename, private ?bool $isPure, - private bool $isPureUnlessCallableIsImpure, private Assertions $asserts, private bool $acceptsNamedArguments, private ?string $phpDocComment, @@ -198,9 +197,6 @@ public function hasSideEffects(): TrinaryLogic if ($this->isPure !== null) { return TrinaryLogic::createFromBoolean(!$this->isPure); } - if ($this->isPureUnlessCallableIsImpure) { - return TrinaryLogic::createNo(); - } return TrinaryLogic::createMaybe(); } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index 409c2d6ad07..f82c9a2ca75 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -160,13 +160,24 @@ public function testBug11101Php84(): void ]); } - public function testPureUnlessCallableIsImpure(): void + public function testBug11101CustomPure(): void { - require_once __DIR__ . '/data/pure-unless-callable-is-impure-definition.php'; - $this->analyse([__DIR__ . '/data/pure-unless-callable-is-impure.php'], [ + $this->analyse([__DIR__ . '/data/bug-11101-custom-pure.php'], [ [ - 'Call to function PureUnlessCallableIsImpure\myFilter() on a separate line has no effect.', - 13, + 'Call to function Bug11101CustomPure\pureWithImmediateCallback() on a separate line has no effect.', + 38, + ], + [ + 'Call to function Bug11101CustomPure\pureWithLaterCallback() on a separate line has no effect.', + 56, + ], + [ + 'Call to function Bug11101CustomPure\pureWithLaterCallback() on a separate line has no effect.', + 59, + ], + [ + 'Call to function Bug11101CustomPure\pureWithLaterCallback() on a separate line has no effect.', + 65, ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101-custom-pure.php b/tests/PHPStan/Rules/Functions/data/bug-11101-custom-pure.php new file mode 100644 index 00000000000..4c57f1bccf9 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11101-custom-pure.php @@ -0,0 +1,68 @@ + $array + * @param callable(int): bool $callback + * @param-immediately-invoked-callable $callback + * @return array + */ +function pureWithImmediateCallback(array $array, callable $callback): array +{ + return array_filter($array, $callback); +} + +/** + * @phpstan-pure + * @param array $array + * @param callable(int): bool $callback + * @param-later-invoked-callable $callback + * @return array + */ +function pureWithLaterCallback(array $array, callable $callback): array +{ + return $array; +} + +class Foo +{ + + /** + * @param array $array + */ + public function testImmediateCallback(array $array, callable $callback): void + { + // Pure callback - should be reported + pureWithImmediateCallback($array, fn ($v) => $v > 5); + + // Impure callback - should NOT be reported + pureWithImmediateCallback($array, function ($v) { + echo $v; + return $v > 0; + }); + + // Unknown callback - should NOT be reported + pureWithImmediateCallback($array, $callback); + } + + /** + * @param array $array + */ + public function testLaterCallback(array $array, callable $callback): void + { + // Pure callback - should be reported + pureWithLaterCallback($array, fn ($v) => $v > 5); + + // Impure callback - should be reported (later-invoked, callback impurity doesn't matter at call site) + pureWithLaterCallback($array, function ($v) { + echo $v; + return $v > 0; + }); + + // Unknown callback - should be reported (later-invoked, callback impurity doesn't matter at call site) + pureWithLaterCallback($array, $callback); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php deleted file mode 100644 index 4bf302a7e7b..00000000000 --- a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure-definition.php +++ /dev/null @@ -1,14 +0,0 @@ - $array - * @param callable(int): bool $callback - * @return array - */ -function myFilter(array $array, callable $callback): array -{ - return array_filter($array, $callback); -} diff --git a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php deleted file mode 100644 index a01521df507..00000000000 --- a/tests/PHPStan/Rules/Functions/data/pure-unless-callable-is-impure.php +++ /dev/null @@ -1,31 +0,0 @@ - $array - */ - public function doFoo(array $array): void - { - myFilter($array, fn ($v) => $v > 5); - } - - /** - * @param array $array - */ - public function doBar(array $array, callable $callback): void - { - // Should NOT be reported - unknown callable might be impure - myFilter($array, $callback); - - // Should NOT be reported - closure is impure - myFilter($array, function ($v) { - echo $v; - return $v > 0; - }); - } - -} From eba6f2038d82c24e426e6f9d5b18488e4e59b70a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 2 May 2026 09:46:11 +0000 Subject: [PATCH 7/7] Remove getFunctionPurityFromMetadata, use lint directives in array-find tests instead of polyfills The polyfill definitions in array-find.php and array-find-key.php caused these functions to be resolved through getCustomFunction() instead of findFunctionReflection(). Rather than adding a metadata fallback in getCustomFunction(), add // lint >= 8.4 directives so the tests only run on PHP 8.4+ where the native functions exist. Co-Authored-By: Claude Opus 4.6 --- .../BetterReflectionProvider.php | 4 ---- .../NativeFunctionReflectionProvider.php | 10 -------- .../PHPStan/Analyser/nsrt/array-find-key.php | 24 +------------------ tests/PHPStan/Analyser/nsrt/array-find.php | 24 +------------------ 4 files changed, 2 insertions(+), 60 deletions(-) diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index e01467c7327..b514bfa1a93 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -322,10 +322,6 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); } - if ($isPure === null) { - $isPure = $this->nativeFunctionReflectionProvider->getFunctionPurityFromMetadata($functionName); - } - return $this->functionReflectionFactory->create( $reflectionFunction, $templateTypeMap, diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index deb77203f7e..a2f12f6daad 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -178,16 +178,6 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef return $functionReflection; } - public function getFunctionPurityFromMetadata(string $functionName): ?bool - { - $lowerCasedFunctionName = strtolower($functionName); - if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { - return !$this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName)['hasSideEffects']; - } - - return null; - } - private function getReturnTypeFromPhpDoc(ResolvedPhpDocBlock $phpDoc): ?Type { $returnTag = $phpDoc->getReturnTag(); diff --git a/tests/PHPStan/Analyser/nsrt/array-find-key.php b/tests/PHPStan/Analyser/nsrt/array-find-key.php index 5caf828f531..20ea054ab2d 100644 --- a/tests/PHPStan/Analyser/nsrt/array-find-key.php +++ b/tests/PHPStan/Analyser/nsrt/array-find-key.php @@ -1,26 +1,4 @@ - $array - * @param callable(mixed, array-key=): mixed $callback - * @return ?array-key - */ - function array_find_key(array $array, callable $callback) - { - foreach ($array as $key => $value) { - if ($callback($value, $key)) { // @phpstan-ignore if.condNotBoolean - return $key; - } - } - - return null; - } - } - -} += 8.4 namespace ArrayFindKey { diff --git a/tests/PHPStan/Analyser/nsrt/array-find.php b/tests/PHPStan/Analyser/nsrt/array-find.php index f3b5b0b8222..62d5334f891 100644 --- a/tests/PHPStan/Analyser/nsrt/array-find.php +++ b/tests/PHPStan/Analyser/nsrt/array-find.php @@ -1,26 +1,4 @@ - $array - * @param callable(mixed, array-key=): mixed $callback - * @return mixed - */ - function array_find(array $array, callable $callback) - { - foreach ($array as $key => $value) { - if ($callback($value, $key)) { // @phpstan-ignore if.condNotBoolean - return $value; - } - } - - return null; - } - } - -} += 8.4 namespace ArrayFind {