From 32a5ed5203b2bf67bdf104f9842d8976a168faea Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 28 Oct 2026 09:06:24 +0000 Subject: [PATCH 1/4] Absorb refined constant scalars into their plain constant within the scalar buckets of `TypeCombinator::union()` `'Foo'|('Foo'&hasMethod(m1))` was never collapsed to `'Foo'` because the scalar buckets are compared only against the non-scalar union members. Repeated `method_exists()` / `property_exists()` / `is_a()` checks on a loop-invariant constant class-string grew the union to 2^N members. Moving these intersections out of the buckets (the previous fix) sent them through the generic scalar shortcut, which drops every plain constant of the same kind once any type with isInteger()/isBoolean()/isFloat() yes is present (`1&T|2` became `1&T`), and made unions of many refined constants quadratic. The intersections now stay in their bucket. Before the bucket is compared against the other members, each one is dropped when the plain constant of the same value is present, or when a less refined intersection of the same value contains it. Different values are disjoint, so the cost stays linear. Ported to the native mirror in turbo-ext/src/TypeCombinator.cpp. Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a --- phpstan-baseline.neon | 2 +- src/Type/TypeCombinator.php | 57 ++++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-15591.php | 27 ++++++++ turbo-ext/src/TypeCombinator.cpp | 82 +++++++++++++++++++++-- 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6f61a664de1..301f9f86803 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1678,7 +1678,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 8 + count: 7 path: src/Type/TypeCombinator.php - diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index e65d09719e6..c1450d2c7f3 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -420,10 +420,7 @@ public static function doUnion(Type ...$types): Type $enumCaseTypes = []; $integerRangeTypes = []; for ($i = 0; $i < $typesCount; $i++) { - // a constant scalar refined by an accessory type ('Foo'&hasMethod(bar)) - // stays with the other types so it is compared against the plain - // constant scalars and other refinements of the same value - if (!$types[$i] instanceof IntersectionType && $types[$i]->isConstantScalarValue()->yes()) { + if ($types[$i]->isConstantScalarValue()->yes()) { $type = $types[$i]; $scalarTypes[get_class($type)][$type->describe(VerbosityLevel::cache())] = $type; unset($types[$i]); @@ -474,6 +471,10 @@ public static function doUnion(Type ...$types): Type $types = array_merge(array_values($types), $integerRangeTypes); $typesCount = count($types); + if (isset($scalarTypes[IntersectionType::class])) { + $scalarTypes[IntersectionType::class] = self::reduceRefinedConstantScalars($scalarTypes[IntersectionType::class], $scalarTypes); + } + foreach ($scalarTypes as $classType => $scalarTypeItems) { if (isset($hasGenericScalarTypes[$classType])) { unset($scalarTypes[$classType]); @@ -703,6 +704,54 @@ private static function isAlreadyNormalized(array $alreadyNormalized, Type $a, T return false; } + /** + * Scalar buckets are compared only against the non-scalar union members, which + * is correct for plain constants (distinct values are disjoint) but not for + * constants refined by another type ('Foo'&hasMethod(bar)). Those are absorbed + * here by the plain constant of the same value, or by a less refined + * intersection of the same value. Values never need to be compared across. + * + * @param array $intersections + * @param array> $scalarTypes + * @return array + */ + private static function reduceRefinedConstantScalars(array $intersections, array $scalarTypes): array + { + $byValue = []; + foreach ($intersections as $key => $intersection) { + $constants = $intersection->getConstantScalarTypes(); + if (count($constants) !== 1) { + continue; + } + $valueKey = $constants[0]->describe(VerbosityLevel::cache()); + $plain = $scalarTypes[get_class($constants[0])][$valueKey] ?? null; + if ($plain !== null && $plain->isSuperTypeOf($intersection)->yes()) { + unset($intersections[$key]); + continue; + } + $byValue[get_class($constants[0])][$valueKey][] = $key; + } + + foreach ($byValue as $valueKeys) { + foreach ($valueKeys as $keys) { + $keysCount = count($keys); + for ($i = 0; $i < $keysCount; $i++) { + for ($j = 0; $j < $keysCount; $j++) { + if ($i === $j || !isset($intersections[$keys[$i]], $intersections[$keys[$j]])) { + continue; + } + if (!$intersections[$keys[$i]]->isSuperTypeOf($intersections[$keys[$j]])->yes()) { + continue; + } + unset($intersections[$keys[$j]]); + } + } + } + } + + return $intersections; + } + /** * @return array{Type, null}|array{null, Type}|null */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-15591.php b/tests/PHPStan/Analyser/nsrt/bug-15591.php index e9549ef8a3d..0210d192997 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15591.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15591.php @@ -40,3 +40,30 @@ function testProperty(): void } assertType("'Bug15591\\\\Foo'", $class); } + +/** + * @template T + * @template TString of string + * @template TBool of bool + * @param T $x + * @param TString $s + * @param TBool $b + */ +function testRefinedConstantKeepsOtherConstants($x, $s, $b): void +{ + assertType('2|(1&T (function Bug15591\testRefinedConstantKeepsOtherConstants(), argument))', $x === 1 ? $x : 2); + assertType("'b'|('a'&TString of string (function Bug15591\\testRefinedConstantKeepsOtherConstants(), argument))", $s === 'a' ? $s : 'b'); + assertType('(TBool of bool (function Bug15591\testRefinedConstantKeepsOtherConstants(), argument)&true)|false', $b === true ? $b : false); +} + +function testAlreadyRefined(): void +{ + $class = Foo::class; + if (method_exists($class, 'm1')) { + foreach (['m2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm10', 'm11', 'm12', 'm13'] as $method) { + if (method_exists($class, $method)) { + } + } + assertType("'Bug15591\\\\Foo'&hasMethod(m1)", $class); + } +} diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index d5225cfa034..f47264d14d3 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -1038,11 +1038,8 @@ class TypeCombinator Level cacheLevel(PT_LC("cache")); for (size_t i = 0; i < typesCount; i++) { zval *type = types[i].raw(); - zend_long isConstantScalarValue = PT_TRI_NO; - if (!isInstance(type, pt_ce_intersection_type)) { - isConstantScalarValue = callTrinary(type, PT_LC("isconstantscalarvalue")); - PT_FAIL_IF_NEG(isConstantScalarValue); - } + zend_long isConstantScalarValue = callTrinary(type, PT_LC("isconstantscalarvalue")); + PT_FAIL_IF_NEG(isConstantScalarValue); if (isConstantScalarValue == PT_TRI_YES) { zv::Val description = describe(type, cacheLevel); zend_string *key = describedKey(description); @@ -1109,6 +1106,14 @@ class TypeCombinator } typesCount = types.size(); + for (ScalarGroup &group : scalarTypes) { + if (group.ce != pt_ce_intersection_type) continue; + zv::Val reduced = reduceRefinedConstantScalars(group.items.raw(), scalarTypes, cacheLevel); + PT_FAIL_IF_UNDEF(reduced); + group.items = zv::Arr::adoptVal(std::move(reduced)); + break; + } + for (ScalarGroup &group : scalarTypes) { zend_class_entry *classType = group.ce; if ((classType == pt_ce_constant_boolean_type && hasGenericBoolean) @@ -1264,6 +1269,73 @@ class TypeCombinator types.insert(types.begin() + (ptrdiff_t) i, std::make_move_iterator(inner.begin()), std::make_move_iterator(inner.end())); } + /* Mirrors reduceRefinedConstantScalars(): the constant-scalar + * intersections without those absorbed by the plain constant of the same + * value or by a less refined intersection of the same value */ + static zv::Val reduceRefinedConstantScalars(zval *intersections, std::vector &groups, Level &cacheLevel) + { + struct Refined + { + zend_string *key; + zv::Val type; + zend_class_entry *valueCe; + zv::Val valueDescription; + bool removed; + }; + std::vector entries; + for (zv::ArrayEntry entry : zv::ArrRef(intersections)) { + zval *intersection = entry.value().raw(); + entries.push_back({ entry.stringKeyOrNull(), copy(intersection), NULL, zv::Val(), false }); + Refined &refined = entries.back(); + zv::Val constants = call(intersection, PT_LC("getconstantscalartypes")); + PT_FAIL_IF_UNDEF(constants); + if (countOf(constants.raw()) != 1) continue; + zval *constant = indexOf(constants.raw(), 0); + if (UNEXPECTED(constant == NULL)) { + zend_throw_error(NULL, "Undefined array key 0"); + return zv::Val(); + } + zv::Val description = describe(constant, cacheLevel); + zend_string *valueKey = describedKey(description); + if (UNEXPECTED(valueKey == NULL)) return zv::Val(); + for (ScalarGroup &group : groups) { + if (group.ce != Z_OBJCE_P(constant)) continue; + zv::Ref plain = group.items.arrRef().find(valueKey); + if (plain.raw() == NULL) break; + int absorbed = isSuperTypeYes(plain.raw(), intersection); + PT_FAIL_IF_NEG(absorbed); + refined.removed = absorbed != 0; + break; + } + if (refined.removed) continue; + refined.valueCe = Z_OBJCE_P(constant); + refined.valueDescription = std::move(description); + } + + for (size_t i = 0; i < entries.size(); i++) { + if (entries[i].valueCe == NULL) continue; + for (size_t j = 0; j < entries.size(); j++) { + if (i == j || entries[i].removed || entries[j].removed) continue; + if (entries[j].valueCe != entries[i].valueCe) continue; + if (!zend_string_equals(Z_STR_P(entries[i].valueDescription.raw()), Z_STR_P(entries[j].valueDescription.raw()))) continue; + int absorbed = isSuperTypeYes(entries[i].type.raw(), entries[j].type.raw()); + PT_FAIL_IF_NEG(absorbed); + if (absorbed) entries[j].removed = true; + } + } + + zv::Arr result = zv::Arr::create((uint32_t) entries.size()); + for (Refined &refined : entries) { + if (refined.removed) continue; + if (UNEXPECTED(refined.key == NULL)) { + zend_throw_error(NULL, "phpstan_turbo: a scalar group key must be a string"); + return zv::Val(); + } + result.set(refined.key, std::move(refined.type)); + } + return zv::Val(std::move(result)); + } + /* $scalarTypes[$ce][$key] = $type */ static void scalarGroupSet(std::vector &groups, zend_class_entry *ce, zend_string *key, zv::Val type) { From f448ab51dc3a7d35ff2d801c7bff3f89e6158950 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 28 Oct 2026 09:27:05 +0000 Subject: [PATCH 2/4] Drop constant scalars from a union only when a type containing every value of that scalar is present The generic scalar shortcut in `TypeCombinator::union()` removed every constant of a scalar once any member answered isInteger()/isBoolean()/isFloat() with yes, so `T of int|5` became `T of int` (and likewise for `T of bool` and `T of float`). The member now also has to be a supertype of the whole scalar type. Ported to the native mirror. The prefixed differential test computed `$a - 3` over a native int and the PHP twin's constant 3 and only passed because the shortcut swallowed the twin's 3; the resulting mixed-family union is now tolerated there like the other prefix-boundary TypeErrors. Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a --- src/Type/TypeCombinator.php | 14 ++++++++--- .../nsrt/union-template-scalar-constant.php | 24 +++++++++++++++++++ turbo-ext/src/TypeCombinator.cpp | 24 ++++++++++++++++--- turbo-ext/tests/initializer-escape.php | 12 ++++++++-- 4 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/union-template-scalar-constant.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index c1450d2c7f3..7a2e6e49666 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -427,12 +427,20 @@ public static function doUnion(Type ...$types): Type continue; } + // only a type containing every value of the scalar (not T of int, 1&T) + // absorbs all the constants of that scalar if ($types[$i]->isBoolean()->yes()) { - $hasGenericScalarTypes[ConstantBooleanType::class] = true; + if ($types[$i]->isSuperTypeOf(new BooleanType())->yes()) { + $hasGenericScalarTypes[ConstantBooleanType::class] = true; + } } elseif ($types[$i]->isFloat()->yes()) { - $hasGenericScalarTypes[ConstantFloatType::class] = true; + if ($types[$i]->isSuperTypeOf(new FloatType())->yes()) { + $hasGenericScalarTypes[ConstantFloatType::class] = true; + } } elseif ($types[$i]->isInteger()->yes() && !$types[$i] instanceof IntegerRangeType) { - $hasGenericScalarTypes[ConstantIntegerType::class] = true; + if ($types[$i]->isSuperTypeOf(new IntegerType())->yes()) { + $hasGenericScalarTypes[ConstantIntegerType::class] = true; + } } elseif ($types[$i]->isString()->yes() && $types[$i]->isClassString()->no() && TypeUtils::getAccessoryTypes($types[$i]) === []) { $hasGenericScalarTypes[ConstantStringType::class] = true; } else { diff --git a/tests/PHPStan/Analyser/nsrt/union-template-scalar-constant.php b/tests/PHPStan/Analyser/nsrt/union-template-scalar-constant.php new file mode 100644 index 00000000000..71235cfcbb3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/union-template-scalar-constant.php @@ -0,0 +1,24 @@ +inner->resolveC // result, which the prefix does not let through - the type callback // was kept before that, reading the type the dependencies are made of // (the native type, read below for the other handlers, is not) - check($label === 'ClassConstFetchHandler' && str_contains($e->getMessage(), 'PHPStanTurbo\\Dependencies'), "escaping getTypeCallback ($label): " . get_class($e) . ': ' . $e->getMessage()); + // the twin resolver computes `$a - 3` over the native int and its own + // constant 3: the native int is not a supertype of the twin's 3, so + // their union mixes both families, which the prefix does not let + // through either - the type callback was kept before that + check( + ($label === 'ClassConstFetchHandler' && str_contains($e->getMessage(), 'PHPStanTurbo\\Dependencies')) + || ($label === 'BinaryOpHandler' && str_contains($e->getMessage(), 'PHPStanTurbo\\TrinaryLogic returned')), + "escaping getTypeCallback ($label): " . get_class($e) . ': ' . $e->getMessage(), + ); } catch (\Throwable $e) { check(false, "escaping getTypeCallback ($label): " . get_class($e) . ': ' . $e->getMessage()); } finally { @@ -138,7 +146,7 @@ public function resolveConcatType($left, $right) { return $this->inner->resolveC unset($storage, $ieVarResult, $ieTypeCallback); gc_collect_cycles(); $ieDeepStack(200); -check(count($ieKeeping->kept) === 16, 'escaping getTypeCallback: the keeping resolver was handed the callbacks (' . count($ieKeeping->kept) . ')'); +check(count($ieKeeping->kept) === 15, 'escaping getTypeCallback: the keeping resolver was handed the callbacks (' . count($ieKeeping->kept) . ')'); foreach ($ieKeeping->kept as $i => [$method, $callback, $asked, $answered]) { $ieDeepStack(50); try { From 082c14d114c294188f94b024896b644882c092b6 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 28 Oct 2026 09:35:10 +0000 Subject: [PATCH 3/4] Regenerate TypeCombinator declarations for reduceRefinedConstantScalars() Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a --- turbo-ext/src/generated/TypeCombinator.h | 151 ++++++++++++----------- 1 file changed, 79 insertions(+), 72 deletions(-) diff --git a/turbo-ext/src/generated/TypeCombinator.h b/turbo-ext/src/generated/TypeCombinator.h index aaf3b79ef1d..4d4020447c3 100644 --- a/turbo-ext/src/generated/TypeCombinator.h +++ b/turbo-ext/src/generated/TypeCombinator.h @@ -47,39 +47,42 @@ inline constexpr char strings[] = "a\0" /* 263 */ "b\0" /* 265 */ "isAlreadyNormalized\0" /* 267 */ - "compareTypesInUnion\0" /* 287 */ - "getAccessoryCaseStringTypes\0" /* 307 */ - "downgradeNonFalsyStringToNonEmpty\0" /* 335 */ - "removeDecimalIntStringAccessory\0" /* 369 */ - "subtractedType\0" /* 401 */ - "unionWithSubtractedType\0" /* 416 */ - "PHPStan\\Type\\SubtractableType\0" /* 440 */ - "intersectWithSubtractedType\0" /* 470 */ - "arrayTypes\0" /* 498 */ - "processArrayAccessoryTypes\0" /* 509 */ - "findEmptyArrayNextToOversizedArray\0" /* 536 */ - "processArrayTypes\0" /* 571 */ - "optimizeConstantArrays\0" /* 589 */ - "countConstantArrayValueTypes\0" /* 612 */ - "array\0" /* 641 */ - "PHPStan\\Type\\Constant\\ConstantArrayType\0" /* 647 */ - "getConstantScalarValuesByKey\0" /* 687 */ - "oneUnrelatedKeyAllowed\0" /* 716 */ - "haveDisjointConstantScalarValues\0" /* 739 */ - "constantArrays\0" /* 772 */ - "preserveTaggedUnions\0" /* 787 */ - "reduceArrays\0" /* 808 */ - "isPlainOrBenevolentUnion\0" /* 821 */ - "PHPStan\\Type\\UnionType\0" /* 846 */ - "intersectFiniteUnions\0" /* 869 */ - "finiteUnionMembers\0" /* 891 */ - "intersect\0" /* 910 */ - "doIntersect\0" /* 920 */ - "intersectDefiniteConstantArrays\0" /* 932 */ - "PHPStan\\Type\\IntersectionType\0" /* 964 */ - "mergeIntersectionsForUnion\0" /* 994 */ - "removeFalsey\0" /* 1021 */ - "removeTruthy"; /* 1034 */ + "intersections\0" /* 287 */ + "scalarTypes\0" /* 301 */ + "reduceRefinedConstantScalars\0" /* 313 */ + "compareTypesInUnion\0" /* 342 */ + "getAccessoryCaseStringTypes\0" /* 362 */ + "downgradeNonFalsyStringToNonEmpty\0" /* 390 */ + "removeDecimalIntStringAccessory\0" /* 424 */ + "subtractedType\0" /* 456 */ + "unionWithSubtractedType\0" /* 471 */ + "PHPStan\\Type\\SubtractableType\0" /* 495 */ + "intersectWithSubtractedType\0" /* 525 */ + "arrayTypes\0" /* 553 */ + "processArrayAccessoryTypes\0" /* 564 */ + "findEmptyArrayNextToOversizedArray\0" /* 591 */ + "processArrayTypes\0" /* 626 */ + "optimizeConstantArrays\0" /* 644 */ + "countConstantArrayValueTypes\0" /* 667 */ + "array\0" /* 696 */ + "PHPStan\\Type\\Constant\\ConstantArrayType\0" /* 702 */ + "getConstantScalarValuesByKey\0" /* 742 */ + "oneUnrelatedKeyAllowed\0" /* 771 */ + "haveDisjointConstantScalarValues\0" /* 794 */ + "constantArrays\0" /* 827 */ + "preserveTaggedUnions\0" /* 842 */ + "reduceArrays\0" /* 863 */ + "isPlainOrBenevolentUnion\0" /* 876 */ + "PHPStan\\Type\\UnionType\0" /* 901 */ + "intersectFiniteUnions\0" /* 924 */ + "finiteUnionMembers\0" /* 946 */ + "intersect\0" /* 965 */ + "doIntersect\0" /* 975 */ + "intersectDefiniteConstantArrays\0" /* 987 */ + "PHPStan\\Type\\IntersectionType\0" /* 1019 */ + "mergeIntersectionsForUnion\0" /* 1049 */ + "removeFalsey\0" /* 1076 */ + "removeTruthy"; /* 1089 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_VOID), /* clearCache return */ reg::packed(12, 0, 17), /* addNull $type */ @@ -110,6 +113,9 @@ inline constexpr reg::PackedArg args[] = { reg::packed(263, 0, 17), /* isAlreadyNormalized $a */ reg::packed(265, 0, 17), /* isAlreadyNormalized $b */ reg::packed(0, MAY_BE_BOOL), /* isAlreadyNormalized return */ + reg::packed(287, MAY_BE_ARRAY), /* reduceRefinedConstantScalars $intersections */ + reg::packed(301, MAY_BE_ARRAY), /* reduceRefinedConstantScalars $scalarTypes */ + reg::packed(0, MAY_BE_ARRAY), /* reduceRefinedConstantScalars return */ reg::packed(263, 0, 17), /* compareTypesInUnion $a */ reg::packed(265, 0, 17), /* compareTypesInUnion $b */ reg::packed(0, MAY_BE_NULL | MAY_BE_ARRAY), /* compareTypesInUnion return */ @@ -120,46 +126,46 @@ inline constexpr reg::PackedArg args[] = { reg::packed(12, 0, 17), /* removeDecimalIntStringAccessory $type */ reg::packed(0, 0, 17), /* removeDecimalIntStringAccessory return */ reg::packed(12, 0, 17), /* unionWithSubtractedType $type */ - reg::packed(401, MAY_BE_NULL, 17), /* unionWithSubtractedType $subtractedType */ + reg::packed(456, MAY_BE_NULL, 17), /* unionWithSubtractedType $subtractedType */ reg::packed(0, 0, 17), /* unionWithSubtractedType return */ - reg::packed(263, 0, 440), /* intersectWithSubtractedType $a */ + reg::packed(263, 0, 495), /* intersectWithSubtractedType $a */ reg::packed(265, 0, 17), /* intersectWithSubtractedType $b */ reg::packed(0, 0, 17), /* intersectWithSubtractedType return */ - reg::packed(498, MAY_BE_ARRAY), /* processArrayAccessoryTypes $arrayTypes */ + reg::packed(553, MAY_BE_ARRAY), /* processArrayAccessoryTypes $arrayTypes */ reg::packed(0, MAY_BE_ARRAY), /* processArrayAccessoryTypes return */ - reg::packed(498, MAY_BE_ARRAY), /* findEmptyArrayNextToOversizedArray $arrayTypes */ + reg::packed(553, MAY_BE_ARRAY), /* findEmptyArrayNextToOversizedArray $arrayTypes */ reg::packed(0, MAY_BE_NULL, 17), /* findEmptyArrayNextToOversizedArray return */ - reg::packed(498, MAY_BE_ARRAY), /* processArrayTypes $arrayTypes */ + reg::packed(553, MAY_BE_ARRAY), /* processArrayTypes $arrayTypes */ reg::packed(0, MAY_BE_ARRAY), /* processArrayTypes return */ reg::packed(126, MAY_BE_ARRAY), /* optimizeConstantArrays $types */ reg::packed(0, MAY_BE_ARRAY), /* optimizeConstantArrays return */ reg::packed(126, MAY_BE_ARRAY), /* countConstantArrayValueTypes $types */ reg::packed(0, MAY_BE_LONG), /* countConstantArrayValueTypes return */ - reg::packed(641, 0, 647), /* getConstantScalarValuesByKey $array */ + reg::packed(696, 0, 702), /* getConstantScalarValuesByKey $array */ reg::packed(0, MAY_BE_NULL | MAY_BE_ARRAY), /* getConstantScalarValuesByKey return */ reg::packed(263, MAY_BE_NULL | MAY_BE_ARRAY), /* haveDisjointConstantScalarValues $a */ reg::packed(265, MAY_BE_NULL | MAY_BE_ARRAY), /* haveDisjointConstantScalarValues $b */ - reg::packed(716, MAY_BE_BOOL), /* haveDisjointConstantScalarValues $oneUnrelatedKeyAllowed */ + reg::packed(771, MAY_BE_BOOL), /* haveDisjointConstantScalarValues $oneUnrelatedKeyAllowed */ reg::packed(0, MAY_BE_BOOL), /* haveDisjointConstantScalarValues return */ - reg::packed(772, MAY_BE_ARRAY), /* reduceArrays $constantArrays */ - reg::packed(787, MAY_BE_BOOL), /* reduceArrays $preserveTaggedUnions */ + reg::packed(827, MAY_BE_ARRAY), /* reduceArrays $constantArrays */ + reg::packed(842, MAY_BE_BOOL), /* reduceArrays $preserveTaggedUnions */ reg::packed(0, MAY_BE_ARRAY), /* reduceArrays return */ reg::packed(12, 0, 17), /* isPlainOrBenevolentUnion $type */ reg::packed(0, MAY_BE_BOOL), /* isPlainOrBenevolentUnion return */ - reg::packed(263, 0, 846), /* intersectFiniteUnions $a */ - reg::packed(265, 0, 846), /* intersectFiniteUnions $b */ + reg::packed(263, 0, 901), /* intersectFiniteUnions $a */ + reg::packed(265, 0, 901), /* intersectFiniteUnions $b */ reg::packed(0, MAY_BE_NULL, 17), /* intersectFiniteUnions return */ - reg::packed(132, 0, 846), /* finiteUnionMembers $union */ + reg::packed(132, 0, 901), /* finiteUnionMembers $union */ reg::packed(0, MAY_BE_NULL | MAY_BE_ARRAY), /* finiteUnionMembers return */ reg::packed(126, 0, 17, false, true), /* intersect $types */ reg::packed(0, 0, 17), /* intersect return */ reg::packed(126, 0, 17, false, true), /* doIntersect $types */ reg::packed(0, 0, 17), /* doIntersect return */ - reg::packed(263, 0, 647), /* intersectDefiniteConstantArrays $a */ - reg::packed(265, 0, 647), /* intersectDefiniteConstantArrays $b */ + reg::packed(263, 0, 702), /* intersectDefiniteConstantArrays $a */ + reg::packed(265, 0, 702), /* intersectDefiniteConstantArrays $b */ reg::packed(0, 0, 17), /* intersectDefiniteConstantArrays return */ - reg::packed(263, 0, 964), /* mergeIntersectionsForUnion $a */ - reg::packed(265, 0, 964), /* mergeIntersectionsForUnion $b */ + reg::packed(263, 0, 1019), /* mergeIntersectionsForUnion $a */ + reg::packed(265, 0, 1019), /* mergeIntersectionsForUnion $b */ reg::packed(0, MAY_BE_NULL, 17), /* mergeIntersectionsForUnion return */ reg::packed(12, 0, 17), /* removeFalsey $type */ reg::packed(0, 0, 17), /* removeFalsey return */ @@ -183,29 +189,30 @@ inline constexpr sigtab::Sig doUnion = { { 138 /* doUnion */, 0, 17, 1, 18, ZEND inline constexpr sigtab::Sig addMarker = { { 213 /* addMarker */, 2, 19, 2, 21, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig unionWithMarkers = { { 228 /* unionWithMarkers */, 2, 22, 2, 24, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig isAlreadyNormalized = { { 267 /* isAlreadyNormalized */, 3, 25, 3, 28, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig compareTypesInUnion = { { 287 /* compareTypesInUnion */, 2, 29, 2, 31, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig getAccessoryCaseStringTypes = { { 307 /* getAccessoryCaseStringTypes */, 1, 32, 1, 33, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig downgradeNonFalsyStringToNonEmpty = { { 335 /* downgradeNonFalsyStringToNonEmpty */, 1, 34, 1, 35, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig removeDecimalIntStringAccessory = { { 369 /* removeDecimalIntStringAccessory */, 1, 36, 1, 37, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig unionWithSubtractedType = { { 416 /* unionWithSubtractedType */, 2, 38, 2, 40, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersectWithSubtractedType = { { 470 /* intersectWithSubtractedType */, 2, 41, 2, 43, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig processArrayAccessoryTypes = { { 509 /* processArrayAccessoryTypes */, 1, 44, 1, 45, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig findEmptyArrayNextToOversizedArray = { { 536 /* findEmptyArrayNextToOversizedArray */, 1, 46, 1, 47, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig processArrayTypes = { { 571 /* processArrayTypes */, 1, 48, 1, 49, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig optimizeConstantArrays = { { 589 /* optimizeConstantArrays */, 1, 50, 1, 51, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig countConstantArrayValueTypes = { { 612 /* countConstantArrayValueTypes */, 1, 52, 1, 53, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig getConstantScalarValuesByKey = { { 687 /* getConstantScalarValuesByKey */, 1, 54, 1, 55, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig haveDisjointConstantScalarValues = { { 739 /* haveDisjointConstantScalarValues */, 3, 56, 3, 59, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig reduceArrays = { { 808 /* reduceArrays */, 2, 60, 2, 62, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig isPlainOrBenevolentUnion = { { 821 /* isPlainOrBenevolentUnion */, 1, 63, 1, 64, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersectFiniteUnions = { { 869 /* intersectFiniteUnions */, 2, 65, 2, 67, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig finiteUnionMembers = { { 891 /* finiteUnionMembers */, 1, 68, 1, 69, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersect = { { 910 /* intersect */, 0, 70, 1, 71, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig doIntersect = { { 920 /* doIntersect */, 0, 72, 1, 73, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersectDefiniteConstantArrays = { { 932 /* intersectDefiniteConstantArrays */, 2, 74, 2, 76, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig mergeIntersectionsForUnion = { { 994 /* mergeIntersectionsForUnion */, 2, 77, 2, 79, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig removeFalsey = { { 1021 /* removeFalsey */, 1, 80, 1, 81, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig removeTruthy = { { 1034 /* removeTruthy */, 1, 82, 1, 83, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig reduceRefinedConstantScalars = { { 313 /* reduceRefinedConstantScalars */, 2, 29, 2, 31, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig compareTypesInUnion = { { 342 /* compareTypesInUnion */, 2, 32, 2, 34, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig getAccessoryCaseStringTypes = { { 362 /* getAccessoryCaseStringTypes */, 1, 35, 1, 36, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig downgradeNonFalsyStringToNonEmpty = { { 390 /* downgradeNonFalsyStringToNonEmpty */, 1, 37, 1, 38, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig removeDecimalIntStringAccessory = { { 424 /* removeDecimalIntStringAccessory */, 1, 39, 1, 40, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig unionWithSubtractedType = { { 471 /* unionWithSubtractedType */, 2, 41, 2, 43, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersectWithSubtractedType = { { 525 /* intersectWithSubtractedType */, 2, 44, 2, 46, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig processArrayAccessoryTypes = { { 564 /* processArrayAccessoryTypes */, 1, 47, 1, 48, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig findEmptyArrayNextToOversizedArray = { { 591 /* findEmptyArrayNextToOversizedArray */, 1, 49, 1, 50, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig processArrayTypes = { { 626 /* processArrayTypes */, 1, 51, 1, 52, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig optimizeConstantArrays = { { 644 /* optimizeConstantArrays */, 1, 53, 1, 54, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig countConstantArrayValueTypes = { { 667 /* countConstantArrayValueTypes */, 1, 55, 1, 56, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig getConstantScalarValuesByKey = { { 742 /* getConstantScalarValuesByKey */, 1, 57, 1, 58, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig haveDisjointConstantScalarValues = { { 794 /* haveDisjointConstantScalarValues */, 3, 59, 3, 62, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig reduceArrays = { { 863 /* reduceArrays */, 2, 63, 2, 65, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig isPlainOrBenevolentUnion = { { 876 /* isPlainOrBenevolentUnion */, 1, 66, 1, 67, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersectFiniteUnions = { { 924 /* intersectFiniteUnions */, 2, 68, 2, 70, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig finiteUnionMembers = { { 946 /* finiteUnionMembers */, 1, 71, 1, 72, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersect = { { 965 /* intersect */, 0, 73, 1, 74, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig doIntersect = { { 975 /* doIntersect */, 0, 75, 1, 76, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersectDefiniteConstantArrays = { { 987 /* intersectDefiniteConstantArrays */, 2, 77, 2, 79, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig mergeIntersectionsForUnion = { { 1049 /* mergeIntersectionsForUnion */, 2, 80, 2, 82, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig removeFalsey = { { 1076 /* removeFalsey */, 1, 83, 1, 84, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig removeTruthy = { { 1089 /* removeTruthy */, 1, 85, 1, 86, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; } // namespace sig } // namespace ptdecl::TypeCombinator From 9d3af6b11d82d22c5cd262ab4f8ca46860f62df1 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Wed, 28 Oct 2026 09:57:51 +0000 Subject: [PATCH 4/4] Add regression test for #6567 Closes https://github.com/phpstan/phpstan/issues/6567 Amp-Thread-ID: https://ampcode.com/threads/T-01a1819f-fbb7-7460-b1bd-aab545ccfc0a --- .../Rules/Functions/ReturnTypeRuleTest.php | 23 ++++++++++++ .../PHPStan/Rules/Functions/data/bug-6567.php | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-6567.php diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index daccbe3882c..b1868e4f98b 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -542,6 +542,29 @@ public function testBug11507(): void $this->analyse([__DIR__ . '/data/bug-11507.php'], []); } + public function testBug6567(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/data/bug-6567.php'], [ + [ + 'Function Bug6567\add() should return T of int but returns int.', + 11, + 'Type int is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Function Bug6567\testBool() should return T of bool but returns false.', + 27, + 'Type false is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Function Bug6567\testFloat() should return T of float but returns float.', + 36, + 'Type float is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + ]); + } + public function testBug15245(): void { $this->checkNullables = true; diff --git a/tests/PHPStan/Rules/Functions/data/bug-6567.php b/tests/PHPStan/Rules/Functions/data/bug-6567.php new file mode 100644 index 00000000000..6d1bf0d08e8 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-6567.php @@ -0,0 +1,37 @@ + $p + */ +function test($p): void { + \PHPStan\dumpType(add($p)); +} + +/** + * @template T of bool + * @param T $p + * @return T + */ +function testBool($p) { + return false; +} + +/** + * @template T of float + * @param T $p + * @return T + */ +function testFloat($p) { + return $p + 1; +}