Skip to content

Fix phpstan/phpstan#14249: Propagate @phpstan-assert-* annotations to First-class callables (Closures) and string callables#5201

Merged
ondrejmirtes merged 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-o8fsdq5
Mar 12, 2026
Merged

Fix phpstan/phpstan#14249: Propagate @phpstan-assert-* annotations to First-class callables (Closures) and string callables#5201
ondrejmirtes merged 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-o8fsdq5

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

When a function with @phpstan-assert-if-true (or similar assertion annotations) was converted to a first-class callable (fn(...)) or used as a string callable ('fn_name'), the assertion metadata was lost. This meant type narrowing did not work when calling the function through a variable.

Changes

  • Added Assertions property to ClosureType (src/Type/ClosureType.php) with a constructor parameter and getter, preserving assertions through traverse() and traverseSimultaneously()
  • Modified InitializerExprTypeResolver::createFirstClassCallable() (src/Reflection/InitializerExprTypeResolver.php) to pass the function/method's assertions to the created ClosureType
  • Added specifyTypesFromCallableCall() method and a new branch in TypeSpecifier::specifyTypesInCondition() (src/Analyser/TypeSpecifier.php) to handle FuncCall nodes where the callee is a variable — it checks for ClosureType with assertions (first-class callables) and ConstantStringType callables (string callables), then applies assertions using the existing specifyTypesFromAsserts mechanism

Root cause

The TypeSpecifier only handled assertion propagation for direct function calls (FuncCall with a Name node). When a function was used as a first-class callable (is_positive_int(...)), it was converted to a ClosureType that did not carry assertion metadata. Similarly, string callables ('is_positive_int') were not resolved back to their function reflection for assertion processing. The fix adds assertion storage to ClosureType and extends TypeSpecifier to resolve assertions from both callable forms.

Test

Added tests/PHPStan/Analyser/nsrt/bug-14249.php — an NSRT test that verifies @phpstan-assert-if-true positive-int $value correctly narrows the type to int<1, max> when the function is called directly, through a first-class callable variable, and through a string callable variable.

Fixes phpstan/phpstan#14249

@ondrejmirtes
Copy link
Member

Stupid fix, should be a a new getAssertions method on CallableParametersAcceptor.

@staabm staabm deleted the create-pull-request/patch-o8fsdq5 branch March 11, 2026 07:26
@staabm staabm restored the create-pull-request/patch-o8fsdq5 branch March 12, 2026 10:04
@staabm staabm reopened this Mar 12, 2026
…ring callables

- Added Assertions support to ClosureType so first-class callables preserve assertion metadata
- Modified InitializerExprTypeResolver::createFirstClassCallable() to pass assertions from function/method reflection to the created ClosureType
- Added specifyTypesFromCallableCall() in TypeSpecifier to handle variable function calls ($f($v)) by extracting assertions from ClosureType or resolving string callables to their function reflection
- New regression test in tests/PHPStan/Analyser/nsrt/bug-14249.php

Closes phpstan/phpstan#14249
@staabm staabm force-pushed the create-pull-request/patch-o8fsdq5 branch from 9d54f40 to 61b5d3e Compare March 12, 2026 10:04
Copy link
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

revert the fix and implement a new getAssertions method on CallableParametersAcceptor instead

@ondrejmirtes
Copy link
Member

In case it doesn't notice, you need to rebase the branch

Move assertion retrieval from ClosureType-specific code to the
CallableParametersAcceptor interface, so TypeSpecifier can use the
interface method instead of instanceof ClosureType checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

Done. Here's a summary of the changes:

Added getAsserts(): Assertions to the CallableParametersAcceptor interface (as requested by the reviewer), instead of relying on instanceof ClosureType checks in TypeSpecifier.

Files changed:

  1. CallableParametersAcceptor.php - Added getAsserts(): Assertions method to the interface
  2. ClosureType.php - Already had getAsserts(), no change needed (implements the interface method)
  3. FunctionCallableVariant.php - Delegates to $this->function->getAsserts() (the underlying function/method reflection)
  4. InaccessibleMethod.php - Returns Assertions::createEmpty()
  5. ExtendedCallableFunctionVariant.php - Added optional $assertions constructor param + getter
  6. TrivialParametersAcceptor.php - Returns Assertions::createEmpty()
  7. CallableType.php - Returns Assertions::createEmpty()
  8. ResolvedFunctionVariantWithCallable.php - Added optional $assertions constructor param + getter
  9. ParametersAcceptorSelector.php - Passes $acceptor->getAsserts() when wrapping a CallableParametersAcceptor
  10. GenericParametersAcceptorResolver.php - Passes assertions through to ResolvedFunctionVariantWithCallable
  11. TypeSpecifier.php - Removed instanceof ClosureType check, now uses $variant->getAsserts() on any CallableParametersAcceptor; removed unused ClosureType import

@clxmstaab clxmstaab force-pushed the create-pull-request/patch-o8fsdq5 branch from cb0a806 to 942f48a Compare March 12, 2026 11:00
@clxmstaab clxmstaab force-pushed the create-pull-request/patch-o8fsdq5 branch from 942f48a to 7467880 Compare March 12, 2026 11:02
Copy link
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

template type resolving does not work yet for first class callable functions. see the newly added failling test case.

fix it

Use ParametersAcceptorSelector::selectFromArgs() to resolve template
types from actual call arguments before applying assertions, matching
the pattern used for direct function calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

Done. Here's what I fixed:

Problem: Template type resolving wasn't working for first-class callable ($f1 = assertIfTemplated(...)) and string callable ($f2 = 'Bug14249\assertIfTemplated') assertions. The specifyTypesFromCallableCall method was using the raw CallableParametersAcceptor directly from getCallableParametersAcceptors(), which had unresolved template types.

Fix: Changed specifyTypesFromCallableCall to pass the callable variants through ParametersAcceptorSelector::selectFromArgs() with the actual call arguments. This triggers GenericParametersAcceptorResolver::resolve() which infers template types from arguments (e.g., T => true when called with true), matching the same pattern used for direct function calls at line 537.

The resolved acceptor's getResolvedTemplateTypeMap() then correctly contains the inferred types, so TemplateTypeHelper::resolveTemplateTypes() can resolve conditional assertions like (T is true ? true : false).

@staabm
Copy link
Contributor

staabm commented Mar 12, 2026

phpstan/phpunit test-failures will be fixed with phpstan/phpstan-phpunit#287 (unrelated to this PR).

I think this one here is good to go

@staabm staabm requested a review from VincentLanglet March 12, 2026 13:17
@ondrejmirtes ondrejmirtes merged commit 8d69bd0 into phpstan:2.1.x Mar 12, 2026
635 of 651 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-o8fsdq5 branch March 12, 2026 13:42
@ondrejmirtes
Copy link
Member

Thank you!

@phpstan-bot
Copy link
Collaborator Author

I processed this review but have nothing to report.

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.

3 participants