Skip to content

Bind intrinsic callback-argument overrides to parameters by name, not by written argument position - #6954

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

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

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

array_filter(callback: fn(string $s): bool => ..., array: $list) reported Parameter $callback of function array_filter expects (callable(mixed): bool)|null, Closure(string): bool given.. The same call with the arguments in signature order was fine. The intrinsic overrides that type a callback from its sibling array argument assumed the argument written first was the array. With named arguments in another order, they read the closure as the "array", so its iterable value type came out as mixed.

The fix ties each override to the argument bound to the parameter, by position or by name. It applies to every intrinsic override that reads sibling arguments, to the parameter closure type extensions, and to their turbo-ext C++ mirrors.

Changes

  • src/Parser/ArrayFilterArgVisitor.php, ArrayWalkArgVisitor.php, ArrayFindArgVisitor.php, CurlSetOptArgVisitor.php, CurlSetOptArrayArgVisitor.php: mark the argument bound to $array / $handle / $options, meaning the positional one at that index or the one passed by that name.
  • src/Parser/ArrayMapArgVisitor.php: attach the array arguments to the callback argument wherever it is written.
  • src/Reflection/ParametersAcceptorSelector.php: the new reorderIntrinsicArgs() puts named arguments in signature order via ArgumentsNormalizer::reorderArgs() before the array_map / array_filter / array_walk / array_find / curl_setopt / curl_setopt_array overrides read $args[0..2]. Calls without named arguments take the unchanged fast path. The NodeScopeResolver path already passes normalized arguments, and the marker attributes travel with them.
  • src/Type/Php/Array{Map,Filter,Walk,Find}ParameterClosureTypeExtension.php, PregReplaceCallbackClosureTypeExtension.php: these also read $functionCall->getArgs() by position. They now reorder named arguments first. Before this, they hid real errors such as array_walk(callback: fn(string $v, int $k) ..., array: array<string, int>), and preg_replace_callback(callback: ..., pattern: ...) could take the callback as the pattern.
  • turbo-ext: the same changes in ArrayFilterArgVisitor.cpp, ArrayWalkArgVisitor.cpp, ArrayFindArgVisitor.cpp, CurlSetOptArgVisitor.cpp, CurlSetOptArrayArgVisitor.cpp, ArrayMapArgVisitor.cpp and ParametersAcceptorSelector.cpp. There is a new shared visitors::argForParameter() helper in ParserVisitors.h, and generated/ParametersAcceptorSelector.h is regenerated.
  • Checked and left as is:
    • The implode() override already handles a named array: argument, so it keeps reading the original arguments.
    • The Closure::bind() / bindTo() overrides are unchanged.
    • Type inference inside closure bodies was already correct for reordered named arguments, because NodeScopeResolver normalizes them. Only the rule-side acceptor selection was affected.

Root cause

The pattern is that positional $args[N] indexing was used to read a call's arguments wherever a call-specific override or extension inspects sibling arguments. The parser visitors marked $args[0] / $args[1], and applyIntrinsicArgOverrides() plus the parameter closure type extensions read $args[N], all assuming arguments are written in signature order. Named arguments break that assumption. The result was false positives (array_filter, array_filter with a named mode:, array_walk, array_find family, array_map) and missed errors (curl_setopt($h, value: ..., option: ...), curl_setopt_array(options: ..., handle: ...), and mismatched closures that the closure type extensions accepted).

Test

CallToFunctionParametersRuleTest::testBug15195 with tests/PHPStan/Rules/Functions/data/bug-15195.php covers:

  • the reproducer from the issue;
  • array_filter with named mode: (ARRAY_FILTER_USE_KEY / ARRAY_FILTER_USE_BOTH);
  • array_walk with and without the arg: argument;
  • array_find, array_find_key, array_any, array_all;
  • array_map(array: ..., callback: ...);
  • curl_setopt / curl_setopt_array with reordered named arguments.

Each family has a valid call, which must not error, and a mismatched one, which must error. The test fails without the fix: it reports false positives and misses expected errors. The turbo extension passes tests/smoke.php, tests/signature-parity.php and bin/side-by-side.php (no new findings), and analysis output was byte-identical with the extension on and off for the probe files.

Fixes phpstan/phpstan#15195

🤖 Generated with Claude Code

… by written argument position

- ArrayFilterArgVisitor, ArrayWalkArgVisitor, ArrayFindArgVisitor,
  CurlSetOptArgVisitor and CurlSetOptArrayArgVisitor mark the argument bound
  to the relevant parameter (positional index or matching name) instead of
  whichever argument was written at that index.
- ArrayMapArgVisitor attaches the array arguments to the `callback:`
  argument even when it is not written first.
- ParametersAcceptorSelector::applyIntrinsicArgOverrides() reorders named
  arguments into signature order (ArgumentsNormalizer::reorderArgs()) before
  reading sibling arguments ($args[1], $args[2], ...) for array_map,
  array_filter, array_walk, array_find/array_find_key/array_any/array_all,
  curl_setopt and curl_setopt_array.
- ArrayMap/ArrayFilter/ArrayWalk/ArrayFind parameter closure type extensions
  and PregReplaceCallbackClosureTypeExtension reorder named arguments before
  reading them by position.
