From ee72f68806eb1598c59ad9418665eaa457296364 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Wed, 20 May 2026 21:34:38 +0000 Subject: [PATCH] Process reordered named arguments in call-site order in `processArgs` - `ArgumentsNormalizer` reorders named arguments into parameter-declaration order for type checking. `processArgs` then processed them in this reordered order, causing variable assignments in earlier call-site arguments to be invisible to later ones. - Sort the processing order in `processArgs` by the original argument's source position (via `ORIGINAL_ARG_ATTRIBUTE`) so scope mutations (variable assignments) happen in call-site evaluation order. - Parameter matching still uses the reordered index, so type checking is unaffected. - Affects all call types: `new`, function calls, method calls, and static method calls. --- src/Analyser/NodeScopeResolver.php | 30 ++++++- tests/PHPStan/Analyser/nsrt/bug-9392.php | 26 +++++++ .../Variables/DefinedVariableRuleTest.php | 10 +++ .../PHPStan/Rules/Variables/data/bug-9392.php | 78 +++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9392.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-9392.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a52b1c94f2d..374aba09970 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3513,7 +3513,35 @@ public function processArgs( $deferredInvalidateExpressions = []; /** @var ProcessClosureResult[] $deferredByRefClosureResults */ $deferredByRefClosureResults = []; - foreach ($args as $i => $arg) { + + $processingOrder = array_keys($args); + $hasReorderedArgs = false; + foreach ($args as $arg) { + if ($arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) !== null) { + $hasReorderedArgs = true; + break; + } + } + if ($hasReorderedArgs) { + usort($processingOrder, static function (int $a, int $b) use ($args): int { + $aOriginal = $args[$a]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + $bOriginal = $args[$b]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + if ($aOriginal === null && $bOriginal === null) { + return $a <=> $b; + } + if ($aOriginal === null) { + return 1; + } + if ($bOriginal === null) { + return -1; + } + + return $aOriginal->getStartTokenPos() <=> $bOriginal->getStartTokenPos(); + }); + } + + foreach ($processingOrder as $i) { + $arg = $args[$i]; $assignByReference = false; $parameter = null; $parameterType = null; diff --git a/tests/PHPStan/Analyser/nsrt/bug-9392.php b/tests/PHPStan/Analyser/nsrt/bug-9392.php new file mode 100644 index 00000000000..e04ce83a378 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9392.php @@ -0,0 +1,26 @@ += 8.0 + +namespace Bug9392; + +use function PHPStan\Testing\assertType; + +class Range +{ + public function __construct( + public ?string $notInRangeMessage = null, + public mixed $min = null, + public mixed $max = null, + ) { + } +} + +function () { + new Range( + min: $min = 20 * 100, + max: $max = 5_000 * 100, + notInRangeMessage: sprintf('The price must be between %s and %s.', round($min / 100, 2), round($max / 100, 2)), + ); + + assertType('2000', $min); + assertType('500000', $max); +}; diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 67f77fa6bfe..73c3a906c27 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1626,4 +1626,14 @@ public function testBug6833(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testBug9392(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-9392.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-9392.php b/tests/PHPStan/Rules/Variables/data/bug-9392.php new file mode 100644 index 00000000000..361e38ac768 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-9392.php @@ -0,0 +1,78 @@ += 8.0 + +namespace Bug9392; + +class Range +{ + public function __construct( + public ?string $notInRangeMessage = null, + public mixed $min = null, + public mixed $max = null, + ) { + } +} + +new Range( + min: $min = 20 * 100, + max: $max = 5_000 * 100, + notInRangeMessage: sprintf('The price must be between %s and %s.', round($min / 100, 2), round($max / 100, 2)), +); + +function foo(?string $c = null, mixed $a = null, mixed $b = null): void +{ +} + +foo( + a: $a = 10, + b: $b = 20, + c: sprintf('%s %s', $a, $b), +); + +class Foo +{ + public function bar(?string $c = null, mixed $a = null, mixed $b = null): void + { + } + + public static function baz(?string $c = null, mixed $a = null, mixed $b = null): void + { + } +} + +$foo = new Foo(); + +$foo->bar( + a: $x = 10, + b: $y = 20, + c: sprintf('%s %s', $x, $y), +); + +Foo::baz( + a: $p = 10, + b: $q = 20, + c: sprintf('%s %s', $p, $q), +); + +// Mixed positional and named args +function mixed_args(int $first, ?string $c = null, mixed $a = null, mixed $b = null): void +{ +} + +mixed_args( + 1, + a: $m1 = 10, + b: $m2 = 20, + c: sprintf('%s %s', $m1, $m2), +); + +// Variable assigned in named arg used after the call +function after_call(?string $c = null, mixed $a = null): void +{ +} + +after_call( + a: $afterVar = 42, + c: (string) $afterVar, +); + +echo $afterVar;