Widen variables aliased by & array items when the array is passed to a call - #6277
Widen variables aliased by & array items when the array is passed to a call#6277phpstan-bot wants to merge 3 commits into
& array items when the array is passed to a call#6277Conversation
…o a call * `NodeScopeResolver::processArgs()` now walks every by-value argument for by-reference array slots and virtual-assigns the referenced expression the union of its current type and the matching offset of the parameter type (`mixed` when the callee is unknown). Copying an array preserves reference elements, so a callee writing into such a slot writes into the caller's variable. * Handles the array literal written directly at the call site (`f([&$retry])`, including string keys, mixed implicit/explicit keys and nested array literals) and an array variable built with by-reference items and only then passed (`$args = [&$retry]; f($args);`). * `MutatingScope::getByRefArrayItemSlots()` exposes the `IntertwinedVariableByReferenceWithExpr` entries recorded by `AssignHandler::processArrayByRefItems()` so the variable case can resolve its offset path. * Because the new code lives in `processArgs()`, every call-like construct is covered by the same path: function calls, method calls, static calls, `new`, closure/`__invoke` calls and calls to unknown callables. Referenced expressions may be variables, property fetches, static property fetches or array offsets. Arguments passed to a by-reference parameter are left to the existing by-ref writeback, and unpacked (`...`) arguments are skipped since PHP does not carry the reference through unpacking. * Probed and left alone: `$arr[0] = true` local writes through a by-reference item already propagate correctly, and array literals hidden behind a ternary/match arm are still not tracked (the reference does not reach a recognizable argument expression there).
The implicit-index bookkeeping that maps an array literal's `&` items to the slots they alias lived in AssignHandler::processArrayByRefItems(); the call-site widening needs exactly the same walk. Extract it into ArrayByRefItemSlots so there is one implementation, parameterized by the expression the slots are rooted at. Along the way the shared walk also handles unpacked items: a spread shifts every subsequent implicit index by an unknown amount, so the following `&` items land on an unknown int key instead of on a wrongly-counted constant one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
processArgs() walked its own offset paths to price a by-reference array item against the parameter type. Both halves of that walk already exist: - MutatingScope::resolveIntertwinedAssignedType() resolves a recorded slot expression against a root type - the same helper assignVariable() uses to propagate through IntertwinedVariableByReferenceWithExpr entries. Scope now exposes resolveByRefArrayItemTypes(), which hands back the aliased expressions already priced against the passed array type, replacing getByRefArrayItemSlots() and the offset-path resolving in NodeScopeResolver. - For an array literal written at the call site, rooting the shared ArrayByRefItemSlots walk at a TypeExpr of the parameter type makes the ordinary dim fetch reading resolve the offsets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed. Yes — it could be simplified, in both directions the review pointed at. What changed1. One place resolves The implicit-index bookkeeping I had added in That extraction also fixed a latent bug in the pre-existing code: 2. Slot types come from the pre-existing intertwined resolving (
Net: Verification
Pushed to |
Summary
When a variable is put into an array by reference (
[&$retry]) and that array is passed to a function, the callee can write into the array slot and the write lands in the caller's variable — copying an array in PHP preserves reference elements. PHPStan kept the variable's narrow type across such a call, so the reporter's code produced a bogusIf condition is always false.for a branch that really does execute.The fix makes
NodeScopeResolver::processArgs()treat a by-reference array item passed by value as a possible write into the referenced expression, widening it to the corresponding offset of the parameter type.Changes
src/Analyser/NodeScopeResolver.phpprocessArgs(): for every argument that is not unpacked, not passed to a by-reference parameter, and that carries by-reference array slots, each referenced expression is virtual-assigned the union of its current type and the parameter type walked down the slot's offset path (mixedwhen there is no known parameter).findByRefArrayItemSlots()— resolves the slots of an argument, either from an array literal written at the call site or from an array variable that was built with by-reference items.collectByRefArrayLiteralSlots()— walks an array literal (recursing into nested literals), mirroringAssignHandler::processArrayByRefItems()'s implicit-index bookkeeping so explicit, implicit and string keys all resolve to the right offset.resolveByRefArrayOffsetPath()— turns a recorded$array[0][1]slot expression into the list of offset types to walk.isByRefArrayItemWritable()/processByRefArrayItemsPassedByValue()— the write itself, reusingprocessVirtualAssign()exactly like the existing by-reference parameter writeback does.src/Analyser/MutatingScope.phpgetByRefArrayItemSlots()exposes theIntertwinedVariableByReferenceWithExprentries thatAssignHandler::processArrayByRefItems()records for$array = [&$v], filtered to the array-slot direction, so the "array variable passed later" case can find its aliases.Call-like siblings all funnel through
processArgs(), so one change covers the whole family. Each was probed with its own failing assertion and is now covered by a test:new, closure call,__invoke, call to an unknowncallablemixed)Probed and deliberately not changed:
$args = [&$v]; $args[0] = true;) already propagate to$v; a test now locks that in so the new code cannot coarsen it.f(...[&$v])) are skipped — PHP does not carry the reference through unpacking, and spread elements cannot be mapped to parameters here.matcharm are still not tracked; the reference never reaches a recognizable argument expression in that case.Root cause
PHPStan already models by-reference aliasing in two places:
AssignHandlerrecordsIntertwinedVariableByReferenceWithExprlinks for$b = &$aand for$array = [&$v], andprocessArgs()writes back parameter types for&$paramarguments. The missing piece was the third escape route: an array that contains a reference is copied on every by-value pass, but the reference elements survive the copy, so the callee can still write through them. Nothing invalidated or widened the referenced variable at the call, so it kept the type it had before the call — herefalse, which made the followingif ($retry)look always-false.The fix closes that route at the single place all call-like nodes go through, using the same
processVirtualAssign()+ parameter-type mechanism the by-reference parameter writeback already uses. The assigned type is unioned with the pre-call type rather than replacing it, because unlike a&$paramcontract nothing guarantees the callee writes into the slot at all.Test
tests/PHPStan/Analyser/nsrt/bug-15116.php— the reporter's playground snippet verbatim;assertType('bool', $retry)aftermakeCoffee(["cappucino", &$retry, $cupsWanted]). Fails withfalsebefore the fix.tests/PHPStan/Analyser/nsrt/array-by-ref-item-passed-to-call.php— 13 cases covering the whole family listed above (call kinds, referenced-expression kinds, key/nesting shapes, unknown value type, the array-variable case) plus two guard cases: the local$args[0] = truewrite must staytrue, and a non-reference item ([$retry]) must not widen anything. All 12 widening assertions fail before the fix.Full test suite and
make phpstanare green; self-analysis wall time is unchanged (52.5s vs. 52.8s baseline).Fixes phpstan/phpstan#15116