Keep property fetches on the receiver when a static method or static closure invalidates it - #6391
Conversation
|
@SanderMuller please review |
|
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.
|
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.
staabm
left a comment
There was a problem hiding this comment.
add a test when $this is passed as argument into a method-call or static-method-call
|
Addressed the review comment; pushed as Added to
|
| case | call | result |
|---|---|---|
doLateStaticBindingGettingThisAsArgument |
static::mutate($this) |
Foo|null |
doStaticMethodCalledOnThisGettingThisAsArgument |
$this->mutate($this) |
Foo|null |
doStaticMethodCalledOnInstanceGettingThisAsArgument |
$other->mutate($this) |
Foo|null |
doMethodCallGettingThisAsArgument |
$other->nonStaticMutate($this) |
Foo|null |
doMethodCallGettingPropertyAsArgument |
$other->mutateFoo($this->foo) |
$this->foo stays Foo, $this->foo->bar becomes Bar|null |
The last one lands on the same semantics as the existing self::mutateFoo($this->foo) control: the callee can change what is inside $this->foo, not which Foo it points at.
Plus a nonStaticMutate() helper on HelloWorld.
No source change was needed — and I verified the tests aren't vacuous
All five already behave correctly on this branch: the receiver invalidation is weakened for a static callee, but NodeScopeResolver::processArgs() (src/Analyser/NodeScopeResolver.php:2308) still invalidates every object-typed argument of a callee with hasSideEffects()->yes(), and $this passed as an argument goes through exactly that path.
To confirm these are real guards rather than assertions that pass no matter what, I weakened that argument invalidation itself — passing keepPropertyFetches: true at the processArgs() call site. The four new $this-as-argument assertions (lines 151, 159, 167, 175) then fail with Foo instead of Foo|null, alongside the pre-existing self::mutate($this) case at line 134. Restored, they pass.
make tests (21273 tests, 96340 assertions, 97 skipped), make phpstan (no errors) and make cs are green. Test-only commit — no turbo-ext/src/ change, so EXPECTED_EXTENSION_VERSION stays at e5a7514.
staabm
left a comment
There was a problem hiding this comment.
rebase this fix onto 2.3.x
…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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Covers the argument channel for the receiver-invalidation carve-out: static::mutate($this), $this->mutate($this) and $other->mutate($this) (static methods reached via -> and via late static binding), a plain non-static method call getting $this, and a method call getting $this->foo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cf4d9b2 to
4df96db
Compare
| path: phpstan-dist | ||
| token: ${{ secrets.PHPSTAN_BOT_TOKEN }} | ||
| ref: 2.2.x | ||
| ref: 2.3.x |
There was a problem hiding this comment.
The monitor has closed. All background work is finished and the outcome stands as reported:
Review addressed — no code change was warranted.
- The octoscan alert targets
.github/workflows/phar.yml(ref: 2.2.x→ref: 2.3.x), a line introduced by upstream commit8ce64e0fc "Open 2.3.x". This branch touches no.github/file:git log <2.3.x>..HEAD -- .github/is empty, and the PR's own file list is the 12 source/test/turbo files only. - It surfaced because the PR base was still
2.2.xwhile the branch is rebased onto2.3.x. The base has since been retargeted to2.3.x, so the hunk is no longer in the diff. - The underlying line isn't a real exposure either: the ref is a literal first-party branch name, and the
commitjob is gated torefs/heads/2.3.x/tags, so it never runs in a PR context.
Verification at unchanged HEAD 4df96db35: make tests 21527 tests / 96712 assertions / 97 skipped, green; make phpstan no errors; both bug-13735 nsrt files pass. Working tree clean, HEAD in sync with origin — no commits to push.
|
Rebased onto What the rebase had to resolveFour conflicts, all from 2.3.x refactorings under the change:
The stale Verification
One thing I could not doThe PR's base branch is still |
staabm
left a comment
There was a problem hiding this comment.
add a test when $this is passed into a static closure like
static function () use ($this) {}
A static closure cannot say '$this' - 'static function () use ($this) {}'
is a fatal error - but it reaches the same object by capturing it under
another name, and an arrow function captures it without a 'use' clause at
all. PHPStan does not track that aliasing, so the write lands on
'$self->foo' while the caller still remembers '$this->foo'.
Gate the property-fetch carve-out on the closure having no write through a
capture, read off the invalidate expressions ClosureType already records for
both closures and arrow functions. Writes through the closure's own
parameters stay exempt: those arrive from the call site, where
invalidateObjectArgs() invalidates them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed as The requested test exposed a real hole — fixed alongside itFirst, a syntax note: All three were regressions this PR introduced. On pre-PR base they were correct; on the branch the narrowing wrongly survived:
The PR's premise — "a static closure is never bound to Fix
Writes through the closure's own parameters stay exempt, so The improvement survivesThree controls, all of which fail on pre-PR base (so they genuinely encode the PR's win, not just the status quo) and pass here:
Verification
One thing I did not chase, to keep scope tight: the same shape with a non- |
| $keepPropertyFetches = $parametersAcceptor->isStaticClosure()->yes() | ||
| && !$this->writesThroughCapturedVariable($parametersAcceptor); |
There was a problem hiding this comment.
we should simplify. don't keep property fetches as soon as the closure has captures.
There was a problem hiding this comment.
Pushed as a34865862.
Simplified as asked
FuncCallScopeEffectsHelper.php:106 is now two flat checks on the ClosureType:
$keepPropertyFetches = $isStaticClosure
&& $parametersAcceptor->getUsedVariables() === []
&& $parametersAcceptor->getInvalidateExpressions() === [];writesThroughCapturedVariable() is gone (35 lines, plus the NodeFinder/is_string/array_key_exists imports). No parameter-name bookkeeping, no AST walk over the closure's writes.
The second clause is there because getUsedVariables() alone can't see an arrow function's captures — ClosureTypeResolver::buildClosureTypeForArrowFunction() passes [] for them (src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php:369), so static fn () => $self->foo = null reports no used variables and only the write gives the capture away.
What the simplification costs, in the tests
Three expectations move to the conservative side, all in bug-13735b.php:
| case | before | now |
|---|---|---|
use ($self) reading the receiver |
Foo |
Foo|null |
use ($message) with a scalar |
Foo |
Foo|null |
$fn($this->foo), closure writes $foo->bar |
Foo |
Foo|null |
The first two are your rule: a capture stands the carve-out down, whether it aliases the receiver or not. The third follows from dropping the parameter exemption — a write through a parameter is no longer told apart from a write through a capture. All three are sound, just less precise. The PR's actual win is untouched: a static closure that captures nothing still keeps $this->foo narrowed, including one taking a scalar parameter.
One coupling had to go with it
invalidateObjectArgs() used to run only when the carve-out applied. Under the new gate the resource closure (static function ($handle) { fseek($handle, 10); }) no longer keeps property fetches — fseek() has side effects, so processArgs() records an InvalidateExprNode for $handle and the closure has an invalidate expression. Leaving the coupling in place made ftell($fh) wrongly stay int. It now runs for every static closure call, independent of the carve-out; non-static closures are untouched.
Each part verified load-bearing
Deleting one clause at a time, in bug-13735b.php:
- without
getUsedVariables() === []→ lines 211, 224 reportFoo(the two capture controls) - without
getInvalidateExpressions() === []→ lines 115, 198 reportFoo(static fn ()capturing the receiver is the one only this clause catches) - without the
invalidateObjectArgs()call → line 133 reportsintinstead ofint|false
Gates
make tests 21527 tests / 96712 assertions / 97 skipped, green. make phpstan no errors. make cs clean. FuncCallScopeEffectsHelper isn't shadowed and turbo-ext/src/ doesn't reference it, so EXPECTED_EXTENSION_VERSION stays at 91db796.
…ture Replaces the write-through-capture analysis with two flat checks on the ClosureType: no used variables, and no invalidate expressions. A capture is never told apart from an alias of the receiver, so any of them stands the carve-out down; an arrow function records no used variables at all, so a closure that writes anywhere stands it down too. Object and resource arguments are now invalidated for every static closure call rather than only when the carve-out applies - the argument channel is what keeps 'ftell($fh)' invalidated across a static closure that seeks it, and that no longer coincides with keeping property fetches.
staabm
left a comment
There was a problem hiding this comment.
I think thats good enough for now
|
merged it into 2.3.x to ease the merge handling in the affected code, which changed a lot in 2.3.x in comparison to 2.2.x |
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