Skip to content

Distrust a TemplateType member's eager Yes in IntersectionType::isAcceptedBy only when that member accepts with Yes - #5962

Merged
ondrejmirtes merged 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-ppv2v9j
Jul 2, 2026
Merged

Distrust a TemplateType member's eager Yes in IntersectionType::isAcceptedBy only when that member accepts with Yes#5962
ondrejmirtes merged 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-ppv2v9j

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since 2.2.3, passing a value of a union type such as (Closure(static): T)|(Closure&T of object) to a parameter (or returning it) typed Closure(static): T started producing a spurious argument.type/return.type error. This restores the 2.2.2 behavior (no error) without re-breaking the bug that 2.2.3 fixed.

Changes

  • src/Type/IntersectionType.php: in isAcceptedBy(), the block added by bad7874ec distrusted lazyMaxMin's eager Yes whenever the intersection contained any TemplateType member and the holistic isSuperTypeOf was Maybe. The condition now additionally requires that the TemplateType member itself accepts with Yes before distrusting the result.
  • tests/PHPStan/Rules/Methods/data/bug-14893.php: regression fixture reproducing the issue in both argument position (offsetSet call) and return position (bar).
  • tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php: testBug14893 covers the argument-position case.
  • tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php: testBug14893 covers the analogous return-position case (also a regression from bad7874ec).

Root cause

IntersectionType::isAcceptedBy() computes acceptance as the max over its members (A & B ⊆ C holds if A ⊆ C or B ⊆ C). A TemplateType member accepts eagerly (inference-mode Yes) even when it is not a genuine subtype, so lazyMaxMin's Yes can be untrustworthy. Commit bad7874ec addressed this for bug 13190 by distrusting the Yes whenever a TemplateType member was present and the holistic isSuperTypeOf was Maybe.

That was too coarse. In the reported case the intersection Closure & T of object is checked against Closure(static): T:

  • the non-template Closure member is accepted with Yes (a plain Closure object is accepted at the object level by a closure-signature type),
  • the template T of object member is accepted only with Maybe.

So the overall Yes came from the genuine non-template match, not from the template's eager acceptance - yet the old condition still threw it away. The distinguishing signal is the template member's own acceptance: in bug 13190 the template accepts with Yes (eager, untrustworthy); here it accepts with Maybe. The fix keys off exactly that.

Test

  • CallMethodsRuleTest::testBug14893 - argument position ($this->offsetSet(0, $value)); fails before the fix with Parameter #2 $value of method ...::offsetSet() expects Closure(static): T, (Closure(static): T)|(Closure&T of object) given.
  • Methods/ReturnTypeRuleTest::testBug14893 - return position (return $value;); fails before the fix with the analogous should return Closure(static): T but returns (Closure(static): T)|(Closure&T of object).
  • Functions/ReturnTypeRuleTest::testBug13190 and testBug13190TemplateGeneric still pass, confirming the 2.2.3 fix is preserved.
  • Probed further analogous intersections against non-signature accepting types (Countable, plain Closure, Iterator&Countable); those never triggered the block (their holistic isSuperTypeOf is Yes, not Maybe) and were already correct, so no extra tests were added for them.

Fixes phpstan/phpstan#14893

…eptedBy only when that member accepts with Yes

- `IntersectionType::isAcceptedBy()` short-circuits lazyMaxMin to Yes, then verifies the result against the holistic `isSuperTypeOf`. Commit bad7874 distrusted that Yes whenever *any* `TemplateType` member was present and the holistic check was Maybe.
- That was too broad: the eager Yes can legitimately come from a non-template member (e.g. a plain `Closure` object accepted at object level by a `Closure(static): T` signature type), while a bounded template member only accepts with Maybe.
- Narrow the condition so the eager Yes is only distrusted when a `TemplateType` member itself accepts with Yes - i.e. the template's inference-mode eager acceptance is what produced the Yes. When the template accepts with Maybe, the Yes came from a genuine non-template match and is trusted.
- Keeps the bug-13190 fix intact (there the template member accepts eagerly with Yes, so the return-type mismatch is still reported) while restoring acceptance for the reported argument- and return-position cases.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type like this is always trouble T|(Closure(static): T)
Basically the inference has to decide what T is and how greedy it should be
Because if you pass a Closure in there you need to decide whether the closure is T or whether the return type is T
Because the closure can be either T or Closure(static): T
Both sides of the union

The signatures are really problematic in this case. The other parameter type is: non-empty-string|class-string<T>

So a class-string is also a non-empty-string. PHPStan’s UnionType has to kind of decide what route to take
Same for T|array<T> for exampl
I think there’s some code to handle this but I don’t know how robust it is
The test cases should also verify what happens when you call cloneWithService with various inputs

I think this is a similar problem a1b7b38

