Skip to content

Calling a local closure makes its by-reference writes definite - #475

Merged
AJenbo merged 2 commits into
PHPantom-dev:mainfrom
phcorp:issue-474-byref-closure
Nov 3, 2026
Merged

AJenbo merged 2 commits into
PHPantom-dev:mainfrom
phcorp:issue-474-byref-closure

Conversation

@phcorp

@phcorp phcorp commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #474.

After $write = static function () use (&$out): void { $out = 'written'; }; $write();, $out still read as null|'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_captures pattern, 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 if in the closure body still merges with the old value; a call inside an if merges with the path that did not call; a closure passed to another function is never treated as called; a closure replaced (or rebound by foreach) 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.php case 1 (DateTime instead of DateTime|null) and bug-2760.php (true instead of bool — the loop runs over [0]). bug-4657's conditional-write and early-return cases still expect DateTime|null. by_ref_closure_capture_widens_when_closure_stored_in_variable in resolution_tests.rs asserted 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 an if, a closure replaced before the call, replaced on one branch only, and rebound by foreach — 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), the examples/php lint and assertions, and analyze --project-root examples/laravel reporting exactly the 3 documented errors.

Every executable line the patch adds under src/ is covered (cargo llvm-cov, 61/61).

Checklist

If applicable:

  • I have updated CHANGELOG.md
  • I have updated the documentation (README.md, docs/, examples/) — not applicable: a fix, no documented behaviour changes
  • I have updated the config schema (config-schema.json) — not applicable: no configuration
  • I have added/updated tests to cover my changes
  • I fully understand the code that I am submitting (what it does,
    how it works, how it's organized), including any code drafted by an LLM.
  • For any prose generated by an LLM, I have proof-read and copy-edited with
    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.

@phcorp
phcorp force-pushed the issue-474-byref-closure branch from 7219600 to f5b4597 Compare October 6, 2026 06:29
@codecov-commenter

codecov-commenter commented Oct 6, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 92.30769% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/type_engine/variable/forward_walk/by_ref.rs 89.84% 13 Missing ⚠️

📢 Thoughts on this report? Let us know!

@phcorp
phcorp force-pushed the issue-474-byref-closure branch 10 times, most recently from ef9e5b7 to 5a72b2c Compare October 6, 2026 14:00
@phcorp
phcorp force-pushed the issue-474-byref-closure branch 5 times, most recently from 9d6ce5f to 216b6d7 Compare October 7, 2026 02:56
@AJenbo
AJenbo force-pushed the issue-474-byref-closure branch from 216b6d7 to 569c7b3 Compare November 3, 2026 00:11
phcorp and others added 2 commits November 3, 2026 01:13
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
AJenbo force-pushed the issue-474-byref-closure branch from 569c7b3 to cb6ce51 Compare November 3, 2026 00:13
@AJenbo
AJenbo merged commit d11f926 into PHPantom-dev:main Nov 3, 2026
14 checks passed
@AJenbo

AJenbo commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Thanks, I don't have much by reference in my own code...

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.

False positive type_mismatch_argument: a by-reference write in a closure invoked right away is not definite after the call

3 participants