Skip to content

Resolve intrinsic argument overrides by parameter position instead of the order arguments are written - #6488

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-vj6os0g
Open

phpstan-bot wants to merge 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-vj6os0g

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

array_filter(callback: $fn, array: $arr) reported a false positive Parameter $callback of function array_filter expects (callable(mixed): bool)|null, Closure(string): bool given., while the same call written as array_filter(array: $arr, callback: $fn) analysed cleanly.

The callback's expected signature is not in the function map - it is synthesized at analysis time from the type of the array argument. That synthesis looked the array argument up as $args[0], i.e. by its position in the source, which named arguments are free to change. The same holds for every other intrinsic argument override PHPStan applies.

Changes

  • New PHPStan\Parser\ArgumentPositionHelper::getArgsByPosition() (src/Parser/ArgumentPositionHelper.php) - maps a call's arguments onto the positions of the callee's parameters. It is a no-op (returns the arguments unchanged) when no named arguments are in play, so the hot path is unaffected.
  • Each intrinsic arg visitor in src/Parser/ gained a PARAMETER_NAMES constant and now marks the argument that fills the parameter it cares about:
    • ArrayFilterArgVisitor, ArrayWalkArgVisitor, ArrayFindArgVisitor, CurlSetOptArgVisitor - the first parameter
    • CurlSetOptArrayArgVisitor - the options parameter
    • ClosureBindArgVisitor - the closure parameter, ClosureBindToVarVisitor - the newThis parameter
    • ImplodeArgVisitor - the separator parameter, falling back to array for implode(array: $a)
    • ArrayMapArgVisitor - the callback argument's value (previously the source-first argument's value, which is not the closure once the arguments are reordered; ContextualClosureParameterResolver and ScopeOps::nodeKey() both look the attribute up on the closure node)
  • ParametersAcceptorSelector::applyIntrinsicArgOverrides() (src/Reflection/ParametersAcceptorSelector.php) reads all of its sibling arguments ($args[0], $args[1], $args[2]) through the same mapping instead of indexing the call's arguments directly.

Analogous cases fixed alongside the reported one, each with its own failing test:

construct symptom before
array_filter(callback: …, array: …) callback typed callable(mixed): bool (reported bug)
array_filter(mode: …, callback: …, array: …) ARRAY_FILTER_USE_KEY / ARRAY_FILTER_USE_BOTH not honoured, callback typed callable(mixed): bool
array_map(array: …, callback: …) arrayMapArgs attached to the array expression rather than the callback
array_walk(callback: …, array: …) callback typed callable(mixed, mixed): mixed
array_walk(arg: …, callback: …, array: …) third callback parameter typed from the wrong argument
array_find / array_any / array_all / array_find_key with reversed named arguments callback typed callable(mixed, mixed): bool
curl_setopt(option: …, handle: …, value: …) CURLOPT_* value type not narrowed (false negative)
curl_setopt_array(options: …, handle: …) option value types not narrowed (false negative)
Closure::bind(newThis: …, closure: …) @param-closure-this not enforced (false negative)

Probed and found already order-independent (they go through the shared mapping now, but their behaviour is unchanged): Closure::bindTo with newScope: written before newThis:, implode/join in every named-argument order, and the closure parameter type inference performed by the FunctionParameterClosureTypeExtensions (which already resolved named arguments correctly).

Root cause

A parser visitor tags one argument of a well-known call with an attribute; ParametersAcceptorSelector::applyIntrinsicArgOverrides() then detects that attribute and reads the other arguments by index to synthesize a more precise parameter type (the callback signature for the array functions, the CURLOPT_* value type for curl, the @param-closure-this type for Closure::bind).

Both halves assumed the source position of an argument equals the position of the parameter it fills. Named arguments break that assumption in two different ways depending on the caller:

  • ParametersAcceptorSelector::selectFromArgs() passes the arguments as written, so the marker was found but the sibling reads picked up the wrong arguments.
  • ArgumentsHandler passes arguments already reordered by ArgumentsNormalizer, so the marker travelled with the argument it was set on and ended up at the wrong index, and the override was skipped entirely.

