From ac0e738954559c1dd71fab25369ad6028894557e Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Oct 2025 18:13:29 +0200 Subject: [PATCH 1/4] 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 54a92fce7c..ade9e02dda 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3072,7 +3072,17 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto } 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 92c6565789..577e36156b 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -235,6 +235,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 d45bfd61ae..3aaaeb1e20 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 a3eb7f9bcd5c75681464245eaf65c99434f66732 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 17:00:56 +0100 Subject: [PATCH 2/4] 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); + } +} From b6eaf14b85d2ccea4869db0c3d1abae468e5255b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 10 Feb 2026 22:22:27 +0100 Subject: [PATCH 3/4] Add test --- tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php | 7 ++++++- tests/PHPStan/Rules/Variables/data/bug-12364.php | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 3aaaeb1e20..2960824b69 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1017,7 +1017,12 @@ public function testBug12364(): void $this->polluteScopeWithLoopInitialAssignments = true; $this->checkMaybeUndefinedVariables = true; $this->polluteScopeWithAlwaysIterableForeach = true; - $this->analyse([__DIR__ . '/data/bug-12364.php'], []); + $this->analyse([__DIR__ . '/data/bug-12364.php'], [ + [ + 'Variable $z might not be defined.', + 18, + ], + ]); } public function testDiscussion10252(): void diff --git a/tests/PHPStan/Rules/Variables/data/bug-12364.php b/tests/PHPStan/Rules/Variables/data/bug-12364.php index 8a6a569a43..c4753fb033 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-12364.php +++ b/tests/PHPStan/Rules/Variables/data/bug-12364.php @@ -4,7 +4,7 @@ use function PHPStan\Testing\assertType; -/** @return array{x: string, y?: string} */ +/** @return array{x: string, y?: string, z?: string} */ function foo(): array { return [ 'x' => 'foo' ]; } @@ -15,6 +15,7 @@ function foo(): array { extract(foo()); assertType('string', $x); assertType('string|null', $y); // <-- should be: null|string +assertType('mixed', $z); var_dump($x); var_dump($y); // <-- does exist From 90d84cb481dbf560daabfc51200e1b9e9ce59588 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 11 Feb 2026 12:41:59 +0100 Subject: [PATCH 4/4] Assert variable certainty --- tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php | 6 +++++- tests/PHPStan/Rules/Variables/data/bug-12364.php | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 2960824b69..193c390873 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1020,7 +1020,11 @@ public function testBug12364(): void $this->analyse([__DIR__ . '/data/bug-12364.php'], [ [ 'Variable $z might not be defined.', - 18, + 20, + ], + [ + 'Variable $z might not be defined.', + 23, ], ]); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-12364.php b/tests/PHPStan/Rules/Variables/data/bug-12364.php index c4753fb033..f8a0c216e3 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-12364.php +++ b/tests/PHPStan/Rules/Variables/data/bug-12364.php @@ -2,7 +2,9 @@ namespace Bug12364; +use PHPStan\TrinaryLogic; use function PHPStan\Testing\assertType; +use function PHPStan\Testing\assertVariableCertainty; /** @return array{x: string, y?: string, z?: string} */ function foo(): array { @@ -16,6 +18,9 @@ function foo(): array { assertType('string', $x); assertType('string|null', $y); // <-- should be: null|string assertType('mixed', $z); +assertVariableCertainty(TrinaryLogic::createYes(), $x); +assertVariableCertainty(TrinaryLogic::createYes(), $y); // <-- should be: null|string +assertVariableCertainty(TrinaryLogic::createMaybe(), $z); var_dump($x); var_dump($y); // <-- does exist