Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Report function calls with an argument count no variant accepts
When no variant of a function accepts the number of arguments, the variants
are combined into one whose parameters missing from some variant are
optional. mt_rand() and rand() have the variants () and ($min, $max), so
mt_rand(1) was accepted although it throws ArgumentCountError. Such a count
is now checked against the variant closest to it.

The functionMap was wrong for a few functions with such gaps: levenshtein()
accepts each cost separately since PHP 8.0, and the $flags and $all
parameters of uopz_add_function() and uopz_del_function() are optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
  • Loading branch information
ondrejmirtes and claude committed Sep 22, 2026
commit 03a2a3ad12c5ccd58714d26d37f513008d81e6c0
6 changes: 3 additions & 3 deletions resources/functionMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -11186,15 +11186,15 @@
'unregister_tick_function' => ['void', 'function_name'=>'callable'],
'unserialize' => ['mixed', 'variable_representation'=>'string', 'allowed_classes='=>'array{allowed_classes?:string[]|bool}'],
'untaint' => ['bool', '&rw_string'=>'string', '&...rw_strings='=>'string'],
'uopz_add_function' => ['bool', 'class'=>'string', 'function'=>'string', 'handler'=>'Closure', '$flags'=>'int', '$all'=>'bool'],
'uopz_add_function\'1' => ['bool', 'function'=>'string', 'handler'=>'Closure', '$flags'=>'int'],
'uopz_add_function' => ['bool', 'class'=>'string', 'function'=>'string', 'handler'=>'Closure', 'flags='=>'int', 'all='=>'bool'],
'uopz_add_function\'1' => ['bool', 'function'=>'string', 'handler'=>'Closure', 'flags='=>'int'],
'uopz_allow_exit' => ['void', 'allow'=>'bool'],
'uopz_backup' => ['void', 'class'=>'string', 'function'=>'string'],
'uopz_backup\'1' => ['void', 'function'=>'string'],
'uopz_compose' => ['void', 'name'=>'string', 'classes'=>'array', 'methods='=>'array', 'properties='=>'array', 'flags='=>'int'],
'uopz_copy' => ['Closure', 'class'=>'string', 'function'=>'string'],
'uopz_copy\'1' => ['Closure', 'function'=>'string'],
'uopz_del_function' => ['bool', 'class'=>'string', 'function'=>'string', '$all'=>'bool'],
'uopz_del_function' => ['bool', 'class'=>'string', 'function'=>'string', 'all='=>'bool'],
'uopz_del_function\'1' => ['bool', 'function'=>'string'],
'uopz_delete' => ['void', 'class'=>'string', 'function'=>'string'],
'uopz_delete\'1' => ['void', 'function'=>'string'],
Expand Down
3 changes: 3 additions & 0 deletions resources/functionMap_php80delta.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
'getenv' => ['string|false', 'varname'=>'string', 'local_only='=>'bool'],
'getenv\'1' => ['array<string, string>', 'varname='=>'null', 'local_only='=>'bool'],
'ldap_set_rebind_proc' => ['bool', 'ldap'=>'resource', 'callback'=>'?callable'],
'levenshtein' => ['int', 'string1'=>'string', 'string2'=>'string', 'insertion_cost='=>'int', 'replacement_cost='=>'int', 'deletion_cost='=>'int'],
'mb_decode_numericentity' => ['string|false', 'string'=>'string', 'convmap'=>'array', 'encoding='=>'string'],
'mb_detect_order' => ['bool', 'encoding_list'=>'non-empty-list<non-falsy-string>|non-falsy-string'],
'mb_detect_order\'1' => ['list<non-falsy-string>', 'encoding_list='=>'null'],
Expand Down Expand Up @@ -245,6 +246,8 @@
'ldap_control_paged_result_response' => ['bool', 'link_identifier'=>'resource', 'result_identifier'=>'resource', '&w_cookie='=>'string', '&w_estimated='=>'int'],
'ldap_set_rebind_proc' => ['bool', 'link_identifier'=>'resource', 'callback'=>'callable'],
'ldap_sort' => ['bool', 'link_identifier'=>'resource', 'result_identifier'=>'resource', 'sortfilter'=>'string'],
'levenshtein' => ['int', 'str1'=>'string', 'str2'=>'string'],
'levenshtein\'1' => ['int', 'str1'=>'string', 'str2'=>'string', 'cost_ins'=>'int', 'cost_rep'=>'int', 'cost_del'=>'int'],
'mb_decode_numericentity' => ['string|false', 'string'=>'string', 'convmap'=>'array', 'encoding='=>'string', 'is_hex='=>'bool'],
'mb_strlen' => ['0|positive-int', 'str'=>'string', 'encoding='=>'string'],
'mktime' => ['int|false', 'hour='=>'int', 'minute='=>'int', 'second='=>'int', 'month='=>'int', 'day='=>'int', 'year='=>'int'],
Expand Down
87 changes: 81 additions & 6 deletions src/Rules/Functions/CallToFunctionParametersRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Internal\SprintfHelper;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\FunctionCallParametersCheck;
use PHPStan\Rules\Rule;
use function count;

