Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect static calls to non-static methods #727

Closed

Conversation

mglaman
Copy link
Contributor

@mglaman mglaman commented Oct 21, 2021

Fixes phpstan/phpstan#5782

This initial commit is definitely not the right way, but I'm going in circles and want to at least get something up for review and direction.

Copy link
Contributor Author

@mglaman mglaman left a comment

Choose a reason for hiding this comment

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

I tried writing a test, but to be honest I had no idea what rule or test this would affect. So I finally just made some changes and tossed it up.

src/Type/Constant/ConstantArrayType.php Outdated Show resolved Hide resolved
src/Type/Constant/ConstantStringType.php Outdated Show resolved Hide resolved
@mglaman
Copy link
Contributor Author

mglaman commented Oct 21, 2021

I made the quick changes for $has->yes(), I'll inspect test failures when I have my PHPStan dev time. Looks like 1 test added more failures, the other is an error. I didn't have time to inspect the test cases.

@mglaman mglaman force-pushed the non-static-methods-call-directly branch from 91ef10d to 67c6ac7 Compare October 28, 2021 19:05
src/Type/Constant/ConstantArrayType.php Outdated Show resolved Hide resolved
tests/PHPStan/Analyser/data/callables.php Outdated Show resolved Hide resolved
tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php Outdated Show resolved Hide resolved
Comment on lines 435 to 442
[
'Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{\'Bug1971\\\HelloWorld\', \'sayHello\'} given.',
14,
],
[
'Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{class-string<static(Bug1971\\HelloWorld)>, \'sayHello\'} given.',
15,
],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Did not update the test data, but cover the fact these instance methods called statically do not exist.

Copy link
Member

Choose a reason for hiding this comment

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

The problem here is that this actually works: https://3v4l.org/mTa9S

It only doesn't work from the outside: https://3v4l.org/dUmHk

The same problem is covered in CallStaticMethodRule here:

$method = $classType->getMethod($methodName, $scope);
if (!$method->isStatic()) {
$function = $scope->getFunction();
if (
!$function instanceof MethodReflection
|| $function->isStatic()
|| !$scope->isInClass()
|| (
$classType instanceof TypeWithClassName
&& $scope->getClassReflection()->getName() !== $classType->getClassName()
&& !$scope->getClassReflection()->isSubclassOf($classType->getClassName())
)
) {
return array_merge($errors, [
RuleErrorBuilder::message(sprintf(
'Static call to instance method %s::%s().',
$method->getDeclaringClass()->getDisplayName(),
$method->getName()
))->build(),
]);
}
}

So we should replicate the same logic here. We can take advantage of Scope being passed to getCallableParametersAcceptors and also pass it along to findTypeAndMethodName but as an optional parameter because of backward compatibility (but go through all the current call sites and pass Scope in it).

Copy link
Contributor Author

@mglaman mglaman Nov 11, 2021

Choose a reason for hiding this comment

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

One problem I'm hitting: isCallable does not have a scope, and that's where findTypeAndMethodName seems to be invoked from. So I added OutOfClassScope, to check if we're in a class. If not, return null, but that always returns false.

EDIT: The fix appears to update $has to maybe if we are not in a class context.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Having ConstantArrayType return maybe on a static call outside of the class or in class with mismatching class names seems to have resolved this. Thanks for the tip!

tests/PHPStan/Rules/Methods/data/bug-727.php Outdated Show resolved Hide resolved
tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php Outdated Show resolved Hide resolved
Comment on lines 435 to 442
[
'Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{\'Bug1971\\\HelloWorld\', \'sayHello\'} given.',
14,
],
[
'Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{class-string<static(Bug1971\\HelloWorld)>, \'sayHello\'} given.',
15,
],
Copy link
Member

Choose a reason for hiding this comment

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

The problem here is that this actually works: https://3v4l.org/mTa9S

It only doesn't work from the outside: https://3v4l.org/dUmHk

The same problem is covered in CallStaticMethodRule here:

$method = $classType->getMethod($methodName, $scope);
if (!$method->isStatic()) {
$function = $scope->getFunction();
if (
!$function instanceof MethodReflection
|| $function->isStatic()
|| !$scope->isInClass()
|| (
$classType instanceof TypeWithClassName
&& $scope->getClassReflection()->getName() !== $classType->getClassName()
&& !$scope->getClassReflection()->isSubclassOf($classType->getClassName())
)
) {
return array_merge($errors, [
RuleErrorBuilder::message(sprintf(
'Static call to instance method %s::%s().',
$method->getDeclaringClass()->getDisplayName(),
$method->getName()
))->build(),
]);
}
}

So we should replicate the same logic here. We can take advantage of Scope being passed to getCallableParametersAcceptors and also pass it along to findTypeAndMethodName but as an optional parameter because of backward compatibility (but go through all the current call sites and pass Scope in it).

@mglaman mglaman force-pushed the non-static-methods-call-directly branch from a9cfcd5 to 6b932d9 Compare November 11, 2021 22:14
Comment on lines 317 to 325
$typeAndMethod = $this->findTypeAndMethodName();
$typeAndMethod = $this->findTypeAndMethodName(new OutOfClassScope());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Passing out of class scope here is causing grief for CallStaticMethodsRuleTest::testBug1971

src/Type/Constant/ConstantArrayType.php Show resolved Hide resolved
Copy link
Contributor Author

@mglaman mglaman left a comment

Choose a reason for hiding this comment

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

@ondrejmirtes I'd like your feedback on a proposal. I believe we need to add the scope to \PHPStan\Type\Type::isCallable like \PHPStan\Type\Type::getCallableParametersAcceptors.

This way we can determine the class context if a static method is truly callable or not.

