Skip to content

Let a callable member of a union parameter take an argument it accepts - #6690

Open
SanderMuller wants to merge 3 commits into
phpstan:2.3.xfrom
SanderMuller:fix-15414-callable-takes-callable-argument
Open

SanderMuller wants to merge 3 commits into
phpstan:2.3.xfrom
SanderMuller:fix-15414-callable-takes-callable-argument

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#15414

With unresolvedTemplateArguments (bleedingEdge), $a->merge($b)->reject(isExcluded(...)) inferred Coll<Field|(Closure(Field): bool)> instead of Coll<Field>. Laravel's Collection::merge() and reject() have this shape.

observeLowerBound() splits a union parameter. When a member takes an argument, the argument does not also flow into a naked marker next to that member. For (callable(TValue): bool)|TValue, the callable member never records a lower bound, because its template is in a contravariant position. The loop therefore saw no change and treated the Closure as not taken, so the Closure became a lower bound of the TValue marker.

Now a callable member takes an argument that it accepts. This is the same rule as for a marker-free member, and as in UnionType::inferTemplateTypes(). A callable that the callable member does not accept, for example reject(isInt(...)) on Coll<Field>, still flows into TValue as on 2.3.0. TemplateArgumentObserver.cpp gets the same change, and the second commit bumps the expected turbo version.

Verification:

  • tests/PHPStan/Analyser/nsrt/bug-15414.php fails without the change, with Coll<Field|(Closure(Field): bool)> on line 63, and passes with it.
  • The third commit adds two cases where a member only maybe takes the argument: a raw \Closure, and Box<X> sent to Box<TValue>|TValue. Both behave as on the base. They fail under the two mutants that Infection reported on the first run, !isCallable()->no() and !isSuperTypeOf()->no().
  • The full test suite passes without and with the turbo extension loaded: 22503 tests, 74 skipped.
  • make phpstan reports no errors.
  • Turbo: the strict build has no compiler warnings, smoke.php reports ALL OK, and signature-parity.php reports OK. The raw analysis output of src/Analyser/Generics and the new test file is identical with and without the extension. Without the .cpp change, the turbo build still infers the Closure, so the port is needed. I could not run make lint-turbo, because clang-tidy is not installed here.
  • Performance: no change. I ran a cold bin/phpstan analyse src/Type src/Analyser with bleedingEdge and without turbo, 3 interleaved runs. The base took 7.09-8.05 s and 57.1-58.1 s user CPU, and this PR took 7.11-7.12 s and 57.6-58.4 s. The output is identical.

🤖 Generated with Claude Code

SanderMuller and others added 3 commits October 6, 2026 21:13
A parameter such as (callable(TValue): bool)|TValue records no lower bound
for its callable member. The union loop then treated an argument that the
callable member accepts as not taken, so the argument became a lower bound
of the TValue marker next to it. After merge(),
reject(isExcluded(...)) inferred Coll<Field|Closure>.

The callable member now takes an argument it accepts, like a marker-free
member does and like UnionType::inferTemplateTypes() does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A raw Closure is only maybe accepted by callable(TValue): bool, so it still
flows into TValue. A Box<X> sent to Box<TValue>|TValue binds TValue to X
through the Box member, which is not callable. Both cases kill the mutants
that the first commit left: !isCallable()->no() and
!isSuperTypeOf()->no().

Co-Authored-By: Claude Opus 5.5 (1M context) <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