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 1/2] 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; From 7738f17ad0cea4e6fed54fba7b34486a5a0cd202 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 23 May 2026 11:07:12 +0000 Subject: [PATCH 2/2] Merge NSRT and rule test fixtures into a single file for bug-9392 Co-Authored-By: Claude Opus 4.6 --- .../Analyser/NodeScopeResolverTest.php | 1 + tests/PHPStan/Analyser/nsrt/bug-9392.php | 26 ------------------- .../PHPStan/Rules/Variables/data/bug-9392.php | 5 ++++ 3 files changed, 6 insertions(+), 26 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-9392.php diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index a8903a13df5..fa3a4f64d41 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -260,6 +260,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/PhpDoc/data/bug-8609-function.php'; yield __DIR__ . '/../Rules/Comparison/data/bug-5365.php'; yield __DIR__ . '/../Rules/Comparison/data/bug-6551.php'; + yield __DIR__ . '/../Rules/Variables/data/bug-9392.php'; yield __DIR__ . '/../Rules/Variables/data/bug-9403.php'; yield __DIR__ . '/../Rules/Variables/data/bug-12364.php'; yield __DIR__ . '/../Rules/Methods/data/bug-9542.php'; diff --git a/tests/PHPStan/Analyser/nsrt/bug-9392.php b/tests/PHPStan/Analyser/nsrt/bug-9392.php deleted file mode 100644 index e04ce83a378..00000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-9392.php +++ /dev/null @@ -1,26 +0,0 @@ -= 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/data/bug-9392.php b/tests/PHPStan/Rules/Variables/data/bug-9392.php index 361e38ac768..7a98de8f3d0 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-9392.php +++ b/tests/PHPStan/Rules/Variables/data/bug-9392.php @@ -2,6 +2,8 @@ namespace Bug9392; +use function PHPStan\Testing\assertType; + class Range { public function __construct( @@ -18,6 +20,9 @@ public function __construct( notInRangeMessage: sprintf('The price must be between %s and %s.', round($min / 100, 2), round($max / 100, 2)), ); +assertType('2000', $min); +assertType('500000', $max); + function foo(?string $c = null, mixed $a = null, mixed $b = null): void { }