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
6 changes: 6 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ services:
tags:
- phpstan.rules.rule

-
class: Pest\PHPStan\Rules\BoundTestCasePrivateMemberRule
tags:
- phpstan.rules.rule

rules:
- Pest\PHPStan\Rules\DisallowedCallInDescribeRule
- Pest\PHPStan\Rules\StaticTestClosureRule
Expand All @@ -136,3 +141,4 @@ rules:
- Pest\PHPStan\Rules\DescribeWithoutTestsRule
- Pest\PHPStan\Rules\InvalidGroupNameRule
- Pest\PHPStan\Rules\RedundantLocalUseRule
- Pest\PHPStan\Rules\BoundTestCasePrivateMemberRule
1 change: 1 addition & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ReadOnlyClassRector::class,
__DIR__.'/tests/Type/data',
__DIR__.'/tests/Rules/data',
__DIR__.'/tests/Type/Fixtures/PrivateMembers',
__DIR__.'/tests/Fixtures/CustomTestCaseInference',
__DIR__.'/tests/Fixtures/UsesHookClosureThis',
UsesToExtendRector::class => [
Expand Down
125 changes: 125 additions & 0 deletions src/Rules/BoundTestCasePrivateMemberRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Rules;

use Override;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassConstantReflection;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPUnit\Framework\TestCase;

/**
* @implements Rule<Expr>
*/
final class BoundTestCasePrivateMemberRule implements Rule
{
#[Override]
public function getNodeType(): string
{
return Expr::class;
}

/**
* @return list<IdentifierRuleError>
*/
#[Override]
public function processNode(Node $node, Scope $scope): array
{
if (! $node instanceof MethodCall && ! $node instanceof StaticCall && ! $node instanceof ClassConstFetch) {
return [];
}

// @note: a closure declared inside a class keeps that class as its scope, so private members stay reachable.
if (! $scope->isInAnonymousFunction() || $scope->isInClass()) {
return [];
}

if (! $node->name instanceof Identifier) {
return [];
}

$receiver = $node instanceof MethodCall ? $node->var : $node->class;

if (! $receiver instanceof Variable || $receiver->name !== 'this') {
return [];
}

if (! $scope->hasVariableType('this')->yes()) {
return [];
}

$thisType = $scope->getVariableType('this');

if (! new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) {
return [];
}

$reflection = $this->memberReflection($node, $node->name->toString(), $thisType, $scope);

if (! $reflection instanceof ExtendedMethodReflection && ! $reflection instanceof ClassConstantReflection) {
return [];
}

if (! $reflection->isPrivate()) {
return [];
}

$declaringClass = $reflection->getDeclaringClass();

// @note: PHPStan lets the member through only when its declaring class is a bind scope class, so anything outside that set is reported by PHPStan already.
if (! in_array($declaringClass->getName(), $thisType->getObjectClassNames(), true)) {
return [];
}

$builder = match (true) {
$node instanceof ClassConstFetch => RuleErrorBuilder::message(sprintf(
'Access to private constant %s of class %s.',
$reflection->getName(),
$declaringClass->getDisplayName(),
))->identifier('classConstant.private'),
$node instanceof StaticCall => RuleErrorBuilder::message(sprintf(
'Call to private static method %s() of class %s.',
$reflection->getName(),
$declaringClass->getDisplayName(),
))->identifier('staticMethod.private'),
default => RuleErrorBuilder::message(sprintf(
'Call to private method %s() of class %s.',
$reflection->getName(),
$declaringClass->getDisplayName(),
))->identifier('method.private'),
};

return [$builder->line($node->getStartLine())->build()];
}

private function memberReflection(
MethodCall|StaticCall|ClassConstFetch $node,
string $member,
Type $thisType,
Scope $scope,
): ExtendedMethodReflection|ClassConstantReflection|null {
if ($node instanceof ClassConstFetch) {
return $thisType->hasConstant($member)->yes()
? $thisType->getConstant($member)
: null;
}

return $thisType->hasMethod($member)->yes()
? $thisType->getMethod($member, $scope)
: null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures\CustomTestCaseInference\PrivateMembers;

use Closure;
use PHPUnit\Framework\TestCase;

final class ClosureInsideTestCaseClass extends TestCase
{
private const string OWN_PRIVATE_CONSTANT = 'own private';

/**
* @return Closure(): string
*/
public function makeClosure(): Closure
{
return function (): string {
return $this->ownPrivate().$this::OWN_PRIVATE_CONSTANT;
};
}

private function ownPrivate(): string
{
return 'own private';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

use Tests\Type\Fixtures\PrivateMembers\BoundTestCase;
use Tests\Type\Fixtures\PrivateMembers\BoundTrait;

uses(BoundTestCase::class, BoundTrait::class);

it('leaves private members of a bound trait to the visibility extension', function (): void {
$this->privateTraitHelper();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

it('reports private members of the default test case', function (): void {
$this->runTest();
});

it('leaves protected and public members of the default test case alone', function (): void {
$this->getActualOutputForAssertion();
$this->getStatus();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

use Tests\Type\Fixtures\PrivateMembers\BoundTestCase;

uses(BoundTestCase::class);

it('reports private members declared by the bound test case', function (): void {
$this->privateHelper();
$this::privateStaticHelper();
$x = $this::PRIVATE_CONSTANT;
});

it('reports private members reached through an arrow function', fn (): string => $this->privateHelper());

it('leaves private members of another object to phpstan', function (): void {
$other = new BoundTestCase;

$other->privateHelper();
$other::privateStaticHelper();
});

it('leaves reachable members of the bound test case alone', function (): void {
$this->protectedHelper();
$this->publicHelper();
});

it('leaves private members inherited from a parent class to phpstan', function (): void {
$this->runTest();
});

it('survives a dynamic member name on this', function (): void {
$method = 'privateHelper';
$constant = 'PRIVATE_CONSTANT';

$this->$method();
$x = $this::${$constant};
});
46 changes: 46 additions & 0 deletions tests/Rules/BoundTestCasePrivateMemberRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Tests\Rules;

use Pest\PHPStan\Rules\BoundTestCasePrivateMemberRule;
use Tests\RuleTestCase;

beforeAll(function (): void {
RuleTestCase::$additionalConfigFiles = [
__DIR__.'/../extension.neon',
];
RuleTestCase::$rule = RuleTestCase::resolveRule(BoundTestCasePrivateMemberRule::class);
});

test('private members of the bound test case are reported', function (): void {
$this->analyse([
__DIR__.'/../Fixtures/CustomTestCaseInference/PrivateMembers/private-members-errors.php',
], [
['Call to private method privateHelper() of class Tests\Type\Fixtures\PrivateMembers\BoundTestCase.', 10],
['Call to private static method privateStaticHelper() of class Tests\Type\Fixtures\PrivateMembers\BoundTestCase.', 11],
['Access to private constant PRIVATE_CONSTANT of class Tests\Type\Fixtures\PrivateMembers\BoundTestCase.', 12],
['Call to private method privateHelper() of class Tests\Type\Fixtures\PrivateMembers\BoundTestCase.', 15],
]);
});

test('private members of a trait bound through uses() are left alone', function (): void {
$this->analyse([
__DIR__.'/../Fixtures/CustomTestCaseInference/PrivateMembers/private-members-bound-trait.php',
], []);
});

test('private members of the default test case are reported', function (): void {
$this->analyse([
__DIR__.'/../Fixtures/CustomTestCaseInference/PrivateMembers/private-members-default-testcase.php',
], [
['Call to private method runTest() of class PHPUnit\Framework\TestCase.', 6],
]);
});

test('a closure declared inside a class keeps its own scope', function (): void {
$this->analyse([
__DIR__.'/../Fixtures/CustomTestCaseInference/PrivateMembers/closure-inside-class.php',
], []);
});
33 changes: 33 additions & 0 deletions tests/Type/Fixtures/PrivateMembers/BoundTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Tests\Type\Fixtures\PrivateMembers;

use PHPUnit\Framework\TestCase;

// @note: rector skips this directory, the private members are called from analysed fixtures and dead code
final class BoundTestCase extends TestCase
{
private const string PRIVATE_CONSTANT = 'private';

public function publicHelper(): string
{
return 'public';
}

protected function protectedHelper(): string
{
return 'protected';
}

private static function privateStaticHelper(): string
{
return 'private static';
}

private function privateHelper(): string
{
return 'private';
}
}
13 changes: 13 additions & 0 deletions tests/Type/Fixtures/PrivateMembers/BoundTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Tests\Type\Fixtures\PrivateMembers;

trait BoundTrait
{
private function privateTraitHelper(): string
{
return 'private trait';
}
}
Loading