Skip to content

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

Merged
staabm merged 6 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-aikn0sp
Nov 9, 2026
Merged

staabm merged 6 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

Comment thread src/Parser/ArrayFilterArgVisitor.php Resolved
Comment thread src/Parser/ArrayMapArgVisitor.php Resolved
@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

Comment thread src/Analyser/ArgumentsNormalizer.php Resolved
VincentLanglet and others added 5 commits November 8, 2026 08:59
… 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).
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>
…etArgByPositionOrName()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
@staabm
staabm force-pushed the create-pull-request/patch-aikn0sp branch from 6f45d08 to 6e4c2dd Compare November 8, 2026 07:59
@staabm

staabm commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

//cc @SanderMuller please review

@SanderMuller SanderMuller 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 ran this locally and found no problems.

  • The reporter's snippet at level 10: the base reports array_filter on line 15, and the PR head reports nothing. The new testBug15195 fails with the src and turbo-ext changes reverted. Then it shows the old false positives and misses the curl_setopt and curl_setopt_array errors.
  • Calls without named arguments keep the old path, because reorderArgs() returns them unchanged. So positional and spread calls do not change.
  • I also tried the neighbours with named arguments in reverse order: the preg_match() matches, parse_str(), array_reduce(), usort() and the preg_replace_callback() closure parameter. They give the same types as with positional arguments.
  • Turbo: the new tests pass with the extension. smoke.php, signature-parity.php (18,494 members) and side-by-side.php are clean. The output on the reporter's snippet, bug-15195.php and my probe file is identical with the extension on and off.
  • make phpstan, phpcs and the full test suite pass, with and without Turbo. One exception: OverridingMethodRuleConfigPhpTest fails on my machine on the base too, and it passes in CI.
  • CPU: cold self-analysis without Turbo, 3 interleaved rounds. The user+sys median is 141.8 s on base and 142.4 s with the PR. The spread is about 3 s, so I see no measurable difference.
  • The 3 red integration jobs are also red on #7004 and #7007.

@staabm
staabm merged commit d9b1d90 into phpstan:2.3.x Nov 9, 2026
890 of 893 checks passed
@staabm
staabm deleted the create-pull-request/patch-aikn0sp branch November 9, 2026 06:19
@staabm

staabm commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

thank you!

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

4 participants