Skip to content

Mark array_filter(), array_map(), array_reduce() and PHP 8.4 array_find/array_any/array_all as pure in function metadata#5580

Closed
phpstan-bot wants to merge 7 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-pgvhhg1
Closed

Mark array_filter(), array_map(), array_reduce() and PHP 8.4 array_find/array_any/array_all as pure in function metadata#5580
phpstan-bot wants to merge 7 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-pgvhhg1

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 1, 2026

Summary

array_filter(), array_map(), and array_reduce() were not marked as pure functions in PHPStan's function metadata, so calling them on a separate line without using the return value was not reported as "has no effect." Additionally, PHP 8.4 callback-accepting array functions (array_find, array_find_key, array_any, array_all) had the same issue, compounded by the fact that functions only available through stubs (not in the signature map) never had their metadata checked.

Changes

  • Added array_filter, array_map, array_reduce to bin/functionMetadata_original.php with hasSideEffects => false (analogous to existing entries like array_udiff, array_uintersect, etc.)
  • Added PHP 8.4 callback-accepting array functions (array_all, array_any, array_find, array_find_key) to the metadata
  • Fixed BetterReflectionProvider::getCustomFunction() (src/Reflection/BetterReflection/BetterReflectionProvider.php) to check function metadata when $isPure from PHPDoc is null. Previously, only NativeFunctionReflectionProvider checked the metadata, but it only handles functions present in the signature map. Functions resolved through stubs (like the PHP 8.4 array functions) went through getCustomFunction() which never checked the metadata.
  • Added NativeFunctionReflectionProvider::getFunctionPurityFromMetadata() (src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php) to expose the metadata lookup
  • Regenerated resources/functionMetadata.php
  • Updated tests/PHPStan/Command/data/file-without-errors.php to assign the array_filter() result, since it's now correctly reported as having no side effects

Analogous cases probed

  • array_walk, usort, uasort, uksort, sort, rsort, asort, arsort, ksort, krsort - correctly NOT in metadata (they modify arrays in place)
  • array_pop, array_push, array_shift, array_unshift, array_splice - correctly marked as hasSideEffects => true
  • array_udiff, array_uintersect and their variants - already correctly marked as pure despite accepting callbacks
  • preg_replace, str_replace, str_ireplace - not added (have &$count by-ref parameters; separate concern)

Root cause

Two issues:

  1. array_filter, array_map, array_reduce were simply missing from functionMetadata_original.php. Since the metadata defaults to hasSideEffects => Maybe for unlisted functions, they were never reported by CallToFunctionStatementWithoutSideEffectsRule.
  2. Functions resolved through JetBrains stubs (not in the signature map) went through BetterReflectionProvider::getCustomFunction(), which only checked PHPDoc for @phpstan-pure. It never consulted the function metadata, making metadata entries for stubs-only functions (like PHP 8.4 functions) ineffective.

Test

  • Added tests/PHPStan/Rules/Functions/data/bug-11101.php testing array_filter, array_map, array_reduce
  • Added tests/PHPStan/Rules/Functions/data/bug-11101-php84.php testing array_find, array_find_key, array_any, array_all (requires PHP >= 8.4)
  • Updated existing test tests/PHPStan/Command/data/file-without-errors.php

Fixes phpstan/phpstan#11101
Fixes phpstan/phpstan#11100

…rray_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)
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

This is wrong because those methods are pure only if the callable passed as arguments is pure.

We need to implements the new phpdoc tag

@phpstan-pure-unless-callable-is-impure $callable

(also supported as @pure-unless-callable-is-impure $callable)

…ck-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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All changes have been committed and pushed. Here's a summary of what was done to address the review:

The reviewer's concern: Marking array_filter(), array_map(), array_reduce(), etc. as unconditionally hasSideEffects => false is wrong because they're only pure when their callable argument is also pure.

What was implemented:

  1. @phpstan-pure-unless-callable-is-impure tag support - The phpdoc-parser already parsed this tag; this commit wires it up in PHPStan core:

    • PhpDocNodeResolver::resolvePureUnlessCallableIsImpure() resolves the tag
    • ResolvedPhpDocBlock stores, merges (with inheritance), and exposes the parameter names via getPureUnlessCallableIsImpureParameterNames()
    • ResolvedPhpDocBlock::isPure() returns true when the tag is present (the function itself is pure)
  2. Function metadata extended - The SignatureMapProvider interface and implementations now support an optional pureUnlessCallableIsImpure key alongside hasSideEffects. All callback-accepting array functions (array_filter, array_map, array_reduce, array_find, array_find_key, array_any, array_all, plus existing array_udiff/array_uintersect variants) now carry this flag.

  3. Tests - Added a test for user-defined functions with @phpstan-pure-unless-callable-is-impure, plus the existing tests continue to pass.

  4. Correctness - The behavior is correct because PHPStan's processArgs mechanism already propagates impure points from callback arguments: if an impure closure is passed to array_filter, its impure points prevent the NoopExpressionNode from being emitted, so no false "has no effect" error is reported.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

if (count($this->getPureUnlessCallableIsImpureParameterNames()) > 0) {
				$this->isPure = true;
				return $this->isPure;
			}

does not seems to be the right way to do it since

array_map($someImpureCallback, $array);

should be considered as impure

…ly 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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The fix is committed and pushed. Here's what was done:

