Skip to content

Walk a finally block with the paths that returned early from its try - #486

Merged
AJenbo merged 2 commits into
PHPantom-dev:mainfrom
phcorp:issue-484-finally-early-return
Nov 3, 2026
Merged

AJenbo merged 2 commits into
PHPantom-dev:mainfrom
phcorp:issue-484-finally-early-return

Conversation

@phcorp

@phcorp phcorp commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Closes #484.

After if (null !== $tool) { return; } in a try body, a null !== $tool check in its finally block was treated as dead code, and $tool->editable() inside it reported Cannot access method 'editable' on type 'never' ('null' on 0.10.0).

Cause. process_try (src/type_engine/variable/forward_walk/control_flow.rs), on the path the full diagnostic walk takes, walked the finally block with the state the try body leaves at its end, joined with the reachable catches. A return inside the try body is a way out of it too, and the finally block runs on that way out, but its state never reached the block.

Fix. When the try has a finally, the try body is walked inside a return frame (push_return_frame, the mechanism closures already use for their by-reference captures), which collects the state each reachable return leaves with. The finally block is walked as before for the code after the statement, then once more with the returned paths joined in. That second walk goes last, so its snapshots are the ones the block's own statements are read with, while the scope after the statement still comes from the paths that did not return. The collected returns are recorded again for an enclosing frame, so a closure's by-reference capture written just before such a return still reaches its caller. A try without finally is untouched. Not covered here: a return inside a catch, which runs the finally block too.

Tests (tests/integration/narrowing_flow_tracking.rs, full diagnostic pipeline): the issue's repro, on a property and on a local, reports nothing (two errors on main); the code after such a try still reads the variable as null and reports the one error it should; a closure writing a by-reference capture before a return inside a try still leaves 'early' in the caller's type, which fails if the returns are not recorded again for the enclosing frame. Porta's own Ide.php, where this was found, analyses clean.

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, 11/11).

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.

@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 97.87234% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../type_engine/variable/forward_walk/control_flow.rs 97.87% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@phcorp
phcorp force-pushed the issue-484-finally-early-return branch 9 times, most recently from ee32412 to e391c79 Compare October 6, 2026 13:58
@phcorp
phcorp force-pushed the issue-484-finally-early-return branch 3 times, most recently from 5a30a39 to 785c5ed Compare October 7, 2026 02:36
phcorp and others added 2 commits November 3, 2026 02:32
A finally block runs on the way out of a return in its try body too, but
the full walk gave it only the state the try body leaves at its end, so a
guard that returned early (`if (null !== $tool) { return; }`) left the
block reading `$tool` as null, and a check that only passes on the
returned path as dead code. When the try has a finally, its returns are
collected; the block is walked once more with them joined in, last, so its
statements are read with them, while the code after the statement carries
on from the paths that did not return. The collected returns are recorded
again for an enclosing closure's by-reference captures.

Closes PHPantom-dev#484.
Walking a finally block a second time for the paths that returned early
doubled per nesting level. Only the walk that records diagnostic snapshots
now takes the second walk, with the carry-on walk recording nothing and
the second walk recording no return edges, so a block nested n deep is
walked n + 1 times. A return or rethrow inside a catch now reaches the
finally block too, and hover and completion inside the block see the
early returns the way diagnostics do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AJenbo
AJenbo force-pushed the issue-484-finally-early-return branch from 785c5ed to 1bb0c1e Compare November 3, 2026 02:07
@AJenbo
AJenbo merged commit 2ada54f into PHPantom-dev:main Nov 3, 2026
14 checks passed
@AJenbo

AJenbo commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Looks like there's a lot of edge cases around this, thanks for working on it

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 scalar_member_access: a finally block does not see the paths that returned early from its try

3 participants