Comment on lines 316 to 325
public function isCallable(): TrinaryLogic
{
$typeAndMethod = $this->findTypeAndMethodName();
// @todo is there a way to fetch the full scope here.
// An OutOfClassScope causes frivolous errors when checking if a
// non-static method was invoked statically in its own class, which
// is allowed per: https://3v4l.org/mTa9S.
// Should add an optional scope parameter to isCallable and if null,
// create a new OutOfClassScope instance.
$typeAndMethod = $this->findTypeAndMethodName(new OutOfClassScope());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

After circling around this issue for the past few weeks, I think we need to pass the scope to isCallable. Knowing if something is callable depends on the scope so we can check the current class. A non-static method on a class can be invoked statically if it is within the same class. This is will always be false given OutOfClassScope.

This would also improve detection in ConstantStringType. I didn't want to make the big change without first discussing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The OutOfClassScope scope here is what is causing the test failures for CallStaticMethodsRuleTest::testBug1971.

1) PHPStan\Rules\Methods\CallStaticMethodsRuleTest::testBug1971
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'16: Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{class-string<static(Bug1971\HelloWorld)>, 'sayHello2'} given.
+'14: Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{'Bug1971\\HelloWorld', 'sayHello'} given.
+15: Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{class-string<static(Bug1971\HelloWorld)>, 'sayHello'} given.
+16: Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{class-string<static(Bug1971\HelloWorld)>, 'sayHello2'} given.
 '

Copy link
Member

Choose a reason for hiding this comment

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

What about a new class called InAnyClassScope that returns true from all the can* methods?

We'd still get the actual validation because getCallableParametersAcceptors accepts the real scope.

Comment on lines 399 to 401
if ($scope === null) {
$scope = new OutOfClassScope();
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't sure if this should be pushed to the top of the method – to provide a value for a default nullable meant for backward compatibility. Or instantiated when it's finally needed.

Comment on lines +460 to +465
// @todo this should also error on mixed type, but doesn't?
// ConstantStringType::getCallableParametersAcceptors returns a
// MixedType but that doesn't cause an error like ConstantArrayType.
[
'Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{\'Bug5782\\\HelloWorld\', \'sayGoodbye\'} given.',
31,
],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I believe adding the scope to ConstantStringType::isCallable will also cause this test failure to be fixed. Without the scope to know if we're in a class or not, this always returns as yes for the method being callable when it actually may not be.

			$classRef = $reflectionProvider->getClass($matches[1]);
			if ($classRef->hasMethod($matches[2])) {
				return TrinaryLogic::createYes();
			}

Comment on lines 316 to 325
public function isCallable(): TrinaryLogic
{
$typeAndMethod = $this->findTypeAndMethodName();
// @todo is there a way to fetch the full scope here.
// An OutOfClassScope causes frivolous errors when checking if a
// non-static method was invoked statically in its own class, which
// is allowed per: https://3v4l.org/mTa9S.
// Should add an optional scope parameter to isCallable and if null,
// create a new OutOfClassScope instance.
$typeAndMethod = $this->findTypeAndMethodName(new OutOfClassScope());
Copy link
Member

Choose a reason for hiding this comment

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

What about a new class called InAnyClassScope that returns true from all the can* methods?

We'd still get the actual validation because getCallableParametersAcceptors accepts the real scope.

@mglaman
Copy link
Contributor Author

mglaman commented Jan 5, 2022

I forgot about this and will work on follow ups – for that InAnyClass object.

@ondrejmirtes
Copy link
Member

Do you plan to finish this or can I close the PR? :)

@mglaman
Copy link
Contributor Author

mglaman commented Feb 8, 2022

I will finish, I forgot (😬). Started a new job and things fell apart in that shuffle. Added back to my Todoist

@mglaman mglaman force-pushed the non-static-methods-call-directly branch from c815c71 to 6db6c4b Compare February 11, 2022 15:37
@mglaman
Copy link
Contributor Author

mglaman commented Feb 11, 2022

Rebased and re-uploading this to my head. I made the InAnyClassScope locally and running the tests.

@mglaman mglaman force-pushed the non-static-methods-call-directly branch from 38a7c84 to 487c7ca Compare February 11, 2022 17:42
@mglaman
Copy link
Contributor Author

mglaman commented Feb 11, 2022

Blah. InAnyClassScope fixed the failures I caused, but doesn't report the failures in the test I created:

1) PHPStan\Rules\Methods\CallStaticMethodsRuleTest::testBug5782
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'23: Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{'Bug5782\\HelloWorld', 'sayGoodbye'} given.
-30: Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{'Bug5782\\HelloWorld', 'sayGoodbye'} given.
-31: Parameter #1 $callback of static method Closure::fromCallable() expects callable(): mixed, array{'Bug5782\\HelloWorld', 'sayGoodbye'} given.
+'
 '

@mglaman
Copy link
Contributor Author

mglaman commented Feb 11, 2022

I am just going to close this. It's over my head, and I keep bumping into other areas. Now I've entered this realm:

RuleLevelHelper::accepts:

		$accepts = $acceptingType->accepts($acceptedType, $strictTypes);
		if (!$accepts->yes() && $acceptingType instanceof UnionType && !$acceptedType instanceof CompoundType) {

The $accepts trinary logic is always YES because of CallableType:

	private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): TrinaryLogic
	{
		$isCallable = $type->isCallable();
		if ($isCallable->no() || $this->isCommonCallable) {
			return $isCallable;
		}

The isCommonCallable is always true since there are no parameters.

So even if getCallableParametersAcceptors returns TrivialParametersAcceptor it isn't reported correctly.

@mglaman mglaman closed this Feb 11, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants