Repository navigation
Conversation
…ble" warning A local a join point may leave undefined (assigned in one `if` arm, in a loop body that may not run, in one `switch` case or `match` arm, in a `try` body, on one lazy `&&`/`||`/`?:`/`??` edge, or `unset()` in a loop or branch) was read straight from its slot: the value a previous call left there, an empty string, or uninitialized memory at the top level, and `isset()`/`??`/`empty()` answered from that stale value. The checker now tracks such locals per construct (`Checker::maybe_undefined_locals`, joined at if/else, loops, switch, try/catch/finally, match, ternaries and lazy operands; see the new `types::checker::condition_edges`) and records every read made in that state. EIR lowering gives only those locals a hidden defined-flag (cleared at frame entry, set by every store, cleared by `unset`) and lowers each recorded read as `flag ? value : null`. A plain read warns "Undefined variable $x" through trigger_error, so `@` applies; isset/??/empty probe silently. Always-defined locals keep native storage. - Binders (foreach key/value, catch variables, list(), global, static, builtin out-parameters) define their names; a local passed to a call that may bind it by reference is not tracked. - `use (&$r)` creates a possibly undefined local as null, `use ($r)` warns and captures null, and an arrow function carries the flag as a hidden capture. - A local assigned in a lazy operand of a condition is defined in the region that edge reaches (the if/while/for body on the true edge of `$a && ($r = f())`, the code after a diverging `if` on the false edge of `!$x || !($y = g())`, the matching ternary arm) and only there. - A guard that is false for null (`instanceof`, `is_array()`, `isset()`) defines the local in the region it guards, so a typed parameter accepts the narrowed read. - `throw $e` on such a local is checked against the local's own type, so a Throwable local stays accepted and a scalar one is still rejected. `echo false && ($x = 1); echo $x;` is no longer a compile error.
[Medium impact] This changes how compiled programs read locals across control-flow paths. Fix the loop compile failures and missed undefined-read paths before merging.
|
| // `while ($ok && ($row = next()))`: the body runs on the condition's true edge. | ||
| let true_defs = self.condition_edge_definitions(condition, env, true); | ||
| let true_inserted = Self::insert_edge_definitions(env, &true_defs); | ||
| let errors = self.check_break_continue_target_body(body, env); | ||
| Self::remove_edge_definitions(env, &true_inserted); |
There was a problem hiding this comment.
Defined loop locals are rejected
Loop bodies keep the maybe-undefined mark after their condition assigns the local. For example, function take(array $a) {} function t($c) { while ($c && ($a = [1])) { take($a); break; } } t(true); reads $a as mixed and rejects the call, although every path into the body assigns an array.
condition_edge_definitions cannot fix this because the lazy-operand join already added $a to the environment. Clear the true-edge marks before checking the body and update, as the if path does. The same gap exists in check_for_tail.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/types/checker/stmt_check/control_flow.rs
Line: 936-940
Comment:
**Defined loop locals are rejected**
Loop bodies keep the maybe-undefined mark after their condition assigns the local. For example, `function take(array $a) {} function t($c) { while ($c && ($a = [1])) { take($a); break; } } t(true);` reads `$a` as `mixed` and rejects the call, although every path into the body assigns an array.
`condition_edge_definitions` cannot fix this because the lazy-operand join already added `$a` to the environment. Clear the true-edge marks before checking the body and update, as the `if` path does. The same gap exists in `check_for_tail`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| for (cond, body) in &clauses { | ||
| self.maybe_undefined_locals = path_marks.clone(); | ||
| self.infer_type_with_assignment_effects(cond, env)?; | ||
| let marks_after_cond = self.maybe_undefined_locals.clone(); |
There was a problem hiding this comment.
Later conditions read undefined locals
An elseif condition is checked before names introduced by earlier bodies are marked maybe-undefined. In if ($c) { $x = 5; } elseif ($x > 3) { echo 'wrong'; }, the path reaching elseif never assigned $x, but its read is not recorded. It still loads the raw slot without a warning and can choose the wrong branch.
Apply body_introduced to the false-path marks before checking the next condition.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/types/checker/stmt_check/control_flow.rs
Line: 691-694
Comment:
**Later conditions read undefined locals**
An `elseif` condition is checked before names introduced by earlier bodies are marked maybe-undefined. In `if ($c) { $x = 5; } elseif ($x > 3) { echo 'wrong'; }`, the path reaching `elseif` never assigned `$x`, but its read is not recorded. It still loads the raw slot without a warning and can choose the wrong branch.
Apply `body_introduced` to the false-path marks before checking the next condition.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| let marks = checker.maybe_undefined_locals.clone(); | ||
| let keys = env_keys(env); | ||
| if !checker.body_cannot_fall_through(body) { | ||
| self.exits.push((marks.clone(), keys.clone())); | ||
| } | ||
| self.try_marks = marks; | ||
| self.introduced = keys.difference(&self.keys_entry).cloned().collect(); |
There was a problem hiding this comment.
Catch reads miss undefined locals
end_try saves only the marks left at the end of the body, not states where an exception can leave it. For example, $x = 1; try { unset($x); if ($c) throw new Exception(); $x = 2; } catch (Exception $e) { var_dump($x); } leaves $x undefined when $c is true. The later assignment clears its mark during checking, so the catch read loads the cleared slot without the required warning.
Keep the intermediate undefined states for catch and finally reads. The scan after the whole try runs too late.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/types/checker/condition_edges.rs
Line: 555-561
Comment:
**Catch reads miss undefined locals**
`end_try` saves only the marks left at the end of the body, not states where an exception can leave it. For example, `$x = 1; try { unset($x); if ($c) throw new Exception(); $x = 2; } catch (Exception $e) { var_dump($x); }` leaves `$x` undefined when `$c` is true. The later assignment clears its mark during checking, so the catch read loads the cleared slot without the required warning.
Keep the intermediate undefined states for catch and finally reads. The scan after the whole `try` runs too late.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if warns { | ||
| let warning = Expr::new( | ||
| ExprKind::FunctionCall { | ||
| name: crate::names::Name::unqualified("trigger_error"), | ||
| args: vec![ | ||
| Expr::new(ExprKind::StringLiteral(format!("Undefined variable ${}", name)), expr.span), | ||
| Expr::new( | ||
| ExprKind::ConstRef(crate::names::Name::unqualified("E_USER_WARNING")), | ||
| expr.span, | ||
| ), | ||
| ], | ||
| }, | ||
| expr.span, | ||
| ); | ||
| let _ = lower_expr(ctx, &warning); |
There was a problem hiding this comment.
Discarded reads lose their warnings
The new warning exists only when a variable read reaches EIR lowering, but the AST optimizer still treats every variable read as pure. In function t($c) { if ($c) { $x = 1; } $x; } t(false);, dead-code removal deletes $x; before this warning can be emitted. This also skips any installed error handler.
Teach the optimizer about recorded warning-producing reads and add a regression that discards the read's value.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ir_lower/expr/maybe_undefined_read.rs
Line: 60-74
Comment:
**Discarded reads lose their warnings**
The new warning exists only when a variable read reaches EIR lowering, but the AST optimizer still treats every variable read as pure. In `function t($c) { if ($c) { $x = 1; } $x; } t(false);`, dead-code removal deletes `$x;` before this warning can be emitted. This also skips any installed error handler.
Teach the optimizer about recorded warning-producing reads and add a regression that discards the read's value.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| fn env_keys(env: &crate::types::TypeEnv) -> NameSet { | ||
| env.keys().cloned().collect() | ||
| } |
There was a problem hiding this comment.
New helpers lack required docs
Several new functions lack the required Rustdoc comments. AGENTS.md requires a concise docblock for every explicit Rust function, including private helpers and methods. Add one to env_keys and the other new undocumented functions, including SwitchMaybeUndefined::enter and TryMaybeUndefined::enter. This repository requirement must be satisfied before merging.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/types/checker/condition_edges.rs
Line: 408-410
Comment:
**New helpers lack required docs**
Several new functions lack the required Rustdoc comments. `AGENTS.md` requires a concise docblock for every explicit Rust function, including private helpers and methods. Add one to `env_keys` and the other new undocumented functions, including `SwitchMaybeUndefined::enter` and `TryMaybeUndefined::enter`. This repository requirement must be satisfied before merging.
**Context Used:** AGENTS.md ([source](https://github.com/illegalstudio/elephc/blob/main/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
A local that a join point may leave undefined (assigned in one
ifarm, in a loop body that maynot run, in one
switchcase ormatcharm, in atrybody, on one lazy&&/||/?:/??edge, or
unset()in a loop or branch) used to be read straight from its slot. Main thereforereturned the value a previous call left there, an empty string, or uninitialized memory at the
top level, and
isset()/??/empty()answered from that stale value too.This PR makes those reads behave like php:
Checker. The checker tracks maybe-undefined locals per construct
(
Checker::maybe_undefined_locals, joined atif/else, loops,switch,try/catch/finally,match, ternaries and lazy operands) and records every read made while a local isin that state (
CheckResult::maybe_undefined). The join logic is in the new moduletypes::checker::condition_edges.list(),global,staticand builtin out-parameters.receiving the local's real storage.
Lowering. EIR lowering gives only those locals a hidden defined-flag. The flag is cleared
at frame entry, set by every store and cleared by
unset. Each recorded read lowers toflag ? value : null.Undefined variable $xthroughtrigger_error, so@applies.isset,??andemptyprobe silently.nothing extra.
Closures.
use (&$r)on a possibly undefined local creates it as null.use ($r)warns and captures null.Condition edges. A local assigned in a lazy operand of a condition is defined in the
region that edge reaches, and only there:
if/while/forbody on the true edge of$a && ($r = f());elsebranch, or the code after anifwhose clauses all diverge, on the false edgeof
!$x || !($y = g());Outside that region the local is maybe-undefined, so later reads go through the flag.
Type guards. A guard that is false for null (
$x instanceof C,is_array($x),isset($x)) defines the local in the region it guards, inifbodies and ternary arms.Inside the guard the local is read directly with its narrowed type, so
if ($o instanceof O) { g($o); }withfunction g(O $x)compiles.throw $e. When$eis a possibly undefined local, the operand is checked against thelocal's own type and not against the
mixedread. Without this check, this PR would startrejecting code that main accepts:
ifarm;finally.When the local is undefined, the runtime raises
Error("Can only throw objects")after thewarning, as php does. A maybe-undefined scalar is still rejected at compile time.
Removed error test.
error_tests::type_system::test_error_short_circuit_assignment_effect_is_not_definiteis gone.
echo false && ($x = 1); echo $x;used to be a compile error and now runs as in php.Closes #1936
Differences from php
Output stream. The warning has php's text,
Warning: Undefined variable $x in <file> on line N.It goes to stderr, like every elephc runtime warning. The php CLI prints it on stdout, with a
leading blank line.
Error level. The warning is raised as
E_USER_WARNING, notE_WARNING. An error handlerinstalled with
set_error_handlersees errno 512 where php passes 2.New compile errors. A possibly undefined read is typed
mixed. Positions that need astatic array or object type now reject it at compile time where main accepted it, because
main read the slot directly. Examples:
if ($c) { $o = new O; } if ($c) { g($o); }withfunction g(O $x);arrayreturn type fed by such a local;$a + [..]where$ais such a local.Scalar parameters (
int,string),foreach, method calls, concatenation and appends arenot affected. Guarding the use with
instanceof,is_array()orisset()avoids theerror (see above). This is documented as a new
[[limitations]]entry inscripts/docs/comparison_catalog.toml, next to the existing note on Mixed-sourced objects.docs/php/compatibility.mdwas regenerated from it.Left out
throwoperand changes from topic 31 (D5): acceptingmixed, and accepting unions ofThrowables and null such as
throw $c ? new A : new B. Only the maybe-undefined lookupabove is included, because this PR needs it.
(
$b = null; $ok || $b = f();). Both build on the assignment-expression lowering in PR 5.Here a lazy operand is still checked on a copy of the environment, as on main, plus the
maybe-undefined join.
array + mixedrefusal innumeric_binary.rs, which was part of the original commit.The checker already rejects that case before lowering.
Tests
The new tests are in
tests/codegen/possibly_undefined_locals.rs. Expected outputs come fromphp 8.5.10. Each case was run with the main binary at 0842fca, the current main (debug build).
Fail on main:
test_maybe_undefined_plain_read_warns_and_yields_null: main prints the previous call'sint(5)/int(2)and"", with no warning.test_top_level_maybe_undefined_reads_answer_php_null: main prints uninitialized memory(
int(8595952200),bool(true)).test_maybe_undefined_local_reads_answer_php_null: coversisset,??,@,empty,lazy edges, a loop that did not run, and
unset.test_maybe_undefined_closure_capturestest_maybe_undefined_locals_across_unset_try_switch_matchtest_condition_edge_definitions_reach_the_guarded_region: fails to compile on main(
Undefined variable: $y).test_condition_edge_definitions_nested_and_negated: fails to compile on main(
Undefined variable: $reflector).test_throw_maybe_undefined_throwable_local: main's stdout matches. The test fails on mainbecause main prints no
Undefined variable $ewarning on stderr.test_type_guard_defines_possibly_undefined_local: main's stdout matches. The test fails onmain because main prints neither of the two warnings.
Controls (pass on main too):
test_condition_edge_local_can_be_retyped_in_the_regiontest_maybe_undefined_array_access_receiver_probeserror_tests::exceptions_enums_magic::test_error_throw_maybe_undefined_scalar_localLocal runs, all green:
cargo build;error_tests,parser_tests,lexer_tests;codegen_tests -- possibly_undefined variable scope if try finally loop closure global static unset isset exception.Refs #1886
AI assistance
This change was written with substantial help from Claude Code (Anthropic). I reviewed it,
checked the expected outputs against php 8.5, and remain responsible for it.
🤖 Generated with Claude Code