Resolving both the marking and the reads through a signature-position mapping makes the two halves agree in either case, and makes the analysis independent of the order named arguments are written in.

Test

  • tests/PHPStan/Rules/Functions/data/bug-15195.php - the reproducer from the issue's playground link verbatim, expected to analyse clean (CallToFunctionParametersRuleTest::testBug15195, with explicit/implicit mixed checks on to match the playground's level 10).
  • tests/PHPStan/Rules/Functions/data/named-arguments-order-intrinsic.php - array_filter (plain, ARRAY_FILTER_USE_KEY, ARRAY_FILTER_USE_BOTH), array_map, array_walk (with and without arg:), curl_setopt, curl_setopt_array and implode with named arguments in reversed order; each construct has a correct call that must stay clean and a deliberately wrong one that must be reported.
  • tests/PHPStan/Rules/Functions/data/named-arguments-order-array-find.php - the same for array_find, array_find_key, array_any and array_all (PHP 8.4).
  • tests/PHPStan/Rules/Methods/data/closure-bind-param-closure-this-named-args.php and .../closure-bind-to-param-closure-this-named-args.php - @param-closure-this enforcement for Closure::bind() and Closure::bindTo() in both named-argument orders.

All of these were confirmed to fail before the fix (false positives on the correct calls, missing errors on the wrong ones) and pass after it. make tests, make phpstan and make cs are green.

Fixes phpstan/phpstan#15195

🤖 Generated with Claude Code

Comment thread src/Parser/ArgumentPositionHelper.php Outdated
@VincentLanglet VincentLanglet self-assigned this Sep 20, 2026
Comment thread src/Analyser/ArgumentsNormalizer.php Outdated

@staabm staabm left a comment

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.

needs rebase and merge conflict resolution.

meanwhile *Vistor classes also have a corresponding .cpp impl which might need the changes mirrored?

phpstan-bot and others added 3 commits September 22, 2026 14:14
… the order arguments are written

* Added `PHPStan\Parser\ArgumentPositionHelper::getArgsByPosition()`, which maps a call's arguments onto the positions of the callee's parameters, honouring named arguments written out of order.
* Every intrinsic arg visitor (`ArrayFilterArgVisitor`, `ArrayMapArgVisitor`, `ArrayWalkArgVisitor`, `ArrayFindArgVisitor`, `ImplodeArgVisitor`, `CurlSetOptArgVisitor`, `CurlSetOptArrayArgVisitor`, `ClosureBindArgVisitor`, `ClosureBindToVarVisitor`) now declares a `PARAMETER_NAMES` constant and marks the argument that actually fills the parameter it cares about, instead of the argument that happens to be written first.
* `ParametersAcceptorSelector::applyIntrinsicArgOverrides()` reads its sibling arguments through the same mapping, so the array / option / callback / closure argument is found wherever it is written.
* `ArrayMapArgVisitor` now attaches `arrayMapArgs` to the callback argument's value in all argument orders, which is where `ContextualClosureParameterResolver` and `ScopeOps::nodeKey()` expect it.
* Fixed the same order-dependence for `array_map`, `array_walk`, `array_find`/`array_any`/`array_all`/`array_find_key`, `curl_setopt`, `curl_setopt_array` and `Closure::bind`; `Closure::bindTo` and `implode` were probed and were already order-independent, but go through the shared mapping now too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ArgumentPositionHelper was a separate class for what is argument
reordering logic, which already has a home. Move the method there and
share the "does this call use named arguments" check with reorderArgs(),
which was scanning for it inline.

The two remain distinct: reorderArgs() needs a ParametersAcceptor and
produces copies of the Arg nodes, while getArgsByPosition() takes the
parameter names spelled out by the caller, so it also works in the parser
visitors where no reflection is available yet, and returns the original
Arg objects so the attributes those visitors set land on the analysed AST.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The class is already marked @api, so the @internal on this one method was
the odd one out. Visitors written outside phpstan-src face the same
named-argument problem the intrinsic arg visitors do, so let them use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-vj6os0g branch from 17e5fce to cbe8eac Compare September 22, 2026 12:14
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.

Type inference for a template depends on the order of named arguments

3 participants