From a587bb5f9875e9125f32f18334b45c941cfa3685 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:39:48 +0000 Subject: [PATCH] Let a callable union member take the callable argument it accepts before it can flow into a naked template argument marker - TemplateArgumentObserver::observeLowerBound(): in a union parameter such as `(callable(TValue): bool)|TValue`, a marker-carrying callable member never changed the constraints (callables put markers in a contravariant position), so it never "took" the argument. The argument then became a lower bound of the naked receiver marker, and `$a->merge($b)->reject(isExcluded(...))` widened `TValue` to `Closure(Field): bool|Field`. A callable member now takes an argument member it is definitely a supertype of, like UnionType::inferTemplateTypes() does. - Ported the same check to turbo-ext/src/TemplateArgumentObserver.cpp. - Covered: function, instance-method and static-method first-class callables, closures, arrow functions, `(Closure(TValue): bool)|TValue` parameters, and invokable objects. - Probed and already correct: function-level templates (`@param (callable(T): bool)|T`), fresh `new Coll()` receivers, and merging a closure into the collection, which still correctly widens it. Co-Authored-By: Claude Opus 5.5 --- .../Generics/TemplateArgumentObserver.php | 9 ++ tests/PHPStan/Analyser/nsrt/bug-15414.php | 112 ++++++++++++++++++ turbo-ext/src/TemplateArgumentObserver.cpp | 10 ++ 3 files changed, 131 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15414.php diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index 43d9cfa68e4..407c97b31a2 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -630,6 +630,15 @@ private function observeLowerBound(TemplateArgumentConstraints $constraints, Typ } continue; } + // a callable member says nothing flows into its markers, but + // it still takes the callable it accepts: (callable(T): bool)|T + // receiving a closure does not bind T to the closure + if ($member->isCallable()->yes()) { + if ($member->isSuperTypeOf($argumentMember)->yes()) { + $taken = true; + } + continue; + } $before = $constraints; $constraints = $this->observeLowerBound($constraints, $member, $argumentMember); if ($constraints === $before) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15414.php b/tests/PHPStan/Analyser/nsrt/bug-15414.php new file mode 100644 index 00000000000..3020503e2a6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15414.php @@ -0,0 +1,112 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15414; + +use Closure; +use function PHPStan\Testing\assertType; + +/** + * @template TValue + * @implements \IteratorAggregate + */ +final class Coll implements \IteratorAggregate +{ + /** @param list $items */ + public function __construct(private array $items = []) + { + } + + /** @return \ArrayIterator */ + #[\Override] + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->items); + } + + /** + * @template TMergeValue + * @param iterable $items + * @return Coll + */ + public function merge(iterable $items): Coll + { + return new Coll(); + } + + /** + * @param (callable(TValue): bool)|TValue $callback + * @return Coll + */ + public function reject($callback): Coll + { + return $this; + } + + /** + * @param (Closure(TValue): bool)|TValue $callback + * @return Coll + */ + public function rejectClosure($callback): Coll + { + return $this; + } +} + +final class Field +{ + public bool $hidden = false; + + public function isHidden(Field $field): bool + { + return $field->hidden; + } + + public static function isHiddenStatic(Field $field): bool + { + return $field->hidden; + } +} + +final class FieldFilter +{ + public function __invoke(Field $field): bool + { + return $field->hidden; + } +} + +function isExcluded(Field $field): bool +{ + return $field->hidden; +} + +/** + * @param Coll $a + * @param Coll $b + */ +function test(Coll $a, Coll $b, Field $f, FieldFilter $filter): void +{ + assertType('Bug15414\Coll', $a->merge($b)->reject(isExcluded(...))); + assertType('Bug15414\Coll', $a->merge($b)->reject(static fn (Field $f): bool => isExcluded($f))); + assertType('Bug15414\Coll', $a->reject(isExcluded(...))); + + assertType('Bug15414\Coll', $a->merge($b)->reject($f->isHidden(...))); + assertType('Bug15414\Coll', $a->merge($b)->reject(Field::isHiddenStatic(...))); + assertType('Bug15414\Coll', $a->merge($b)->reject(static function (Field $f): bool { + return isExcluded($f); + })); + + assertType('Bug15414\Coll', $a->merge($b)->rejectClosure(isExcluded(...))); + assertType('Bug15414\Coll', $a->merge($b)->rejectClosure(static fn (Field $f): bool => isExcluded($f))); + + assertType('Bug15414\Coll', $a->merge($b)->reject($filter)); +} + +function testEmpty(Field $f): void +{ + $c = new Coll(); + $c = $c->merge([$f])->reject(isExcluded(...)); + assertType('Bug15414\Coll', $c); +} diff --git a/turbo-ext/src/TemplateArgumentObserver.cpp b/turbo-ext/src/TemplateArgumentObserver.cpp index 265a244c6f9..43337091132 100644 --- a/turbo-ext/src/TemplateArgumentObserver.cpp +++ b/turbo-ext/src/TemplateArgumentObserver.cpp @@ -989,6 +989,16 @@ class TemplateArgumentObserver if (verdict == PT_TRI_YES) taken = true; continue; } + zend_long memberCallable = pt_type_op_trinary(Z_OBJ_P(member), PT_OP_IS_CALLABLE, 0, NULL); + if (UNEXPECTED(memberCallable < 0)) return zv::Val(); + if (memberCallable == PT_TRI_YES) { + zv::Val superType = pt_type_op(Z_OBJ_P(member), PT_OP_IS_SUPER_TYPE_OF, 1, argumentMember); + if (UNEXPECTED(superType.isUndef())) return zv::Val(); + zend_long verdict = pt_type_result_trinary(superType.raw()); + if (UNEXPECTED(verdict < 0)) return zv::Val(); + if (verdict == PT_TRI_YES) taken = true; + continue; + } zv::Val before = zv::Val::copyOf(zv::Ref(constraints.raw())); constraints = observeLowerBound(std::move(constraints), member, argumentMember); if (UNEXPECTED(constraints.isUndef())) return zv::Val();