Skip to content

Apply FunctionParameterClosureTypeExtension override to the in-function-call stack so the closure's inferred return type matches its analysed body#5991

Open
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-8bzmbs4
Open

Apply FunctionParameterClosureTypeExtension override to the in-function-call stack so the closure's inferred return type matches its analysed body#5991
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-8bzmbs4

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

preg_replace_callback (and any other function/method whose callback parameter
type is refined by a *ParameterClosureTypeExtension) reported a false positive
Anonymous function should return 'aa' but returns 'possible?' for a callback
whose body clearly returns both values:

preg_replace_callback(
    '/a|(?<b>b)/',
    function (array $match): string {
        if ($match['b'] !== null) {
            return 'aa';
        }
        return 'possible?';
    },
    'abcd',
    flags: PREG_UNMATCHED_AS_NULL,
);

The fix makes the closure's inferred return type use the same (extension-overridden)
parameter types that the closure body is actually analysed with.

Changes

  • src/Analyser/MutatingScope.php: add replaceInFunctionCallStackParameterType(),
    which returns a scope whose top inFunctionCallsStack entry carries a replaced
    parameter type.
  • src/Analyser/NodeScopeResolver.php: in both the Expr\Closure and
    Expr\ArrowFunction argument branches, after a parameter closure type extension
    produces an overriding type, update the scope passed to
    processClosureNode()/processArrowFunctionNode() with that type. Because this
    lives in the shared argument-processing loop, the same fix applies to function,
    method and static-method parameter closure type extensions.
  • Tests:
    • tests/PHPStan/Rules/Functions/data/bug-14914.php + ClosureReturnTypeRuleTest::testBug14914
      — the reported preg_replace_callback reproducer (function extension).
    • tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php,
      its .neon, and ClosureReturnTypeParameterClosureExtensionRuleTest — custom
      function/method/static-method parameter closure type extensions plus a closure
      and an arrow function, proving the analogous method and static-method cases were
      broken the same way and are now fixed.

Root cause

A closure's return type is inferred by ClosureTypeResolver::getClosureType(),
which re-derives the callable parameter types from the parameter reflection on top
of MutatingScope::$inFunctionCallsStack. That parameter is pushed via
pushInFunctionCall() as the declared callback parameter, before any
FunctionParameterClosureTypeExtension / MethodParameterClosureTypeExtension /
StaticMethodParameterClosureTypeExtension override is computed. The override is
only forwarded to processClosureNode() as $passedToType and used to analyse the
body, so two different parameter types were in play:

  • return-type inference used the plain function-map type
    (array<int|string, string>, where $match['b'] is a non-null string, so the
    !== null check is always true and return 'possible?' looks unreachable → the
    return type collapses to 'aa');
  • the actual body walk used the overridden regex-shape type ($match['b'] is
    'b'|null, so both branches are live and return 'possible?' is checked against
    the collapsed 'aa').

replaceInFunctionCallStackParameterType() re-aligns the stack with the override so
both paths agree. The pattern affects every parameter closure type extension; the
fix is placed in the shared call-argument loop so function, method and
static-method extensions are all covered, and both closures and arrow functions are
handled.

Test

  • ClosureReturnTypeRuleTest::testBug14914 reproduces the exact issue snippet and
    fails without the fix (return.type error on the return 'possible?' line),
    passes with it.
  • ClosureReturnTypeParameterClosureExtensionRuleTest registers custom function,
    method and static-method parameter closure type extensions that widen a callback's
    array offset to be nullable; without the fix the function, method and
    static-method closures each raise a false return.type error, and all pass with
    the fix. It also exercises an arrow function on the same code path.

Fixes phpstan/phpstan#14914

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of how I addressed staabm's review.

What the reviewer asked

this should also fix https://phpstan.org/r/afe20725-1ad2-4a7d-8043-ed517d3499d8

What I found

I retrieved both playground snippets via the PHPStan playground API. Despite the issue text calling it "array-function style", the second link is actually the arrow-function variant of the same preg_replace_callback bug, at level 10, with no explicit return type:

preg_replace_callback(
    '/a|(?<b>b)/',
    fn (array $match) => $match['b'] !== null ? 'aa' : 'possible?',
    'abcd',
    flags: PREG_UNMATCHED_AS_NULL,
);

The existing PR fix (replaceInFunctionCallStackParameterType(), applied in both the closure and arrow-function branches of processArgs) already fixes this case — I confirmed it reports "No errors" with the fix and fails with Anonymous function should return 'aa' but returns 'aa'|'possible?' without it.

The one gap was test coverage: the PR's bug-14914.php reproducer only had the closure form, tested via ClosureReturnTypeRuleTest. Arrow functions go through a different rule (ArrowFunctionReturnTypeRule), which fires on ArrowFunctionReturnStatementsNode — so the reviewer's exact snippet wasn't exercised.

Change made

  • Added the arrow-function reproducer (verbatim from the playground) as a second function in tests/PHPStan/Rules/Functions/data/bug-14914.php.
  • Added testBug14914() to ArrowFunctionReturnTypeRuleTest asserting no errors.

Verification

  • New test fails without the source fix (return 'aa' but returns 'aa'|'possible?' at the arrow-function line) and passes with it — a genuine regression test.
  • All 22 tests across the three affected rule test classes pass.
  • make phpstan clean.

Committed as de25b6f51 and pushed to the PR branch.

staabm and others added 2 commits July 4, 2026 09:36
…on-call stack so the closure's inferred return type matches its analysed body

- `ClosureTypeResolver` infers a closure's return type by re-deriving the
  callable parameter types from the parameter on top of `inFunctionCallsStack`.
  That parameter was the plain declared callback parameter (e.g.
  `callable(array<int|string, string>): string` from the function map), even
  when a `*ParameterClosureTypeExtension` had overridden it (e.g.
  `preg_replace_callback` narrowing `$matches` to the regex shape). The body was
  analysed with the overridden type but the return type was inferred with the
  original, so branches could be considered dead during return-type inference
  and reachable during the actual walk, producing false
  `Anonymous function should return X but returns Y` errors.
- Add `MutatingScope::replaceInFunctionCallStackParameterType()` which replaces
  the type of the parameter on top of the in-function-call stack.
- In `NodeScopeResolver`, after a closure/arrow-function parameter type is
  overridden by a parameter closure type extension, update the scope passed to
  `processClosureNode`/`processArrowFunctionNode` so return-type inference reads
  the overridden type. This covers function, method and static-method parameter
  closure type extensions (all three go through the same argument-processing loop).
…ctionReturnTypeRuleTest

The second playground link on phpstan/phpstan#14914 uses an arrow function
without an explicit return type as the preg_replace_callback callback. That
case goes through ArrowFunctionReturnTypeRule (not ClosureReturnTypeRule), so
add it to bug-14914.php and assert no error there. Without the
replaceInFunctionCallStackParameterType fix it reports
"Anonymous function should return 'aa' but returns 'aa'|'possible?'".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-8bzmbs4 branch from de25b6f to b93cd6c Compare July 4, 2026 07:36
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.

False positive: "Anonymous function should return ... but returns ... "

2 participants