Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
5 changes: 5 additions & 0 deletions src/Rules/Methods/MethodCallableRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use stdClass;
use function sprintf;

/**
Expand Down Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions src/Rules/Methods/StaticMethodCallableRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use stdClass;
use function sprintf;

/**
Expand Down Expand Up @@ -56,6 +57,10 @@ public function processNode(Node $node, Scope $scope): array
return $errors;
}

if ($declaringClass->getName() === stdClass::class && !$declaringClass->hasMethod($methodNameName)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is isDummy() method on DummyPropertyReflection/ExtendedPropertyReflection

I feel like it's better to have a isDummy() method on DummyMethodReflection/ExtendedMethodReflection too rather than explicitly checking stdClass.

WDYT ?

return $errors;
}

$messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()');

$errors[] = RuleErrorBuilder::message(sprintf('Creating callable from a non-native static method %s.', $messagesMethodName))
Expand Down
6 changes: 6 additions & 0 deletions tests/PHPStan/Rules/Methods/MethodCallableRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
31 changes: 31 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-13596.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php // lint >= 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.';
}
}
Loading