From 77aedcd144fba4d2d545abb7fa60d7216a113ceb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 10:50:33 +0200 Subject: [PATCH] Move list-contradiction `NeverType` out of `unsetOffset` into `tryRemove` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unsetOffset` is a "what does this array look like after unsetting X?" operation — its result is always still an array. The three identical `elseif ($this->isList->yes() && $newIsList->no()) { return new NeverType(); }` returns sprinkled through the three branches were subtraction semantics leaking up from the only two callers that pass `preserveListCertainty = true`: the `HasOffsetType` and `HasOffsetValueType` arms of `tryRemove`. Push that check up to its actual home in `tryRemove`. The unset branches now consistently return the post-unset shape; `tryRemove` decides on its own that "definitely-a-list minus a definitely-present key = empty set" and returns `NeverType` for that one case. Behaviour is preserved (full test suite green). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 41561fd015a..e0ee7290b4a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -859,8 +859,6 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals ); if (!$preserveListCertainty) { $newIsList = $newIsList->and(TrinaryLogic::createMaybe()); - } elseif ($this->isList->yes() && $newIsList->no()) { - return new NeverType(); } return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); @@ -1744,12 +1742,16 @@ public function tryRemove(Type $typeToRemove): ?Type return new ConstantArrayType([], []); } - if ($typeToRemove instanceof HasOffsetType) { - return $this->unsetOffset($typeToRemove->getOffsetType(), true); - } - - if ($typeToRemove instanceof HasOffsetValueType) { - return $this->unsetOffset($typeToRemove->getOffsetType(), true); + if ($typeToRemove instanceof HasOffsetType || $typeToRemove instanceof HasOffsetValueType) { + $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true); + // When the source was definitely a list but the post-unset shape + // definitely isn't (e.g. unsetting a non-optional leading key + // creates a hole), no value of $this could have lacked the + // removed key — the subtraction yields the empty set. + if ($this->isList->yes() && $unsetResult->isList()->no()) { + return new NeverType(); + } + return $unsetResult; } return null;