From b5379a576ed3d090a19a4cda27b6b9c7bd355e90 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:14:15 +0000 Subject: [PATCH 01/10] Fix array_key_exists false positive on list types with optional trailing keys When `ConstantArrayType::unsetOffset()` removed an optional trailing key from a list type, it unconditionally set `isList` to `No`. This caused `TypeCombinator::remove()` to produce `NeverType` when re-intersecting with `AccessoryArrayListType`, since the list accessor rejects non-list arrays. The `NeverType` falsy branch then collapsed scope merging after conditionals like ternaries, propagating the narrowed (truthy) type beyond the conditional and triggering false "always true" reports on subsequent `array_key_exists()` calls. The fix preserves the original `isList` value when unsetting an optional key whose removal leaves the remaining keys as consecutive integers starting from 0. Fixes https://github.com/phpstan/phpstan/issues/14177 --- src/Type/Constant/ConstantArrayType.php | 16 ++++++++++- tests/PHPStan/Analyser/nsrt/bug-14177.php | 21 +++++++++++++++ ...mpossibleCheckTypeFunctionCallRuleTest.php | 6 +++++ .../Rules/Comparison/data/bug-14177.php | 27 +++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14177.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14177.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 6cff7ef3af5..2cc4776bf7d 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -741,7 +741,21 @@ public function unsetOffset(Type $offsetType): Type $k++; } - return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, TrinaryLogic::createNo()); + $newIsList = TrinaryLogic::createNo(); + if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) { + $preserveIsList = true; + foreach ($newKeyTypes as $k2 => $newKeyType2) { + if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) { + $preserveIsList = false; + break; + } + } + if ($preserveIsList) { + $newIsList = $this->isList; + } + } + + return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); } return $this; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php new file mode 100644 index 00000000000..45df4cf02c4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -0,0 +1,21 @@ +analyse([__DIR__ . '/data/pr-4375.php'], []); } + public function testBug14177(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14177.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14177.php b/tests/PHPStan/Rules/Comparison/data/bug-14177.php new file mode 100644 index 00000000000..9c45152e4ef --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14177.php @@ -0,0 +1,27 @@ +', + $id, + array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '', + ); + + return array_key_exists(2, $matches) && $matches[2] !== '' + ? sprintf('%s', $matches[2], $replacement) + : $replacement; + }, + $html, + ); + } +} From 86e7f3f7254d4804233d568f19d76c271a7c7e07 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 22 Feb 2026 20:34:33 +0100 Subject: [PATCH 02/10] WIP --- src/Type/Constant/ConstantArrayType.php | 11 +-- tests/PHPStan/Analyser/nsrt/bug-14177.php | 98 +++++++++++++++++++++++ 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 2cc4776bf7d..cd37f1ac131 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -743,16 +743,7 @@ public function unsetOffset(Type $offsetType): Type $newIsList = TrinaryLogic::createNo(); if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) { - $preserveIsList = true; - foreach ($newKeyTypes as $k2 => $newKeyType2) { - if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) { - $preserveIsList = false; - break; - } - } - if ($preserveIsList) { - $newIsList = $this->isList; - } + $newIsList = TrinaryLogic::createMaybe(); } return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 45df4cf02c4..a5230531650 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -18,4 +18,102 @@ public function testList(array $b): void } assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b); } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset0(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[0]); + assertType('false', array_is_list($b)); + $b[] = 'foo'; + assertType('false', array_is_list($b)); + } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset1(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[1]); + assertType('bool', array_is_list($b)); + $b[] = 'foo'; + assertType('false', array_is_list($b)); + } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset2(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[2]); + assertType('bool', array_is_list($b)); + $b[] = 'foo'; + assertType('false', array_is_list($b)); + } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset3(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[3]); + assertType('true', array_is_list($b)); + $b[] = 'foo'; + assertType('false', array_is_list($b)); + } + + public function placeholderToEditor(string $html): void + { + $result = preg_replace_callback( + '~\[image\\sid="(\\d+)"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?\]~', + function (array $matches): string { + $id = (int) $matches[1]; + + assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches); + + $replacement = sprintf( + '', + $id, + array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '', + ); + + assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches); + + return array_key_exists(2, $matches) && $matches[2] !== '' + ? sprintf('%s', $matches[2], $replacement) + : $replacement; + }, + $html, + ); + } + + public function placeholderToEditor2(string $html): void + { + $result = preg_replace_callback( + '~\[image\\sid="(\\d+)?"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?\]~', + function (array $matches): string { + $id = (int) $matches[0]; + + assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches); + + $replacement = sprintf( + '', + $id, + array_key_exists(2, $matches) ? sprintf(' class="%s"', $matches[2]) : '', + ); + + assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches); + + return array_key_exists(1, $matches) && $matches[1] !== '' + ? sprintf('%s', $matches[1], $replacement) + : $replacement; + }, + $html, + ); + } } From d7d28b93777a992e7a9a8c1f53c0579970436509 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 22 Feb 2026 22:34:58 +0100 Subject: [PATCH 03/10] Rework --- src/Type/Constant/ConstantArrayType.php | 27 +++- tests/PHPStan/Analyser/nsrt/bug-14177.php | 147 +++++++++++++++------- 2 files changed, 124 insertions(+), 50 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index cd37f1ac131..318b5ada418 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -742,8 +742,31 @@ public function unsetOffset(Type $offsetType): Type } $newIsList = TrinaryLogic::createNo(); - if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) { - $newIsList = TrinaryLogic::createMaybe(); + if (!$this->isList->no()) { + $preserveIsList = true; + $isListOnlyIfKeysAreOptional = false; + foreach ($newKeyTypes as $k2 => $newKeyType2) { + if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) { + // We found a non-optional key that implies that the array is never a list. + if (!in_array($k2, $newOptionalKeys, true)) { + $preserveIsList = false; + break; + } + + // The array can still be a list if all the following keys are also optional. + $isListOnlyIfKeysAreOptional = true; + continue; + } + + if ($isListOnlyIfKeysAreOptional && !in_array($k2, $newOptionalKeys, true)) { + $preserveIsList = false; + break; + } + } + + if ($preserveIsList) { + $newIsList = $this->isList; + } } return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index a5230531650..8e2a20db945 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -19,54 +19,6 @@ public function testList(array $b): void assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b); } - /** - * @param list{0: string, 1: string, 2?: string, 3?: string} $b - */ - public function testUnset0(array $b): void - { - assertType('true', array_is_list($b)); - unset($b[0]); - assertType('false', array_is_list($b)); - $b[] = 'foo'; - assertType('false', array_is_list($b)); - } - - /** - * @param list{0: string, 1: string, 2?: string, 3?: string} $b - */ - public function testUnset1(array $b): void - { - assertType('true', array_is_list($b)); - unset($b[1]); - assertType('bool', array_is_list($b)); - $b[] = 'foo'; - assertType('false', array_is_list($b)); - } - - /** - * @param list{0: string, 1: string, 2?: string, 3?: string} $b - */ - public function testUnset2(array $b): void - { - assertType('true', array_is_list($b)); - unset($b[2]); - assertType('bool', array_is_list($b)); - $b[] = 'foo'; - assertType('false', array_is_list($b)); - } - - /** - * @param list{0: string, 1: string, 2?: string, 3?: string} $b - */ - public function testUnset3(array $b): void - { - assertType('true', array_is_list($b)); - unset($b[3]); - assertType('true', array_is_list($b)); - $b[] = 'foo'; - assertType('false', array_is_list($b)); - } - public function placeholderToEditor(string $html): void { $result = preg_replace_callback( @@ -117,3 +69,102 @@ function (array $matches): string { ); } } + +class HelloWorld2 +{ + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset0OnList(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[0]); + assertType('false', array_is_list($b)); + $b[] = 'foo'; + assertType('false', array_is_list($b)); + } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset1OnList(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[1]); + assertType('bool', array_is_list($b)); + $b[] = 'foo'; + assertType('bool', array_is_list($b)); // Could be false + } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset2OnList(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[2]); + assertType('bool', array_is_list($b)); + $b[] = 'foo'; + assertType('bool', array_is_list($b)); // Could be false + } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $b + */ + public function testUnset3OnList(array $b): void + { + assertType('true', array_is_list($b)); + unset($b[3]); + assertType('bool', array_is_list($b)); // Could be true + $b[] = 'foo'; + assertType('bool', array_is_list($b)); // Could be false + } + + /** + * @param array{0: string, 1?: string, 2: string, 3?: string} $b + */ + public function testUnset0OnArray(array $b): void + { + assertType('bool', array_is_list($b)); + unset($b[0]); + assertType('false', array_is_list($b)); + $b[] = 'foo'; + assertType('false', array_is_list($b)); + } + + /** + * @param array{0: string, 1?: string, 2: string, 3?: string} $b + */ + public function testUnset1OnArray(array $b): void + { + assertType('bool', array_is_list($b)); + unset($b[1]); + assertType('false', array_is_list($b)); + $b[] = 'foo'; + assertType('false', array_is_list($b)); + } + + /** + * @param array{0: string, 1?: string, 2: string, 3?: string} $b + */ + public function testUnset2OnArray(array $b): void + { + assertType('bool', array_is_list($b)); + unset($b[2]); + assertType('bool', array_is_list($b)); + $b[] = 'foo'; + assertType('bool', array_is_list($b)); // Could be false + } + + /** + * @param array{0: string, 1?: string, 2: string, 3?: string} $b + */ + public function testUnset3OnArray(array $b): void + { + assertType('bool', array_is_list($b)); + unset($b[3]); + assertType('bool', array_is_list($b)); + $b[] = 'foo'; + assertType('bool', array_is_list($b)); // Could be false + } +} From 89f5a54620f575581018c57b669101a2e91593b1 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 22 Feb 2026 23:02:21 +0100 Subject: [PATCH 04/10] Simplify --- src/Type/Constant/ConstantArrayType.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 318b5ada418..68d35361701 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -742,7 +742,10 @@ public function unsetOffset(Type $offsetType): Type } $newIsList = TrinaryLogic::createNo(); - if (!$this->isList->no()) { + // We're unsetting something that might not be on the array, + // so it might still be a list (with PHPStan definition) + // because the nextAutoIndexes will not change. + if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) { $preserveIsList = true; $isListOnlyIfKeysAreOptional = false; foreach ($newKeyTypes as $k2 => $newKeyType2) { @@ -765,7 +768,7 @@ public function unsetOffset(Type $offsetType): Type } if ($preserveIsList) { - $newIsList = $this->isList; + $newIsList = TrinaryLogic::createMaybe(); } } From 405eecf2cedaf1807bc962202e30a50f41a299e6 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 22 Feb 2026 23:06:07 +0100 Subject: [PATCH 05/10] Fix --- tests/PHPStan/Analyser/nsrt/array-is-list-unset.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14177.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php index b14b8c97df8..f464ff183e3 100644 --- a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php +++ b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php @@ -19,7 +19,7 @@ function () { assertType('true', array_is_list($a)); unset($a[2]); assertType('array{1, 2}', $a); - assertType('false', array_is_list($a)); + assertType('false', array_is_list($a)); // Could be true $a[] = 4; assertType('array{0: 1, 1: 2, 3: 4}', $a); assertType('false', array_is_list($a)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 8e2a20db945..c0eed10781b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -1,6 +1,6 @@ Date: Sun, 22 Feb 2026 23:19:42 +0100 Subject: [PATCH 06/10] Handle non constant unset --- src/Type/Constant/ConstantArrayType.php | 87 +++++++++++++++---------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 68d35361701..719d774750f 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -741,36 +741,12 @@ public function unsetOffset(Type $offsetType): Type $k++; } - $newIsList = TrinaryLogic::createNo(); - // We're unsetting something that might not be on the array, - // so it might still be a list (with PHPStan definition) - // because the nextAutoIndexes will not change. - if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) { - $preserveIsList = true; - $isListOnlyIfKeysAreOptional = false; - foreach ($newKeyTypes as $k2 => $newKeyType2) { - if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) { - // We found a non-optional key that implies that the array is never a list. - if (!in_array($k2, $newOptionalKeys, true)) { - $preserveIsList = false; - break; - } - - // The array can still be a list if all the following keys are also optional. - $isListOnlyIfKeysAreOptional = true; - continue; - } - - if ($isListOnlyIfKeysAreOptional && !in_array($k2, $newOptionalKeys, true)) { - $preserveIsList = false; - break; - } - } - - if ($preserveIsList) { - $newIsList = TrinaryLogic::createMaybe(); - } - } + $newIsList = $this->isListAfterUnset( + $newKeyTypes, + $newOptionalKeys, + $this->isList, + in_array($i, $this->optionalKeys, true), + ); return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); } @@ -801,21 +777,64 @@ public function unsetOffset(Type $offsetType): Type } } - return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, TrinaryLogic::createNo()); + $newIsList = $this->isListAfterUnset( + $this->keyTypes, + $optionalKeys, + $this->isList, + count($optionalKeys) === count($this->optionalKeys), + ); + + return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); } $optionalKeys = $this->optionalKeys; - $isList = $this->isList; foreach ($this->keyTypes as $i => $keyType) { if (!$offsetType->isSuperTypeOf($keyType)->yes()) { continue; } $optionalKeys[] = $i; - $isList = TrinaryLogic::createNo(); } $optionalKeys = array_values(array_unique($optionalKeys)); - return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $isList); + $newIsList = $this->isListAfterUnset( + $this->keyTypes, + $optionalKeys, + $this->isList, + count($optionalKeys) === count($this->optionalKeys), + ); + + return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); + } + + /** + * If we're unsetting something that might not be on the array, it might still be a list (with PHPStan definition) + * because the nextAutoIndexes will not change. + */ + private function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic + { + if (!$unsetOptionalKey || $arrayIsList->no()) { + return TrinaryLogic::createNo(); + } + + $isListOnlyIfKeysAreOptional = false; + foreach ($newKeyTypes as $k2 => $newKeyType2) { + if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) { + // We found a non-optional key that implies that the array is never a list. + if (!in_array($k2, $newOptionalKeys, true)) { + return TrinaryLogic::createNo(); + } + + // The array can still be a list if all the following keys are also optional. + $isListOnlyIfKeysAreOptional = true; + continue; + } + + if ($isListOnlyIfKeysAreOptional && !in_array($k2, $newOptionalKeys, true)) { + return TrinaryLogic::createNo(); + } + } + + return TrinaryLogic::createMaybe(); } public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type From 77b49bfb4290550fd67cfa28e57a37e2312f3805 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 22 Feb 2026 23:26:26 +0100 Subject: [PATCH 07/10] More --- .../Analyser/nsrt/array-is-list-unset.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14177.php | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php index f464ff183e3..b14b8c97df8 100644 --- a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php +++ b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php @@ -19,7 +19,7 @@ function () { assertType('true', array_is_list($a)); unset($a[2]); assertType('array{1, 2}', $a); - assertType('false', array_is_list($a)); // Could be true + assertType('false', array_is_list($a)); $a[] = 4; assertType('array{0: 1, 1: 2, 3: 4}', $a); assertType('false', array_is_list($a)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index c0eed10781b..c20901af8f7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -151,7 +151,7 @@ public function testUnset2OnArray(array $b): void { assertType('bool', array_is_list($b)); unset($b[2]); - assertType('false', array_is_list($b)); // Could be true + assertType('false', array_is_list($b)); $b[] = 'foo'; assertType('false', array_is_list($b)); } @@ -167,4 +167,37 @@ public function testUnset3OnArray(array $b): void $b[] = 'foo'; assertType('bool', array_is_list($b)); // Could be false } + + /** + * @param list{0: string, 1: string, 2?: string, 3?: string} $a + * @param list{0: string, 1?: string, 2?: string, 3?: string} $b + * @param list{0: string, 1?: string, 2?: string, 3: string} $c + * @param 1|2 $int + */ + public function testUnsetNonConstant(array $a, array $b, array $c, int $int): void + { + assertType('true', array_is_list($a)); + assertType('true', array_is_list($b)); + assertType('true', array_is_list($c)); + unset($a[$int]); + unset($b[$int]); + unset($c[$int]); + assertType('false', array_is_list($a)); + assertType('bool', array_is_list($b)); + assertType('bool', array_is_list($c)); + } + + /** + * @param list{0?: string, 1?: string, 2?: string, 3?: string} $a + * @param list{0: string, 1?: string, 2?: string, 3?: string} $b + */ + public function testUnsetInt(array $a, array $b, array $c, int $int): void + { + assertType('true', array_is_list($a)); + assertType('true', array_is_list($b)); + unset($a[$int]); + unset($b[$int]); + assertType('bool', array_is_list($a)); + assertType('false', array_is_list($b)); + } } From 1d76e964aeb40089e8ef6ed1f42d4cee77ee745f Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 22 Feb 2026 23:40:34 +0100 Subject: [PATCH 08/10] Improve --- src/Type/Constant/ConstantArrayType.php | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 719d774750f..80679e3365f 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -741,7 +741,7 @@ public function unsetOffset(Type $offsetType): Type $k++; } - $newIsList = $this->isListAfterUnset( + $newIsList = self::isListAfterUnset( $newKeyTypes, $newOptionalKeys, $this->isList, @@ -758,6 +758,7 @@ public function unsetOffset(Type $offsetType): Type if (count($constantScalars) > 0) { $optionalKeys = $this->optionalKeys; + $arrayHasChanged = false; foreach ($constantScalars as $constantScalar) { $constantScalar = $constantScalar->toArrayKey(); if (!$constantScalar instanceof ConstantIntegerType && !$constantScalar instanceof ConstantStringType) { @@ -769,6 +770,7 @@ public function unsetOffset(Type $offsetType): Type continue; } + $arrayHasChanged = true; if (in_array($i, $optionalKeys, true)) { continue 2; } @@ -777,7 +779,11 @@ public function unsetOffset(Type $offsetType): Type } } - $newIsList = $this->isListAfterUnset( + if (!$arrayHasChanged) { + return $this; + } + + $newIsList = self::isListAfterUnset( $this->keyTypes, $optionalKeys, $this->isList, @@ -788,15 +794,21 @@ public function unsetOffset(Type $offsetType): Type } $optionalKeys = $this->optionalKeys; + $arrayHasChanged = false; foreach ($this->keyTypes as $i => $keyType) { if (!$offsetType->isSuperTypeOf($keyType)->yes()) { continue; } + $arrayHasChanged = true; $optionalKeys[] = $i; } $optionalKeys = array_values(array_unique($optionalKeys)); - $newIsList = $this->isListAfterUnset( + if (!$arrayHasChanged) { + return $this; + } + + $newIsList = self::isListAfterUnset( $this->keyTypes, $optionalKeys, $this->isList, @@ -807,10 +819,10 @@ public function unsetOffset(Type $offsetType): Type } /** - * If we're unsetting something that might not be on the array, it might still be a list (with PHPStan definition) - * because the nextAutoIndexes will not change. + * When we're unsetting something not on the array, it will be untouched, + * So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition. */ - private function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic + private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic { if (!$unsetOptionalKey || $arrayIsList->no()) { return TrinaryLogic::createNo(); From faf726db33f687542fb2b9f61e952158e19305bd Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 22 Feb 2026 23:47:05 +0100 Subject: [PATCH 09/10] Fix --- src/Type/Constant/ConstantArrayType.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 80679e3365f..e2db1f96456 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -821,6 +821,9 @@ public function unsetOffset(Type $offsetType): Type /** * When we're unsetting something not on the array, it will be untouched, * So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition. + * + * @param list $newKeyTypes + * @param int[] $newOptionalKeys */ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic { From 2a30bf181587dee42b4e08b4f3e90190c8410ad8 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 23 Feb 2026 09:36:01 +0100 Subject: [PATCH 10/10] Feedback --- src/Type/Constant/ConstantArrayType.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14177.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e2db1f96456..cc1a545e252 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -823,7 +823,7 @@ public function unsetOffset(Type $offsetType): Type * So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition. * * @param list $newKeyTypes - * @param int[] $newOptionalKeys + * @param int[] $newOptionalKeys */ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index c20901af8f7..16107251705 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -91,7 +91,7 @@ public function testUnset1OnList(array $b): void { assertType('true', array_is_list($b)); unset($b[1]); - assertType('false', array_is_list($b)); // Could be true + assertType('false', array_is_list($b)); $b[] = 'foo'; assertType('false', array_is_list($b)); } @@ -105,7 +105,7 @@ public function testUnset2OnList(array $b): void unset($b[2]); assertType('bool', array_is_list($b)); $b[] = 'foo'; - assertType('bool', array_is_list($b)); // Could be false + assertType('bool', array_is_list($b)); } /** @@ -115,9 +115,9 @@ public function testUnset3OnList(array $b): void { assertType('true', array_is_list($b)); unset($b[3]); - assertType('bool', array_is_list($b)); // Could be true + assertType('bool', array_is_list($b)); $b[] = 'foo'; - assertType('bool', array_is_list($b)); // Could be false + assertType('bool', array_is_list($b)); } /** @@ -165,7 +165,7 @@ public function testUnset3OnArray(array $b): void unset($b[3]); assertType('bool', array_is_list($b)); $b[] = 'foo'; - assertType('bool', array_is_list($b)); // Could be false + assertType('bool', array_is_list($b)); } /**