Recognize [$obj, $method] as callable when is_callable($obj, $method) is known true in scope#5547
Recognize [$obj, $method] as callable when is_callable($obj, $method) is known true in scope#5547phpstan-bot wants to merge 11 commits intophpstan:2.1.xfrom
[$obj, $method] as callable when is_callable($obj, $method) is known true in scope#5547Conversation
| count($expr->items) === 2 | ||
| && $expr->items[0]->key === null | ||
| && $expr->items[1]->key === null |
There was a problem hiding this comment.
I feel like we don't need all these check since ConstantArrayType will already do it in isCallable.
There was a problem hiding this comment.
Done. Removed the redundant count($expr->items) === 2, $expr->items[0]->key === null, and $expr->items[1]->key === null checks as the reviewer suggested — ConstantArrayType::isCallable() already validates the array structure, so $type->isCallable()->maybe() is sufficient. Kept isset($expr->items[0], $expr->items[1]) for safe AST access. All tests and static analysis pass.
|
the fact that a method exists does not mean it is callable: https://3v4l.org/7cKRe#v |
VincentLanglet
left a comment
There was a problem hiding this comment.
The fact a method exist doesn't imply it's callable since the method might be private for instance.
So only
function testIsCallableInlineArray(string $method): void {
$instance = new Foo();
if (!is_callable([$instance, $method])) {
return;
}
assertType('list{Bug4510Nsrt\Foo, string}&callable(): mixed', [$instance, $method]);
}
is valid.
|
Pushed. Here's a summary of the changes: Problem: The reviewer correctly pointed out that Root cause: Both Fix (3 parts):
|
|
|
||
| if ( | ||
| $type->isCallable()->maybe() | ||
| && isset($expr->items[0], $expr->items[1]) |
There was a problem hiding this comment.
should we limit this to 2 item arrays only?
that way we would not need to check any literal array we find, whether its callable
There was a problem hiding this comment.
Done. Added count($expr->items) === 2 as an early guard before the isCallable()->maybe() check, so we only evaluate callable-related logic on arrays that could actually be [$obj, $method] pairs. Tests and static analysis both pass.
| if ( | ||
| count($expr->items) === 2 | ||
| && $type->isCallable()->maybe() | ||
| && isset($expr->items[0], $expr->items[1]) |
There was a problem hiding this comment.
isset($expr->items[0], $expr->items[1]) is faster than $type->isCallable()->maybe() so you can swap the condition.
Also, how are indexed the item ?
Cause array{0: string, 1: string} might be callable but array{1: string, 2: string} won't.
There was a problem hiding this comment.
Pushed. Here's a summary of the changes addressing VincentLanglet's review:
-
Reordered conditions in
ArrayHandler: Movedisset($expr->items[0], $expr->items[1])and the new key null checks before$type->isCallable()->maybe()since they're cheaper to evaluate. -
Added key null checks: Added
$expr->items[0]->key === null && $expr->items[1]->key === nullto ensure we only consider auto-indexed arrays ([0: ..., 1: ...]), not explicitly-keyed arrays like[1: ..., 2: ...]which wouldn't be callable. -
Inlined
isCallableMarkervariable inIsCallableFunctionTypeSpecifyingExtensionas requested in the previous review.
All tests (11,927) and static analysis pass.
| return $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); | ||
| $methodExistsTypes = $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); | ||
|
|
||
| $isCallableMarker = $this->typeSpecifier->create( |
There was a problem hiding this comment.
isCallableMarker var is not needed
There was a problem hiding this comment.
The remote already has the commit addressing this review. My rebase resolved cleanly to the same state (no diff). The changes are already pushed:
- Inlined
$isCallableMarkerinIsCallableFunctionTypeSpecifyingExtension.php(VincentLanglet's current review comment) - Reordered conditions in
ArrayHandler.php— cheapisset()and key-null checks before expensiveisCallable()->maybe()(VincentLanglet's previous comment) - Added key-null checks (
$expr->items[0]->key === null && $expr->items[1]->key === null) to ensure we only handle auto-indexed[$obj, $method]arrays, not explicitly-keyed ones like[1 => $a, 0 => $b](VincentLanglet's indexing concern)
All tests pass and static analysis is clean.
VincentLanglet
left a comment
There was a problem hiding this comment.
Add test for the array
[0 => 'FooClass', 1 => 'method']
which should work.
|
|
||
| public function testBug4510(): void | ||
| { | ||
| $this->analyse([__DIR__ . '/data/bug-4510.php'], [ |
There was a problem hiding this comment.
Remove the data/bug-4510 and analyse the nsrt file instead
There was a problem hiding this comment.
The branch is up to date with the remote and all review comments are addressed:
-
"Remove the data/bug-4510 and analyse the nsrt file instead" - Done.
tests/PHPStan/Rules/Functions/data/bug-4510.phpis deleted.CallCallablesRuleTest::testBug4510()now analysestests/PHPStan/Analyser/nsrt/bug-4510.phpwhich serves as both the NSRT type inference test and the rule test data file. -
"Add test for
[0 => 'FooClass', 1 => 'method']" (from previous review) - Done.testIsCallableExplicitKeysandtestIsCallableExplicitKeysWithClassStringtest explicit0 =>/1 =>keys. The key null checks were removed fromArrayHandlersinceisCallable()->maybe()already validates the array structure.
All 11,927 tests pass and static analysis reports no errors. The commits are already pushed to the remote.
| @@ -0,0 +1,58 @@ | |||
| <?php declare(strict_types = 1); | |||
|
|
|||
| namespace Bug4510Nsrt; | |||
There was a problem hiding this comment.
Merge the nsrt and data bug-4510 file into a single one which is loaded for both the nsrt and rule tests
There was a problem hiding this comment.
Done. Here's what changed:
-
Merged test files: Combined
tests/PHPStan/Rules/Functions/data/bug-4510.phpandtests/PHPStan/Analyser/nsrt/bug-4510.phpinto a single file at the nsrt location, used by both the NSRT type inference test and theCallCallablesRuleTestrule test. -
Added explicit keys + class-string test: New
testIsCallableExplicitKeysWithClassStringcovers[0 => HelloWorld::class, 1 => $method]as VincentLanglet requested. -
Updated rule test path:
CallCallablesRuleTest::testBug4510()now references__DIR__ . '/../../Analyser/nsrt/bug-4510.php'with corrected line numbers. -
Deleted old data file: Removed the separate
tests/PHPStan/Rules/Functions/data/bug-4510.php.
All 11,927 tests pass and static analysis reports no errors.
|
The monitor timed out after the tests already completed — no action needed, everything was already committed and pushed. |
VincentLanglet
left a comment
There was a problem hiding this comment.
FOUND 2 ERRORS AFFECTING 2 LINES
385 | ERROR | [x] String "Trying to invoke array{Bug4510\HelloWorld, string}
| | but it might not be a callable." does not require double
| | quotes; use single quotes instead
| | (Squiz.Strings.DoubleQuoteUsage.NotRequired)
393 | ERROR | [x] String "Trying to invoke array{Bug4510\HelloWorld, string}
| | but it might not be a callable." does not require double
| | quotes; use single quotes instead
| | (Squiz.Strings.DoubleQuoteUsage.NotRequired)
PHPCBF CAN FIX THE 2 MARKED SNIFF VIOLATIONS AUTOMATICALLY
|
Fixed the 2 coding standard violations: replaced double quotes with single quotes on lines 385 and 393 of |
…thod)` is known true in scope - In `ArrayHandler::resolveType()`, after computing the array type, check if the 2-element array's `isCallable()` returns `maybe` and whether `method_exists(item0, item1)` is known to be `true` in the current scope - When both conditions are met, intersect the array type with `CallableType`, making `isCallable()` return `yes` instead of `maybe` - This fixes false positives from `CallCallablesRule` for patterns like: `method_exists($obj, $method)` followed by `[$obj, $method]()` - Also fixes the same pattern via `is_callable([$obj, $method])` for inline arrays, since `IsCallableFunctionTypeSpecifyingExtension` delegates to `MethodExistsTypeSpecifyingExtension` which stores the `method_exists` result in scope - Tested with: `$this`, class-string first element, `is_callable` inline array, and if/else branching patterns
ConstantArrayType::isCallable() already validates that the array has exactly 2 elements with keys 0 and 1, so the explicit count and key checks are unnecessary when isCallable()->maybe() is used as a guard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…n true, not method_exists() method_exists() confirms a method exists but does not verify callability (e.g. private methods exist but are not callable from outside the class). Only is_callable([$obj, $method]) truly verifies that the array is callable. - Modified IsCallableFunctionTypeSpecifyingExtension to store an additional is_callable marker expression in scope alongside the method_exists delegation - Changed ArrayHandler to check for this is_callable marker (using hasExpressionType to avoid evaluating the synthetic call as a real function) - Updated tests: method_exists cases now correctly report "might not be callable" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Only arrays with exactly 2 elements can be [$obj, $method] callable pairs, so skip the isCallable() evaluation for all other array sizes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…isCallableMarker - Move cheap isset() and key null checks before expensive isCallable()->maybe() - Verify array items have null keys (auto-indexed 0,1) to exclude non-sequential arrays - Inline isCallableMarker variable in IsCallableFunctionTypeSpecifyingExtension Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…check Remove the key === null restriction in ArrayHandler since isCallable()->maybe() already validates the array structure (keys 0 and 1). This allows [0 => $obj, 1 => $method] to be recognized as callable when is_callable() is known true in scope, not just [$obj, $method]. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… rule tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lCallablesRuleTest Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
a646a7d to
f4e3d82
Compare
staabm
left a comment
There was a problem hiding this comment.
I wonder why issue-bot does not report changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| ); | ||
| if ( | ||
| $scope->hasExpressionType($isCallableCall)->yes() | ||
| && (new ConstantBooleanType(true))->isSuperTypeOf($scope->getType($isCallableCall))->yes() |
There was a problem hiding this comment.
should be
| && (new ConstantBooleanType(true))->isSuperTypeOf($scope->getType($isCallableCall))->yes() | |
| && $scope->getType($isCallableCall)->isTrue()->yes() |
instead
There was a problem hiding this comment.
Pushed. Here's a summary of what happened:
The reviewer's suggestion ($scope->getType($isCallableCall)->isTrue()->yes()) doesn't work because getType() on the synthetic is_callable() FuncCall returns NeverType — the synthetic expression isn't resolvable as a real function call, so addTypeToExpression intersects ConstantBooleanType(true) with NeverType, yielding NeverType. While ConstantBooleanType(true)->isSuperTypeOf(NeverType) returns Yes (never is subtype of everything), NeverType->isTrue() returns No.
The fix: Removed the redundant isSuperTypeOf(getType(...)) check entirely. The hasExpressionType() check alone is sufficient — the is_callable marker is only stored in scope when is_callable() is known to be true (in the false context, removeTypeFromExpression short-circuits when it encounters NeverType). This is simpler and avoids the NeverType semantics issue.
The `isSuperTypeOf` check on `getType()` was redundant: the synthetic `is_callable()` FuncCall resolves to NeverType (due to intersection with the original unresolvable expression type), and NeverType is a subtype of everything, making the check always pass when hasExpressionType is true. The `isTrue()` alternative suggested in review also doesn't work with NeverType. Remove the unnecessary second condition entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
I would say it's because issue are old and using the previous playground ? |
| new FullyQualified('is_callable'), | ||
| [new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)], | ||
| ); | ||
| if ($scope->hasExpressionType($isCallableCall)->yes()) { |
There was a problem hiding this comment.
Why would getType returning "NeverType" ? It would make sens that it returns True.
Just checking hasExpressionType is no enough cause the day we'll save is_callable it will produce an error.
We should add a test with
$instance = new HelloWorld();
if (is_callable([$instance, $method])) {
return;
}
assertType('list{Bug4510\HelloWorld, string}', [$instance, $method]);
assertType('false', is_callable([$instance, $method]));
[$instance, $method](); // error reported
There was a problem hiding this comment.
All changes are pushed. Here's a summary of what was done:
Root cause of getType() returning NeverType: The synthetic is_callable FuncCall used 2 separate arguments ($instance, $method). The is_callable stub has @return ($value is callable ? true : false) conditional return type, which evaluated $value = $instance (first arg) as non-callable → returned false. Then addTypeToExpression intersected true ∩ false = NeverType.
Fix: Changed both IsCallableFunctionTypeSpecifyingExtension and ArrayHandler to pass the whole array as a single argument to the synthetic is_callable call. Now the conditional return type evaluates [$obj, $method] whose isCallable() is maybe → returns bool. So intersect(true, bool) = true, making getType()->isTrue()->yes() work correctly.
Changes:
ArrayHandler.php: Synthetic call now uses[new Arg($expr)]instead of splitting into 2 args. AddedgetType()->isTrue()->yes()check alongsidehasExpressionType.IsCallableFunctionTypeSpecifyingExtension.php: Marker stored with[new Arg($value)](whole array) instead of 2 separate element args.bug-4510.php: AddedtestIsCallableFalseBranchtest verifying the array is NOT treated as callable whenis_callablereturned false.CallCallablesRuleTest.php: Added expected error for the false-branch test case.
All 11,935 tests pass and static analysis reports no errors.
[$obj, $method] as callable when method_exists($obj, $method) is known true in scope[$obj, $method] as callable when is_callable($obj, $method) is known true in scope
…yHandler The synthetic is_callable FuncCall used 2 separate args (obj, method), which caused the conditional return type `($value is callable ? true : false)` to evaluate $obj alone as non-callable, returning false. Then addTypeToExpression intersected true ∩ false = NeverType. By passing the whole array as a single arg, the conditional return type evaluates the array's isCallable() which returns maybe → bool, so intersect(true, bool) = true. This allows getType()->isTrue()->yes() to work correctly in ArrayHandler. Added false-branch test to verify [$obj, $method]() is not treated as callable when is_callable([$obj, $method]) returned false. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Please review again with the new changes @staabm |
Summary
When
method_exists($obj, $method)confirms a method exists and$methodis not a constant string, PHPStan still reports "Trying to invoke array{Foo, string} but it might not be a callable" when[$obj, $method]()is invoked. This is a false positive because themethod_existscheck has already verified the method exists.The same false positive occurs with
is_callable([$obj, $method])for inline arrays with non-constant method names.Changes
src/Analyser/ExprHandler/ArrayHandler.phpto check whethermethod_exists()is known to betruein scope when resolving the type of a 2-element array literalmethod_exists(item0, item1)istruein scope and the array'sisCallable()returnsmaybe, intersect the array type withCallableTypeto confirm callabilitytests/PHPStan/Rules/Functions/CallCallablesRuleTest.phpandtests/PHPStan/Rules/Functions/data/bug-4510.phptests/PHPStan/Analyser/nsrt/bug-4510.phpRoot cause
MethodExistsTypeSpecifyingExtensionhandles non-constant method names by storingmethod_exists($obj, $method) → truein the scope's expression types. However, when[$obj, $method]is later constructed as an array literal,ArrayHandler::resolveType()computed the type purely from element types without considering scope conditions. TheConstantArrayType::isCallable()method returnedmaybefor non-constant method names, triggering theCallCallablesRulefalse positive.The fix bridges this gap:
ArrayHandlernow constructs a syntheticmethod_exists()call from the array elements and checks whether the scope already knows it to betrue.Analogous cases probed
is_callable([$obj, $method])inline array: Fixed by the same mechanism —IsCallableFunctionTypeSpecifyingExtensiondelegates toMethodExistsTypeSpecifyingExtensionwhich storesmethod_exists() → true$thisas first element: Works — expression key matches[Foo::class, $method]): Works — expression key matchesis_callable($variable): Already worked before this fix (variable narrowing viaCallableType)method_existsin if/else branches: Works — scope correctly has the expression type in the true branch'foo'|'bar'): Not affected — when both methods don't exist on the class,isCallable()returnsno(notmaybe), which is correct behaviorproperty_exists: Different axis — property access is not callable, no change neededTest
testBug4510inCallCallablesRuleTest— verifies no false positive errors formethod_exists+[$obj, $method](),is_callable([$obj, $method])+[$obj, $method](), class-string variant,$thisvariant, variable variant (already worked), and if-else branch varianttests/PHPStan/Analyser/nsrt/bug-4510.php— verifies the inferred types are intersection of array type withcallable(): mixedFixes phpstan/phpstan#4510