Skip to content

Commit cdb9f8b

Browse files
ondrejmirtesclaude
andcommitted
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
1 parent cda0159 commit cdb9f8b

6 files changed

Lines changed: 158 additions & 9 deletions

File tree

‎resources/functionMap.php‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11186,15 +11186,15 @@
1118611186
'unregister_tick_function' => ['void', 'function_name'=>'callable'],
1118711187
'unserialize' => ['mixed', 'variable_representation'=>'string', 'allowed_classes='=>'array{allowed_classes?:string[]|bool}'],
1118811188
'untaint' => ['bool', '&rw_string'=>'string', '&...rw_strings='=>'string'],
11189-
'uopz_add_function' => ['bool', 'class'=>'string', 'function'=>'string', 'handler'=>'Closure', '$flags'=>'int', '$all'=>'bool'],
11190-
'uopz_add_function\'1' => ['bool', 'function'=>'string', 'handler'=>'Closure', '$flags'=>'int'],
11189+
'uopz_add_function' => ['bool', 'class'=>'string', 'function'=>'string', 'handler'=>'Closure', 'flags='=>'int', 'all='=>'bool'],
11190+
'uopz_add_function\'1' => ['bool', 'function'=>'string', 'handler'=>'Closure', 'flags='=>'int'],
1119111191
'uopz_allow_exit' => ['void', 'allow'=>'bool'],
1119211192
'uopz_backup' => ['void', 'class'=>'string', 'function'=>'string'],
1119311193
'uopz_backup\'1' => ['void', 'function'=>'string'],
1119411194
'uopz_compose' => ['void', 'name'=>'string', 'classes'=>'array', 'methods='=>'array', 'properties='=>'array', 'flags='=>'int'],
1119511195
'uopz_copy' => ['Closure', 'class'=>'string', 'function'=>'string'],
1119611196
'uopz_copy\'1' => ['Closure', 'function'=>'string'],
11197-
'uopz_del_function' => ['bool', 'class'=>'string', 'function'=>'string', '$all'=>'bool'],
11197+
'uopz_del_function' => ['bool', 'class'=>'string', 'function'=>'string', 'all='=>'bool'],
1119811198
'uopz_del_function\'1' => ['bool', 'function'=>'string'],
1119911199
'uopz_delete' => ['void', 'class'=>'string', 'function'=>'string'],
1120011200
'uopz_delete\'1' => ['void', 'function'=>'string'],

‎resources/functionMap_php80delta.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
'getenv' => ['string|false', 'varname'=>'string', 'local_only='=>'bool'],
8686
'getenv\'1' => ['array<string, string>', 'varname='=>'null', 'local_only='=>'bool'],
8787
'ldap_set_rebind_proc' => ['bool', 'ldap'=>'resource', 'callback'=>'?callable'],
88+
'levenshtein' => ['int', 'string1'=>'string', 'string2'=>'string', 'insertion_cost='=>'int', 'replacement_cost='=>'int', 'deletion_cost='=>'int'],
8889
'mb_decode_numericentity' => ['string|false', 'string'=>'string', 'convmap'=>'array', 'encoding='=>'string'],
8990
'mb_detect_order' => ['bool', 'encoding_list'=>'non-empty-list<non-falsy-string>|non-falsy-string'],
9091
'mb_detect_order\'1' => ['list<non-falsy-string>', 'encoding_list='=>'null'],
@@ -245,6 +246,8 @@
245246
'ldap_control_paged_result_response' => ['bool', 'link_identifier'=>'resource', 'result_identifier'=>'resource', '&w_cookie='=>'string', '&w_estimated='=>'int'],
246247
'ldap_set_rebind_proc' => ['bool', 'link_identifier'=>'resource', 'callback'=>'callable'],
247248
'ldap_sort' => ['bool', 'link_identifier'=>'resource', 'result_identifier'=>'resource', 'sortfilter'=>'string'],
249+
'levenshtein' => ['int', 'str1'=>'string', 'str2'=>'string'],
250+
'levenshtein\'1' => ['int', 'str1'=>'string', 'str2'=>'string', 'cost_ins'=>'int', 'cost_rep'=>'int', 'cost_del'=>'int'],
248251
'mb_decode_numericentity' => ['string|false', 'string'=>'string', 'convmap'=>'array', 'encoding='=>'string', 'is_hex='=>'bool'],
249252
'mb_strlen' => ['0|positive-int', 'str'=>'string', 'encoding='=>'string'],
250253
'mktime' => ['int|false', 'hour='=>'int', 'minute='=>'int', 'second='=>'int', 'month='=>'int', 'day='=>'int', 'year='=>'int'],

‎src/Rules/Functions/CallToFunctionParametersRule.php‎

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
use PHPStan\Analyser\Scope;
1010
use PHPStan\DependencyInjection\RegisteredRule;
1111
use PHPStan\Internal\SprintfHelper;
12+
use PHPStan\Reflection\ParametersAcceptor;
1213
use PHPStan\Reflection\ParametersAcceptorSelector;
1314
use PHPStan\Reflection\ReflectionProvider;
1415
use PHPStan\Rules\FunctionCallParametersCheck;
1516
use PHPStan\Rules\Rule;
17+
use function count;
1618

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

48+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
49+
$scope,
50+
$node->getArgs(),
51+
$function->getVariants(),
52+
$function->getNamedArgumentsVariants(),
53+
);
54+
4655
return $this->check->check(
47-
ParametersAcceptorSelector::selectFromArgs(
48-
$scope,
49-
$node->getArgs(),
50-
$function->getVariants(),
51-
$function->getNamedArgumentsVariants(),
52-
),
56+
self::getVariantForArgumentsCount($node->getArgs(), $function->getVariants(), $parametersAcceptor) ?? $parametersAcceptor,
5357
$scope,
5458
$function->isBuiltin(),
5559
$node,
@@ -77,4 +81,75 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE
7781
);
7882
}
7983