we need tests which contain assertTypes to show the whole picture.

…h, reject mismatch)

Extends the regression fixture to call offsetSet with concrete arguments on a
HelloWorld<DateTimeInterface> instance, confirming the fix keeps genuine
acceptance/rejection intact: a closure returning the concrete T is accepted,
while a closure returning an unrelated type is still reported. Guards against
the fix becoming over-permissive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed the review. Here's a summary of what I did.

Response to @staabm's review

You're right that T|(Closure(static): T) signatures are inherently tricky for inference, and the concern about the fix being over-permissive is well-founded. Rather than change the fix, I probed the adjacent problematic signatures you flagged and extended the test coverage to prove the fix stays precise.

What I verified against the actual analyzer (throwaway probes, not committed):

  • T|array<T>T param: correctly errors (array<T> is genuinely not T). Not over-accepted.
  • non-empty-string|class-string<T>: passing to a class-string<T> param correctly errors; passing to a non-empty-string param correctly does not (a class-string is a non-empty-string). Both routes behave sensibly.

So the union-inference machinery isn't disturbed by the fix — those cases were already correct and remain correct.

What I committed — extended bug-14893.php to call offsetSet with concrete inputs on a HelloWorld<\DateTimeInterface> instance (this is your "call it with various inputs" ask):

  • callWithMatchingClosure — a Closure(): \DateTimeInterface is accepted (no error).
  • callWithMismatchedClosure — a Closure(): \stdClass is still rejected with argument.type.

The CallMethodsRuleTest::testBug14893 expectation now asserts that mismatch error, so the test locks in that the fix accepts genuine matches and rejects genuine mismatches — not blanket acceptance.

Regression guard confirmed: reverting the fix makes the union-passthrough cases (foo, bar) error again, while the concrete matching closure never errors and the mismatched one errors either way — showing the fix removes exactly the spurious union-passthrough error and nothing else.

Checks: make phpstan → no errors; CallMethodsRuleTest + both ReturnTypeRuleTest suites (336 tests) green; testBug13190/testBug13190TemplateGeneric still pass (2.2.3 fix preserved).

The commit RRB note about a1b7b38fb (eliminate non-generic types before inferring from unions): I looked at whether it's the same root cause. It operates in UnionType inference-from-union, whereas this regression is in IntersectionType::isAcceptedBy acceptance — a different code path. The a1b7b38fb path isn't what produced this false positive, so I left it untouched, but the new concrete-input tests would catch a regression there too.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I verified against the actual analyzer (throwaway probes, not committed):

* **`T|array<T>` → `T` param**: correctly _errors_ (`array<T>` is genuinely not `T`). Not over-accepted.

* **`non-empty-string|class-string<T>`**: passing to a `class-string<T>` param correctly _errors_; passing to a `non-empty-string` param correctly _does not_ (a `class-string` is a `non-empty-string`). Both routes behave sensibly.

add these tests to the PR

… bug-14893

Adds the reviewer-requested probe cases as committed tests:
- T|array<T> passed to a T param is still rejected (array<T> is not T).
- non-empty-string|class-string<T> is rejected by a class-string<T> param
  but accepted by a non-empty-string param (a class-string is a non-empty-string).

These lock in that the IntersectionType::isAcceptedBy fix stays precise and
does not disturb the union-inference machinery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed staabm's review by committing the two probe cases that were previously only verified as throwaway probes.

What I changed

tests/PHPStan/Rules/Methods/data/bug-14893.php — added two new fixture classes:

  • ArrayCase (T|array<T>T param): passing the union to a T parameter is still rejected because array<T> is genuinely not T — proving the fix doesn't over-accept.
  • ClassStringCase (non-empty-string|class-string<T>): passing the union to a class-string<T> param is rejected, but passing it to a non-empty-string param is accepted (a class-string is always a non-empty-string) — both routes behave sensibly.

tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php — added the two resulting argument.type errors to testBug14893's expectations (lines 72 and 101), so the behavior is locked in.

Verification

  • CallMethodsRuleTest::testBug14893 + ReturnTypeRuleTest::testBug14893 → green (2 tests).
  • make phpstanNo errors.

The error messages in the expectations are the exact ones produced by the analyzer, and the non-empty-string-route call correctly produces no error, demonstrating the union-inference machinery is undisturbed by the fix.

Commit dba8cd094 pushed to create-pull-request/patch-ppv2v9j.

@staabm
staabm requested a review from VincentLanglet July 1, 2026 14:21
@ondrejmirtes
ondrejmirtes merged commit d057cd1 into phpstan:2.2.x Jul 2, 2026
670 of 672 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-ppv2v9j branch July 2, 2026 05:47
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.

regression: 2.2.3 starts to error about generic union parameter-type

3 participants