/**
* @implements Rule<Node\Expr\FuncCall>
Expand Down Expand Up @@ -43,13 +45,15 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
$function = $this->reflectionProvider->getFunction($node->name, $scope);
$functionName = SprintfHelper::escapeFormatString($function->getName());

$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$node->getArgs(),
$function->getVariants(),
$function->getNamedArgumentsVariants(),
);

return $this->check->check(
ParametersAcceptorSelector::selectFromArgs(
$scope,
$node->getArgs(),
$function->getVariants(),
$function->getNamedArgumentsVariants(),
),
self::getVariantForArgumentsCount($node->getArgs(), $function->getVariants(), $parametersAcceptor) ?? $parametersAcceptor,
$scope,
$function->isBuiltin(),
$node,
Expand Down Expand Up @@ -77,4 +81,75 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
);
}

/**
* When no variant accepts the number of arguments, they are combined into
* one whose parameters missing from some variant are optional, so mt_rand()
* with the variants () and ($min, $max) would accept a single argument.
* Such a count is checked against the variant closest to it instead.
*
* @param Node\Arg[] $args
* @param list<ParametersAcceptor> $variants
*/
private static function getVariantForArgumentsCount(array $args, array $variants, ParametersAcceptor $selectedParametersAcceptor): ?ParametersAcceptor
{
if (count($variants) < 2) {
return null;
}

foreach ($args as $arg) {
if ($arg->unpack || $arg->name !== null) {
return null;
}
}

$argsCount = count($args);
if (!self::acceptsArgumentsCount($selectedParametersAcceptor, $argsCount)) {
return null;
}

$closestVariant = null;
$closestDistance = null;
foreach ($variants as $variant) {
if (self::acceptsArgumentsCount($variant, $argsCount)) {
return null;
}

[$minCount, $maxCount] = self::getArgumentsCountRange($variant);
$isInsufficient = $argsCount < $minCount;
$distance = $isInsufficient ? $minCount - $argsCount : $argsCount - (int) $maxCount;
if ($closestDistance !== null && ($distance > $closestDistance || ($distance === $closestDistance && !$isInsufficient))) {
continue;
}

$closestVariant = $variant;
$closestDistance = $distance;
}

return $closestVariant;
}

private static function acceptsArgumentsCount(ParametersAcceptor $parametersAcceptor, int $argsCount): bool
{
[$minCount, $maxCount] = self::getArgumentsCountRange($parametersAcceptor);

return $argsCount >= $minCount && ($maxCount === null || $argsCount <= $maxCount);
}

/**
* @return array{int, int|null}
*/
private static function getArgumentsCountRange(ParametersAcceptor $parametersAcceptor): array
{
$minCount = 0;
foreach ($parametersAcceptor->getParameters() as $parameter) {
if ($parameter->isOptional()) {
continue;
}

$minCount++;
}

return [$minCount, $parametersAcceptor->isVariadic() ? null : count($parametersAcceptor->getParameters())];
}

}
28 changes: 28 additions & 0 deletions tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3123,4 +3123,32 @@ public function testBug11041(): void
$this->analyse([__DIR__ . '/data/bug-11041.php'], []);
}

public function testArgumentsCountAcceptedByNoVariant(): void
{
$this->analyse([__DIR__ . '/data/arguments-count-accepted-by-no-variant.php'], [
[
'Function mt_rand invoked with 1 parameter, 2 required.',
12,
],
[
'Function rand invoked with 1 parameter, 2 required.',
15,
],
[
'Function mt_rand invoked with 3 parameters, 0-2 required.',
16,
],
[
'Function stream_context_set_option invoked with 3 parameters, 4 required.',
20,
],
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testLevenshteinArgumentsCount(): void
{
$this->analyse([__DIR__ . '/data/levenshtein-arguments-count.php'], []);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace ArgumentsCountAcceptedByNoVariant;

/**
* @param resource $context
*/
function doFoo($context): void
{
$a = mt_rand();
$a = mt_rand(1, 2);
$a = mt_rand(1);
$a = rand();
$a = rand(1, 2);
$a = rand(1);
$a = mt_rand(1, 2, 3);

stream_context_set_option($context, 'http', 'method', 'POST');
stream_context_set_option($context, ['http' => ['method' => 'POST']]);
stream_context_set_option($context, 'http', 'method');

$cb = static fn ($a, $b): int => $a <=> $b;
$b = array_intersect_uassoc([1], [1], $cb);
$b = array_intersect_uassoc([1], [1], [1], $cb);
$b = array_intersect_ukey([1], [1], [1], $cb);

$handler = static function (): void {
};
uopz_add_function('Foo', 'bar', $handler, 1);
uopz_add_function('bar', $handler);
uopz_del_function('Foo', 'bar');
}
11 changes: 11 additions & 0 deletions tests/PHPStan/Rules/Functions/data/levenshtein-arguments-count.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace LevenshteinArgumentsCount;

function doFoo(string $a, string $b): void
{
$c = levenshtein($a, $b);
$c = levenshtein($a, $b, 1);
$c = levenshtein($a, $b, 1, 2);
$c = levenshtein($a, $b, 1, 2, 3);
}