discovery: map the full boundary of string-keyed & cross-scope PHP-variable rename references - #167
Conversation
…tion Discovery (#96): map the full boundary of string-keyed and cross-scope references a single-file PHP `$variable` rename can encounter, so the fail-open denylist stops being discovered one corruption at a time. Two latent silent-corruption paths are now guarded — the rename refuses rather than emit a partial edit that strands a string reference: - `${'x'}` — dynamic variable over a string literal (`scope_references_dynamic_string_var`). `$$name` (inner `variable_name`) is deliberately not caught: it renames safely. - `$GLOBALS['x']` — superglobal array alias of the program-scope global (`scope_references_globals_array`), resolution-aware so a function-local `$x` with an unrelated `$GLOBALS['x']` still renames. Every shape classified with a test fixture (REWRITE / REFUSE / DEFER): - REWRITE: `$$name`, string interpolation + heredoc (nowdoc left literal), by-reference positions (`&$x`, `&` token preserved). - REFUSE: `${'x'}`, `$GLOBALS['x']` (plus existing `global`, compact/extract). - DEFER: `extract($runtimeArray)`, `get_defined_vars()` — no source literal, undetectable in one file; recorded as `KNOWN LIMITATION` at their sites. Decision recorded in the module doc: stay **fail-open** with a complete denylist; fail-closed would over-refuse ordinary safe renames to buy protection against only the deferred dynamic forms, which are undetectable from source either way. Refs #96
There was a problem hiding this comment.
🔄 Changes Requested
Genuinely strong discovery PR — the boundary is mapped, both latent corruption paths (${'x'}, $GLOBALS['x']) are closed with resolution-aware guards, the fail-open decision is argued in the module doc, and the seven shapes are each classified and pinned. CI is green (LSP test/fmt/clippy, wasm check, CodeQL all pass). I fanned out four blind read-only lenses (AC / correctness / security / test-honesty) over the checkout and adversarially verified the one AC-conformance objection against the tree. Nine of ten ACs are cleanly met and the production logic is correct. One in-PR test-coverage gap blocks.
Issues Found
🔴 The new ${'x'} guard has no over-refusal companion test, unlike its structural twin — a real asymmetry in the PR's own new code. (php_variable_rename.rs:~395 guard, missing fixture in php_variable_rename/tests.rs)
scope_references_dynamic_string_var and scope_references_globals_array are built the same way: walk the binding scope's subtree, and (for a non-program scope) fire only when resolve_binding_scope(ref).id() == scope.id() — i.e. only when the reference binds to this scope. That scope-id check is exactly what prevents over-refusing a legitimate local.
The $GLOBALS guard gets both directions pinned:
globals_array_referenced_variable_is_refused— the refuse (TRUE) case.function_local_is_renamable_despite_unrelated_globals_access— the over-refusal (FALSE) case: a function-local$xstays renameable when the only$GLOBALS['x']names the program global.
The ${'x'} guard gets only the TRUE case (dynamic_string_variable_reference_is_refused, where $x and ${'x'} share the same function scope). The FALSE arm of its resolution-aware branch — a function-local $x that must stay renameable even though a ${'x'} lives in a different binding scope in the same file — is exercised by no test. As written, a future change that broadened the dynamic-string guard to fire across scope boundaries would over-refuse valid locals and every test would stay green.
This matters more than it looks because over-refusal avoidance is the PR's own central thesis — the module doc rejects fail-closed precisely to not over-refuse safe renames. Pinning that property for one guard and not its twin leaves the thesis half-tested.
Fix: add a companion fixture mirroring function_local_is_renamable_despite_unrelated_globals_access, but for the dynamic-string guard — a function-local $x that renames cleanly even though ${'x'} binds elsewhere. The rigorous construction drives the scope-id FALSE arm directly, e.g.:
<?php
function f() {
$x = 1;
$g = function () { return ${'x'}; }; // binds to the closure, not f
return $x;
}Renaming f's $x should produce the two function-local targets and leave the ${'x'} inside the closure untouched. (Confirmed against the tree: the guard already behaves correctly here — resolve_binding_scope returns the closure scope ≠ f — so the test will pass; it's the regression pin that's missing.)
What's Good
- ✅ Production logic is correct.
scope_references_dynamic_string_vardiscriminates${'x'}(inner child is astring) from$$name(inner child is avariable_name) via thestring_literal_contentcheck — no false positive on variable-variables.scope_references_globals_arraycorrectly early-returns for non-programscopes so an unrelated$GLOBALS['x']never over-refuses a local. Both are OR'd intorenameable_variable_at(:505-510) so refusal fires regardless of cursor position. No new panics/unwraps on PHP input. - ✅ The two REFUSE tests are load-bearing, not vacuous — both
dynamic_string_variable_reference_is_refusedandglobals_array_referenced_variable_is_refusedwould turn RED onmain(neither shape was guarded there) and pass only because the new guards exist. Delete either guard and its test fails. That's honest coverage. - ✅ The REWRITE tests pin behaviour, not compilation —
variable_variables_inner_name_renames_safelyasserts the inner$nameof$$nameis rewritten while the$$wrapper stays valid;interpolation_and_heredoc_are_rewritten_nowdoc_is_notasserts exactly 4 sites and that the nowdoc$x(index 4) is excluded;by_reference_positions_rename_and_keep_the_ampersandcovers both param and call-site&$xand asserts the&token is never edited. - ✅ The DEFER tests genuinely distinguish proceed-vs-refuse (non-empty target count vs the empty
Ok(vec![])of a refusal), correctly documenting the deliberate fail-open boundary forextract($runtimeArray)andget_defined_vars(). - ✅ AC #10 holds — both corruption paths closed and wired; the three deferred dynamic forms each carry a
// KNOWN LIMITATION (#96)note (:338-344,:496-502) rather than being left silently open. - ✅ The fail-open decision (AC #9) is argued, not asserted — the
//!doc gives the over-refusal-tax rationale for rejecting fail-closed.
On AC #4 (adversarially verified — no action needed)
The AC-conformance lens flagged AC #4 (get_defined_vars() KNOWN LIMITATION) as possibly mis-placed, since AC #3's "at the call site" wording implies a dedicated processing function like extract's. I sent that to an adversarial verifier: refuted. The // KNOWN LIMITATION (#96) comment at php_variable_rename.rs:496-502 explicitly names get_defined_vars() and states the engine cannot detect it. "At the call site" is unsatisfiable by construction for get_defined_vars() — the engine has no code that processes it (by design: it carries no source literal), so there is no call site to annotate. The refusal-decision block comment is the only sensible location, and that's where it lives. AC #4 is met; the "at the call site" phrasing is AC-authoring carry-over from the extract case (#3) — flagging only so the AC wording can be tightened, not a code defect.
📋 Non-blocking follow-ups
- None. (The over-refusal gap above is in this PR's own new guard, not a general observation about untouched code — fix it in-PR, not as a separable follow-up.)
Please add the over-refusal companion test for the dynamic-string guard, then re-request review.
Holmes flagged an asymmetry in #96's new guards: the $GLOBALS guard pins both its TRUE (refuse) and FALSE (over-refusal) arms, but the ${'x'} guard pinned only the refuse case. A future change broadening scope_references_dynamic_string_var across scope boundaries would over-refuse valid locals while every test stayed green. Add the companion fixture: a function-local $x stays renameable even though a ${'x'} binds in a nested closure (a separate scope). Drives the resolution-aware FALSE arm directly, mirroring function_local_is_renamable_despite_unrelated_globals_access. Refs: #96
|
Addressed the one blocker: added the over-refusal companion test for the New test No non-blocking follow-ups in your review, so nothing spun out. CI is green (LSP test/fmt/clippy, wasm check, both CodeQL jobs). Re-requesting review. |
There was a problem hiding this comment.
✅ Approved — round 2.
The single round-1 blocker is genuinely fixed, all ten acceptance criteria are met, and CI is green (LSP test/fmt/clippy, wasm check, CodeQL/Analyze all pass). I re-fanned four blind read-only lenses (AC / correctness / security / test-honesty) over the checkout at HEAD 7c25d66 and adversarially verified the one in-PR objection that surfaced. The delta since the reviewed commit is exactly the test I asked for — nothing else changed.
Round-1 blocker — fixed 🎯
The missing over-refusal companion for the ${'x'} guard now exists: function_local_is_renamable_despite_dynamic_string_in_inner_scope (tests.rs:644-669), built precisely as its $GLOBALS twin (function_local_is_renamable_despite_unrelated_globals_access).
function f() {
$x = 1;
$g = function () { return ${'x'}; }; // binds to the closure, not f
return $x;
}It is load-bearing, not decorative. resolve_binding_scope on the ${'x'} inside the closure returns the anonymous_function scope ≠ f, so the scope-id check in scope_references_dynamic_string_var is false and the outer $x stays renameable. The assert_eq!(targets.len(), 2) is the real pin: broaden the guard to fire across scope boundaries (drop the resolve_binding_scope(...).id() == scope.id() arm) and renameable_variable_at returns None → targets.len() collapses to 0 → the test goes RED. The PR's central thesis — avoid over-refusal — is now pinned for both resolution-aware guards, not just one.
Acceptance criteria — 10/10 met
- AC #1 ✅
${'x'}→ REFUSE, fixture + classification comment (tests.rs:614). - AC #2 ✅
$GLOBALS['x']→ REFUSE; corruption path closed by the newscope_references_globals_arrayguard (php_variable_rename.rs:449-468), wired into the denylist (:507-508). The fixture turns RED onmain. - AC #3 ✅
extract($array)runtime keys → DEFER; rename proceeds,// KNOWN LIMITATION (#96)at the call site (php_variable_rename.rs:338-344). - AC #4 ✅
get_defined_vars()→ DEFER; rename proceeds, KNOWN LIMITATION recorded (:496-502). (The AC's "at the call site" phrasing is unsatisfiable by construction — the engine has no detection branch forget_defined_vars()because it carries no source literal, so the refusal-decision block is the only sensible home. Adversarially refuted in round 1; AC-text carry-over, not a code defect.) - AC #5 ✅
$$name→ REWRITE; inner$namerewrites,$$wrapper stays valid, asserted at byte level (tests.rs:776-806). - AC #6 ✅ heredoc/interpolation → REWRITE; exactly 4 sites, nowdoc
$xexcluded (tests.rs:808-839). - AC #7 ✅ by-ref
f(&$x)→ REWRITE; both param and call-site covered,&token proven untouched via offset check (tests.rs:846-889). - AC #8 ✅ all seven shapes tagged rewrite/refuse/defer (legend
tests.rs:605-612). - AC #9 ✅ module
//!doc records fail-open with a complete denylist, with the over-refusal-tax rationale for rejecting fail-closed (php_variable_rename.rs:68-98). - AC #10 ✅ both corruption paths (
${'x'},$GLOBALS['x']) guarded; the three deferred forms produce complete renames, not partial edits. No corruption path left open.
What's good
- Production logic is correct.
scope_references_dynamic_string_vardiscriminates${'x'}(inner child is astring/string_content) from$$name(inner child is avariable_name), so variable-variables still rename rather than being falsely refused — confirmed against thetree-sitter-php 0.24.2node-types.json. No new panics/unwraps; everyOption-returning tree-sitter call is guarded. - The REFUSE tests are load-bearing — both
dynamic_string_variable_reference_is_refusedandglobals_array_referenced_variable_is_refusedturn RED onmain; delete a guard and its test fails. - The REWRITE tests pin behaviour, not compilation — each asserts the actual edited text/offsets (
$$wrapper intact, nowdoc excluded,&untouched), not a non-empty count.
Adversarial verification (no action needed)
- The correctness lens flagged that
scope_references_dynamic_string_varskips the resolution check at program scope, so a program-global rename could be over-refused when the only${'x'}resolves to a nested function-local. Refuted: it's a conservative over-refusal (refuse, never a partial edit — no corruption), it mirrors the identicalprogram_scope || …short-circuit in the pre-existingscope_references_compact_extractandscope_aliases_globalguards (repo convention), and the module doc's fail-open-with-over-refusal thesis (AC #9) explicitly accepts it. Dropped. - Two
!edited.contains(...)negative assertions in the new tests are structurally always-true (adynamic_variable_name/$GLOBALStoken is never collected). They're intent-documentation backed by the load-bearingtargets.len()assertion, and they mirror the round-1-approved$GLOBALStwin verbatim — convention-matching, not a defect.
📋 Non-blocking follow-ups
- None.
Ready for @mikebronner to merge.
Summary
Implements #96 — the proactive discovery pass over the PHP
$variablerename engine (#87 shipped the scope-aware core). Enumerates every reference shape a single-file scope-local rename can encounter, classifies each, closes the two latent silent-corruption paths it found, and records the fail-open-vs-fail-closed decision so the denylist stops being discovered one corruption at a time.Changes
scope_references_dynamic_string_var— refuses${'x'}(dynamic variable over a string literal).$$nameis deliberately not caught (its inner$nameis a realvariable_nameand renames safely).scope_references_globals_array— refuses$GLOBALS['x']at program scope (it aliases the global through a string key). Resolution-aware: a function-local$xwith an unrelated$GLOBALS['x']still renames.// KNOWN LIMITATIONnotes at the relevant sites for the deferred dynamic forms —extract($runtimeArray)andget_defined_vars()— which carry no source literal and are undetectable in one file.//!): stay fail-open with a now-complete denylist, with the rationale for rejecting fail-closed.$GLOBALS.Classification (the discovery result)
${'x'}(string-literal dynamic var)$GLOBALS['x']global $x;was guarded) → now guardedextract($runtimeArray)get_defined_vars()$$name(variable-variables)$namerenamed,$$wrapper stays valid"$x","{$x}")$xleft literal; delimiter untouchedf(&$x)$xrewritten,&reference token preservedAcceptance Criteria
${'x'}— current behaviour asserted, classification recorded as a comment intests.rs.$GLOBALS['x']— corruption path exposed; guard added (scope_references_globals_array); now refused.extract($array)runtime form — rename not refused;// KNOWN LIMITATIONat the call site inphp_variable_rename.rs.get_defined_vars()— rename proceeds;// KNOWN LIMITATIONdocumenting the undetectable reference.$$name— inner$namerewritten correctly,$$wrapper valid.f(&$x)—$xcollected and rewritten,&intact (by-ref param + call-site forms).tests.rswith a REWRITE / REFUSE / DEFER comment.//!doc: fail-open with rationale.${'x'}and$GLOBALS['x']both guarded.Test Plan
cargo test --lib php_variable_rename— 33 passed (25 existing + 8 new).cargo test(full lib) — 1768 passed. (8 pre-existingintegration_testsfailures are environmental — missingtest-project/.envfixtures in a fresh clone — and fail identically onmain, unrelated to this diff.)cargo fmtclean,cargo clippy --lib --testsclean.Fixes #96