The reviewer's concern was valid. Setting isPure = true in ResolvedPhpDocBlock::isPure() when @phpstan-pure-unless-callable-is-impure is present was semantically wrong — it would cause FunctionPurityCheck to validate the function body as purely pure, incorrectly flagging callback invocations as impure points inside the function.

The fix introduces a separate $isPureUnlessCallableIsImpure flag on PhpFunctionReflection that decouples hasSideEffects() from isPure():

  • hasSideEffects() returns No → the function itself generates no impure point at call sites (so the "no effect" rule fires for pure callbacks)
  • isPure() returns MaybeFunctionPurityCheck does not apply pure body checks

The callback arguments' impure points are still correctly propagated through processArgs() — when an impure callback is passed (e.g., a closure with echo, or a generic callable variable), the overall expression has impure points and NoopExpressionNode is not emitted, preventing false positives.

Comment thread bin/functionMetadata_original.php Outdated
'apcu_key_info' => ['hasSideEffects' => true],
'apcu_sma_info' => ['hasSideEffects' => true],
'apcu_store' => ['hasSideEffects' => true],
'array_all' => ['hasSideEffects' => false, 'pureUnlessCallableIsImpure' => true],
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.

In case some function has multiple callback parameter, the value associated to the pureUnlessCallableIsImpure key should be an array of parameter name instead.

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.

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

The pureUnlessCallableIsImpure metadata value is now an array of parameter names instead of a boolean. This properly handles functions with multiple callback parameters. For example:

  • array_filter['callback']
  • array_udiff_uassoc['data_comp_func', 'key_comp_func']
  • array_uintersect_uassoc['data_compare_func', 'key_compare_func']

Changes across 8 files:

  • bin/functionMetadata_original.php — all 17 entries updated with specific parameter names
  • bin/generate-function-metadata.php — generator emits array syntax
  • resources/functionMetadata.php — regenerated
  • SignatureMapProvider interface + FunctionSignatureMapProvider + Php8SignatureMapProvider — type annotations updated from bool to list<string>
  • NativeFunctionReflectionProvider — metadata checks use count() instead of truthiness
  • FunctionMetadataTest — schema validates listOf(string) instead of bool

…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 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Do we really need pureUnlessCallableIsImpure or could we just rely on @pure, @impure, @param-immediately-invoked-callable and @param-later-invoked-callable behavior ?

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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of what was done to address VincentLanglet's review:

The reviewer asked: Do we really need pureUnlessCallableIsImpure or could we just rely on @pure, @impure, @param-immediately-invoked-callable and @param-later-invoked-callable?

Answer: For function metadata (built-in functions), no — it's not needed. The pureUnlessCallableIsImpure key was removed from all metadata entries. Just hasSideEffects => false is sufficient because:

  1. hasSideEffects => false makes SimpleImpurePoint::createFromVariant() return null — no impure point for the function call itself
  2. Callback impure points are already correctly propagated via callCallbackImmediately() in NodeScopeResolver — when isImmediatelyInvokedCallable() returns Maybe (the default), it checks if the parameter type is callable and returns true, so impure closures/callables have their impure points merged into the caller
  3. This was already working for array_udiff, array_uintersect, etc. which had hasSideEffects => false without any callback tracking before this PR

For user-defined functions, the @phpstan-pure-unless-callable-is-impure PHPDoc tag IS still needed (kept in this commit) because @phpstan-pure + @param-immediately-invoked-callable doesn't achieve the same semantics:

  • @phpstan-pure sets isPure() = Yes, which triggers FunctionPurityCheck body validation (line 51 of FunctionPurityCheck.php), which would flag $callback() invocations inside the body as impure points
  • @phpstan-pure-unless-callable-is-impure sets hasSideEffects() = No without isPure() = Yes, so body validation is skipped

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

remove the whole phpstan-pure-unless-callable-is-impure implementation and test then.

Just test array_filter (and so) with

  • a pure cb
  • an impure cb
  • an unknown purity cb

Also, do the same with

  • A custom method with @pure and @param-later-invoked-callable
  • A custom method with @pure and @param-immediatly-invoked-callable

 + @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 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what was done to address VincentLanglet's review:

Removed the entire @phpstan-pure-unless-callable-is-impure implementation:

  • PhpDocNodeResolver::resolvePureUnlessCallableIsImpure() method removed
  • ResolvedPhpDocBlock — removed property, getter, merger, and changeParameterNamesByMapping handling for pureUnlessCallableIsImpureParameterNames
  • BetterReflectionProvider::getCustomFunction() — removed $isPureUnlessCallableIsImpure flag; the metadata lookup now runs whenever $isPure === null (no special-casing)
  • FunctionReflectionFactory and PhpFunctionReflection — removed $isPureUnlessCallableIsImpure parameter/property and the hasSideEffects() override
  • Deleted pure-unless-callable-is-impure-definition.php and pure-unless-callable-is-impure.php test data files

Added test for custom @pure functions (bug-11101-custom-pure.php) covering:

  • @phpstan-pure + @param-immediately-invoked-callable: pure callback → reported, impure callback → not reported, unknown callback → not reported
  • @phpstan-pure + @param-later-invoked-callable: all three (pure/impure/unknown) → reported (callback impurity doesn't propagate at call site)

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

Comment on lines +325 to +327
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.

…nd 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 <noreply@anthropic.com>
@VincentLanglet VincentLanglet requested a review from staabm May 2, 2026 10:18
@staabm staabm deleted the create-pull-request/patch-pgvhhg1 branch May 2, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants