├── .github
└── workflows
│ └── code_analysis.yaml
├── .gitignore
├── LICENSE
├── README.md
├── bin
└── fix
├── composer.json
├── composer.lock
├── ecs.php
├── phpstan.neon.dist
├── phpunit.xml
├── resources
└── cover.png
└── utils
└── PHPStan
├── src
├── ActionAllowedParameterRule.php
├── ActionReturnsResponseRule.php
├── AutoIncrementingModelIdRule.php
├── CollectFunctionCalls.php
├── CollectVariableNamesVisitor.php
├── ControllerDetermination.php
├── DeterminationBasedOnSuffix.php
├── DoctrineEntityWithAnnotation.php
├── EntityDetermination.php
├── ForbiddenImplementsRule.php
├── ForbiddenMethodCallRule.php
├── ForbiddenObjectTypeInCommandRule.php
├── ForbiddenOverrideRule.php
├── ForbiddenParentClassRule.php
├── ForbiddenTwigFunctionsRule.php
├── ForbiddenTwigVarsRule.php
├── FriendOf.php
├── FriendRule.neon
├── FriendRule.php
├── ModelCreatedWithArrayRule.php
├── NoContainerGetRule.php
├── NoEntityInTemplateRule.php
├── NoErrorSilencingRule.php
├── OneActionPerControllerRule.php
└── UseEmailValidatorRule.php
└── tests
├── ActionAllowedParameterRule
├── ActionAllowedParameterRuleTest.php
├── ActionReturnsResponseRuleTest.php
├── Fixtures
│ ├── action-does-not-return-response.php
│ ├── controller-has-more-than-one-action.php
│ ├── parameter-type-not-allowed.php
│ ├── skip-action-only-has-allowed-parameter.php
│ ├── skip-action-returns-response.php
│ ├── skip-controller-has-no-actions.php
│ ├── skip-controller-has-one-action.php
│ ├── skip-controller-has-private-methods.php
│ ├── skip-not-a-controller.php
│ └── skip-not-a-public-method.php
└── OneActionPerControllerRuleTest.php
├── AutoIncrementingModelIdRule
├── AutoIncrementingModelIdRuleTest.php
└── Fixtures
│ ├── ModelHasAutoIncrementingId.php
│ ├── ModelHasImplicitAutoIncrementingId.php
│ ├── SkipModelHasNonAutoIncrementingId.php
│ └── SkipNotAModel.php
├── ForbiddenImplementsRule
├── Fixtures
│ ├── class-implements-forbidden-interface.php
│ └── skip-class-implements-allowed-interface.php
└── ForbiddenImplementsRuleTest.php
├── ForbiddenMethodCallRule
├── Fixtures
│ ├── CallToForbiddenMethod.php
│ ├── SkipDifferentClass.php
│ └── SkipDifferentMethod.php
└── ForbiddenMethodCallRuleTest.php
├── ForbiddenObjectTypeInCommandRule
├── Fixtures
│ ├── AnActualCommand.php
│ └── NotACommand.php
└── ForbiddenObjectTypeInCommandRuleTest.php
├── ForbiddenOverrideRule
├── Fixtures
│ ├── class-has-forbidden-override.php
│ ├── skip-ancestor-does-not-match.php
│ └── skip-method-name-does-not-match.php
└── ForbiddenOverrideRuleTest.php
├── ForbiddenParentClassRule
├── Fixtures
│ ├── extends-abstract-controller.php
│ ├── skip-class-extends-nothing.php
│ └── skip-class-extends-something-else.php
└── ForbiddenParentClassRuleTest.php
├── ForbiddenTwigFunctionsRule
├── Fixtures
│ ├── okay.html.twig
│ ├── skip-not-a-call-to-render.php
│ ├── skip-not-a-call-to-twig-environment.php
│ ├── skip-not-a-constant-string.php
│ ├── skip-template-is-okay.php
│ ├── twig-template-uses-forbidden-function.php
│ └── uses-forbidden-function.html.twig
└── ForbiddenTwigFunctionsRuleTest.php
├── ForbiddenTwigVarsRule
├── Fixtures
│ ├── okay.html.twig
│ ├── skip-not-a-call-to-render.php
│ ├── skip-not-a-call-to-twig-environment.php
│ ├── skip-not-a-constant-string.php
│ ├── skip-template-is-okay.php
│ ├── twig-template-uses-forbidden-var.php
│ └── uses-forbidden-var.html.twig
└── ForbiddenTwigVarsRuleTest.php
├── FriendRule
├── Fixtures
│ ├── ATrueFriend.php
│ ├── ClassWithFriendAttribute.php
│ └── NotAFriend.php
└── FriendRuleTest.php
├── ModelCreatedWithArrayRule
├── Fixtures
│ ├── AModel.php
│ ├── NotAModel.php
│ ├── model-created-with-array.php
│ ├── model-created-with-no-arguments.php
│ ├── skip-model-created-with-multiple-arguments.php
│ ├── skip-model-created-with-non-array-argument.php
│ ├── skip-not-a-model.php
│ └── skip-not-create.php
└── ModelCreatedWithArrayRuleTest.php
├── NoContainerGetRule
├── Fixtures
│ ├── container-get.php
│ ├── skip-container-get-in-controller.php
│ ├── skip-different-method.php
│ └── skip-different-object.php
├── NoContainerGetRuleTest.php
└── phpstan.neon
├── NoEntityInTemplateRule
├── Fixtures
│ ├── AnEntity.php
│ ├── entity-passed-to-twig.php
│ ├── skip-no-entity-passed.php
│ ├── skip-not-a-call-to-render.php
│ ├── skip-not-a-call-to-twig-environment.php
│ └── skip-not-a-constant-string.php
└── NoEntityInTemplateRuleTest.php
├── NoErrorSilencingRule
├── Fixtures
│ └── error-silencing.php
└── NoErrorSilencingRuleTest.php
└── UseEmailValidatorRule
├── Fixtures
├── skip-validate-has-no-strings.php
├── skip-validate-string-does-not-contain-email.php
└── validate-string-contains-email.php
└── UseEmailValidatorRuleTest.php
/.github/workflows/code_analysis.yaml:
--------------------------------------------------------------------------------
1 | name: "Code Analysis"
2 |
3 | on:
4 | pull_request: null
5 | push:
6 | branches:
7 | - "main"
8 |
9 | jobs:
10 | code_analysis:
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | include:
15 | - name: "Tests"
16 | dependencies: "highest"
17 | run: |
18 | composer exec -- phpunit
19 | - name: "Static Analysis"
20 | dependencies: "highest"
21 | run: |
22 | composer exec -- phpstan
23 |
24 | name: ${{ matrix.name }}
25 | runs-on: "ubuntu-latest"
26 |
27 | steps:
28 | - uses: "actions/checkout@v2"
29 |
30 | # See https://github.com/shivammathur/setup-php
31 | - uses: "shivammathur/setup-php@v2"
32 | with:
33 | php-version: "8.1"
34 | coverage: "none"
35 |
36 | # Composer install and cache - https://github.com/ramsey/composer-install
37 | - uses: "ramsey/composer-install@v1"
38 | with:
39 | dependency-versions: ${{ matrix.dependencies }}
40 |
41 | - run: ${{ matrix.run }}
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | .phpunit.result.cache
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) Matthias Noback
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is furnished
8 | to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PHPStan rules from the book "Recipes for Decoupling" by Matthias Noback
2 |
3 | In the book ["Recipes for Decoupling"](https://leanpub.com/recipes-for-decoupling/) we discuss how to decouple from web and CLI frameworks, ORMs, validation libraries, and test frameworks. While doing so, we develop a number of custom rules for use with PHPStan. That way we can use automated static analysis to keep our code decoupled in the long run.
4 |
5 | This project contains all the final versions of each of the rules from the book. They are not production-ready but feel free to use them as a starting point for your own custom rules.
6 |
7 | [](https://leanpub.com/recipes-for-decoupling/)
8 |
--------------------------------------------------------------------------------
/bin/fix:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eu
4 |
5 | # Run several times to fix things fixed by the first fix run
6 | vendor/bin/ecs check --fix
7 | vendor/bin/ecs check --fix
8 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "require": {
3 | "php": "^8.1",
4 | "twig/twig": "^3.4",
5 | "symfony/console": "^6.1",
6 | "illuminate/database": "^9.19"
7 | },
8 | "require-dev": {
9 | "phpunit/phpunit": "^9.5",
10 | "phpstan/phpstan": "^1.7",
11 | "symplify/easy-coding-standard": "^11.0",
12 | "symplify/coding-standard": "^11.0",
13 | "illuminate/http": "^9.19"
14 | },
15 | "license": "MIT",
16 | "autoload-dev": {
17 | "psr-4": {
18 | "Utils\\PHPStan\\": "utils/PHPStan/src",
19 | "Utils\\PHPStan\\Tests\\": "utils/PHPStan/tests/"
20 | }
21 | },
22 | "config": {
23 | "allow-plugins": {
24 | "phpstan/extension-installer": true
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/ecs.php:
--------------------------------------------------------------------------------
1 | paths([__DIR__ . '/utils', __DIR__ . '/ecs.php']);
11 |
12 | $config->skip([StandardizeHereNowDocKeywordFixer::class, '*/Fixtures/*']);
13 |
14 | $config->sets([SetList::CONTROL_STRUCTURES, SetList::PSR_12, SetList::COMMON, SetList::SYMPLIFY]);
15 | };
16 |
--------------------------------------------------------------------------------
/phpstan.neon.dist:
--------------------------------------------------------------------------------
1 | parameters:
2 | paths:
3 | - utils/PHPStan/src/
4 | # Needs to be 9
5 | level: 7
6 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | ./utils/PHPStan/tests
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/resources/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matthiasnoback/recipes-for-decoupling-phpstan-rules/eab24cba178e3b84501bbaaa59854d3d361b912d/resources/cover.png
--------------------------------------------------------------------------------
/utils/PHPStan/src/ActionAllowedParameterRule.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | final class ActionAllowedParameterRule implements Rule
18 | {
19 | public function __construct(
20 | private readonly string $allowedParameterType,
21 | private readonly ControllerDetermination $determination,
22 | ) {
23 | }
24 |
25 | public function getNodeType(): string
26 | {
27 | return InClassMethodNode::class;
28 | }
29 |
30 | /**
31 | * @param InClassMethodNode $node
32 | */
33 | public function processNode(Node $node, Scope $scope): array
34 | {
35 | if (! $node->getOriginalNode()->isPublic()) {
36 | // The method is not a controller action
37 | return [];
38 | }
39 |
40 | if (
41 | ! $this->determination->isController($scope->getClassReflection())
42 | ) {
43 | // We're not inside a controller
44 | return [];
45 | }
46 |
47 | $errors = [];
48 |
49 | foreach ($node->getOriginalNode()->params as $param) {
50 | if (
51 |
52 | ! (new ObjectType($this->allowedParameterType))
53 | ->isSuperTypeOf($scope->getType($param->var))
54 | ->yes()
55 | ) {
56 | $errors[] =
57 | RuleErrorBuilder::message(
58 | sprintf(
59 | 'Controller actions can only have parameters of type "%s"',
60 | $this->allowedParameterType,
61 | )
62 | )->build();
63 | }
64 | }
65 |
66 | return $errors;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ActionReturnsResponseRule.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | final class ActionReturnsResponseRule implements Rule
20 | {
21 | public function __construct(
22 | private readonly string $requiredReturnType,
23 | private readonly ControllerDetermination $determination,
24 | ) {
25 | }
26 |
27 | public function getNodeType(): string
28 | {
29 | return InClassMethodNode::class;
30 | }
31 |
32 | /**
33 | * @param InClassMethodNode $node
34 | */
35 | public function processNode(Node $node, Scope $scope): array
36 | {
37 | if (! $node->getOriginalNode()->isPublic()) {
38 | // The method is not a controller action
39 | return [];
40 | }
41 |
42 | if (
43 | ! $this->determination->isController($scope->getClassReflection())
44 | ) {
45 | // We're not inside a controller
46 | return [];
47 | }
48 |
49 | $methodReflection = $scope->getFunction();
50 | if (! $methodReflection instanceof MethodReflection) {
51 | /*
52 | * This shouldn't happen, since this rule subscribes
53 | * to `InClassMethodNode`...
54 | */
55 | return [];
56 | }
57 |
58 | $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType();
59 |
60 | if ((new ObjectType($this->requiredReturnType))
61 | ->isSuperTypeOf($returnType)
62 | ->yes()) {
63 | // The action already returns a Response
64 | return [];
65 | }
66 |
67 | return [
68 | RuleErrorBuilder::message(
69 | sprintf(
70 | 'Method %s::%s() should return %s',
71 | $methodReflection->getDeclaringClass()
72 | ->getName(),
73 | $methodReflection->getName(),
74 | $this->requiredReturnType,
75 | )
76 | )->build(),
77 | ];
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/AutoIncrementingModelIdRule.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | final class AutoIncrementingModelIdRule implements Rule
20 | {
21 | public function getNodeType(): string
22 | {
23 | return ClassPropertiesNode::class;
24 | }
25 |
26 | /**
27 | * @param ClassPropertiesNode $node
28 | */
29 | public function processNode(Node $node, Scope $scope): array
30 | {
31 | if (! $scope->isInClass()) {
32 | // Should not happen
33 | return [];
34 | }
35 |
36 | if (! in_array(Model::class, $scope->getClassReflection() ->getParentClassesNames(), true)) {
37 | // This class does not extend `Model`
38 | return [];
39 | }
40 |
41 | $type = $this->defaultValueTypeOfIncrementingProperty($node, $scope);
42 |
43 | if (! $type instanceof ConstantBooleanType) {
44 | /*
45 | * The default value of `$incrementing` is not
46 | * recognized as a boolean, we can't analyze it
47 | */
48 | return [];
49 | }
50 |
51 | if ($type->getValue() === false) {
52 | /*
53 | * Indeed, we want the default value of `$incrementing`
54 | * to be `false`
55 | */
56 | return [];
57 | }
58 |
59 | return [RuleErrorBuilder::message('This model has an auto-incrementing ID')->build()];
60 | }
61 |
62 | private function defaultValueTypeOfIncrementingProperty(ClassPropertiesNode $node, Scope $scope): Type
63 | {
64 | foreach ($node->getProperties() as $property) {
65 | if ($property->getName() !== 'incrementing') {
66 | continue;
67 | }
68 |
69 | return $scope->getType($property->getDefault());
70 | }
71 |
72 | /*
73 | * The default value inherited from the `Model` parent
74 | * class is supposed to be true
75 | */
76 | return new ConstantBooleanType(true);
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/CollectFunctionCalls.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | private array $functionCalls = [];
18 |
19 | public function enterNode(Node $node, Environment $env): Node
20 | {
21 | if ($node instanceof FunctionExpression) {
22 | $this->functionCalls[] = $node;
23 | }
24 | return $node;
25 | }
26 |
27 | /**
28 | * @return list
29 | */
30 | public function functionCalls(): array
31 | {
32 | return $this->functionCalls;
33 | }
34 |
35 | public function leaveNode(Node $node, Environment $env): ?Node
36 | {
37 | return $node;
38 | }
39 |
40 | public function getPriority(): int
41 | {
42 | return 10;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/CollectVariableNamesVisitor.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | private array $variableNames = [];
18 |
19 | public function enterNode(Node $node, Environment $env): Node
20 | {
21 | if ($node instanceof NameExpression) {
22 | $this->variableNames[] = $node;
23 | }
24 | return $node;
25 | }
26 |
27 | /**
28 | * @return list
29 | */
30 | public function variableNames(): array
31 | {
32 | return $this->variableNames;
33 | }
34 |
35 | public function leaveNode(Node $node, Environment $env): ?Node
36 | {
37 | return $node;
38 | }
39 |
40 | public function getPriority(): int
41 | {
42 | return 10;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ControllerDetermination.php:
--------------------------------------------------------------------------------
1 | getName(), $this->suffix);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/DoctrineEntityWithAnnotation.php:
--------------------------------------------------------------------------------
1 | getResolvedPhpDoc() ->getPhpDocString(), '@ORM\Entity',);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/EntityDetermination.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | final class ForbiddenImplementsRule implements Rule
17 | {
18 | private string $forbiddenInterface;
19 |
20 | public function __construct(string $forbiddenInterface)
21 | {
22 | $this->forbiddenInterface = $forbiddenInterface;
23 | }
24 |
25 | public function getNodeType(): string
26 | {
27 | return InClassNode::class;
28 | }
29 |
30 | /**
31 | * @param InClassNode $node
32 | */
33 | public function processNode(Node $node, Scope $scope): array
34 | {
35 | if (! $node->getClassReflection()->implementsInterface($this->forbiddenInterface)) {
36 | return [];
37 | }
38 |
39 | return [
40 | RuleErrorBuilder::message(
41 | sprintf('Class implements forbidden interface %s', $this->forbiddenInterface,)
42 | )->build(),
43 | ];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ForbiddenMethodCallRule.php:
--------------------------------------------------------------------------------
1 |
17 | */
18 | final class ForbiddenMethodCallRule implements Rule
19 | {
20 | private ObjectType $class;
21 |
22 | public function __construct(
23 | string $class,
24 | private readonly string $method,
25 | ) {
26 | $this->class = new ObjectType($class);
27 | }
28 |
29 | public function getNodeType(): string
30 | {
31 | return MethodCall::class;
32 | }
33 |
34 | /**
35 | * @param MethodCall $node
36 | */
37 | public function processNode(Node $node, Scope $scope): array
38 | {
39 | if (! $node->name instanceof Identifier) {
40 | // Dynamic method name, can not be analyzed
41 | return [];
42 | }
43 |
44 | if ($node->name->toString() !== $this->method) {
45 | // The method is a different one
46 | return [];
47 | }
48 |
49 | if (! $this->class->isSuperTypeOf($scope->getType($node->var))->yes()
50 | ) {
51 | // The class does not match the expected type
52 | return [];
53 | }
54 |
55 | return [
56 | RuleErrorBuilder::message(
57 | sprintf('Call to forbidden method %s::%s()', $this->class->getClassName(), $this->method,)
58 | )->build(),
59 | ];
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ForbiddenObjectTypeInCommandRule.php:
--------------------------------------------------------------------------------
1 |
17 | */
18 | final class ForbiddenObjectTypeInCommandRule implements Rule
19 | {
20 | private readonly ObjectType $forbiddenType;
21 |
22 | public function __construct(string $forbiddenType)
23 | {
24 | $this->forbiddenType = new ObjectType($forbiddenType);
25 | }
26 |
27 | public function getNodeType(): string
28 | {
29 | return MethodCall::class;
30 | }
31 |
32 | /**
33 | * @param MethodCall $node
34 | */
35 | public function processNode(Node $node, Scope $scope): array
36 | {
37 | if (! $this->forbiddenType->isSuperTypeOf($scope->getType($node->var))->yes()) {
38 | return [];
39 | }
40 |
41 | if (! $scope->isInClass()) {
42 | // The call is made outside a class; okay for now
43 | return [];
44 | }
45 |
46 | if (in_array(Command::class, $scope->getClassReflection() ->getParentClassesNames(), true)) {
47 | // One of the parent classes of this class is `Command`
48 | return [];
49 | }
50 |
51 | // This call is inside a class that is not a `Command`
52 | return [
53 | RuleErrorBuilder::message(
54 | sprintf(
55 | 'Object of type %s is used in a class that does not extend %s',
56 | $this->forbiddenType->getClassName(),
57 | Command::class
58 | )
59 | )->build(),
60 | ];
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ForbiddenOverrideRule.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | final class ForbiddenOverrideRule implements Rule
17 | {
18 | public function __construct(
19 | private readonly string $overrideFromClass,
20 | private readonly string $overrideMethod,
21 | ) {
22 | }
23 |
24 | public function getNodeType(): string
25 | {
26 | return ClassMethod::class;
27 | }
28 |
29 | /**
30 | * @param ClassMethod $node
31 | */
32 | public function processNode(Node $node, Scope $scope): array
33 | {
34 | if ($node->name->toString() !== $this->overrideMethod) {
35 | // The method name does not match
36 | return [];
37 | }
38 |
39 | if (! in_array($this->overrideFromClass, $scope->getClassReflection() ->getParentClassesNames(), true)) {
40 | // The method name matches, but not the class
41 | return [];
42 | }
43 |
44 | return [
45 | RuleErrorBuilder::message(
46 | sprintf(
47 | 'Overriding method %s::%s() is not allowed',
48 | $this->overrideFromClass,
49 | $this->overrideMethod,
50 | )
51 | )->build(),
52 | ];
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ForbiddenParentClassRule.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | final class ForbiddenParentClassRule implements Rule
17 | {
18 | public function __construct(
19 | private readonly string $forbiddenParentClass,
20 | ) {
21 | }
22 |
23 | public function getNodeType(): string
24 | {
25 | return Class_::class;
26 | }
27 |
28 | /**
29 | * @param Class_ $node
30 | */
31 | public function processNode(Node $node, Scope $scope): array
32 | {
33 | if ($node->extends === null) {
34 | // This class does not `extend` anything
35 | return [];
36 | }
37 |
38 | if (
39 | $node->extends->toString() !== $this->forbiddenParentClass
40 | ) {
41 | // The extended class is not the forbidden parent class
42 | return [];
43 | }
44 |
45 | return [
46 | RuleErrorBuilder::message(
47 | sprintf('Parent class %s is forbidden', $this->forbiddenParentClass,),
48 | )->build(),
49 | ];
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ForbiddenTwigFunctionsRule.php:
--------------------------------------------------------------------------------
1 |
22 | */
23 | final class ForbiddenTwigFunctionsRule implements Rule
24 | {
25 | /**
26 | * @param list $forbiddenFunctions
27 | */
28 | public function __construct(
29 | private readonly string $templateDir,
30 | private readonly array $forbiddenFunctions,
31 | ) {
32 | }
33 |
34 | public function getNodeType(): string
35 | {
36 | return MethodCall::class;
37 | }
38 |
39 | /**
40 | * @param MethodCall $node
41 | */
42 | public function processNode(Node $node, Scope $scope): array
43 | {
44 | $twigEnvironment = new ObjectType(Environment::class);
45 | if (! $twigEnvironment
46 | ->isSuperTypeOf($scope->getType($node->var))
47 | ->yes()) {
48 | // The object is not a Twig `Environment` instance
49 | return [];
50 | }
51 |
52 | if (! $node->name instanceof Identifier
53 | || $node->name->toString() !== 'render') {
54 | // The method is called dynamically, or is not `render()`
55 | return [];
56 | }
57 |
58 | if (! isset($node->getArgs()[0])) {
59 | // The method call has no arguments
60 | return [];
61 | }
62 |
63 | $firstArgument = $node->getArgs()[0];
64 | $firstArgumentType = $scope->getType($firstArgument->value);
65 | if (! $firstArgumentType instanceof ConstantStringType) {
66 | // The first argument is not a constant string
67 | return [];
68 | }
69 |
70 | $templateName = $firstArgumentType->getValue();
71 |
72 | // Load the template
73 | $loader = new FilesystemLoader($this->templateDir);
74 |
75 | $source = $loader->getSourceContext($templateName);
76 |
77 | // Parse the template
78 | $twig = new Environment($loader, [
79 | 'debug' => true,
80 | ]);
81 | $twig->addExtension(new DebugExtension());
82 |
83 | $nodeTree = $twig->parse($twig->tokenize($source));
84 |
85 | // Traverse the node tree and collect all variable names
86 | $visitor = new CollectFunctionCalls();
87 | $nodeTraverser = new NodeTraverser($twig, [$visitor]);
88 | $nodeTraverser->traverse($nodeTree);
89 |
90 | $errors = [];
91 |
92 | foreach ($visitor->functionCalls() as $functionCall) {
93 | $functionName = $functionCall->getAttribute('name');
94 |
95 | if (in_array($functionName, $this->forbiddenFunctions, true,)) {
96 | $errors[] = RuleErrorBuilder::message(sprintf(
97 | 'Template uses forbidden function %s',
98 | $functionName,
99 | ))
100 | ->file(
101 | $functionCall->getSourceContext()
102 | ->getPath()
103 | ?: $functionCall->getSourceContext()
104 | ->getName()
105 | )
106 | ->line($functionCall->getTemplateLine())
107 | ->build();
108 | }
109 | }
110 |
111 | return $errors;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ForbiddenTwigVarsRule.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | final class ForbiddenTwigVarsRule implements Rule
23 | {
24 | /**
25 | * @param list $forbiddenVariables
26 | */
27 | public function __construct(
28 | private readonly string $templateDir,
29 | private readonly array $forbiddenVariables,
30 | ) {
31 | }
32 |
33 | public function getNodeType(): string
34 | {
35 | return MethodCall::class;
36 | }
37 |
38 | /**
39 | * @param MethodCall $node
40 | */
41 | public function processNode(Node $node, Scope $scope): array
42 | {
43 | $twigEnvironment = new ObjectType(Environment::class);
44 | if (! $twigEnvironment
45 | ->isSuperTypeOf($scope->getType($node->var))
46 | ->yes()) {
47 | // The object is not a Twig `Environment` instance
48 | return [];
49 | }
50 |
51 | if (! $node->name instanceof Identifier
52 | || $node->name->toString() !== 'render') {
53 | // The method is called dynamically, or is not `render()`
54 | return [];
55 | }
56 |
57 | if (! isset($node->getArgs()[0])) {
58 | // The method call has no arguments
59 | return [];
60 | }
61 |
62 | $firstArgument = $node->getArgs()[0];
63 | $firstArgumentType = $scope->getType($firstArgument->value);
64 | if (! $firstArgumentType instanceof ConstantStringType) {
65 | // The first argument is not a constant string
66 | return [];
67 | }
68 |
69 | $templateName = $firstArgumentType->getValue();
70 |
71 | // Load the template
72 | $loader = new FilesystemLoader($this->templateDir);
73 |
74 | $source = $loader->getSourceContext($templateName);
75 |
76 | // Parse the template
77 | $twig = new Environment($loader);
78 | $nodeTree = $twig->parse($twig->tokenize($source));
79 |
80 | // Traverse the node tree and collect all variable names
81 | $visitor = new CollectVariableNamesVisitor();
82 | $nodeTraverser = new NodeTraverser($twig, [$visitor]);
83 | $nodeTraverser->traverse($nodeTree);
84 |
85 | $errors = [];
86 |
87 | foreach ($visitor->variableNames() as $nameNode) {
88 | $variableName = $nameNode->getAttribute('name');
89 | if (in_array($variableName, $this->forbiddenVariables, true,)) {
90 | $errors[] = RuleErrorBuilder::message(sprintf('Template uses forbidden var %s', $variableName,))
91 | ->file($nameNode->getSourceContext() ->getPath() ?: $nameNode->getSourceContext() ->getName())
92 | ->line($nameNode->getTemplateLine())
93 | ->build();
94 | }
95 | }
96 |
97 | return $errors;
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/FriendOf.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | final class FriendRule implements Rule
21 | {
22 | public function __construct(private readonly ReflectionProvider $reflectionProvider)
23 | {
24 | }
25 |
26 | public function getNodeType(): string
27 | {
28 | return MethodCall::class;
29 | }
30 |
31 | /**
32 | * @param MethodCall $node
33 | */
34 | public function processNode(Node $node, Scope $scope): array
35 | {
36 | if (! $node->name instanceof Identifier) {
37 | // Dynamic method call, we can't analyze it
38 | return [];
39 | }
40 |
41 | $objectType = $scope->getType($node->var);
42 | if (! $objectType instanceof ObjectType) {
43 | /*
44 | * We can't find out what type of object this method is
45 | * called on
46 | */
47 | return [];
48 | }
49 |
50 | try {
51 | $methodReflection = $this->reflectionProvider
52 | ->getClass($objectType->getClassName())
53 | ->getNativeReflection()
54 | ->getMethod($node->name->toString());
55 | } catch (ReflectionException) {
56 | // Could not find the actual method in the code, nothing to analyze
57 | return [];
58 | }
59 |
60 | $friendOfAttributes = $methodReflection->getAttributes(FriendOf::class);
61 | if ($friendOfAttributes === []) {
62 | /*
63 | * The method has no `#[FriendOf]` attributes, so it's
64 | * okay to call this method
65 | */
66 | return [];
67 | }
68 |
69 | $thisClassType = new ObjectType($scope->getClassReflection() ->getName());
70 |
71 | foreach ($friendOfAttributes as $attribute) {
72 | /** @var FriendOf $instance */
73 | $instance = $attribute->newInstance();
74 | $friendClassType = (new ObjectType($instance->friendClass));
75 |
76 | if ($friendClassType->isSuperTypeOf($thisClassType)->yes()) {
77 | return [];
78 | }
79 | }
80 |
81 | return [
82 | RuleErrorBuilder::message(
83 | sprintf(
84 | 'Method call %s::%s() is only allowed inside friend classes',
85 | $objectType->getClassName(),
86 | $methodReflection->getName()
87 | )
88 | )->build(),
89 | ];
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/ModelCreatedWithArrayRule.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | final class ModelCreatedWithArrayRule implements Rule
21 | {
22 | public function getNodeType(): string
23 | {
24 | return StaticCall::class;
25 | }
26 |
27 | /**
28 | * @param StaticCall $node
29 | */
30 | public function processNode(Node $node, Scope $scope): array
31 | {
32 | if (! $this->isCallToModelCreate($node, $scope)) {
33 | return [];
34 | }
35 |
36 | if (count($node->getArgs()) > 1) {
37 | // Multiple arguments were provided: great!
38 | return [];
39 | }
40 |
41 | if (count($node->getArgs()) === 0) {
42 | return [
43 | RuleErrorBuilder::message(
44 | 'Model is created with no arguments, ' .
45 | 'use explicit arguments instead'
46 | )->build(),
47 | ];
48 | }
49 |
50 | $firstArgument = $node->getArgs()[0];
51 | if (! $scope->getType($firstArgument->value)->isArray()
52 | ->yes()) {
53 | // The only argument is not an array
54 | return [];
55 | }
56 |
57 | return [
58 | RuleErrorBuilder::message(
59 | 'Model is created with an array argument, ' .
60 | 'use explicit arguments instead'
61 | )->build(),
62 | ];
63 | }
64 |
65 | private function isCallToModelCreate(StaticCall $node, Scope $scope): bool
66 | {
67 | if (! $node->class instanceof Name) {
68 | // The class part is dynamic, we can't do anything with it
69 | return false;
70 | }
71 |
72 | $type = $scope->resolveTypeByName($node->class);
73 | if (! (new ObjectType(Model::class))
74 | ->isSuperTypeOf($type)
75 | ->yes()) {
76 | // The class is definitely not a model
77 | return false;
78 | }
79 |
80 | if (! $node->name instanceof Identifier) {
81 | // It's a dynamic method call
82 | return false;
83 | }
84 |
85 | return $node->name->toString() === 'create';
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/NoContainerGetRule.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | final class NoContainerGetRule implements Rule
20 | {
21 | public function __construct(
22 | private readonly ControllerDetermination $determination,
23 | ) {
24 | }
25 |
26 | public function getNodeType(): string
27 | {
28 | return MethodCall::class;
29 | }
30 |
31 | /**
32 | * @param MethodCall $node
33 | */
34 | public function processNode(Node $node, Scope $scope): array
35 | {
36 | $objectType = $scope->getType($node->var);
37 | $containerType = new ObjectType(ContainerInterface::class);
38 |
39 | if (! $containerType->isSuperTypeOf($objectType)->yes()) {
40 | return [];
41 | }
42 |
43 | if (! $node->name instanceof Identifier) {
44 | // This is a dynamic method call, let's ignore it
45 | return [];
46 | }
47 |
48 | if ($node->name->name !== 'get') {
49 | // Not a call to `ContainerInterface::get()`, ignore it
50 | return [];
51 | }
52 |
53 | if ($scope->isInClass()
54 | && $this->determination->isController($scope->getClassReflection())) {
55 | /*
56 | * We're allowed to call `ContainerInterface::get()`
57 | * inside controllers
58 | */
59 | return [];
60 | }
61 |
62 | return [RuleErrorBuilder::message('Don\'t use the container as a service locator')->build()];
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/NoEntityInTemplateRule.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | final class NoEntityInTemplateRule implements Rule
21 | {
22 | public function __construct(
23 | private readonly EntityDetermination $determine,
24 | ) {
25 | }
26 |
27 | public function getNodeType(): string
28 | {
29 | return MethodCall::class;
30 | }
31 |
32 | /**
33 | * @param MethodCall $node
34 | */
35 | public function processNode(Node $node, Scope $scope): array
36 | {
37 | $twigEnvironment = new ObjectType(Environment::class);
38 | if (! $twigEnvironment
39 | ->isSuperTypeOf($scope->getType($node->var))
40 | ->yes()) {
41 | // The object is not a Twig `Environment` instance
42 | return [];
43 | }
44 |
45 | if (! $node->name instanceof Identifier
46 | || $node->name->toString() !== 'render') {
47 | // The method is called dynamically, or is not `render()`
48 | return [];
49 | }
50 |
51 | if (! isset($node->getArgs()[1])) {
52 | // The method call has no second argument
53 | return [];
54 | }
55 |
56 | $templateVars = $node->getArgs()[1]
57 | ->value;
58 |
59 | $arrayType = $scope->getType($templateVars);
60 | if (! $arrayType instanceof ConstantArrayType) {
61 | return [];
62 | }
63 |
64 | $valueTypes = $arrayType->getValueTypes();
65 |
66 | $errors = [];
67 |
68 | foreach ($valueTypes as $valueType) {
69 | if (! $valueType instanceof ObjectType) {
70 | continue;
71 | }
72 |
73 | if (
74 | $this->determine->isEntity($valueType->getClassReflection())
75 | ) {
76 | $errors[] = RuleErrorBuilder::message(
77 | sprintf(
78 | 'Entity of type %s should not ' .
79 | 'be passed to a template',
80 | $valueType->getClassName(),
81 | ),
82 | )->build();
83 | }
84 | }
85 |
86 | return $errors;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/NoErrorSilencingRule.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | final class NoErrorSilencingRule implements Rule
17 | {
18 | public function getNodeType(): string
19 | {
20 | return ErrorSuppress::class;
21 | }
22 |
23 | public function processNode(Node $node, Scope $scope): array
24 | {
25 | return [RuleErrorBuilder::message('You should not use the silencing operator (@)')->build()];
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/OneActionPerControllerRule.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | final class OneActionPerControllerRule implements Rule
18 | {
19 | public function __construct(
20 | private readonly ControllerDetermination $determination,
21 | ) {
22 | }
23 |
24 | public function getNodeType(): string
25 | {
26 | return InClassNode::class;
27 | }
28 |
29 | /**
30 | * @param InClassNode $node
31 | */
32 | public function processNode(Node $node, Scope $scope): array
33 | {
34 | if (
35 | ! $this->determination->isController($node->getClassReflection())
36 | ) {
37 | // This class is not a controller
38 | return [];
39 | }
40 |
41 | $actionMethods = array_filter(
42 | $node->getOriginalNode()
43 | ->getMethods(),
44 | fn (ClassMethod $method) => $method->isPublic()
45 | );
46 |
47 | if (count($actionMethods) > 1) {
48 | return [
49 | RuleErrorBuilder::message(
50 | sprintf('Controller %s should have only one action', $scope->getClassReflection()->getName(),)
51 | )->build(),
52 | ];
53 | }
54 |
55 | return [];
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/UseEmailValidatorRule.php:
--------------------------------------------------------------------------------
1 |
20 | */
21 | final class UseEmailValidatorRule implements Rule
22 | {
23 | public function getNodeType(): string
24 | {
25 | return MethodCall::class;
26 | }
27 |
28 | /**
29 | * @param MethodCall $node
30 | */
31 | public function processNode(Node $node, Scope $scope): array
32 | {
33 | if (! $this->isCallToRequestValidate($node, $scope)) {
34 | return [];
35 | }
36 |
37 | if (count($node->getArgs()) === 0) {
38 | // No arguments provided, nothing to analyze
39 | return [];
40 | }
41 |
42 | $firstArgumentNode = $node->getArgs()[0];
43 | $firstArgumentType = $scope->getType($firstArgumentNode->value);
44 |
45 | if (! $firstArgumentType instanceof ConstantArrayType) {
46 | // No constant array provided, we can't analyze this
47 | return [];
48 | }
49 |
50 | foreach ($firstArgumentType->getValueTypes() as $valueType) {
51 | if (! $valueType instanceof ConstantStringType) {
52 | // The value is not a plain string
53 | continue;
54 | }
55 |
56 | $parts = explode('|', $valueType->getValue());
57 | if (! in_array('email', $parts, true)) {
58 | // The string doesn't contain 'email'
59 | continue;
60 | }
61 |
62 | // Return an error on the first use of "email":
63 |
64 | return [RuleErrorBuilder::message('Use App\Models\Email::validator() instead of "email"')->build()];
65 | }
66 |
67 | return [];
68 | }
69 |
70 | private function isCallToRequestValidate(MethodCall $node, Scope $scope): bool
71 | {
72 | if (! (new ObjectType(Request::class))
73 | ->isSuperTypeOf($scope->getType($node->var))
74 | ->yes()) {
75 | // The object is not an instance of `Request`
76 | return false;
77 | }
78 |
79 | if (! $node->name instanceof Identifier) {
80 | // It's a dynamic method call, we can't analyze it
81 | return false;
82 | }
83 |
84 | return $node->name->toString() === 'validate';
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ActionAllowedParameterRule/ActionAllowedParameterRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
18 | [__DIR__ . '/Fixtures/parameter-type-not-allowed.php'],
19 | [
20 | [
21 | 'Controller actions can only have parameters of type "Symfony\Component\HttpFoundation\Request"',
22 | 11,
23 | ],
24 | ]
25 | );
26 | }
27 |
28 | public function testSkipNotAPublicMethod(): void
29 | {
30 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-public-method.php'], []);
31 | }
32 |
33 | public function testSkipNotAController(): void
34 | {
35 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-controller.php'], []);
36 | }
37 |
38 | public function testActionOnlyHasAllowedParameter(): void
39 | {
40 | $this->analyse([__DIR__ . '/Fixtures/skip-action-only-has-allowed-parameter.php'], []);
41 | }
42 |
43 | protected function getRule(): Rule
44 | {
45 | return new ActionAllowedParameterRule(Request::class, new DeterminationBasedOnSuffix('Controller'),);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ActionAllowedParameterRule/ActionReturnsResponseRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
18 | [__DIR__ . '/Fixtures/action-does-not-return-response.php'],
19 | [['Method SomeController::someAction() should return Symfony\Component\HttpFoundation\Response', 9]]
20 | );
21 | }
22 |
23 | public function testSkipNotAPublicMethod(): void
24 | {
25 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-public-method.php'], []);
26 | }
27 |
28 | public function testSkipNotAController(): void
29 | {
30 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-controller.php'], []);
31 | }
32 |
33 | public function testSkipActionReturnsResponse(): void
34 | {
35 | $this->analyse([__DIR__ . '/Fixtures/skip-action-returns-response.php'], []);
36 | }
37 |
38 | protected function getRule(): Rule
39 | {
40 | return new ActionReturnsResponseRule(Response::class, new DeterminationBasedOnSuffix('Controller'),);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ActionAllowedParameterRule/Fixtures/action-does-not-return-response.php:
--------------------------------------------------------------------------------
1 | analyse(
17 | [__DIR__ . '/Fixtures/controller-has-more-than-one-action.php'],
18 | [['Controller SomeController should have only one action', 8]]
19 | );
20 | }
21 |
22 | public function testSkipControllerHasOneAction(): void
23 | {
24 | $this->analyse([__DIR__ . '/Fixtures/skip-controller-has-one-action.php'], []);
25 | }
26 |
27 | public function testSkipControllerHasNoActions(): void
28 | {
29 | $this->analyse([__DIR__ . '/Fixtures/skip-controller-has-no-actions.php'], []);
30 | }
31 |
32 | public function testSkipNotAController(): void
33 | {
34 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-controller.php'], []);
35 | }
36 |
37 | public function testSkipControllerHasPrivateMethods(): void
38 | {
39 | $this->analyse([__DIR__ . '/Fixtures/skip-controller-has-private-methods.php'], []);
40 | }
41 |
42 | protected function getRule(): Rule
43 | {
44 | return new OneActionPerControllerRule(new DeterminationBasedOnSuffix('Controller'),);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/AutoIncrementingModelIdRule/AutoIncrementingModelIdRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/ModelHasAutoIncrementingId.php'],
17 | [['This model has an auto-incrementing ID', 9]]
18 | );
19 | }
20 |
21 | public function testModelHasImplicitAutoIncrementingId(): void
22 | {
23 | $this->analyse(
24 | [__DIR__ . '/Fixtures/ModelHasImplicitAutoIncrementingId.php'],
25 | [['This model has an auto-incrementing ID', 9]]
26 | );
27 | }
28 |
29 | public function testSkipModelHasNonAutoIncrementingId(): void
30 | {
31 | $this->analyse([__DIR__ . '/Fixtures/SkipModelHasNonAutoIncrementingId.php'], []);
32 | }
33 |
34 | public function testSkipNotAModel(): void
35 | {
36 | $this->analyse([__DIR__ . '/Fixtures/SkipNotAModel.php'], []);
37 | }
38 |
39 | protected function getRule(): Rule
40 | {
41 | return new AutoIncrementingModelIdRule();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/AutoIncrementingModelIdRule/Fixtures/ModelHasAutoIncrementingId.php:
--------------------------------------------------------------------------------
1 | analyse(
17 | [__DIR__ . '/Fixtures/class-implements-forbidden-interface.php'],
18 | [['Class implements forbidden interface PHPUnit\Runner\BeforeFirstTestHook', 9]]
19 | );
20 | }
21 |
22 | public function testSkipClassImplementsAllowedInterface(): void
23 | {
24 | $this->analyse([__DIR__ . '/Fixtures/skip-class-implements-allowed-interface.php'], []);
25 | }
26 |
27 | protected function getRule(): Rule
28 | {
29 | return new ForbiddenImplementsRule(BeforeFirstTestHook::class);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenMethodCallRule/Fixtures/CallToForbiddenMethod.php:
--------------------------------------------------------------------------------
1 | createMock();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenMethodCallRule/Fixtures/SkipDifferentClass.php:
--------------------------------------------------------------------------------
1 | createMock();
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenMethodCallRule/Fixtures/SkipDifferentMethod.php:
--------------------------------------------------------------------------------
1 | assertTrue();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenMethodCallRule/ForbiddenMethodCallRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
17 | [__DIR__ . '/Fixtures/CallToForbiddenMethod.php'],
18 | [['Call to forbidden method PHPUnit\Framework\TestCase::createMock()', 13]]
19 | );
20 | }
21 |
22 | public function testSkipDifferentMethod(): void
23 | {
24 | $this->analyse([__DIR__ . '/Fixtures/SkipDifferentMethod.php'], []);
25 | }
26 |
27 | public function testSkipDifferentClass(): void
28 | {
29 | $this->analyse([__DIR__ . '/Fixtures/SkipDifferentClass.php'], []);
30 | }
31 |
32 | protected function getRule(): Rule
33 | {
34 | return new ForbiddenMethodCallRule(TestCase::class, 'createMock',);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenObjectTypeInCommandRule/Fixtures/AnActualCommand.php:
--------------------------------------------------------------------------------
1 | getArgument('arg');
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenObjectTypeInCommandRule/Fixtures/NotACommand.php:
--------------------------------------------------------------------------------
1 | getArgument('arg');
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenObjectTypeInCommandRule/ForbiddenObjectTypeInCommandRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
17 | [__DIR__ . '/Fixtures/NotACommand.php'],
18 | [
19 | [
20 | 'Object of type Symfony\Component\Console\Input\InputInterface is used in a class that does not extend Symfony\Component\Console\Command\Command',
21 | 13,
22 | ],
23 | ]
24 | );
25 | }
26 |
27 | public function testSkipInputOutputUsedInCommand(): void
28 | {
29 | $this->analyse([__DIR__ . '/Fixtures/AnActualCommand.php'], []);
30 | }
31 |
32 | protected function getRule(): Rule
33 | {
34 | return new ForbiddenObjectTypeInCommandRule(InputInterface::class);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenOverrideRule/Fixtures/class-has-forbidden-override.php:
--------------------------------------------------------------------------------
1 | analyse(
17 | [__DIR__ . '/Fixtures/class-has-forbidden-override.php'],
18 | [['Overriding method PHPUnit\Framework\TestCase::setUpBeforeClass() is not allowed', 11]]
19 | );
20 | }
21 |
22 | public function testSkipAncestorDoesNotMatch(): void
23 | {
24 | $this->analyse([__DIR__ . '/Fixtures/skip-ancestor-does-not-match.php'], []);
25 | }
26 |
27 | public function testSkipMethodNameDoesNotMatch(): void
28 | {
29 | $this->analyse([__DIR__ . '/Fixtures/skip-method-name-does-not-match.php'], []);
30 | }
31 |
32 | protected function getRule(): Rule
33 | {
34 | return new ForbiddenOverrideRule(TestCase::class, 'setUpBeforeClass');
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenParentClassRule/Fixtures/extends-abstract-controller.php:
--------------------------------------------------------------------------------
1 | analyse(
17 | [__DIR__ . '/Fixtures/extends-abstract-controller.php'],
18 | [['Parent class Symfony\Bundle\FrameworkBundle\Controller\AbstractController is forbidden', 7]]
19 | );
20 | }
21 |
22 | public function testExtendsNothing(): void
23 | {
24 | $this->analyse([__DIR__ . '/Fixtures/skip-class-extends-nothing.php'], []);
25 | }
26 |
27 | public function testExtendsSomethingElse(): void
28 | {
29 | $this->analyse([__DIR__ . '/Fixtures/skip-class-extends-something-else.php'], []);
30 | }
31 |
32 | protected function getRule(): Rule
33 | {
34 | return new ForbiddenParentClassRule(AbstractController::class,);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/okay.html.twig:
--------------------------------------------------------------------------------
1 | {{ date(okay) }}
2 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-not-a-call-to-render.php:
--------------------------------------------------------------------------------
1 | twig->load(
13 | 'uses-forbidden-var.html.twig',
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-not-a-call-to-twig-environment.php:
--------------------------------------------------------------------------------
1 | twig->render(
11 | 'uses-forbidden-var.html.twig',
12 | );
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-not-a-constant-string.php:
--------------------------------------------------------------------------------
1 | twig->load(
13 | $template,
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-template-is-okay.php:
--------------------------------------------------------------------------------
1 | twig->render(
13 | 'okay.html.twig',
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/twig-template-uses-forbidden-function.php:
--------------------------------------------------------------------------------
1 | twig->render(
13 | 'uses-forbidden-function.html.twig',
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/uses-forbidden-function.html.twig:
--------------------------------------------------------------------------------
1 | {{ dump(something) }}
2 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigFunctionsRule/ForbiddenTwigFunctionsRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/twig-template-uses-forbidden-function.php'],
17 | [['Template uses forbidden function dump', 1]]
18 | );
19 | }
20 |
21 | public function testSkipNotACallToTwigEnvironment(): void
22 | {
23 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-twig-environment.php'], []);
24 | }
25 |
26 | public function testSkipNotACallToRender(): void
27 | {
28 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-render.php'], []);
29 | }
30 |
31 | public function testSkipNotAConstantString(): void
32 | {
33 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-constant-string.php'], []);
34 | }
35 |
36 | public function testTemplateIsOkay(): void
37 | {
38 | $this->analyse([__DIR__ . '/Fixtures/skip-template-is-okay.php'], []);
39 | }
40 |
41 | protected function getRule(): Rule
42 | {
43 | return new ForbiddenTwigFunctionsRule(__DIR__ . '/Fixtures', ['dump']);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/okay.html.twig:
--------------------------------------------------------------------------------
1 | {{ okay.userIdentifier }}
2 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-not-a-call-to-render.php:
--------------------------------------------------------------------------------
1 | twig->load(
13 | 'uses-forbidden-var.html.twig',
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-not-a-call-to-twig-environment.php:
--------------------------------------------------------------------------------
1 | twig->render(
11 | 'uses-forbidden-var.html.twig',
12 | );
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-not-a-constant-string.php:
--------------------------------------------------------------------------------
1 | twig->load(
13 | $template,
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-template-is-okay.php:
--------------------------------------------------------------------------------
1 | twig->render(
13 | 'okay.html.twig',
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/twig-template-uses-forbidden-var.php:
--------------------------------------------------------------------------------
1 | twig->render(
13 | 'uses-forbidden-var.html.twig',
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/uses-forbidden-var.html.twig:
--------------------------------------------------------------------------------
1 | {{ app.user.userIdentifier }}
2 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ForbiddenTwigVarsRule/ForbiddenTwigVarsRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/twig-template-uses-forbidden-var.php'],
17 | [['Template uses forbidden var app', 1]]
18 | );
19 | }
20 |
21 | public function testSkipNotACallToTwigEnvironment(): void
22 | {
23 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-twig-environment.php'], []);
24 | }
25 |
26 | public function testSkipNotACallToRender(): void
27 | {
28 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-render.php'], []);
29 | }
30 |
31 | public function testSkipNotAConstantString(): void
32 | {
33 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-constant-string.php'], []);
34 | }
35 |
36 | public function testTemplateIsOkay(): void
37 | {
38 | $this->analyse([__DIR__ . '/Fixtures/skip-template-is-okay.php'], []);
39 | }
40 |
41 | protected function getRule(): Rule
42 | {
43 | return new ForbiddenTwigVarsRule(__DIR__ . '/Fixtures', ['app']);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/FriendRule/Fixtures/ATrueFriend.php:
--------------------------------------------------------------------------------
1 | internalMethod();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/FriendRule/Fixtures/ClassWithFriendAttribute.php:
--------------------------------------------------------------------------------
1 | internalMethod();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/FriendRule/FriendRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/NotAFriend.php'],
17 | [
18 | [
19 | 'Method call Utils\PHPStan\Tests\FriendRule\Fixtures\ClassWithFriendAttribute::internalMethod() is only allowed inside friend classes',
20 | 13,
21 | ],
22 | ]
23 | );
24 | }
25 |
26 | public function testSkipATrueFriend(): void
27 | {
28 | $this->analyse([__DIR__ . '/Fixtures/ATrueFriend.php'], []);
29 | }
30 |
31 | public static function getAdditionalConfigFiles(): array
32 | {
33 | return [__DIR__ . '/../../src/FriendRule.neon'];
34 | }
35 |
36 | protected function getRule(): Rule
37 | {
38 | return self::getContainer()->getByType(FriendRule::class);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/ModelCreatedWithArrayRule/Fixtures/AModel.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/model-created-with-array.php'],
17 | [['Model is created with an array argument, use explicit arguments instead', 9]]
18 | );
19 | }
20 |
21 | public function testModelCreatedWithNoArguments(): void
22 | {
23 | $this->analyse(
24 | [__DIR__ . '/Fixtures/model-created-with-no-arguments.php'],
25 | [['Model is created with no arguments, use explicit arguments instead', 7]]
26 | );
27 | }
28 |
29 | public function testSkipModelCreatedWithMultipleArguments(): void
30 | {
31 | $this->analyse([__DIR__ . '/Fixtures/skip-model-created-with-multiple-arguments.php'], []);
32 | }
33 |
34 | public function testSkipModelCreatedWithNonArrayArgument(): void
35 | {
36 | $this->analyse([__DIR__ . '/Fixtures/skip-model-created-with-non-array-argument.php'], []);
37 | }
38 |
39 | public function testSkipNotAModel(): void
40 | {
41 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-model.php'], []);
42 | }
43 |
44 | public function testSkipNotCreate(): void
45 | {
46 | $this->analyse([__DIR__ . '/Fixtures/skip-not-create.php'], []);
47 | }
48 |
49 | protected function getRule(): Rule
50 | {
51 | return new ModelCreatedWithArrayRule();
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoContainerGetRule/Fixtures/container-get.php:
--------------------------------------------------------------------------------
1 | get('logger');
10 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoContainerGetRule/Fixtures/skip-container-get-in-controller.php:
--------------------------------------------------------------------------------
1 | container->get('logger');
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoContainerGetRule/Fixtures/skip-different-method.php:
--------------------------------------------------------------------------------
1 | set('logger', null);
10 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoContainerGetRule/Fixtures/skip-different-object.php:
--------------------------------------------------------------------------------
1 | get('logger');
8 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoContainerGetRule/NoContainerGetRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/container-get.php'],
17 | [['Don\'t use the container as a service locator', 9]]
18 | );
19 | }
20 |
21 | public function testSkipOtherMethods(): void
22 | {
23 | $this->analyse(
24 | [__DIR__ . '/Fixtures/skip-different-method.php'],
25 | [
26 | // we expect no errors
27 | ]
28 | );
29 | }
30 |
31 | public function testSkipOtherObjects(): void
32 | {
33 | $this->analyse(
34 | [__DIR__ . '/Fixtures/skip-different-object.php'],
35 | [
36 | // we expect no errors
37 | ]
38 | );
39 | }
40 |
41 | public function testSkipContainerGetInController(): void
42 | {
43 | $this->analyse(
44 | [__DIR__ . '/Fixtures/skip-container-get-in-controller.php'],
45 | [
46 | // we expect no errors
47 | ]
48 | );
49 | }
50 |
51 | public static function getAdditionalConfigFiles(): array
52 | {
53 | return [__DIR__ . '/phpstan.neon'];
54 | }
55 |
56 | protected function getRule(): Rule
57 | {
58 | return self::getContainer()->getByType(NoContainerGetRule::class,);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoContainerGetRule/phpstan.neon:
--------------------------------------------------------------------------------
1 | services:
2 | -
3 | class: Utils\PHPStan\NoContainerGetRule
4 | tags:
5 | - phpstan.rules.rule
6 | -
7 | class: Utils\PHPStan\DeterminationBasedOnSuffix
8 | arguments:
9 | suffix: "Controller"
10 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/AnEntity.php:
--------------------------------------------------------------------------------
1 | twig->render(
14 | 'template.html.twig',
15 | [
16 | 'anEntity' => new AnEntity(),
17 | ]
18 | );
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-no-entity-passed.php:
--------------------------------------------------------------------------------
1 | twig->render(
13 | 'template.html.twig',
14 | [
15 | 'notAnEntity' => new DateTimeImmutable(),
16 | ]
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-not-a-call-to-render.php:
--------------------------------------------------------------------------------
1 | twig->load(
13 | 'uses-forbidden-var.html.twig',
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-not-a-call-to-twig-environment.php:
--------------------------------------------------------------------------------
1 | twig->render(
11 | 'uses-forbidden-var.html.twig',
12 | );
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-not-a-constant-string.php:
--------------------------------------------------------------------------------
1 | twig->load(
13 | $template,
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoEntityInTemplateRule/NoEntityInTemplateRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
17 | [__DIR__ . '/Fixtures/entity-passed-to-twig.php'],
18 | [[
19 | 'Entity of type Utils\PHPStan\Tests\NoEntityInTemplateRule\Fixtures\AnEntity should not be passed to a template',
20 | 13,
21 | ]]
22 | );
23 | }
24 |
25 | public function testSkipNoEntityPassed(): void
26 | {
27 | $this->analyse([__DIR__ . '/Fixtures/skip-no-entity-passed.php'], []);
28 | }
29 |
30 | public function testSkipNotACallToTwigEnvironment(): void
31 | {
32 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-twig-environment.php'], []);
33 | }
34 |
35 | public function testSkipNotACallToRender(): void
36 | {
37 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-render.php'], []);
38 | }
39 |
40 | public function testSkipNotAConstantString(): void
41 | {
42 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-constant-string.php'], []);
43 | }
44 |
45 | protected function getRule(): Rule
46 | {
47 | return new NoEntityInTemplateRule(new DoctrineEntityWithAnnotation());
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/NoErrorSilencingRule/Fixtures/error-silencing.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/error-silencing.php'],
17 | [['You should not use the silencing operator (@)', 5]]
18 | );
19 | }
20 |
21 | protected function getRule(): Rule
22 | {
23 | return new NoErrorSilencingRule();
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/UseEmailValidatorRule/Fixtures/skip-validate-has-no-strings.php:
--------------------------------------------------------------------------------
1 | validate(
13 | [
14 | 'email_address' => Email::validator()
15 | ]
16 | );
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/UseEmailValidatorRule/Fixtures/skip-validate-string-does-not-contain-email.php:
--------------------------------------------------------------------------------
1 | validate(
12 | [
13 | 'email_address' => 'required'
14 | ]
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/UseEmailValidatorRule/Fixtures/validate-string-contains-email.php:
--------------------------------------------------------------------------------
1 | validate(
12 | [
13 | 'email_address' => 'required|email'
14 | ]
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/UseEmailValidatorRule/UseEmailValidatorRuleTest.php:
--------------------------------------------------------------------------------
1 | analyse(
16 | [__DIR__ . '/Fixtures/validate-string-contains-email.php'],
17 | [['Use App\Models\Email::validator() instead of "email"', 11]]
18 | );
19 | }
20 |
21 | public function testSkipValidateHasNoStrings(): void
22 | {
23 | $this->analyse([__DIR__ . '/Fixtures/skip-validate-has-no-strings.php'], []);
24 | }
25 |
26 | public function testSkipValidateStringDoesNotContainEmail(): void
27 | {
28 | $this->analyse([__DIR__ . '/Fixtures/skip-validate-string-does-not-contain-email.php'], []);
29 | }
30 |
31 | protected function getRule(): Rule
32 | {
33 | return new UseEmailValidatorRule();
34 | }
35 | }
36 |
--------------------------------------------------------------------------------