Repository navigation
Conversation
…3387 `$bag->map()` returned `Bag<int>&static` though `$bag` isn't late static, so `return $other->fluent()` passed as `static`. - `$this instanceof Child` dropped late static; with the change that became a false LessSpecificReturnStatement on `$this->fluent()` - an `a&static` part bound to `$this` was bound again by the next call
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 764ba67. Configure here.
✅ Performance: unchanged · Issues: decreased
How to read this table
✅ laravel: issues +71 −78Memory 1 821 → 1 821 MB (<0.1 %) · Wall 69.9 → 65.6 s (−6.2 %) Weighted 51 144 → 51 117 (−27, −0.05 %): L3 −5 · L6 −2
Added (71)
Removed (78)
✅ symfony: issues +11 −11Memory 4 002 → 4 002 MB (<0.1 %) · Wall 136.1 → 132.1 s (−3.0 %)
Added (11)
Removed (11)
✅ doctrine-orm: issues +1 −5Memory 428 → 428 MB (<0.1 %) · Wall 17.8 → 17.8 s (+0.4 %) Weighted 5 574 → 5 562 (−12, −0.22 %): L3 −4
Added (1)
Removed (5)
✅ tempest: issues +0 −2Memory 922 → 922 MB (<0.1 %) · Wall 24.8 → 24.8 s (+0.1 %) Weighted 9 616 → 9 610 (−6, −0.06 %): L3 −2
Removed (2)
✅ drupal: issues +84 −88Memory 5 196 → 5 196 MB (<0.1 %) · Wall 119.2 → 119.4 s (+0.2 %) Weighted 161 668 → 161 656 (−12, −0.01 %): L3 −4
Added (84)
Removed (88)
head 4411c95 · base 8a88460 (merge-base) · workflow run |
`parent::create()` forwarded to `__call` on a plain parent-typed receiver, so its `static` lost late static binding.
…#13387 The receiver bound the late static of the caller's own types too, once they came in through the templates: `make(static::class)` on a Container gave `Component&Container`. That was masked while every non-final receiver kept the result late static. - first-class callables and @method pseudo-methods bind `static` the same way; a parameter's `static` stays late static as in a call - templates of the receiver are the caller's: the call no longer binds them to never - the TypeExpander resolved-flag workaround is no longer needed
…o#13387 Binding the signature's static before the template replacement lost static that comes from the callee's template arguments (`@use HasCollection<Collection<array-key, static>>`) and turned `static::new()->count()` into `Factory<Model>` (Laravel perf corpus). The template bounds now carry the caller's late static marked as bound, so the original order binds only the callee's own static. A bound static is no longer given default type arguments, which dropped its static flag.
Marking the template bounds also marked `static & self` from the callee's own `@use HasCollection<...>`, so Eloquent `hydrate()` and relations lost the binding to the receiver (Laravel perf corpus). Only `X&static` parsed from a signature needs binding; a late static built in analysis (`$this`, `new static`, `static::class`) is the caller's. TNamedObject now starts such a type as bound, which drops the bound-static visitor.
Merges vimeo#13461 into this PR: a called object binds no static inside a late static that is already bound (`$this` with `@template T of static` otherwise hung), and narrowing keeps the bound flag of the child. Its tests come along, with `(new Post())->find()`, `box($this)->stat()` and `$this->keys()->same()` no longer late static: their receivers aren't.
…o#13387 Review follow-ups: - binding `static` to the class of a receiver that isn't late static is sound only in covariant positions: a callable's parameters and invariant template arguments keep it late static - constructors bind static as on master: `new Child($this)` rebound the caller's static to Child - narrowing a late static to a child checks the child's type arguments (`static<int>` vs `@extends Bag<string>` contradicts) - `@psalm-if-this-is` keeps `$this` late static - the CallMap path, a `@return static` inherited by a native `self` and `$other::create()` bind static like an instance call - a generic expansion of a bound static keeps its type arguments - setValueIsStatic() sets the resolved flag it is given
…-static-of-non-static-receivers # Conflicts: # src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/ExistingAtomicStaticCallAnalyzer.php # tests/MethodCallTest.php
alies-dev
left a comment
There was a problem hiding this comment.
Self-review: notes on the parts that are not obvious from the diff.
| } | ||
|
|
||
| $this->value = $value; | ||
| $this->is_static_resolved = $is_static; |
There was a problem hiding this comment.
The core idea of the PR. Only X&static parsed from a signature (including a native : static, which TypeHintResolver parses as Foo&static) is set through $first_type->is_static = true and stays unbound until a call binds it to its receiver. Everything constructed with $is_static during analysis ($this, new static, static::class, instanceof static) is the late static of the calling code, so it starts bound: a later call must never bind it to its own receiver. That rebinding was the cause of #13441 (box($this)->same() nesting) and of make(static::class) on a container giving Component&Container. Doing it in the constructor covers every creation site at once instead of patching each one.
| $cloned->value = $value; | ||
| $cloned->is_static = $is_static; | ||
| $cloned->is_static_resolved = $is_static; | ||
| $cloned->is_static_resolved = $is_static_resolved; |
There was a problem hiding this comment.
Pre-existing bug: this stored $is_static and ignored the $is_static_resolved argument it compares two lines above. Harmless while nothing relied on the flag; now the barrier in TypeExpander::expandAtomic() does.
| public static function bindsStaticToReceiverClass(Codebase $codebase, ?Atomic $static_type): bool | ||
| { | ||
| return $static_type instanceof TNamedObject |
There was a problem hiding this comment.
The receiver rule for #13387. static in a called method's signature is the runtime class of the receiver. For $this (or any value that is itself late static) that is still unknown, so the result stays X&static. For a plain $bag: Bag it is "some subclass of Bag", which Bag already means, so the result is Bag. Before, every non-final receiver produced &static, which also let return $other->fluent() pass as the caller's own static (now LessSpecificReturnStatement).
This only feeds the $final flag of TypeExpander; where that flag may actually be applied is restricted to covariant positions, see isFinalWhereNotCovariant().
| true, | ||
| false, | ||
| false, | ||
| self::bindsStaticToReceiverClass($codebase, $static_type), |
There was a problem hiding this comment.
Sibling path: CallMap methods (DateTime::setTime()) passed a hard-coded false, so $date->setTime(1, 2) stayed DateTime&static.
| if ($return_type instanceof TNamedObject | ||
| && $return_type->is_static_resolved | ||
| && !is_string($static_class_type) | ||
| ) { | ||
| $static_class_type = null; | ||
| } |
There was a problem hiding this comment.
Barrier for #13441: a bound late static belongs to the caller, so the receiver of this call must not bind any static inside it, including inside its type arguments (Foo<T of static>, which otherwise recursed). Exempt: a string static class. That is a call by class name (Child::make()), where the called class is exact and still has to replace a static bound to a parent (Methods::getMethodReturnType() pre-binds to the declaring class).
| $tmp_context->vars_in_scope['$__tmp_parent_var__'] = new Union([ | ||
| $lhs_type_part instanceof TNamedObject ? $lhs_type_part->setIsStatic(true, true) : $lhs_type_part, | ||
| ]); |
There was a problem hiding this comment.
parent::create() with no such method forwards to __call through a temporary variable typed as the parent class. That variable stands for $this, so it must be late static, otherwise the receiver rule above would answer the plain parent class (parentMagicMethodCall broke without this).
| // still the late static `$this`, narrowed | ||
| $this_types = []; | ||
| foreach ($this->storage->if_this_is_type->getAtomicTypes() as $this_type) { | ||
| $this_types[] = $this_type instanceof TNamedObject | ||
| ? $this_type->setIsStatic(!$storage->final, true) | ||
| : $this_type; | ||
| } | ||
|
|
||
| $context->vars_in_scope['$this'] = $this->storage->if_this_is_type | ||
| ->setTypes($this_types) | ||
| ->setProperties($props); |
There was a problem hiding this comment.
@psalm-if-this-is replaced $this with the declared type, which is not late static, so return $this->fluent() in such a method became a false LessSpecificReturnStatement under the new receiver rule. $this is still the late static object, only narrowed.
| $defining_class_storage->parent_class, | ||
| true, | ||
| false, | ||
| MethodCallReturnTypeFetcher::bindsStaticToReceiverClass($codebase, $lhs_type_part), |
There was a problem hiding this comment.
@method static fluent() pseudo-methods: same receiver rule as real methods (this path used the default final = false, i.e. always late static, even for a final class).
|
|
||
| /** @template T */ | ||
| interface Bag { | ||
| abstract class Bag { |
There was a problem hiding this comment.
The preventDoubleStaticResolution* cases (from #13442 and older) now call through $this inside the class. With a plain receiver there is no late static left to re-bind, so the old global-scope versions would no longer exercise the double-binding guard; I checked that each of them fails when the guard (is_static_resolved / the #13442 combiner change) is removed. The global-scope variants are kept as preventDoubleStaticResolutionOnAPlainReceiver*.
| '$z===' => 'C&static', | ||
| '$y===' => 'C&static', | ||
| '$chained===' => 'C&static', | ||
| '$z===' => 'C', |
There was a problem hiding this comment.
Changed expectations in existing tests are all X&static → X for a call on a receiver that isn't late static ((new C())->returnThis() is exactly a C). Where the type is invariant the old &static stays (e.g. $posts in staticDeclaredByTheReceiverClassIsStillBoundToTheReceiver).

staticreturned by a method called on another object stays late staticBefore
After
$this instanceof Childdrops late staticBefore
After
a&staticbound to$thisis bound again by the next callBefore
After
The caller's own
staticis bound to the receiver of the call it is passed toBefore
After
$thisin a template argument is bound to every later receiver, nesting the typeBefore
After
The same on other call paths: the CallMap and an inherited
@return staticBefore
After
staticbecomes the receiver's class only where the type is covariant: a callable's parameters and invariant template arguments stay late static. Code returning another instance'sstaticas its ownstaticis now reported.Follow-up to #13387.
Fixes #13441