Skip to content

Let a callable union member take the callable argument it accepts before it can flow into a naked template argument marker - #6809

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-gubwtsa
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-gubwtsa

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

With bleedingEdge (unresolvedTemplateArguments), passing a first-class callable to a (callable(TValue): bool)|TValue parameter added the callable to the receiver's TValue. This happened when the receiver came from a call that left a template argument unresolved, e.g. Coll<TValue|TMergeValue> from merge(). So $a->merge($b)->reject(isExcluded(...)) gave Coll<(Closure(Field): bool)|Field> instead of Coll<Field>. This is the shape of Laravel's Collection::merge()->reject().

Changes

  • src/Analyser/Generics/TemplateArgumentObserver.php: in the union branch of observeLowerBound(), a member that carries markers and is a callable now takes an argument member it is definitely a supertype of. That argument member is then no longer sent as a lower bound to the naked markers next to it.
  • turbo-ext/src/TemplateArgumentObserver.cpp: the same change in the native mirror.
  • Analogous cases fixed by the same change, each covered in the test: first-class callables of functions, instance methods and static methods; closures; (Closure(TValue): bool)|TValue parameters; invokable objects.
  • Probed and already correct: function-level templates (@template T, @param (callable(T): bool)|T, in either argument order), fresh new Coll() receivers, and merge([closure]), which still correctly widens the element type.

Root cause

observeLowerBound() mirrors UnionType::inferTemplateTypes(): an argument member that a sibling member takes does not flow into a naked marker. A member "took" an argument only if it was marker-free and a supertype of it, or if recursing into it added constraints. Callable members return early, because markers inside a callable signature are in a contravariant position and are never lower bounds. So the recursion never changed anything, the callable member never took the closure, and the closure became a lower bound of the naked TValue marker. The marker's merge site then resolved to Field|Closure(Field): bool. Now a callable member takes the arguments it accepts, as the legacy isSuperTypeOf()->yes() absorption does.

Test

tests/PHPStan/Analyser/nsrt/bug-15414.php contains the issue's reproducer verbatim, plus method, static-method, closure, Closure-typed-parameter and invokable-object variants, and a fresh-receiver case. Without the fix, 5 of the assertions fail (the function, method and static-method first-class callables, the Closure-typed parameter, and the invokable object); with it, all pass, with and without the turbo extension loaded.

Fixes phpstan/phpstan#15414

🤖 Generated with Claude Code

…ore 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A first-class callable passed to (callable(TValue): bool)|TValue widens the receiver's TValue (unresolvedTemplateArguments)

1 participant