Skip to content

Resolve static in parameter and writable property types to the called-on type without the new Foo() exactness - #6946

Merged
VincentLanglet merged 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-wvh1mb4
Nov 4, 2026
Merged

VincentLanglet merged 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-wvh1mb4

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since 2.2.13, a new Foo() expression has an object type with a "final-by-keyword override", meaning it is exactly this class. When a method was called on such a value, static in its parameter types was replaced with that exact type. So @param A<covariant static> became A<covariant B(exact)>.

The argument new A($b) infers T as plain B, because inferred template arguments always drop the exactness (#15166). Exact B is only a maybe supertype of B, so the call was rejected:

Parameter #1 $a of method B::method() expects A<covariant B>, A<B> given.

The same call inside a method taking B $b, where $b is not exact, was accepted.

With this change, static in types that values are checked against (parameter types and writable property types) is replaced with the called-on type without the exactness. Output-side types (return, readable property, out, self-out, closure-this, throw and assert types) keep it, so (new Foo())->fluent() is still known to be exactly Foo.

Changes

  • src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php: transformStaticType() takes an $isParameterType flag. Parameter getType() and getPhpDocType() resolve static and GenericStaticType against TemplateTypeHelper::removeFinalByKeywordOverrides($this->calledOnType).
  • src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php: same change for the writable type.
  • turbo-ext (native mirrors):
    • TypeTraits.h: PrototypeTransformer gains an inputFn / transformInput().
    • TypeTraits.cpp: protoParameter() and the writable type in protoTransformProperty() use transformInput().
    • CalledOnTypeUnresolved{Method,Property}PrototypeReflection.cpp: pass the flag as the native callback's state and strip through TemplateTypeHelper::removeFinalByKeywordOverrides().
    • CallbackUnresolved{Method,Property}PrototypeReflection.cpp: use the same function for both, so their behaviour is unchanged.

Analogous cases

Fixed, each with a test:

  • instance method call (the reported case)
  • static method call through $b::staticMethod(...)
  • first-class callable $b->method(...) called afterwards
  • property assignment $b->prop = new A($b) with @var A<covariant static>

Probed and already correct:

  • invariant @param A<static>
  • nested static in a return type passed on to @param A<B>
  • top-level @param static

Not touched (separate, pre-existing issue): in constructor parameters (new B(new A($b))), static is never resolved and is reported as A<covariant static(B)>. This happens even when $b is not exact, so it is unrelated to the new exactness.

Root cause

Commit a042651 removed the new Foo() exactness from inferred template arguments, on the reasoning that no written PHPDoc type can carry it. But resolving static against the called-on type does put it into written parameter and property types. A covariant template argument then compared exact B against plain B and got a maybe. The fix strips the exactness wherever a resolved static ends up in an input-side type, in both the method and the property prototype reflections and in their native mirrors.

Test

These tests fail without the fix and pass with it, both with and without the turbo extension loaded:

  • tests/PHPStan/Rules/Methods/data/bug-15235.php: the issue's reproducer, plus a static method call and a first-class callable. Used by CallMethodsRuleTest, CallStaticMethodsRuleTest and CallCallablesRuleTest.
  • tests/PHPStan/Rules/Properties/data/bug-15235.php: property assignment, in TypesAssignedToPropertiesRuleTest.

Fixes phpstan/phpstan#15235

🤖 Generated with Claude Code

…ed-on type without the `new Foo()` exactness

- `CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType()` now
  takes an `$isParameterType` flag. For parameter types (`getType()` and
  `getPhpDocType()`) it replaces `static` with
  `TemplateTypeHelper::removeFinalByKeywordOverrides($calledOnType)`. Without
  this, `$b = new B(); $b->method(new A($b))` with
  `@param A<covariant static>` expected `A<covariant B(exact)>`, but the
  inferred `A<B>` never carries exactness, so it was rejected.
- `CalledOnTypeUnresolvedPropertyPrototypeReflection` does the same for the
  writable type, so `$b->prop = new A($b)` is accepted for
  `@var A<covariant static>`.
- Return, readable, self-out, out, closure-this, throw and assert types keep
  the exact flavour, so `(new Foo())->fluent()` stays exact.
- turbo-ext: `PrototypeTransformer` gains an `inputFn`, used for parameter
  types and the writable property type. The CalledOnType natives pass the flag
  through the native callback state and strip through
  `TemplateTypeHelper::removeFinalByKeywordOverrides()`. The Callback natives
  use the same fn for both.
- Covered: instance method calls, static method calls on `$b::`,
  first-class callables (`$b->method(...)`) and property assignments. The
  invariant `A<static>` parameter and nested `static` in return types
  already worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@VincentLanglet VincentLanglet 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.

Compare the strategy with the one in #6487

phpstan-bot and others added 2 commits November 4, 2026 12:52
…ariant type arguments

The "exactly this class" flavour cannot be written in a PHPDoc type argument,
and the invariant branch of isValidVariance() already ignores it through
equals(). Stripping it from both sides in the covariant and contravariant
branches covers every way the flavour reaches a type argument - `static`
resolved in a parameter type, `static` resolved in a return type passed on to
another parameter, get_class() narrowing - in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… exactness

The variance comparison now ignores the flavour, so the prototype reflections
(and their turbo-ext mirrors) no longer need a separate input-side transform.

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

Copy link
Copy Markdown
Collaborator Author

I compared the two strategies, switched this PR to #6487's approach, and pushed two commits.

What I compared. This PR stripped the new Foo() exactness wherever static is resolved into a parameter type or writable property type. #6487 strips it in one place instead: the covariant and contravariant branches of TemplateTypeVariance::isValidVariance(). The invariant branch there already ignores the exactness, because it compares with equals().

I ran #6487's test data against this PR. Everything passed except one case: a @return Wrapper<static> called on a new E() receiver, passed to @param Wrapper<contravariant E>. That still failed with "expects Wrapper<contravariant E>, Wrapper<E> given". Here the exactness arrives from the argument side, through a resolved return type. Stripping the parameter side can't catch it, and stripping return types would lose the precision that (new Foo())->fluent() relies on.

#6487's approach handles every case, including that one. It's also much smaller: about 25 lines instead of about 150, mostly C++.

Commits:

  1. 8063599ce: strips the exactness from both sides before isSuperTypeOf() in the covariant/contravariant branches. I wrote it inline rather than as a private helper, since you said on Ignore the new Foo() exactness flavour when comparing generic type arguments for variance #6487 that a helper wasn't worth it for an almost one-liner. The C++ mirror in TemplateTypeVariance.cpp does the same, through a new exported pt_type_template_type_helper_remove_final_by_keyword_overrides(). The test data now has all of Ignore the new Foo() exactness flavour when comparing generic type arguments for variance #6487's cases plus this PR's static-method and first-class-callable cases. That includes the get_class() narrowing, class-declared variance, Traversable, the argument-side case above, and nested cases (generics, arrays, unions). The properties test now needs PHP 8.1 because it uses typed properties.
  2. 75046c18e: reverts this PR's original changes to the prototype reflections and their C++ mirrors. Those files are now identical to before the PR.

Verification:

  • Tests fail without the fix: all four testBug15235 tests fail with the variance change removed and pass with it, after the revert.
  • PHP side: make tests (23163 tests) and make phpstan pass.
  • Turbo extension: I rebuilt it with the strict warning flags. smoke.php and signature-parity.php pass, and the full suite passes with the extension loaded. With the extension loaded and only the PHP change undone, the regression tests still pass, so the C++ mirror carries the fix on its own.
  • side-by-side.php: method parity passes, but it reports 56 generated headers as stale. That isn't from this change: regenerating them only reorders their string tables, and it happens for headers I didn't touch too. I didn't commit the regenerated files.

The PR title and description still describe the old approach, so they need updating.

@VincentLanglet
VincentLanglet requested a review from staabm November 4, 2026 13:05
@VincentLanglet VincentLanglet self-assigned this Nov 4, 2026

@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.

lgtm - I am not a generics variance expert though

@VincentLanglet
VincentLanglet merged commit 4be202f into phpstan:2.3.x Nov 4, 2026
1071 of 1076 checks passed
@VincentLanglet
VincentLanglet deleted the create-pull-request/patch-wvh1mb4 branch November 4, 2026 14:50
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.

"Parameter #1 expects A<covariant B>, A<B> given" error since 2.2.13

3 participants