84+
/**
85+
* When no variant accepts the number of arguments, they are combined into
86+
* one whose parameters missing from some variant are optional, so mt_rand()
87+
* with the variants () and ($min, $max) would accept a single argument.
88+
* Such a count is checked against the variant closest to it instead.
89+
*
90+
* @param Node\Arg[] $args
91+
* @param list<ParametersAcceptor> $variants
92+
*/
93+
private static function getVariantForArgumentsCount(array $args, array $variants, ParametersAcceptor $selectedParametersAcceptor): ?ParametersAcceptor
94+
{
95+
if (count($variants) < 2) {
96+
return null;
97+
}
98+
99+
foreach ($args as $arg) {
100+
if ($arg->unpack || $arg->name !== null) {
101+
return null;
102+
}
103+
}
104+
105+
$argsCount = count($args);
106+
if (!self::acceptsArgumentsCount($selectedParametersAcceptor, $argsCount)) {
107+
return null;
108+
}
109+
110+
$closestVariant = null;
111+
$closestDistance = null;
112+
foreach ($variants as $variant) {
113+
if (self::acceptsArgumentsCount($variant, $argsCount)) {
114+
return null;
115+
}
116+
117+
[$minCount, $maxCount] = self::getArgumentsCountRange($variant);
118+
$isInsufficient = $argsCount < $minCount;
119+
$distance = $isInsufficient ? $minCount - $argsCount : $argsCount - (int) $maxCount;
120+
if ($closestDistance !== null && ($distance > $closestDistance || ($distance === $closestDistance && !$isInsufficient))) {
121+
continue;
122+
}
123+
124+
$closestVariant = $variant;
125+
$closestDistance = $distance;
126+
}
127+
128+
return $closestVariant;
129+
}
130+
131+
private static function acceptsArgumentsCount(ParametersAcceptor $parametersAcceptor, int $argsCount): bool
132+
{
133+
[$minCount, $maxCount] = self::getArgumentsCountRange($parametersAcceptor);
134+
135+
return $argsCount >= $minCount && ($maxCount === null || $argsCount <= $maxCount);
136+
}
137+
138+
/**
139+
* @return array{int, int|null}
140+
*/
141+
private static function getArgumentsCountRange(ParametersAcceptor $parametersAcceptor): array
142+
{
143+
$minCount = 0;
144+
foreach ($parametersAcceptor->getParameters() as $parameter) {
145+
if ($parameter->isOptional()) {
146+
continue;
147+
}
148+
149+
$minCount++;
150+
}
151+
152+
return [$minCount, $parametersAcceptor->isVariadic() ? null : count($parametersAcceptor->getParameters())];
153+
}
154+
80155
}

‎tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3123,4 +3123,32 @@ public function testBug11041(): void
31233123
$this->analyse([__DIR__ . '/data/bug-11041.php'], []);
31243124
}
31253125

3126+
public function testArgumentsCountAcceptedByNoVariant(): void
3127+
{
3128+
$this->analyse([__DIR__ . '/data/arguments-count-accepted-by-no-variant.php'], [
3129+
[
3130+
'Function mt_rand invoked with 1 parameter, 2 required.',
3131+
12,
3132+
],
3133+
[
3134+
'Function rand invoked with 1 parameter, 2 required.',
3135+
15,
3136+
],
3137+
[
3138+
'Function mt_rand invoked with 3 parameters, 0-2 required.',
3139+
16,
3140+
],
3141+
[
3142+
'Function stream_context_set_option invoked with 3 parameters, 4 required.',
3143+
20,
3144+
],
3145+
]);
3146+
}
3147+
3148+
#[RequiresPhp('>= 8.0.0')]
3149+
public function testLevenshteinArgumentsCount(): void
3150+
{
3151+
$this->analyse([__DIR__ . '/data/levenshtein-arguments-count.php'], []);
3152+
}
3153+
31263154
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace ArgumentsCountAcceptedByNoVariant;
4+
5+
/**
6+
* @param resource $context
7+
*/
8+
function doFoo($context): void
9+
{
10+
$a = mt_rand();
11+
$a = mt_rand(1, 2);
12+
$a = mt_rand(1);
13+
$a = rand();
14+
$a = rand(1, 2);
15+
$a = rand(1);
16+
$a = mt_rand(1, 2, 3);
17+
18+
stream_context_set_option($context, 'http', 'method', 'POST');
19+
stream_context_set_option($context, ['http' => ['method' => 'POST']]);
20+
stream_context_set_option($context, 'http', 'method');
21+
22+
$cb = static fn ($a, $b): int => $a <=> $b;
23+
$b = array_intersect_uassoc([1], [1], $cb);
24+
$b = array_intersect_uassoc([1], [1], [1], $cb);
25+
$b = array_intersect_ukey([1], [1], [1], $cb);
26+
27+
$handler = static function (): void {
28+
};
29+
uopz_add_function('Foo', 'bar', $handler, 1);
30+
uopz_add_function('bar', $handler);
31+
uopz_del_function('Foo', 'bar');
32+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace LevenshteinArgumentsCount;
4+
5+
function doFoo(string $a, string $b): void
6+
{
7+
$c = levenshtein($a, $b);
8+
$c = levenshtein($a, $b, 1);
9+
$c = levenshtein($a, $b, 1, 2);
10+
$c = levenshtein($a, $b, 1, 2, 3);
11+
}

0 commit comments

Comments
 (0)