Skip to content

Reuse a stored narrowing result only when the asking scope matches - #6702

Open
pindab0ter wants to merge 2 commits into
phpstan:2.3.xfrom
pindab0ter:fix/array-filter-typed-callback-narrowing
Open

pindab0ter wants to merge 2 commits into
phpstan:2.3.xfrom
pindab0ter:fix/array-filter-typed-callback-narrowing

Conversation

@pindab0ter

Copy link
Copy Markdown

Problem

A 2.3.0 regression (2.2.16 is fine) when an array_filter() callback with a typed parameter narrows by isset():

/** @param array<mixed> $trace */
function f(array $trace): void
{
	$frames = array_values(array_filter($trace, fn (array $frame) => isset($frame['file'])));
	// 2.2.16: list<mixed~null>
	// 2.3.0:  list<(non-empty-array&hasOffsetValue('file', mixed~null))|(ArrayAccess&hasOffsetValue('file', mixed~null))>
}

Passing an element on to an array{file: string, ...} parameter then reports argument.type. Without the array parameter type, both versions give list<mixed~null>. !empty(), array_find() and ARRAY_FILTER_USE_BOTH regressed the same way.

Cause

ArrayFilterFunctionReturnTypeHelper::processKeyAndItemType() binds the callback parameter to the element type (mixed) and asks for the body's type and narrowing on that scope. The body itself was walked with $frame as array, so both are counterfactual asks.

  • The type ask goes through resolveTypeOfNewWorldHandlerNode(), which checks askScopeVariableStateMatches() and re-prices the node on the asking scope when a variable it reads differs.
  • The narrowing ask (filterByTruthyValue() → specifyTypesOfNewWorldHandlerNode() → obtainResultForNode()) returned the stored result without that check. Its narrowing was computed where $frame is array, so isset() emitted HasOffsetType('file'), which it skips for a mixed container. Applied to mixed, that becomes (array|ArrayAccess)&hasOffsetValue('file', ...).

Fix

obtainResultForNode() returns the stored result only when askScopeVariableStateMatches() holds, and otherwise processes the node on demand on the asking scope, as the type path does. The turbo mirror gets the same check.

The guard sits at the reuse point rather than in the isset() narrowing because every narrowing callback runs at its node's own evaluation point (see ExpressionResult::getSpecifiedTypes()), so any of them can read a type that a counterfactual ask re-binds. Guarding the reuse point covers all handlers, as it already does for getType().

It uses the strict, engine-side comparison. The rule-facing variant ($ruleFacingAsk) accepts an asking type that is wider than the walk-position type, which is exactly mixed against array, so it would keep the stale answer.

Outside array_filter()/array_find(), the test suites reach the new branch only in bug-14604 ((... ?? []) ?: throw, re-asked by AssignHandler on the post-condition scope) and in NodeCallbackScopeFilterByValueRule's chained filterByTruthyValue(). Both now narrow on the asking scope, as 2.2 did, and their expectations are unchanged. The check returns early when the asking scope is the stored result's own beforeScope.

🤖 Generated with Claude Code

https://claude.ai/code/session_0138Sy8qW1oxcf7yyQ2sWCub

pindab0ter and others added 2 commits October 8, 2026 13:22
array_filter() and array_find() narrow by their callback body with the
parameter re-bound to the element type. obtainResultForNode() returned
the body's stored result, whose narrowing was computed for the declared
parameter type, so with `fn (array $frame) => isset($frame['file'])`
mixed elements were narrowed to (array|ArrayAccess)&hasOffsetValue.
getType() already re-prices such an ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138Sy8qW1oxcf7yyQ2sWCub
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.

1 participant