diff --git a/CLAUDE.md b/CLAUDE.md index 993b10f193..d12fa8b9ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -415,3 +415,7 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines ### Ternary expression type narrowing in TypeSpecifier `TypeSpecifier::specifyTypesInCondition()` handles ternary expressions (`$cond ? $a : $b`) for type narrowing. In a truthy context (e.g., inside `assert()`), the ternary is semantically equivalent to `($cond && $a) || (!$cond && $b)` — meaning if the condition is true, the "if" branch must be truthy, and if false, the "else" branch must be truthy. The fix converts ternary expressions to this `BooleanOr(BooleanAnd(...), BooleanAnd(...))` form so the existing OR/AND narrowing logic handles both branches correctly. This enables `assert($cond ? $x instanceof A : $x instanceof B)` to narrow `$x` to `A|B`. The `AssertFunctionTypeSpecifyingExtension` calls `specifyTypesInCondition` with `TypeSpecifierContext::createTruthy()` context for the assert argument. + +### DummyMethodReflection wrapping and HasMethodType-resolved methods + +When `method_exists($this, 'foo')` narrows a variable's type, `HasMethodType('foo')` is added as an accessory type via intersection. The `HasMethodType::getMethod()` returns a `DummyMethodReflection` whose `getDeclaringClass()` returns `stdClass`. When this method reflection is used through `IntersectionType::getMethod()`, it gets wrapped in `ChangedTypeMethodReflection` (via `CallbackUnresolvedMethodPrototypeReflection`) and then `ResolvedMethodReflection`. Neither wrapper exposes the inner reflection, but the declaring class remains `stdClass`. Rules that check `$declaringClass->hasNativeMethod()` (like `MethodCallableRule` and `StaticMethodCallableRule`) must handle this case: when `$declaringClass->getName() === stdClass::class` and `$declaringClass->hasMethod($methodName)` is `false`, the method was resolved through `HasMethodType` and should not be treated as a non-native `@method` PHPDoc method. Note: `ClassReflection::hasMethod()` returns `bool`, not `TrinaryLogic`. diff --git a/src/Rules/Methods/MethodCallableRule.php b/src/Rules/Methods/MethodCallableRule.php index 395089046f..26668b2b99 100644 --- a/src/Rules/Methods/MethodCallableRule.php +++ b/src/Rules/Methods/MethodCallableRule.php @@ -10,6 +10,7 @@ use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use stdClass; use function sprintf; /** @@ -56,6 +57,10 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + if ($declaringClass->getName() === stdClass::class && !$declaringClass->hasMethod($methodNameName)) { + return $errors; + } + $messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()'); $errors[] = RuleErrorBuilder::message(sprintf('Creating callable from a non-native method %s.', $messagesMethodName)) diff --git a/src/Rules/Methods/StaticMethodCallableRule.php b/src/Rules/Methods/StaticMethodCallableRule.php index e1b33d1321..b31f008db4 100644 --- a/src/Rules/Methods/StaticMethodCallableRule.php +++ b/src/Rules/Methods/StaticMethodCallableRule.php @@ -10,6 +10,7 @@ use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use stdClass; use function sprintf; /** @@ -56,6 +57,10 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + if ($declaringClass->getName() === stdClass::class && !$declaringClass->hasMethod($methodNameName)) { + return $errors; + } + $messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()'); $errors[] = RuleErrorBuilder::message(sprintf('Creating callable from a non-native static method %s.', $messagesMethodName)) diff --git a/tests/PHPStan/Rules/Methods/MethodCallableRuleTest.php b/tests/PHPStan/Rules/Methods/MethodCallableRuleTest.php index 6dcf45e242..60511e6b15 100644 --- a/tests/PHPStan/Rules/Methods/MethodCallableRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodCallableRuleTest.php @@ -39,6 +39,12 @@ public function testNotSupportedOnOlderVersions(): void ]); } + #[RequiresPhp('>= 8.1')] + public function testBug13596(): void + { + $this->analyse([__DIR__ . '/data/bug-13596.php'], []); + } + #[RequiresPhp('>= 8.1')] public function testRule(): void { diff --git a/tests/PHPStan/Rules/Methods/data/bug-13596.php b/tests/PHPStan/Rules/Methods/data/bug-13596.php new file mode 100644 index 0000000000..f633248c0c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-13596.php @@ -0,0 +1,31 @@ += 8.1 + +namespace Bug13596; + +use Closure; + +class BaseClass +{ + public function getCallable(): ?Closure + { + return method_exists($this, 'myCallable') ? $this->myCallable(...) : null; + } + + public function getCallableWithIsCallable(): ?Closure + { + return is_callable([$this, 'myCallable']) ? $this->myCallable(...) : null; + } +} + +class ChildOne extends BaseClass +{ + // +} + +class ChildTwo extends BaseClass +{ + public function myCallable(): string + { + return 'I exist on child two.'; + } +}