Repository navigation
Calling a local closure makes its by-reference writes definite - #475
Merged
Merged
Conversation
phcorp
force-pushed
the
issue-474-byref-closure
branch
from
October 6, 2026 06:29
7219600 to
f5b4597
Compare
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
phcorp
force-pushed
the
issue-474-byref-closure
branch
10 times, most recently
from
October 6, 2026 14:00
ef9e5b7 to
5a72b2c
Compare
phcorp
force-pushed
the
issue-474-byref-closure
branch
5 times, most recently
from
October 7, 2026 02:56
9d6ce5f to
216b6d7
Compare
AJenbo
force-pushed
the
issue-474-byref-closure
branch
from
November 3, 2026 00:11
216b6d7 to
569c7b3
Compare
A closure assigned to a variable and then called as `$write()` left the variables it captures by reference holding the union of their old value and what the closure writes, because the capture was only widened where the closure was defined. The scope now remembers which closure literal a variable holds, and a call through that variable runs the closure's body there, as an immediately invoked closure already did. Any write to the variable forgets the closure, and a join keeps it only when every path agrees, so a closure called on one branch, handed elsewhere, or replaced before the call keeps today's union. Closes PHPantom-dev#474
…losure Calling a variable that holds a by-reference capturing closure re-ran its body with by-value captures read at the call site, but PHP copies them when the closure is created. The scope now keeps a snapshot of the by-value captures alongside the closure's offset and enters the body with it. Passing the variable to a call also forgets the held closure, since a by-reference parameter may replace it without the scope noticing. Only the top-level statement containing the closure is searched when locating it. Closes PHPantom-dev#474 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AJenbo
force-pushed
the
issue-474-byref-closure
branch
from
November 3, 2026 00:13
569c7b3 to
cb6ce51
Compare
Contributor
|
Thanks, I don't have much by reference in my own code... |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #474.
After
$write = static function () use (&$out): void { $out = 'written'; }; $write();,$outstill read asnull|'written'.Cause.
process_by_ref_closure_captures(src/type_engine/variable/forward_walk/by_ref.rs) treated a call as running the closure only when the callee was a closure literal, so(function () use (&$x) {…})()worked but$write()did not: a stored closure was only considered where it was defined, where its by-ref captures are widened to a union.Fix. Following the existing
closure_capturespattern, the scope remembers which by-ref-capturing closure a local variable holds (closure_literals, keyed by the closure's offset). It is forgotten on any write to the variable (set,seed,set_unknown,set_untyped,remove,invalidate_proofs) and survives an if/else join only when both branches hold the same closure. At$write()the closure is found again by its offset and run the same way an immediately-invoked closure is; its final state replaces the outer variable's type.Unchanged, and covered: a write inside an
ifin the closure body still merges with the old value; a call inside anifmerges with the path that did not call; a closure passed to another function is never treated as called; a closure replaced (or rebound byforeach) before the call no longer counts.This goes beyond PHPStan, which does not narrow here. Two ported fixtures change accordingly, each with the runner's documented "PHPantom is more precise than PHPStan here" comment, and both match what happens at runtime:
tests/phpstan_nsrt/bug-4657.phpcase 1 (DateTimeinstead ofDateTime|null) andbug-2760.php(trueinstead ofbool— the loop runs over[0]). bug-4657's conditional-write and early-return cases still expectDateTime|null.by_ref_closure_capture_widens_when_closure_stored_in_variableinresolution_tests.rsasserted the union after$fn(); it now checks the widening at the definition, and a new test checks the value after the call.Tests (
tests/integration/diag_forward_walk_closures.rs): the repro; an immediately-invoked closure (a guard — it already passed); a conditional write inside the closure, a closure passed elsewhere, a call inside anif, a closure replaced before the call, replaced on one branch only, and rebound byforeach— all still reported. Breaking the forget-on-write step fails three of them, breaking the join two.Checks:
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test(all suites and doc tests), theexamples/phplint and assertions, andanalyze --project-root examples/laravelreporting exactly the 3 documented errors.Every executable line the patch adds under
src/is covered (cargo llvm-cov, 61/61).Checklist
If applicable:
CHANGELOG.mdREADME.md,docs/,examples/) — not applicable: a fix, no documented behaviour changesconfig-schema.json) — not applicable: no configurationhow it works, how it's organized), including any code drafted by an LLM.
an eye towards deleting anything that is irrelevant, clarifying anything
that is confusing, and adding details that are relevant. This includes,
for example, commit descriptions, PR descriptions, and code comments.