From 2c10f47512dffc2057d9622e09aa37f59c1194b4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 13:26:03 +0200 Subject: [PATCH 1/7] Count constant array finite types before building them ConstantArrayType::getFiniteTypes() forked a builder per optional key and only noticed after each key that the partial set had grown past CALCULATE_SCALARS_LIMIT, so a shape with many optional keys paid hundreds of builder clones per call for an empty result. Every caller that asks a large shape (the remove() finite-set difference, the ternary-arm and re-assignment checks in AssignHandler) hit this. Multiply the per-key counts first and only build when the product stays within the limit; the result is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GvDxWpzXzticTTm5eLCsb2 --- src/Type/Constant/ConstantArrayType.php | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 2f3471bbd6..9257a650c6 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3606,15 +3606,29 @@ public function getFiniteTypes(): array // Build finite array types incrementally, processing one key at a time. // For optional keys, fork each partial result into with/without variants. // This avoids generating 2^N ConstantArrayType objects via getAllArrays(). - /** @var list $partials */ - $partials = [ConstantArrayTypeBuilder::createEmpty()]; - + // Count first: a shape with many optional keys overflows the limit after a + // handful of keys, and building the partial arrays up to that point costs + // hundreds of builder clones per call for a result that is thrown away. + $finiteValueTypesPerKey = []; + $count = 1; foreach ($this->keyTypes as $i => $keyType) { $finiteValueTypes = $this->valueTypes[$i]->getFiniteTypes(); if ($finiteValueTypes === []) { return []; } + $finiteValueTypesPerKey[$i] = $finiteValueTypes; + $count *= count($finiteValueTypes) + ($this->isOptionalKey($i) ? 1 : 0); + if ($count > $limit) { + return []; + } + } + + /** @var list $partials */ + $partials = [ConstantArrayTypeBuilder::createEmpty()]; + + foreach ($this->keyTypes as $i => $keyType) { + $finiteValueTypes = $finiteValueTypesPerKey[$i]; $isOptional = $this->isOptionalKey($i); $newPartials = []; @@ -3630,9 +3644,6 @@ public function getFiniteTypes(): array } $partials = $newPartials; - if (count($partials) > $limit) { - return []; - } } $finiteTypes = []; From 9dcc323d0e04ae9dd76bd6907dbec4987b1cb919 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 13:35:42 +0200 Subject: [PATCH 2/7] Remove a union of enum cases from a sealed hierarchy in one subtraction TypeCombinator::remove() removes a union from a sealed hierarchy at once only when the object type is a supertype of the whole union. As soon as one member is already subtracted (`$a !== $b` narrowing inside a loop over Enum::cases() gets there quickly), the check fails and the members are peeled off one at a time - every peel rebuilds and re-describes the growing subtracted union, so removing 250 cases costs O(cases^2) describes. ObjectType::tryRemove() now drops the members the type no longer holds and subtracts the rest in one go, which is the same set difference. The allowed subtypes go through FiniteTypeSet: subtracted enum cases are matched by FiniteTypeSet::key() (a lookup per case) instead of an equals() sweep over every case, and the allowed subtypes it cannot key (a sealed class hierarchy) keep the sweep. ClassReflection::getAllowedSubTypes() memoises its result instead of rebuilding every case object on each call. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DfQHLTyyoMfjfa4sXJCGa5 --- src/Reflection/ClassReflection.php | 12 +++++- src/Type/Constant/ConstantArrayType.php | 4 +- src/Type/ObjectType.php | 53 +++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 3623c8f6ce..a7b1aefb08 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -110,6 +110,11 @@ final class ClassReflection private ?bool $isDeprecated = null; + /** @var array|null */ + private ?array $allowedSubTypes = null; + + private bool $allowedSubTypesResolved = false; + private ?bool $isGeneric = null; private ?bool $isInternal = null; @@ -2330,9 +2335,14 @@ public function getResolvedMixinTypes(): array */ public function getAllowedSubTypes(): ?array { + if ($this->allowedSubTypesResolved) { + return $this->allowedSubTypes; + } + + $this->allowedSubTypesResolved = true; foreach ($this->classReflectionExtensionRegistryProvider->getRegistry()->getAllowedSubTypesClassReflectionExtensions() as $allowedSubTypesClassReflectionExtension) { if ($allowedSubTypesClassReflectionExtension->supports($this)) { - return $allowedSubTypesClassReflectionExtension->getAllowedSubTypes($this); + return $this->allowedSubTypes = $allowedSubTypesClassReflectionExtension->getAllowedSubTypes($this); } } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 9257a650c6..b7c9ddd745 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3611,8 +3611,8 @@ public function getFiniteTypes(): array // hundreds of builder clones per call for a result that is thrown away. $finiteValueTypesPerKey = []; $count = 1; - foreach ($this->keyTypes as $i => $keyType) { - $finiteValueTypes = $this->valueTypes[$i]->getFiniteTypes(); + foreach ($this->valueTypes as $i => $valueType) { + $finiteValueTypes = $valueType->getFiniteTypes(); if ($finiteValueTypes === []) { return []; } diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index a9cf87f7d2..0db90aefa2 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -1850,13 +1850,32 @@ private function matchAllowedSubTypes(array $subtractedTypes): ?Type $allowedSubTypesCount = count($allowedSubTypes); $subtractedSubTypes = []; + // Enum cases are finite values: FiniteTypeSet keys them by value identity, so + // a subtracted case is a lookup instead of an equals() sweep over every + // allowed case - quadratic in the enum size. Allowed subtypes it cannot key + // (a sealed class hierarchy) still take the sweep. + $allowedSet = FiniteTypeSet::create(array_values($allowedSubTypes)); + $keyedAllowedSubTypes = $allowedSet !== null ? $allowedSet->getMembers() : []; + $otherAllowedSubTypes = $allowedSet !== null ? $allowedSet->getOthers() : array_values($allowedSubTypes); + foreach ($subtractedTypes as $subType) { - foreach ($allowedSubTypes as $key => $allowedSubType) { + $key = FiniteTypeSet::key($subType); + if ($key !== null) { + if (!array_key_exists($key, $keyedAllowedSubTypes) || !$subType->equals($keyedAllowedSubTypes[$key])) { + return null; + } + + $subtractedSubTypes[] = $subType; + unset($keyedAllowedSubTypes[$key]); + continue; + } + + foreach ($otherAllowedSubTypes as $i => $allowedSubType) { if ($subType->equals($allowedSubType)) { // An allowed subtype is dropped as it matches, so no two matches // can be the same one and the matches need no keying. $subtractedSubTypes[] = $subType; - unset($allowedSubTypes[$key]); + unset($otherAllowedSubTypes[$i]); continue 2; } } @@ -1864,8 +1883,9 @@ private function matchAllowedSubTypes(array $subtractedTypes): ?Type return null; } - if (count($allowedSubTypes) === 1) { - return array_values($allowedSubTypes)[0]; + $remainingAllowedSubTypes = array_merge(array_values($keyedAllowedSubTypes), array_values($otherAllowedSubTypes)); + if (count($remainingAllowedSubTypes) === 1) { + return $remainingAllowedSubTypes[0]; } $subtractedSubTypesCount = count($subtractedSubTypes); @@ -2064,6 +2084,31 @@ public function tryRemove(Type $typeToRemove): ?Type return $this->subtract($typeToRemove); } + $classReflection = $this->getClassReflection(); + if ($typeToRemove instanceof UnionType && $classReflection !== null && $classReflection->getAllowedSubTypes() !== null) { + // A sealed hierarchy subtracts by set difference, so the members this + // type no longer holds (already subtracted) are no-ops and the rest come + // off in one subtraction - the same result as removing them one at a + // time, without rebuilding the subtracted union once per member. + $membersToRemove = []; + foreach ($typeToRemove->getTypes() as $member) { + $isSuperTypeOfMember = $this->isSuperTypeOf($member); + if ($isSuperTypeOfMember->yes()) { + $membersToRemove[] = $member; + continue; + } + if ($isSuperTypeOfMember->maybe()) { + return null; + } + } + + if ($membersToRemove === []) { + return $this; + } + + return $this->subtract(count($membersToRemove) === 1 ? $membersToRemove[0] : new UnionType($membersToRemove)); + } + return null; } From 2f472e5d992b1196f9c0854e0a69461abf43c9dd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 14:32:08 +0200 Subject: [PATCH 3/7] Drop repeated members and flatten in one pass in TypeCombinator::doUnion() A member passed to union() more than once contributes nothing, but the pairwise comparison still paid for every repeat - and array_values() of a shape with optional keys unions the same value type once per slot, so a 250-entry map built in a loop over an enum cost 10 s in one call. The repeats are dropped by identity before anything else. The flattening loop also rebuilt the argument list with array_splice() once per union member, quadratic in the number of members; it is a single pass now. array_merge() hands the first array back as it is when the second one is empty, so the keys the bucketing unset are renumbered inside the merge argument. build/spl-autoload-functions-pre-php-7.neon only ignored the "array_values is already a list" report that the old standalone renumbering produced on PHP < 8 (the array_merge() stub returns a list there); nothing reports it any more, so the file and its include go. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DfQHLTyyoMfjfa4sXJCGa5 --- build/ignore-by-php-version.neon.php | 4 +- build/spl-autoload-functions-pre-php-7.neon | 5 -- src/Type/TypeCombinator.php | 76 ++++++++++++++------- 3 files changed, 52 insertions(+), 33 deletions(-) delete mode 100644 build/spl-autoload-functions-pre-php-7.neon diff --git a/build/ignore-by-php-version.neon.php b/build/ignore-by-php-version.neon.php index 1eaaa6863d..44d5354d93 100644 --- a/build/ignore-by-php-version.neon.php +++ b/build/ignore-by-php-version.neon.php @@ -21,9 +21,7 @@ $includes[] = __DIR__ . '/more-enum-adapter-errors.neon'; } -if (PHP_VERSION_ID < 80000) { - $includes[] = __DIR__ . '/spl-autoload-functions-pre-php-7.neon'; -} else { +if (PHP_VERSION_ID >= 80000) { $includes[] = __DIR__ . '/spl-autoload-functions-php-8.neon'; } diff --git a/build/spl-autoload-functions-pre-php-7.neon b/build/spl-autoload-functions-pre-php-7.neon deleted file mode 100644 index 42cd820e71..0000000000 --- a/build/spl-autoload-functions-pre-php-7.neon +++ /dev/null @@ -1,5 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Parameter \#1 \$array \(list\) of array_values is already a list, call has no effect\.$#' - path: ../src/Type/TypeCombinator.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 86f54a799d..ef19595cc2 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -43,6 +43,7 @@ use function get_class; use function implode; use function in_array; +use function spl_object_id; use function sprintf; use function usort; use const PHP_INT_MAX; @@ -293,52 +294,76 @@ public static function doUnion(Type ...$types): Type } } + // A member passed more than once contributes nothing; dropping the + // repeats up front keeps the pairwise comparison below from paying for + // them (array_values() of a shape unions the same value type per slot). + if ($typesCount > 2) { + $seenTypes = []; + $uniqueTypes = []; + foreach ($types as $type) { + $typeId = spl_object_id($type); + if (isset($seenTypes[$typeId])) { + continue; + } + $seenTypes[$typeId] = true; + $uniqueTypes[] = $type; + } + if (count($uniqueTypes) === 1) { + return $uniqueTypes[0]; + } + if (count($uniqueTypes) === 2) { + return self::union($uniqueTypes[0], $uniqueTypes[1]); + } + $types = $uniqueTypes; + } + $alreadyNormalized = []; $alreadyNormalizedCounter = 0; $benevolentTypes = []; $neverCount = 0; - // transform A | (B | C) to A | B | C - for ($i = 0; $i < $typesCount; $i++) { + // transform A | (B | C) to A | B | C - in one pass, a union's members are + // never unions, implicit never or implicit mixed themselves + $flattenedTypes = []; + foreach ($types as $type) { if ( - $types[$i] instanceof MixedType - && !$types[$i]->isExplicitMixed() - && !$types[$i] instanceof TemplateMixedType - && $types[$i]->getSubtractedType() === null + $type instanceof MixedType + && !$type->isExplicitMixed() + && !$type instanceof TemplateMixedType + && $type->getSubtractedType() === null ) { - return $types[$i]; + return $type; } - if ($types[$i] instanceof NeverType && !$types[$i]->isExplicit()) { + if ($type instanceof NeverType && !$type->isExplicit()) { $neverCount++; + $flattenedTypes[] = $type; continue; } - if ($types[$i] instanceof BenevolentUnionType) { - if ($types[$i] instanceof TemplateType) { + if ($type instanceof BenevolentUnionType) { + if ($type instanceof TemplateType) { + $flattenedTypes[] = $type; continue; } - $benevolentTypesCount = 0; - $typesInner = $types[$i]->getTypes(); - foreach ($typesInner as $benevolentInnerType) { - $benevolentTypesCount++; + foreach ($type->getTypes() as $benevolentInnerType) { $benevolentTypes[$benevolentInnerType->describe(VerbosityLevel::value())] = $benevolentInnerType; + $flattenedTypes[] = $benevolentInnerType; } - array_splice($types, $i, 1, $typesInner); - $typesCount += $benevolentTypesCount - 1; continue; } - if (!($types[$i] instanceof UnionType)) { - continue; - } - if ($types[$i] instanceof TemplateType) { + if (!($type instanceof UnionType) || $type instanceof TemplateType) { + $flattenedTypes[] = $type; continue; } - $typesInner = $types[$i]->getTypes(); + $typesInner = $type->getTypes(); $alreadyNormalized[$alreadyNormalizedCounter] = $typesInner; $alreadyNormalizedCounter++; - array_splice($types, $i, 1, $typesInner); - $typesCount += count($typesInner) - 1; + foreach ($typesInner as $innerType) { + $flattenedTypes[] = $innerType; + } } + $types = $flattenedTypes; + $typesCount = count($types); // Bulk-remove implicit NeverTypes (skipped during the loop above) if ($neverCount > 0) { @@ -427,8 +452,9 @@ public static function doUnion(Type ...$types): Type static fn (IntegerRangeType $a, IntegerRangeType $b): int => ($a->getMin() ?? PHP_INT_MIN) <=> ($b->getMin() ?? PHP_INT_MIN) ?: ($a->getMax() ?? PHP_INT_MAX) <=> ($b->getMax() ?? PHP_INT_MAX), ); - $types = array_merge($types, $integerRangeTypes); - $types = array_values($types); + // array_merge() hands the first array back as it is when the second one + // is empty, so the keys the bucketing above unset must be renumbered here + $types = array_merge(array_values($types), $integerRangeTypes); $typesCount = count($types); foreach ($scalarTypes as $classType => $scalarTypeItems) { From efa6d34f43652ff851d658cc8c2abbc2d61048e3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 16:42:03 +0200 Subject: [PATCH 4/7] Return the union as it is when the other operand is a constant scalar it holds union(U, x) rebuilt U's members whenever x was a constant scalar U already held: every member was re-bucketed by its description and a new union was assembled. ArrayType::setOffsetValueType() unions the key type with the offset for every offset written, so a rule checking a write to an array keyed by a union of a few hundred locale strings paid that once per member of the offset type. The union is now handed back untouched when it is a supertype of the scalar - the same members the general path produces. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GvDxWpzXzticTTm5eLCsb2 --- src/Type/TypeCombinator.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index ef19595cc2..9a7177126f 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -237,6 +237,15 @@ public static function union(Type ...$types): Type return self::doUnion(...$types); } + private static function isUnionHoldingConstantScalar(Type $union, Type $scalar): bool + { + return $union instanceof UnionType + && !$union instanceof BenevolentUnionType + && !$union instanceof TemplateType + && $scalar->isConstantScalarValue()->yes() + && $union->isSuperTypeOf($scalar)->yes(); + } + /** @internal Delegated to from TypeCombinatorCache, which the native extension shadows to memoize it. */ public static function doUnion(Type ...$types): Type { @@ -292,6 +301,17 @@ public static function doUnion(Type ...$types): Type if ($a === $b || ($a->equals($b) && $a->isArray()->yes())) { return $a; } + + // union(U, x) = U when x is a constant scalar U already holds: the + // general path would rebuild the same members. Adding a known key to + // a large array key union (a few hundred locale strings) hits this + // once per key. + if (self::isUnionHoldingConstantScalar($a, $b)) { + return $a; + } + if (self::isUnionHoldingConstantScalar($b, $a)) { + return $b; + } } // A member passed more than once contributes nothing; dropping the From 36170acfddd5b840f1127bd939906286b0cff70b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 17:39:30 +0200 Subject: [PATCH 5/7] Write a shape's optional keys as optional into a general array's item shape ArrayType::setExistingOffsetValueType() merged a written constant array into the item shape all-or-nothing: once with every key required and once with every optional key unset. A written shape whose optional keys can be present independently (a nested `$a[$i][$j][$k]['x'] = ...` write in a loop) then came out as `array{}|array{k1: T, ..., kN: T}`, which is unsound for a value holding only some of the keys. Optional keys are now written as optional: an existing key keeps its certainty and unions the value, a missing one is added as optional. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DfQHLTyyoMfjfa4sXJCGa5 --- src/Type/ArrayType.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 300f5ae787..6d7d951ed9 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -428,7 +428,22 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T if ($this->itemType->isConstantArray()->yes() && $valueType->isConstantArray()->yes()) { $newItemTypes = []; + $itemConstantArrays = $this->itemType->getConstantArrays(); foreach ($valueType->getConstantArrays() as $constArray) { + if ($constArray->getOptionalKeys() !== [] && count($itemConstantArrays) === 1) { + // A written shape with optional keys is not all-or-nothing: each + // optional key may or may not be present on its own, so it is + // written as optional (present keys keep their certainty, the + // value unions with what the key held) instead of once with every + // key required and once with every optional key unset. + $builder = ConstantArrayTypeBuilder::createFromConstantArray($itemConstantArrays[0]); + foreach ($constArray->getKeyTypes() as $i => $keyType) { + $builder->setOffsetValueType($keyType, $constArray->getOffsetValueType($keyType), $constArray->isOptionalKey($i)); + } + $newItemTypes[] = TypeCombinator::intersect($builder->getArray(), ...TypeUtils::getAccessoryTypes($this->itemType)); + continue; + } + $newItemType = $this->itemType; $optionalKeyTypes = []; foreach ($constArray->getKeyTypes() as $i => $keyType) { From d0068fd318e6a6b11d263dae966498763d1a0598 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 17:39:30 +0200 Subject: [PATCH 6/7] Do not expand optional-key shapes into every variant before loop widening MutatingScope::generalizeType() flattened both sides with TypeUtils::flattenTypes(), which turns a constant array with N optional keys into its 2^N concrete variants (four lossy representatives above ten) - only for the union right after to merge them back into the same shape, paying the pairwise reduceArrays() pass for hundreds of variants on every loop iteration. A method building a shape from a dozen conditional writes spent 1.8 s there. The per-key widening reads keys, values and optionality straight from the shape, so only unions are split now. The bug-13637 fixture keeps the loop's int<0, 8> bound on the middle key (the comment had called the previous `int` a residual) and describes the innermost level as the optional-key shape it is. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DfQHLTyyoMfjfa4sXJCGa5 --- src/Analyser/MutatingScope.php | 29 +++++++++++++++++++++-- tests/PHPStan/Analyser/ScopeTest.php | 9 +++++-- tests/PHPStan/Analyser/nsrt/bug-13637.php | 8 +++---- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index d0eb57ef6a..f8e4744d7f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -5006,6 +5006,31 @@ private function generalizeVariableTypeHolders( return $newVariableTypeHolders; } + /** + * TypeUtils::flattenTypes() expands a shape with optional keys into every + * concrete variant (2^N of them, four lossy representatives above ten), + * only for the union below to merge them back into the very same shape - + * quadratic in the number of variants. The per-key widening reads the + * shape's keys, values and optionality directly, so only unions are split. + * + * @return list + */ + private function flattenUnionForGeneralization(Type $type): array + { + if (!$type instanceof UnionType) { + return [$type]; + } + + $types = []; + foreach ($type->getTypes() as $innerType) { + foreach ($this->flattenUnionForGeneralization($innerType) as $flattenedType) { + $types[] = $flattenedType; + } + } + + return $types; + } + private function generalizeType(Type $a, Type $b, int $depth): Type { if ($a->equals($b)) { @@ -5033,8 +5058,8 @@ private function generalizeType(Type $a, Type $b, int $depth): Type $otherTypes = []; foreach ([ - 'a' => TypeUtils::flattenTypes($a), - 'b' => TypeUtils::flattenTypes($b), + 'a' => $this->flattenUnionForGeneralization($a), + 'b' => $this->flattenUnionForGeneralization($b), ] as $key => $types) { foreach ($types as $type) { if ($type instanceof ConstantIntegerType) { diff --git a/tests/PHPStan/Analyser/ScopeTest.php b/tests/PHPStan/Analyser/ScopeTest.php index 88d9753efc..78d88cf8d1 100644 --- a/tests/PHPStan/Analyser/ScopeTest.php +++ b/tests/PHPStan/Analyser/ScopeTest.php @@ -6,6 +6,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Name\FullyQualified; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; @@ -27,7 +28,11 @@ class ScopeTest extends PHPStanTestCase public static function dataGeneralize(): array { - return [ + // A directly constructed shape is a legacy (unsealed) one only while bleeding + // edge is off - the toggle is process-global, and PHPUnit 9 evaluates the + // provider after earlier test classes may have left it on. The widening + // contract below is the legacy one, so the inputs are pinned to it. + return BleedingEdgeToggle::withBleedingEdge(false, static fn (): array => [ [ new ConstantStringType('a'), new ConstantStringType('a'), @@ -229,7 +234,7 @@ public static function dataGeneralize(): array IntegerRangeType::fromInterval(null, 16), 'int', ], - ]; + ]); } #[DataProvider('dataGeneralize')] diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php index 0176f95a08..ca5fe8b73f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13637.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13637.php @@ -17,10 +17,10 @@ function doesNotWork(): void } // The reported regression (innermost values widening to `int<0, max>`) is - // fixed: they stay `int<0, 4>`. The middle key degenerates to `int` rather - // than the ideal `int<0, 8>` — a minor key-precision residual in 3-level - // nesting, not the value-widening bug from the issue. - assertType('non-empty-array, non-empty-array, array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}>>>', $final); + // fixed: they stay `int<0, 4>`, and the middle key keeps the loop's + // `int<0, 8>` bound now that widening no longer expands optional-key + // shapes into every variant before merging them back. + assertType('non-empty-array, non-empty-array, non-empty-array{3?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 1?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 2?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 4?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 5?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 6?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 7?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 8?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}, 9?: array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}}>>', $final); } function thisWorks(): void From c971e689a08909f7699bb3d1078fcea437bd648b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 19:01:07 +0200 Subject: [PATCH 7/7] Run the NoDiscard rule tests wherever their syntax parses 76f47ddb80 dropped the PHP 8.5 requirement from the three NoDiscard rule tests, but their fixtures still use the pipe operator and the (void) cast, which are PHP 8.5 syntax - on the PHP 7.4 job the pipe token cannot even be lexed. Each fixture is split: the plain calls stay in the original file and run on every PHP with attributes (7.4 reads #[\NoDiscard] as a comment, so nothing would be reported there), the (void) casts and pipes move to a -php85 fixture behind #[RequiresPhp('>= 8.5.0')]. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DfQHLTyyoMfjfa4sXJCGa5 --- ...FunctionStatementWithNoDiscardRuleTest.php | 43 ++++++++++++------- ...-call-statement-result-discarded-php85.php | 30 +++++++++++++ ...nction-call-statement-result-discarded.php | 16 +------ ...ToMethodStatementWithNoDiscardRuleTest.php | 33 +++++++++----- ...icMethodStatementWithNoDiscardRuleTest.php | 33 +++++++++----- ...-call-statement-result-discarded-php85.php | 37 ++++++++++++++++ ...method-call-statement-result-discarded.php | 16 +------ ...-call-statement-result-discarded-php85.php | 33 ++++++++++++++ ...method-call-statement-result-discarded.php | 15 +------ 9 files changed, 174 insertions(+), 82 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded-php85.php create mode 100644 tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded-php85.php create mode 100644 tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded-php85.php diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithNoDiscardRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithNoDiscardRuleTest.php index 2c8fab46fb..0475ab9d39 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithNoDiscardRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithNoDiscardRuleTest.php @@ -4,6 +4,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -16,6 +17,8 @@ protected function getRule(): Rule return new CallToFunctionStatementWithNoDiscardRule(self::createReflectionProvider()); } + // #[\NoDiscard] is an attribute, a comment on PHP 7.4 + #[RequiresPhp('>= 8.0.0')] public function testRule(): void { $this->analyse([__DIR__ . '/data/function-call-statement-result-discarded.php'], [ @@ -25,47 +28,55 @@ public function testRule(): void ], [ 'Call to function FunctionCallStatementResultDiscarded\differentCase() on a separate line discards return value.', - 25, + 23, ], [ 'Call to callable \'FunctionCallStateme…\' on a separate line discards return value.', - 30, + 28, ], [ 'Call to callable Closure(int): array on a separate line discards return value.', - 35, + 33, ], [ 'Call to callable Closure(): 1 on a separate line discards return value.', - 40, + 38, ], [ 'Call to callable Closure(): 1 on a separate line discards return value.', - 45, + 43, ], + ]); + } + + // the (void) cast and the pipe operator are PHP 8.5 syntax + #[RequiresPhp('>= 8.5.0')] + public function testRulePhp85(): void + { + $this->analyse([__DIR__ . '/data/function-call-statement-result-discarded-php85.php'], [ [ - 'Call to function FunctionCallStatementResultDiscarded\canDiscard() in (void) cast but function allows discarding return value.', - 55, + 'Call to function FunctionCallStatementResultDiscardedPhp85\canDiscard() in (void) cast but function allows discarding return value.', + 17, ], [ 'Call to callable \'FunctionCallStateme…\' in (void) cast but callable allows discarding return value.', - 59, + 20, ], [ - 'Call to function FunctionCallStatementResultDiscarded\withSideEffects() on a separate line discards return value.', - 61, + 'Call to function FunctionCallStatementResultDiscardedPhp85\withSideEffects() on a separate line discards return value.', + 22, ], [ - 'Call to function FunctionCallStatementResultDiscarded\canDiscard() in (void) cast but function allows discarding return value.', - 64, + 'Call to function FunctionCallStatementResultDiscardedPhp85\canDiscard() in (void) cast but function allows discarding return value.', + 25, ], [ - 'Call to function FunctionCallStatementResultDiscarded\withSideEffects() on a separate line discards return value.', - 66, + 'Call to function FunctionCallStatementResultDiscardedPhp85\withSideEffects() on a separate line discards return value.', + 27, ], [ - 'Call to function FunctionCallStatementResultDiscarded\canDiscard() in (void) cast but function allows discarding return value.', - 69, + 'Call to function FunctionCallStatementResultDiscardedPhp85\canDiscard() in (void) cast but function allows discarding return value.', + 30, ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded-php85.php b/tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded-php85.php new file mode 100644 index 0000000000..a01feea8fe --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded-php85.php @@ -0,0 +1,30 @@ += 8.5 + +namespace FunctionCallStatementResultDiscardedPhp85; + +#[\NoDiscard] +function withSideEffects(int $i): array { + echo __FUNCTION__ . "\n"; + return [1]; +} + +function canDiscard(int $i): int +{ + return 1; +} + +(void)withSideEffects(5); +(void) canDiscard(5); + +$canDiscardCb = 'FunctionCallStatementResultDiscardedPhp85\\canDiscard'; +(void) $canDiscardCb(); + +5 |> withSideEffects(...); +5 |> canDiscard(...); +(void) 5 |> withSideEffects(...); +(void) 5 |> canDiscard(...); + +5 |> (fn ($x) => withSideEffects($x)); +5 |> (fn ($x) => canDiscard($x)); +(void) 5 |> (fn ($x) => withSideEffects($x)); +(void) 5 |> (fn ($x) => canDiscard($x)); diff --git a/tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded.php b/tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded.php index a7fa4c8b1e..ca6504b267 100644 --- a/tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded.php +++ b/tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded.php @@ -1,4 +1,4 @@ -= 8.5 += 8.1 namespace FunctionCallStatementResultDiscarded; @@ -10,8 +10,6 @@ function withSideEffects(int $i): array { withSideEffects(5); -(void)withSideEffects(5); - foreach (withSideEffects(5) as $num) { var_dump($num); } @@ -52,18 +50,6 @@ function canDiscard(int $i): int } canDiscard(5); -(void) canDiscard(5); $canDiscardCb = 'FunctionCallStatementResultDiscarded\\canDiscard'; $canDiscardCb(); -(void) $canDiscardCb(); - -5 |> withSideEffects(...); -5 |> canDiscard(...); -(void) 5 |> withSideEffects(...); -(void) 5 |> canDiscard(...); - -5 |> (fn ($x) => withSideEffects($x)); -5 |> (fn ($x) => canDiscard($x)); -(void) 5 |> (fn ($x) => withSideEffects($x)); -(void) 5 |> (fn ($x) => canDiscard($x)); diff --git a/tests/PHPStan/Rules/Methods/CallToMethodStatementWithNoDiscardRuleTest.php b/tests/PHPStan/Rules/Methods/CallToMethodStatementWithNoDiscardRuleTest.php index c7be0ca590..4443372a2c 100644 --- a/tests/PHPStan/Rules/Methods/CallToMethodStatementWithNoDiscardRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallToMethodStatementWithNoDiscardRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -28,6 +29,8 @@ protected function getRule(): Rule ); } + // #[\NoDiscard] is an attribute, a comment on PHP 7.4 + #[RequiresPhp('>= 8.0.0')] public function testRule(): void { $this->analyse([__DIR__ . '/data/method-call-statement-result-discarded.php'], [ @@ -41,27 +44,35 @@ public function testRule(): void ], [ 'Call to method MethodCallStatementResultDiscarded\ClassWithInstanceSideEffects::differentCase() on a separate line discards return value.', - 30, + 27, ], + ]); + } + + // the (void) cast and the pipe operator are PHP 8.5 syntax + #[RequiresPhp('>= 8.5.0')] + public function testRulePhp85(): void + { + $this->analyse([__DIR__ . '/data/method-call-statement-result-discarded-php85.php'], [ [ - 'Call to method MethodCallStatementResultDiscarded\Foo::canDiscard() in (void) cast but method allows discarding return value.', - 45, + 'Call to method MethodCallStatementResultDiscardedPhp85\Foo::canDiscard() in (void) cast but method allows discarding return value.', + 27, ], [ - 'Call to method MethodCallStatementResultDiscarded\ClassWithInstanceSideEffects::instanceMethod() on a separate line discards return value.', - 47, + 'Call to method MethodCallStatementResultDiscardedPhp85\ClassWithInstanceSideEffects::instanceMethod() on a separate line discards return value.', + 29, ], [ - 'Call to method MethodCallStatementResultDiscarded\Foo::canDiscard() in (void) cast but method allows discarding return value.', - 50, + 'Call to method MethodCallStatementResultDiscardedPhp85\Foo::canDiscard() in (void) cast but method allows discarding return value.', + 32, ], [ - 'Call to method MethodCallStatementResultDiscarded\ClassWithInstanceSideEffects::instanceMethod() on a separate line discards return value.', - 52, + 'Call to method MethodCallStatementResultDiscardedPhp85\ClassWithInstanceSideEffects::instanceMethod() on a separate line discards return value.', + 34, ], [ - 'Call to method MethodCallStatementResultDiscarded\Foo::canDiscard() in (void) cast but method allows discarding return value.', - 55, + 'Call to method MethodCallStatementResultDiscardedPhp85\Foo::canDiscard() in (void) cast but method allows discarding return value.', + 37, ], ]); } diff --git a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithNoDiscardRuleTest.php b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithNoDiscardRuleTest.php index fd53633625..b304a00c21 100644 --- a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithNoDiscardRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithNoDiscardRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -30,6 +31,8 @@ protected function getRule(): Rule ); } + // #[\NoDiscard] is an attribute, a comment on PHP 7.4 + #[RequiresPhp('>= 8.0.0')] public function testRule(): void { $this->analyse([__DIR__ . '/data/static-method-call-statement-result-discarded.php'], [ @@ -39,27 +42,35 @@ public function testRule(): void ], [ 'Call to static method StaticMethodCallStatementResultDiscarded\ClassWithStaticSideEffects::differentCase() on a separate line discards return value.', - 27, + 25, ], + ]); + } + + // the (void) cast and the pipe operator are PHP 8.5 syntax + #[RequiresPhp('>= 8.5.0')] + public function testRulePhp85(): void + { + $this->analyse([__DIR__ . '/data/static-method-call-statement-result-discarded-php85.php'], [ [ - 'Call to static method StaticMethodCallStatementResultDiscarded\Foo::canDiscard() in (void) cast but method allows discarding return value.', - 41, + 'Call to static method StaticMethodCallStatementResultDiscardedPhp85\Foo::canDiscard() in (void) cast but method allows discarding return value.', + 23, ], [ - 'Call to static method StaticMethodCallStatementResultDiscarded\ClassWithStaticSideEffects::staticMethod() on a separate line discards return value.', - 43, + 'Call to static method StaticMethodCallStatementResultDiscardedPhp85\ClassWithStaticSideEffects::staticMethod() on a separate line discards return value.', + 25, ], [ - 'Call to static method StaticMethodCallStatementResultDiscarded\Foo::canDiscard() in (void) cast but method allows discarding return value.', - 46, + 'Call to static method StaticMethodCallStatementResultDiscardedPhp85\Foo::canDiscard() in (void) cast but method allows discarding return value.', + 28, ], [ - 'Call to static method StaticMethodCallStatementResultDiscarded\ClassWithStaticSideEffects::staticMethod() on a separate line discards return value.', - 48, + 'Call to static method StaticMethodCallStatementResultDiscardedPhp85\ClassWithStaticSideEffects::staticMethod() on a separate line discards return value.', + 30, ], [ - 'Call to static method StaticMethodCallStatementResultDiscarded\Foo::canDiscard() in (void) cast but method allows discarding return value.', - 51, + 'Call to static method StaticMethodCallStatementResultDiscardedPhp85\Foo::canDiscard() in (void) cast but method allows discarding return value.', + 33, ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded-php85.php b/tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded-php85.php new file mode 100644 index 0000000000..e0320d9050 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded-php85.php @@ -0,0 +1,37 @@ += 8.5 + +namespace MethodCallStatementResultDiscardedPhp85; + +class ClassWithInstanceSideEffects { + #[\NoDiscard] + public function instanceMethod(): array { + echo __METHOD__ . "\n"; + return [2]; + } +} + +class Foo +{ + + public function canDiscard(): array { + return []; + } + +} + +$o = new ClassWithInstanceSideEffects(); +$foo = new Foo(); + +(void)$o->instanceMethod(); +(void)$o?->instanceMethod(); +(void) $foo->canDiscard(); + +5 |> $o->instanceMethod(...); +5 |> $foo->canDiscard(...); +(void) 5 |> $o->instanceMethod(...); +(void) 5 |> $foo->canDiscard(...); + +5 |> (fn ($x) => $o->instanceMethod($x)); +5 |> (fn ($x) => $foo->canDiscard($x)); +(void) 5 |> (fn ($x) => $o->instanceMethod($x)); +(void) 5 |> (fn ($x) => $foo->canDiscard($x)); diff --git a/tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded.php b/tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded.php index 194f9753ff..4919dade33 100644 --- a/tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded.php +++ b/tests/PHPStan/Rules/Methods/data/method-call-statement-result-discarded.php @@ -1,4 +1,4 @@ -= 8.5 += 8.1 namespace MethodCallStatementResultDiscarded; @@ -20,9 +20,6 @@ public function differentCase(): array { $o->instanceMethod(); $o?->instanceMethod(); -(void)$o->instanceMethod(); -(void)$o?->instanceMethod(); - foreach ($o->instanceMethod() as $num) { var_dump($num); } @@ -42,14 +39,3 @@ public function canDiscard(): array { $foo = new Foo(); $foo->canDiscard(); -(void) $foo->canDiscard(); - -5 |> $o->instanceMethod(...); -5 |> $foo->canDiscard(...); -(void) 5 |> $o->instanceMethod(...); -(void) 5 |> $foo->canDiscard(...); - -5 |> (fn ($x) => $o->instanceMethod($x)); -5 |> (fn ($x) => $foo->canDiscard($x)); -(void) 5 |> (fn ($x) => $o->instanceMethod($x)); -(void) 5 |> (fn ($x) => $foo->canDiscard($x)); diff --git a/tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded-php85.php b/tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded-php85.php new file mode 100644 index 0000000000..8cc7cbc472 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded-php85.php @@ -0,0 +1,33 @@ += 8.5 + +namespace StaticMethodCallStatementResultDiscardedPhp85; + +class ClassWithStaticSideEffects { + #[\NoDiscard] + public static function staticMethod(): array { + echo __METHOD__ . "\n"; + return [2]; + } +} + +class Foo +{ + + public static function canDiscard(): array { + return []; + } + +} + +(void)ClassWithStaticSideEffects::staticMethod(); +(void) Foo::canDiscard(); + +5 |> ClassWithStaticSideEffects::staticMethod(...); +5 |> Foo::canDiscard(...); +(void) 5 |> ClassWithStaticSideEffects::staticMethod(...); +(void) 5 |> Foo::canDiscard(...); + +5 |> (fn ($x) => ClassWithStaticSideEffects::staticMethod($x)); +5 |> (fn ($x) => Foo::canDiscard($x)); +(void) 5 |> (fn ($x) => ClassWithStaticSideEffects::staticMethod($x)); +(void) 5 |> (fn ($x) => Foo::canDiscard($x)); diff --git a/tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded.php b/tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded.php index d717446d7e..8bcf4b2da5 100644 --- a/tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded.php +++ b/tests/PHPStan/Rules/Methods/data/static-method-call-statement-result-discarded.php @@ -1,4 +1,4 @@ -= 8.5 += 8.1 namespace StaticMethodCallStatementResultDiscarded; @@ -18,8 +18,6 @@ public static function differentCase(): array { ClassWithStaticSideEffects::staticMethod(); -(void)ClassWithStaticSideEffects::staticMethod(); - foreach (ClassWithStaticSideEffects::staticMethod() as $num) { var_dump($num); } @@ -38,14 +36,3 @@ public static function canDiscard(): array { } Foo::canDiscard(); -(void) Foo::canDiscard(); - -5 |> ClassWithStaticSideEffects::staticMethod(...); -5 |> Foo::canDiscard(...); -(void) 5 |> ClassWithStaticSideEffects::staticMethod(...); -(void) 5 |> Foo::canDiscard(...); - -5 |> (fn ($x) => ClassWithStaticSideEffects::staticMethod($x)); -5 |> (fn ($x) => Foo::canDiscard($x)); -(void) 5 |> (fn ($x) => ClassWithStaticSideEffects::staticMethod($x)); -(void) 5 |> (fn ($x) => Foo::canDiscard($x));