Repository navigation
Conversation
gnutix
force-pushed
the
array-reduce-callback-inference
branch
4 times, most recently
from
July 5, 2026 11:40
6f3b3b7 to
05830c5
Compare
gnutix
force-pushed
the
array-reduce-callback-inference
branch
from
October 12, 2026 16:38
05830c5 to
ef1f6b5
Compare
…ray arguments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QW8VaDtn3cMdQowPSh1xGG
gnutix
force-pushed
the
array-reduce-callback-inference
branch
from
October 12, 2026 18:51
ef1f6b5 to
87a2f4e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes phpstan/phpstan#7280
Description
Inside an
array_reduce()callback,$carrywas typed from the naive closure analysis (non-empty-arrayin the linked issue) instead of from the$initialargument, and the call's result type was correspondingly imprecise.Root cause
array_reduce()'s callback parameter type is recursive —stubs/arrayFunctions.stubtypes itcallable(TReturn, TIn): TReturn— and PHPStan has no fixed-point machinery to resolve it:ParametersAcceptorSelector::selectFromArgs()types the closure argument naively viaClosureTypeResolver::getClosureType(), using only the declared parameter hints.GenericParametersAcceptorResolverthen unions that polluted return type intoTReturn, whichNodeScopeResolverpropagates into the callback body.ArrayReduceFunctionReturnTypeExtensionreads the same garbage-in closure type for the call's result.Unlike
array_map(), there was no visitor connecting the callback to the other arguments of the call (ArrayMapArgVisitoris howarray_map()callbacks get precise parameter types).Design
A new
ArrayReduceArgVisitorattaches the array and initial arguments (positional or named — the issue's own reproducer usesinitial:) to the callback expression, skipping first-class-callablearray_reduce(...)and argument unpacking.ClosureTypeResolver::getClosureType()then resolves the callback with a bounded fixed-point iteration, for both closures and arrow functions:$carry= type of$initialand$item= the array's iterable value type. If the carry absorbs the resulting return type (union(carry, return)equals the carry), the carry type is a fixed point and we are done.union(initial, return), generalized shape-preservingly (ConstantArrayType::generalizeValues()when all union members are constant arrays,generalize(GeneralizePrecision::lessSpecific())otherwise), and pass 2 re-analyses the body with the widened carryW.union(W, f(W))equalsW. If even the widened type is not a fixed point (e.g. a nesting callback likefn ($carry, $v) => ['x' => $carry], or a list that gains an element per iteration), the callback is analysed once more with an unknown (mixed) carry and the array's item type, instead of claiming a shape the carry can outgrow.The convergence check is deliberately strict.
isSuperTypeOf()is not enough: without definite sealedness, an array shape is a supertype of the same shape with extra keys, so a carry growing by one key per iteration would pass as converged (e.g.$c[] = $xwas inferred as a list of at most three elements). An implicitneverreturn (inference giving up on the body) is not accepted as a fixed point either; an explicitnever(a callback that always throws) is, since the carry then stays the initial value.The fixed point is what resolves
array_reduce()'sTReturn; the callback body and the rules then see the callback through that resolved acceptor, so$carryinside the body becomes the union of the initial type and the callback's (fixed-point) return type. Once the carry parameter no longer holds a template type,getClosureType()types the callback by the resolved context instead of recomputing the fixed point, so the rule view and the body walk agree (without this gate,array_reduce($ints, fn ($c, $x) => $c . $x, 'a')was reported as an invalid callback). An earlier revision of this PR, written against 2.2.x, overrode the callback parameter inParametersAcceptorSelector; on 2.3.x the override's type getter reads the closure through a separate on-demand walk that never reaches the fixed point, so the gate replaces it.ScopeOps::nodeKey()includes the new attribute in the expression cache key for the same reasonarrayMapArgsis included: cached closure types must not leak between contexts.ArrayReduceFunctionReturnTypeExtensionis unchanged; it now simply reads a precise callback type.For the issue's reproducer this infers:
Turbo
ClosureTypeResolverandScopeOpsare shadowed by phpstan_turbo, and the newArrayReduceArgVisitoris a rich parser visitor, so the change ships with native mirrors:turbo-ext/src/ArrayReduceArgVisitor.cpp(new, with its generated header and smoke/parser-visitor coverage), the fixed point inClosureTypeResolver.cpp, and the node-key change inpt_node_key()(support.cpp). The full test suite passes with the extension loaded and without it. The second commit bumpsEXPECTED_EXTENSION_VERSION.Alternatives
generalize(lessSpecific)of it before falling back tomixed— e.g. list append would becomelist<int>instead ofmixed. Left out to keep the pass count bounded at three; easy to add if wanted.ParametersAcceptorSelectoroverride (as in the 2.2.x revision) would need its type getter to consult the fixed point; the gate is the smaller change on 2.3.x.$d = []; $d[count($d)] = 1;infers*NEVER*(not addressed here; this PR just no longer treats such an implicitneveras convergence).Prior art
#5168 attempted this with a
FunctionParameterClosureTypeExtensionand was closed unmerged after review: typing$carryas the initial type is only correct for the first iteration, and unioning with the declared return type destroyed precision. This PR answers that soundness objection differently: the carry type isinitial ∪ callbackReturn(exactly the union the reviewers asked for), the callback return comes from a verified fixed point rather than from declared hints, and when no fixed point is reached the implementation falls back to today's behaviour entirely. The extension-only route also could not fix the result type, because the return type extension reads the naive closure type, which never consults parameter-closure-type extensions.Performance
array_reduce()callback bodies are now analysed up to two times insidegetClosureType()(a third pass happens only for non-converging callbacks). Non-array_reducepaths throughClosureTypeResolverare untouched. On a synthetic file of 150 reduce-heavy functions analysis took ~1.7x as long as before (3.25s → 5.4–5.9s); nested reduces stay bounded by the existing re-walk depth guard.Integration test impact
return.typeerror inWebhookManagerSubscriber::partitionArray(), a true positive — the result is keyed by the callback's partition number, so it is not thelist<list<T>>the PHPDoc claims; before this PR the carry wasmixed, which hid it.argument.typeerror inWorkspaceController.php(aUser-typed closure reducing anarray<object>) stays; its message now showsarray<string, string>carry types instead ofarray.Companion baseline update: phpstan/phpstan#15471 (merge together with this PR).
Ecosystem note
Every
array_reduce()user's carry/result types become more precise, so downstream projects may see new findings (e.g. the result of a summing reduce is nowintinstead of a benevolent(array|float|int)).Existing expectations survive unchanged: the six
array_reducereturn types intests/PHPStan/Analyser/nsrt/array-functions.php, the sixCallToFunctionParametersRulemessages (identical carry/item types), andtests/PHPStan/Rules/Operators/data/bug-7280-comment.php.🤖 Generated with Claude Code, model Fable 5.