Verified on latest main
PHPantom version / commit
phpantom_lsp 0.10.0
Installation method
Pre-built binary from GitHub Releases
Operating system
Linux x86_64
Editor
Zed
Bug description
A variable reassigned by an immediately-invoked by-reference closure keeps its correct type right up until a catch block, where it reverts to whatever it was before the try started.
process_try() in control_flow.rs resets the catch scope to a clone of the pre-try scope on entry:
// Merge pre-try scope (since the exception could have
// been thrown at any point in the try body) with the
// catch variable.
*scope = pre_try_scope.clone();
That comment's premise is right, the exception could fire after any statement, but the fix throws out everything learned in the try body, including the by-ref closure widening added for #329 (commit 238413f). A variable that's definitely reassigned before the throw still reads as its pre-try type inside catch.
Expected: catch scope accounts for assignments in the try body, not just the state before it.
Actual: catch scope is an exact copy of pre-try scope, so any assignment before the throw is invisible there.
Steps to reproduce
- Create a file with the following content:
<?php
function process(): void
{
$path = null;
try {
(function () use (&$path): void {
$path = 'a';
})();
throw new RuntimeException('boom');
} catch (\Throwable $e) {
unlink($path);
}
}
- Open diagnostics for the file.
- Expected: no diagnostic on unlink($path). $path is provably 'a' by that point.
- Actual: type_mismatch_argument on unlink($path), "expects string, got null".
Error output or panic trace
N/A. No crash. This is a false diagnostic, not a panic.
.phpantom.toml
Additional context
Related: #329 ("Value is still treated as empty after a non-empty check"), fixed by commit 238413f, which taught process_by_ref_closure_capture() in assignment.rs to union a by-ref closure's assigned type into the outer variable when invocation can't be proven immediate. That fix works outside try/catch. It doesn't survive a catch block because process_try() discards the try-body scope wholesale instead of merging it with the pre-try snapshot.
Suggested fix direction: build the catch scope as a union of the pre-try type and the end-of-try type per variable, instead of a hard reset to pre-try:
catch_scope[$v] = union(pre_try_scope[$v], try_body_end_scope[$v])
Still sound. The exception could have fired at any point, so the true state is somewhere between those two snapshots. It stops discarding real information the try body already established, though.
Found in a Laravel app while debugging a false positive on a DB::transaction() by-ref closure capture checked in a catch block. The repro above strips out the framework. The bug isn't Laravel-specific.
Verified on latest main
mainbranch.PHPantom version / commit
phpantom_lsp 0.10.0
Installation method
Pre-built binary from GitHub Releases
Operating system
Linux x86_64
Editor
Zed
Bug description
A variable reassigned by an immediately-invoked by-reference closure keeps its correct type right up until a catch block, where it reverts to whatever it was before the try started.
process_try() in control_flow.rs resets the catch scope to a clone of the pre-try scope on entry:
That comment's premise is right, the exception could fire after any statement, but the fix throws out everything learned in the try body, including the by-ref closure widening added for #329 (commit 238413f). A variable that's definitely reassigned before the throw still reads as its pre-try type inside catch.
Expected: catch scope accounts for assignments in the try body, not just the state before it.
Actual: catch scope is an exact copy of pre-try scope, so any assignment before the throw is invisible there.
Steps to reproduce
Error output or panic trace
.phpantom.toml
default / no config fileAdditional context
Related: #329 ("Value is still treated as empty after a non-empty check"), fixed by commit 238413f, which taught process_by_ref_closure_capture() in assignment.rs to union a by-ref closure's assigned type into the outer variable when invocation can't be proven immediate. That fix works outside try/catch. It doesn't survive a catch block because process_try() discards the try-body scope wholesale instead of merging it with the pre-try snapshot.
Suggested fix direction: build the catch scope as a union of the pre-try type and the end-of-try type per variable, instead of a hard reset to pre-try:
Still sound. The exception could have fired at any point, so the true state is somewhere between those two snapshots. It stops discarding real information the try body already established, though.
Found in a Laravel app while debugging a false positive on a DB::transaction() by-ref closure capture checked in a catch block. The repro above strips out the framework. The bug isn't Laravel-specific.