From 841040d1a79ba856a4079c2e667841c31447c154 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 11 May 2026 12:22:13 +0000 Subject: [PATCH 1/6] Do not match conditional expression guards when ConstantArrayType has extra keys In filterBySpecifiedTypes Pass 2 (supertype match), isSuperTypeOf with structural subtyping for ConstantArrayType allowed a current type with extra keys to satisfy a guard expecting fewer keys. This caused incorrect type narrowing through a chain of conditional expressions. Closes https://github.com/phpstan/phpstan/issues/14595 --- src/Analyser/MutatingScope.php | 19 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14595.php | 28 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14595.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index daf5bb9eb37..cdc45d0bb41 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3338,6 +3338,25 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self ) { continue 2; } + + $guardConstantArrays = $conditionalTypeHolder->getType()->getConstantArrays(); + if (count($guardConstantArrays) !== 1) { + continue; + } + + $currentConstantArrays = $specifiedExpressions[$holderExprString]->getType()->getConstantArrays(); + $hasExtraKeys = false; + foreach ($currentConstantArrays as $currentConstantArray) { + foreach ($currentConstantArray->getKeyTypes() as $currentKeyType) { + if ($guardConstantArrays[0]->hasOffsetValueType($currentKeyType)->no()) { + $hasExtraKeys = true; + break 2; + } + } + } + if ($hasExtraKeys) { + continue 2; + } } $conditions[$conditionalExprString][] = $conditionalExpression; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14595.php b/tests/PHPStan/Analyser/nsrt/bug-14595.php new file mode 100644 index 00000000000..6c580ca32ec --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14595.php @@ -0,0 +1,28 @@ + $data + * @param array{ + * multiple: 0|1|2 + * , total: bool + * } $options + */ +function formulaire_edition(array $data, array $options): void { + $instructions = [ ]; + $instructions[] = "foo"; + if ($options['multiple'] != 1 || $options['total']) + $instructions[] = "bar"; + assertType('0|1|2', $options['multiple']); + if (!$options['total']) + $instructions[] = "baz"; + assertType('0|1|2', $options['multiple']); + if (!$options['total']) + $instructions[] = "qux"; + assertType('0|1|2', $options['multiple']); +} From 4d35fc3ec6ccbe349a0339226c37330caa144fab Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 11 May 2026 19:37:11 +0000 Subject: [PATCH 2/6] Add regression test for reviewer's variant with separate bool parameter The bug also reproduces when `total` is a separate `bool` parameter instead of a key in the `$options` array. The existing fix handles this case because the extra-keys check applies to `$instructions` (the guard condition variable that grows), not to `$options`. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14595.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14595.php b/tests/PHPStan/Analyser/nsrt/bug-14595.php index 6c580ca32ec..c0517f151c0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14595.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14595.php @@ -26,3 +26,26 @@ function formulaire_edition(array $data, array $options): void { $instructions[] = "qux"; assertType('0|1|2', $options['multiple']); } + +/** + * @param array $data + * @param array{ + * multiple: 0|1|2 + * } $options + */ +function formulaire_edition_separate_bool(array $data, array $options, bool $total): void { + $instructions = [ ]; + $instructions[] = "foo"; + if ($options['multiple'] != 1 || $total) { + $instructions[] = "bar"; + } + assertType('0|1|2', $options['multiple']); + if (!$total) { + $instructions[] = "baz"; + } + assertType('0|1|2', $options['multiple']); + if (!$total) { + $instructions[] = "qux"; + } + assertType('0|1|2', $options['multiple']); +} From 32d8455e5fc527f9a9de8f9b2381400643313422 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 11 May 2026 20:33:20 +0000 Subject: [PATCH 3/6] Simplify ConstantArrayType guard check to use key count comparison Instead of checking individual keys with hasOffsetValueType(), compare key counts. Since isSuperTypeOf() already verified all guard keys exist in the current type, any extra keys manifest as a higher key count. This addresses the reviewer's concern about hasOffsetValueType() returning Maybe and simplifies the logic. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index cdc45d0bb41..db47c52e023 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3344,14 +3344,12 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } - $currentConstantArrays = $specifiedExpressions[$holderExprString]->getType()->getConstantArrays(); + $guardKeyCount = count($guardConstantArrays[0]->getKeyTypes()); $hasExtraKeys = false; - foreach ($currentConstantArrays as $currentConstantArray) { - foreach ($currentConstantArray->getKeyTypes() as $currentKeyType) { - if ($guardConstantArrays[0]->hasOffsetValueType($currentKeyType)->no()) { - $hasExtraKeys = true; - break 2; - } + foreach ($specifiedExpressions[$holderExprString]->getType()->getConstantArrays() as $currentConstantArray) { + if (count($currentConstantArray->getKeyTypes()) > $guardKeyCount) { + $hasExtraKeys = true; + break; } } if ($hasExtraKeys) { From 6cc1a792601255d687216cd0df090d930319732d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 11 May 2026 20:43:58 +0000 Subject: [PATCH 4/6] Remove $hasExtraKeys flag and support multiple guard constant arrays Address review feedback: - Use `continue 3` directly instead of a flag variable - Support multiple guard constant arrays by comparing against the max key count, instead of skipping when count !== 1 Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index db47c52e023..74c0cb03a8b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3340,21 +3340,16 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } $guardConstantArrays = $conditionalTypeHolder->getType()->getConstantArrays(); - if (count($guardConstantArrays) !== 1) { + if (count($guardConstantArrays) === 0) { continue; } - $guardKeyCount = count($guardConstantArrays[0]->getKeyTypes()); - $hasExtraKeys = false; + $guardMaxKeyCount = max(array_map(static fn ($a) => count($a->getKeyTypes()), $guardConstantArrays)); foreach ($specifiedExpressions[$holderExprString]->getType()->getConstantArrays() as $currentConstantArray) { - if (count($currentConstantArray->getKeyTypes()) > $guardKeyCount) { - $hasExtraKeys = true; - break; + if (count($currentConstantArray->getKeyTypes()) > $guardMaxKeyCount) { + continue 3; } } - if ($hasExtraKeys) { - continue 2; - } } $conditions[$conditionalExprString][] = $conditionalExpression; From 0e63f4e09bd564e87780cedf1359b00005635add Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 11 May 2026 21:00:33 +0000 Subject: [PATCH 5/6] Add regression test for multiple guard constant arrays with different key counts Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14595.php | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14595.php b/tests/PHPStan/Analyser/nsrt/bug-14595.php index c0517f151c0..a5284f6bc76 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14595.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14595.php @@ -49,3 +49,32 @@ function formulaire_edition_separate_bool(array $data, array $options, bool $tot } assertType('0|1|2', $options['multiple']); } + +/** + * @param array $data + * @param array{ + * multiple: 0|1|2 + * } $options + */ +function multiple_guard_constant_arrays(array $data, array $options, bool $flag1, bool $flag2): void { + $instructions = []; + if ($flag1) { + $instructions[] = "a"; + $instructions[] = "b"; + } else { + $instructions[] = "c"; + } + // $instructions is array{'a', 'b'}|array{'c'} — guard has 2 constant arrays with key counts 2 and 1 + if ($options['multiple'] != 1 || $flag2) { + $instructions[] = "d"; + } + assertType('0|1|2', $options['multiple']); + if (!$flag2) { + $instructions[] = "e"; + } + assertType('0|1|2', $options['multiple']); + if (!$flag2) { + $instructions[] = "f"; + } + assertType('0|1|2', $options['multiple']); +} From 4c2336e9d3aec9ce9a5ba0ba9edb4988fcd23df5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 11 May 2026 21:21:47 +0000 Subject: [PATCH 6/6] Add use function max import for proper namespaced reference Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 74c0cb03a8b..edb7fb5549f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -122,6 +122,7 @@ use function is_array; use function is_string; use function ltrim; +use function max; use function md5; use function sprintf; use function str_starts_with;