From 61b98ea8d19f05c64f6f1353ae468a6c2e97d441 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Oct 2025 18:13:29 +0200 Subject: [PATCH 1/2] Respect original variable type when using extract on optional keys --- src/Analyser/NodeScopeResolver.php | 12 +++++++++++- .../Analyser/NodeScopeResolverTest.php | 1 + .../Variables/DefinedVariableRuleTest.php | 9 +++++++++ .../Rules/Variables/data/bug-12364.php | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-12364.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e093860bac..515b494497 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2957,7 +2957,17 @@ static function (): void { } foreach ($properties as $name => $type) { $optional = in_array($name, $optionalProperties, true) || $refCount[$name] < count($constantArrays); - $scope = $scope->assignVariable($name, $type, $type, $optional ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes()); + + if (!$optional) { + $scope = $scope->assignVariable($name, $type, $type, TrinaryLogic::createYes()); + } else { + $hasVariable = $scope->hasVariableType($name); + if (!$hasVariable->no()) { + $type = TypeCombinator::union($scope->getVariableType($name), $type); + } + + $scope = $scope->assignVariable($name, $type, $type, $scope->hasVariableType($name)->or(TrinaryLogic::createMaybe())); + } } } else { $scope = $scope->afterExtractCall(); diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index c08efc1a78..53cd2f9f62 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -226,6 +226,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Comparison/data/bug-5365.php'; yield __DIR__ . '/../Rules/Comparison/data/bug-6551.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'; yield __DIR__ . '/../Rules/Functions/data/bug-9803.php'; yield __DIR__ . '/../Rules/PhpDoc/data/bug-10594.php'; diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 96b89cf070..01628eaaa9 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1011,6 +1011,15 @@ public function testIsStringNarrowsCertainty(): void ]); } + public function testBug12364(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-12364.php'], []); + } + public function testDiscussion10252(): void { $this->cliArgumentsVariablesRegistered = true; diff --git a/tests/PHPStan/Rules/Variables/data/bug-12364.php b/tests/PHPStan/Rules/Variables/data/bug-12364.php new file mode 100644 index 0000000000..84cb57f799 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12364.php @@ -0,0 +1,19 @@ + 'foo' ]; +} + +$x = $y = null; +assertType('null', $x); +assertType('null', $y); +extract(foo()); +assertType('string', $x); +assertType('string|null', $y); // <-- should be: null|string +var_dump($x); +var_dump($y); // <-- does exist From 9f4771a22382644e0570567578a3dfbe1255cf11 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 17:00:56 +0100 Subject: [PATCH 2/2] Add test --- .../Rules/Variables/data/bug-12364.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/PHPStan/Rules/Variables/data/bug-12364.php b/tests/PHPStan/Rules/Variables/data/bug-12364.php index 84cb57f799..8a6a569a43 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-12364.php +++ b/tests/PHPStan/Rules/Variables/data/bug-12364.php @@ -17,3 +17,24 @@ function foo(): array { assertType('string|null', $y); // <-- should be: null|string var_dump($x); var_dump($y); // <-- does exist + +/** @return array{xx: string, yy?: string} */ +function foo2(): array { + return [ 'xx' => 'foo' ]; +} + +function testUndefined() +{ + if (rand(0, 1)) { + $xx = $yy = 0; + assertType('0', $xx); + assertType('0', $yy); + } + + extract(foo2()); + assertType('string', $xx); + + if (isset($yy)) { + assertType('0|string', $yy); + } +}