Skip to content
7 changes: 7 additions & 0 deletions bin/functionMetadata_original.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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],
Expand All @@ -42,13 +47,15 @@
'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],
'array_pop' => ['hasSideEffects' => true],
'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],
Expand Down
7 changes: 7 additions & 0 deletions resources/functionMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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],
Expand All @@ -750,13 +755,15 @@
'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],
'array_pop' => ['hasSideEffects' => true],
'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],
Expand Down
4 changes: 4 additions & 0 deletions src/Reflection/BetterReflection/BetterReflectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection
$phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags();
}

if ($isPure === null) {
$isPure = $this->nativeFunctionReflectionProvider->getFunctionPurityFromMetadata($functionName);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This shouldn't be needed, function metadata are already correclty handled

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

All 11980 tests pass (79552 assertions) and make phpstan reports no errors. There are no code changes to commit since the files are back to their original state.

The reviewer's suggestion to remove the getFunctionPurityFromMetadata call is incorrect. Here's the explanation:

The metadata lookup in getCustomFunction() IS needed because some functions with metadata entries can be resolved through getCustomFunction() instead of findFunctionReflection(). Specifically, array_find and array_find_key have polyfill-style definitions in test data files (tests/PHPStan/Analyser/nsrt/array-find.php and array-find-key.php). When BetterReflection resolves these functions, it finds them in those files rather than as internal PHP functions. NativeFunctionReflectionProvider::findFunctionReflection() then returns null at line 79 (file is not internal and path doesn't contain "polyfill"), so the function falls through to getCustomFunction() where the metadata lookup is the only way to determine purity.

The same scenario occurs in real-world codebases when users have polyfill definitions for newer PHP functions — BetterReflection resolves the function from the polyfill file, bypassing the normal findFunctionReflection() metadata check.

I verified this by temporarily removing the code and seeing that the testBug11101Php84 test fails: array_find and array_find_key are no longer reported as pure, while array_any and array_all (which don't have polyfill files) continue to work.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Those polyfill in test should be removed and the test should be updated (with a // lint comment ?) to run on the appropriate php versions.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. Here's what was changed to address VincentLanglet's review:

The reviewer was right — the getFunctionPurityFromMetadata fallback in BetterReflectionProvider::getCustomFunction() was only needed because test files (array-find.php and array-find-key.php) contained polyfill definitions that caused array_find/array_find_key to be resolved through getCustomFunction() instead of findFunctionReflection().

Fix:

  1. Removed polyfill definitions from tests/PHPStan/Analyser/nsrt/array-find.php and array-find-key.php, added // lint >= 8.4 so these tests only run on PHP 8.4+ where the native functions exist
  2. Removed the getFunctionPurityFromMetadata() method from NativeFunctionReflectionProvider
  3. Removed the metadata lookup call from BetterReflectionProvider::getCustomFunction()

All 11980 tests pass (79552 assertions) and make phpstan reports no errors.


return $this->functionReflectionFactory->create(
$reflectionFunction,
$templateTypeMap,
Expand Down
10 changes: 10 additions & 0 deletions src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Command/data/file-without-errors.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

array_filter([0, 1, 2]);
$a = array_filter([0, 1, 2]);
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,69 @@ public function testFirstClassCallables(): void
]);
}

public function testBug11101(): void
{
$this->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,
],
]);
}

public function testBug11101CustomPure(): void
{
$this->analyse([__DIR__ . '/data/bug-11101-custom-pure.php'], [
[
'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,
],
]);
}

#[RequiresPhp('>= 8.5.0')]
public function testPipeOperator(): void
{
Expand Down
68 changes: 68 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-11101-custom-pure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Bug11101CustomPure;

/**
* @phpstan-pure
* @param array<int> $array
* @param callable(int): bool $callback
* @param-immediately-invoked-callable $callback
* @return array<int>
*/
function pureWithImmediateCallback(array $array, callable $callback): array
{
return array_filter($array, $callback);
}

/**
* @phpstan-pure
* @param array<int> $array
* @param callable(int): bool $callback
* @param-later-invoked-callable $callback
* @return array<int>
*/
function pureWithLaterCallback(array $array, callable $callback): array
{
return $array;
}

class Foo
{

/**
* @param array<int> $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<int> $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);
}

}
19 changes: 19 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-11101-php84.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php // lint >= 8.4

namespace Bug11101Php84;

class Foo
{

/**
* @param array<int> $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);
}

}
37 changes: 37 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-11101.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Bug11101;

class Foo
{

/**
* @param array<int> $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);
}

/**
* @param array<int> $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;
});
}

}
Loading