Keep property fetches on the receiver when a static method or static closure invalidates it - #6391
Keep property fetches on the receiver when a static method or static closure invalidates it#6391phpstan-bot wants to merge 6 commits into
Conversation
…closure invalidates it - Add a `$keepPropertyFetches` flag to `MutatingScope::invalidateExpression()`, threaded through `ScopeOps::invalidateExpressionEntries()` and `ScopeOps::shouldInvalidateExpression()`. - New `ScopeOps::isPropertyFetchChainOn()` carve-out: an entry that is a chain of property fetches rooted at the invalidated expression (`$this->foo`, `$this->foo?->bar`, `$this->$name`) is kept, because a callee that never receives the object cannot reach it. Method calls, offset accesses and static property fetches keep being invalidated. - `StaticCallHandler` passes the flag for `self::`/`static::`/`parent::` calls to a static method; `parent::__construct()` and non-static methods called statically still invalidate. - Analogous case: `MethodCallHandler` passes it for a static method called with `->` (`$this->staticMethod()`, `$obj->staticMethod()`). - Analogous case: `FuncCallScopeEffectsHelper` passes it for calling a static closure - a static closure has no `$this` binding. - Ported the same logic to the native `turbo-ext/src/ScopeOps.cpp` mirror, with two new class references (`nullsafePropertyFetch`, `identifier`); analysis output is byte-identical with and without the extension. - Probed and left alone: first-class callables of static methods still invalidate, because the `ClosureType` they produce is not marked as a static closure. Closes phpstan/phpstan#13735
|
@SanderMuller please review |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
regarding turbo errors on macos, I updated a upstream issue: shivammathur/setup-php#1116 |
SanderMuller
left a comment
There was a problem hiding this comment.
Two false negatives, both reproduced on the branch and both correct on 2.2.x. The rest of the change holds up well, including the native mirror, which I was able to test. I am not the maintainer, so the calls are yours and @ondrejmirtes's.
A static callee can reach the object, in two ways
The premise in the description is "a static method never receives $this, so it cannot change the object's own properties". A static callee gets no $this, but it can still be handed the object, or find it.
1. A static closure that takes the receiver as an argument. I varied the closure along both axes, with a body that actually writes. $this->foo starts as Foo in every row:
| closure | body writes | 2.2.x |
this branch |
|---|---|---|---|
static fn () |
Holder::$seen |
Foo|null |
Foo |
static fn (int $i), called $fn(1) |
Holder::$seen |
Foo|null |
Foo |
static fn (Holder $h), called $fn($this) |
$h->foo = null |
Foo|null |
Foo |
fn (Holder $h), called $fn($this) |
$h->foo = null |
Foo|null |
Foo|null |
fn () |
$this->foo = null |
Foo|null |
Foo|null |
Rows 1 and 2 are the improvement, and they are clearly right: that closure cannot reach $this. Rows 4 and 5 are correct controls. Row 3 is the hole. The closure is handed $this and writes through it, and the narrowing survives.
The static method path already covers that channel on this branch:
self::mutate($this); // still invalidates
$this->mutate($this); // static method via ->, still invalidates
$other->mutate($other); // still invalidatesSo StaticCallHandler and MethodCallHandler handle the argument case and FuncCallScopeEffectsHelper does not.
2. A static method that reaches the object through static state. Also green on 2.2.x, also lost here:
public static ?Holder $instance = null;
public static function mutateStored(): void
{
if (self::$instance !== null) {
self::$instance->foo = null;
}
}
public function reachedViaStaticProperty(): void
{
self::$instance = $this;
$this->foo = new Foo();
self::mutateStored();
assertType('Foo|null', $this->foo); // 2.2.x: passes. This branch: 'Foo'.
}The description says "everything that can observe static state keeps being invalidated". Property fetches on the receiver no longer are, and static state is the channel by which a static method reaches $this. Registries and singletons write self::$instance = $this routinely.
Case 1 looks like a plain gap to me, and a narrow one. Case 2 is the boundary the description draws, so where you put it is your call.
The native mirror agrees, and I checked rather than read it
ScopeOps carries #[ShadowedByTurboExtension], so this is the case where the PHP and C++ implementations can silently disagree. I built turbo-ext locally on PHP 8.5 arm64 and confirmed the extension was really active, PHPStan\Analyser\ScopeOps extending PHPStanTurbo\ScopeOps, rather than merely loaded.
- The PR's two nsrt files plus my three probe files: 6 errors, byte-identical with and without the extension. Both false negatives above reproduce identically under the native path, so the port is faithful, including in its faults.
- Full suite green both ways: 21324 tests / 96430 assertions, without the extension and with it.
bin/phpstan analyse -c build/phpstan.neon srcbyte-identical with and without.
The version bump is right too. EXPECTED_EXTENSION_VERSION = 'e5a7514' is the short SHA of e5a751490, the only commit touching turbo-ext/src/, which is the convention the constant's own docblock states. The [!NOTE] in the description asking for a follow-up make bump-turbo is stale, since 65955e019 already did it.
The tests do fail first
Both nsrt files against 2.2.x: 12 wrong assertType()s. The description says 11, which was right before the static:: case was added on review.
Performance
Flat. Cold corpus run, 2 interleaved rounds, on a 4524-file real-world project: CPU 141.9 / 140.6 s on base against 142.2 / 138.6 s here. maxRSS 348 to 397 MB in both arms, and the error output is identical.
That matches what isPropertyFetchChainOn() does. It returns on the first instanceof for anything that is not a property fetch. The pretty-print only runs for a real chain.
CI
Every red is accounted for and none is this change.
The four macOS Compile Turbo Extension jobs are the setup-php problem you just filed upstream. Worth adding one data point: the same .cpp built cleanly here on PHP 8.5 arm64, so nothing is hiding behind that red.
Tests with old PHPUnit (8.1, ubuntu-latest) is three data sets of IntersectionTypeTest::testIsAcceptedBy, Maybe against No. That flake has been failing on unrelated PRs all week. PHPStan (8.1, windows-latest) is red on base head.
For the maintainer
Whether case 1 blocks. I think it should. 2.2.x is correct there, and the fix looks small. Treat the receiver as invalidated when it appears among the call's arguments, the way the static-method path already does.
Whether case 2 is in scope, or a documented limitation of the carve-out.
A static closure is never bound to $this, but it can still be handed the
object as an argument and write through it:
$fn = static function (Holder $h): void { $h->foo = null; };
$fn($this);
Keeping property fetches on the receiver across such a call forgot that
write. NodeScopeResolver::processArgs() already invalidates object
arguments for a callee with side effects - that is what keeps
'self::mutate($this)' correct on the static method path - but a closure
has no FunctionReflection, so it is skipped there. Mirror it in
FuncCallScopeEffectsHelper for the case where the invalidation of the
receiver is weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A static method can also find the object through static state
('self::$instance = $this;'). PHPStan does not track that for any
receiver - the same call has never invalidated '$other->foo' after
'HelloWorld::$instance = $other' - so this is a pre-existing limitation
the carve-out now shares, not a new one. Record it on
isPropertyFetchChainOn() and as a test of the current behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both findings addressed and pushed as two commits. Case 1 — static closure handed the object: fixed (
|
| case | before | after |
|---|---|---|
$fn($this), static closure |
Foo ❌ |
Foo|null |
$fn($this), static arrow fn |
Foo ❌ |
Foo|null |
$fn($this->foo) writing $f->bar |
Bar ❌ |
Bar|null, and $this->foo still Foo |
$fn(1) |
Foo |
Foo (unchanged — the improvement survives) |
self::mutate($this) / self::mutateFoo($this->foo) |
correct | correct (controls) |
The property-argument row lands on the same semantics the static-method path has: the callee can change what is inside $this->foo, not which Foo $this->foo points at. Verified by stashing only the source change — lines 93, 102 and 115 fail, the two controls pass either way.
Case 2 — reached through static state: documented, not fixed (b51771485)
Your reproducer is real, but it is not a boundary the branch moved. I checked the same shape with a receiver that is not $this:
Holder::$instance = $other;
$other->foo = new Foo();
Holder::mutateStored();
assertType('Foo', $other->foo); // passes on 2.2.x and on this branchStaticCallHandler only ever invalidated new Variable('this'), so the static-state channel has always been untracked for every other receiver — $this was the accident, not the rule. Tracking it soundly would mean assuming any static call can reach any object, which is the feature #13735 asks to remove. So I recorded it as a limitation on isPropertyFetchChainOn()'s docblock and added doReachedViaStaticProperty() asserting today's behaviour, so a future change surfaces there. Happy to flip it if @ondrejmirtes wants the carve-out drawn tighter.
Verification
make tests (21273 tests, 96340 assertions, 97 skipped), make phpstan (no errors) and make cs are green. No turbo-ext/src/ file changed — the ScopeOps.php edit is comment-only — so EXPECTED_EXTENSION_VERSION stays at e5a7514 and no make bump-turbo follow-up is needed.
Two notes on the description, which I have not edited: the assertion count is now higher again after these tests, and the [!NOTE] asking for a make bump-turbo follow-up is stale — 65955e019 already did it, as you spotted.
SanderMuller
left a comment
There was a problem hiding this comment.
Both findings resolved. Case 1 is fixed and case 2 I withdraw, because your counter-argument is right and I checked it.
Case 1 is fixed, and the improvement survived
Same channel matrix as before, $this->foo starting as Foo in every row:
| closure | body writes | 2.2.x |
02766a1ad |
now |
|---|---|---|---|---|
static fn () |
Holder::$seen |
Foo|null |
Foo |
Foo |
static fn (int $i), $fn(1) |
Holder::$seen |
Foo|null |
Foo |
Foo |
static fn (Holder $h), $fn($this) |
$h->foo = null |
Foo|null |
Foo |
Foo|null |
fn (Holder $h), $fn($this) |
$h->foo = null |
Foo|null |
Foo|null |
Foo|null |
fn () |
$this->foo = null |
Foo|null |
Foo|null |
Foo|null |
Row 3 fixed, rows 1 and 2 still carry the win. That is the shape I wanted.
I confirmed your fails-before claim independently. Reverting only FuncCallScopeEffectsHelper.php to 02766a1ad makes lines 93, 102 and 115 of bug-13735b.php fail, and the two controls pass either way.
Case 2: you are right, I withdraw it
I tested the same static-state shape with three different receivers on 2.2.x:
| receiver | 2.2.x |
this branch |
|---|---|---|
$this |
Foo|null |
Foo |
$other, a parameter |
Foo |
Foo |
$local, a local variable |
Foo |
Foo |
So 2.2.x tracked that channel for $this alone, which is the accident you describe, not a rule the branch broke. Making $this behave like every other receiver is the consistent choice, and pinning it in doReachedViaStaticProperty() is the right way to leave it. My earlier framing was accurate about the one shape and wrong about what it meant.
The two cases you found while probing are real additions. The static arrow function is the same hole in different syntax. The property-argument case landing on "the callee can change what is inside $this->foo, not which Foo it points at" matches the static-method path exactly.
Turbo bookkeeping checks out
ScopeOps.php is the only shadowed class here, and the new commit adds five lines of docblock to it. FuncCallScopeEffectsHelper is not shadowed and turbo-ext/src/ references it nowhere, so e5a7514 stays correct and no make bump-turbo is due.
I re-ran the differential anyway with the extension built and active, PHPStan\Analyser\ScopeOps extending PHPStanTurbo\ScopeOps: 7 files, 12 errors, byte-identical with and without.
Gate at b51771485
Suite 21324 tests / 96430 assertions green, both with and without the extension. make phpstan clean. phpcs clean on the changed files.
Performance re-measured at this head, since invalidateObjectArgs() now runs on every impure closure call. Cold, 2 interleaved rounds, 4524-file corpus. CPU 143.3 / 137.7 s on base against 138.3 / 139.8 s here, with overlapping spreads. maxRSS 361 to 403 MB in both, error output identical. No measurable cost.
CI reds are the setup-php macOS problem you filed upstream, the IntersectionTypeTest::testIsAcceptedBy flake, and PHPStan (8.1, windows-latest) which is red on base head.
Nothing left open from my side. The description still needs its assertion count and its stale [!NOTE] fixing before merge.
Summary
Calling a static method from an instance method threw away every narrowed type
rooted at
$this, including plain property fetches:A static method never receives
$this, so it cannot change the object's ownproperties. This PR keeps property fetches on the receiver alive across such a
call, while everything that can observe static state (method calls, offset
accesses on the result of a call, static property fetches) keeps being
invalidated - exactly the boundary drawn in the issue discussion.
Changes
src/Analyser/ScopeOps.phpinvalidateExpressionEntries()andshouldInvalidateExpression()take a newoptional
bool $keepPropertyFetchesand thread it to all threeshouldInvalidateExpression()call sites (expression table, conditionalexpression targets, conditional expression conditions).
isPropertyFetchChainOn()helper: is the entry a chain of propertyfetches (
PropertyFetch/NullsafePropertyFetch, named by anIdentifieror aplain variable) whose root prints as the invalidated expression's key?
shouldInvalidateExpression(), placed right after the cheapVariablefast path and before the substring gate and AST walk.src/Analyser/MutatingScope.php-invalidateExpression()gains the flag andforwards it;
src/Analyser/NodeCallbackScope.phpforwards it in its override andin the recorded scope op.
src/Analyser/ExprHandler/StaticCallHandler.php- the reported bug: pass$methodReflection->isStatic().src/Analyser/ExprHandler/MethodCallHandler.php- analogous case: a staticmethod invoked with
->($this->staticMethod(),$obj->staticMethod()) had thesame bug on its receiver.
src/Analyser/ExprHandler/Helper/FuncCallScopeEffectsHelper.php- analogouscase: invoking a static closure invalidated
$this, although a static closureis never bound to
$this. Non-static closures keep invalidating (they can bebound to
$thisand really can write to it).turbo-ext/src/ScopeOps.cpp,turbo-ext/src/support.{h,cpp},build/PHPStan/Build/TurboAttributeCollector.php- the same logic ported intothe native mirror of
ScopeOps(#[ShadowedByTurboExtension]), plus two newclass-map entries (
nullsafePropertyFetch,identifier) the native helper needs.Analogous cases probed
Fixed (each has a failing-before test):
$this->foo->bar$this->foo?->bar$this->{$name}$this->arr/$this->arr['x']->on$thisor on another objectProbed and confirmed still correctly invalidated (kept as regression tests):
$this->getFoo()- the method body can read static stateself::$staticProp- a static method really can write to itself::nonStatic()Probed and deliberately left alone:
$fn = self::sideEffect(...); $fn();)still invalidate.
InitializerExprTypeResolver::createFirstClassCallable()leavesisStaticClosure()asmaybefor them; making ityesis semantically correctbut changes
describe()output (static Closure(...)), callable variance inCallableTypeHelperand theClosure::bind()extensions, which is well beyondthis fix.
Root cause
The pattern is "invalidate the whole receiver when the callee may be impure".
MutatingScope::invalidateExpression($receiver, requireMoreCharacters: true)dropsevery scope entry whose expression contains the receiver, which is right for a
callee that gets the object (a non-static method,
parent::__construct(), a$this-bound closure) but far too broad for a callee that never does. The existingcarve-outs in
ScopeOps::shouldInvalidateExpression()(readonly property fetches,private properties of a different class) already encode the same idea for narrower
reasons; this adds the "the callee cannot reach the object at all" one, applied at
every site that invalidates a receiver for a callee without a
$this:StaticCallHandler,MethodCallHandlerandFuncCallScopeEffectsHelper.The carve-out is intentionally limited to property-fetch chains. Anything else
rooted at the receiver - a method call, an offset access on a call result - can
observe static state, which a static callee can change, so it keeps being
invalidated.
Test
tests/PHPStan/Analyser/nsrt/bug-13735.php- both playground reproducers fromthe issue verbatim (the PHPUnit-style
self::assertTrue()case, and thenon-final / final / private-static-method constructor cases).
tests/PHPStan/Analyser/nsrt/bug-13735b.php- the analogous cases and thecontrols listed above.
Both files fail on
2.2.x(11 wrongassertType()s) and pass with the fix.make tests,make phpstanandmake cs-fixare green; the full suite alsopasses with
phpstan_turboloaded, andbin/phpstan analyse -c build/phpstan.neon srcproduces byte-identical output with and without the extension.turbo-ext/tests/smoke.php,turbo-ext/tests/signature-parity.phpandturbo-ext/bin/side-by-side.phpall pass.Note
turbo-ext/src/changed, so this needs the follow-upmake bump-turbocommit settingTurboExtensionEnabler::EXPECTED_EXTENSION_VERSIONto this change's short SHAonce it lands on the target branch.
Fixes phpstan/phpstan#13735