- Ported to the turbo-ext C++ mirrors (the five visitors, ArrayMapArgVisitor,
  ParametersAcceptorSelector, new visitors::argForParameter() helper);
  regenerated generated/ParametersAcceptorSelector.h.
- implode() and Closure::bind()/bindTo() overrides keep reading the original
  arguments (implode already handles a named `array:` argument).
Comment thread src/Parser/ArrayFilterArgVisitor.php Resolved
The intrinsic-override parser visitors each spelled out the same loop to find
the argument bound to a parameter by position or by name. The native
ArgumentsNormalizer mirrors the new method via visitors::argForParameter().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/Parser/ArrayMapArgVisitor.php Resolved
…etArgByPositionOrName()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VincentLanglet VincentLanglet self-assigned this Nov 4, 2026
@VincentLanglet
VincentLanglet requested a review from staabm November 4, 2026 21:53

@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.

I think this looks mostly good and it hits a nail which I talked about with ondrej yesterday in private somewhere else:

atm it seems we need to duplicate the parameter-positions and arguments "dance" we see in

/**
* Rewrites the call to pass every argument positionally, skipped optional parameters get their default values.
*
* @return (Closure(Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_): (Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_))|false
*/
private function createPositionalArgumentsFix(
ParametersAcceptor $parametersAcceptor,
Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $funcCall,
): Closure|false
{
if ((bool) $funcCall->getAttribute('isAttribute', false)) {
// the call was created from an attribute, it is not in the file
return false;
}
$parameters = $parametersAcceptor->getParameters();
$parameterPositions = [];
foreach ($parameters as $i => $parameter) {
$parameterPositions[$parameter->getName()] = $i;
}
/** @var array<int, int> $argIndexByPosition */
$argIndexByPosition = [];
$lastPosition = 0;
foreach ($funcCall->getArgs() as $i => $arg) {
if ($arg->unpack) {
return false;
}
if ($arg->name === null) {
$argIndexByPosition[$i] = $i;
$lastPosition = max($lastPosition, $i);
continue;
}
$position = $parameterPositions[$arg->name->toString()] ?? null;
if (
$position === null
// a named argument that is collected by the variadic parameter keeps its string key
|| $parameters[$position]->isVariadic()
|| array_key_exists($position, $argIndexByPosition)
) {
return false;
}
$argIndexByPosition[$position] = $i;
$lastPosition = max($lastPosition, $position);
}
/** @var array<int, Expr> $defaultValues */
$defaultValues = [];
for ($position = 0; $position < $lastPosition; $position++) {
if (array_key_exists($position, $argIndexByPosition)) {
continue;
}
$parameter = $parameters[$position];
$defaultValue = $parameter->getDefaultValue();
if (!$parameter->isOptional() || !$parameter->passedByReference()->no() || $defaultValue === null) {
return false;
}
$defaultValueExpr = $this->createDefaultValueExpr($defaultValue);
if ($defaultValueExpr === null) {
return false;
}
$defaultValues[$position] = $defaultValueExpr;
}
return static function (Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $call) use ($argIndexByPosition, $defaultValues, $lastPosition): Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ {
$callArgs = $call->getArgs();
$args = [];
for ($position = 0; $position <= $lastPosition; $position++) {
if (!array_key_exists($position, $argIndexByPosition)) {
$args[] = new Node\Arg(clone $defaultValues[$position]);
continue;
}
$arg = clone $callArgs[$argIndexByPosition[$position]];
$arg->name = null;
$args[] = $arg;
}
$call->args = $args;
return $call;
};
}
also in other fixer trying to fiddle with args

*
* @param Arg[] $args
*/
public static function getArgByPositionOrName(array $args, int $position, string $name): ?Arg

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.

we miss some unit tests for this method

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.

I added the unit tests for ArgumentsNormalizer::getArgByPositionOrName(). Running them with the turbo extension loaded turned up a difference between the PHP method and its C++ mirror, which I fixed. Both commits are pushed.

Tests (6f45d0882, tests/PHPStan/Analyser/ArgumentsNormalizerTest.php): testGetArgByPositionOrName takes its cases from a data provider:

  • no arguments;
  • a positional argument at the position, and too few positional arguments;
  • a named argument in signature order, and in another order (callback:, array: and mode:, callback:, array:);
  • positional arguments followed by the named one;
  • a named argument sitting at the position but belonging to a different parameter, which must return null;
  • argument names are case-sensitive;
  • a negative position.

Fix (c2e266143, turbo-ext/src/ArgumentsNormalizer.cpp): with a negative position, the native binding returned null straight away. The PHP version still finds an argument passed by name in that case, for example ([x, array: y], -1, 'array') returns y. I removed the early return. The C++ now compares the array key the same way PHP's $i === $position does, so both give the same result.

Checks:

  • With the extension loaded, the new tests now pass, as do tests/smoke.php and tests/signature-parity.php.
  • make tests passes (23417 tests, 107 skipped), and so do make phpstan and make cs.

On the broader point about repeating the parameter-position and argument handling from FunctionCallParametersCheck: I left that alone. Merging it into one shared helper would be a refactor beyond this PR.

phpstan-bot and others added 2 commits November 5, 2026 08:36
…PositionOrName()

The PHP method still finds an argument passed by name when the position is
negative; the C++ mirror returned null early. Comparing the hash key as
zend_ulong keeps exact parity with $i === $position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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