Skip to content

Commit

Permalink
Add ClassDependencyTree rule (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasVotruba committed Jan 3, 2024
1 parent 6de2d13 commit 0362504
Show file tree
Hide file tree
Showing 21 changed files with 387 additions and 24 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/code_analysis.yaml
Expand Up @@ -40,7 +40,7 @@ jobs:

- uses: shivammathur/setup-php@v2
with:
php-version: 8.1
php-version: 8.2
coverage: none

# composer install cache - https://github.com/ramsey/composer-install
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/downgraded_release.yaml
Expand Up @@ -16,7 +16,7 @@ jobs:
-
uses: "shivammathur/setup-php@v2"
with:
php-version: 8.1
php-version: 8.2
coverage: none

- uses: "ramsey/composer-install@v2"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/rector.yaml
Expand Up @@ -21,7 +21,7 @@ jobs:
-
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
php-version: 8.2
coverage: none

- uses: "ramsey/composer-install@v2"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/various_php_install.yaml
Expand Up @@ -14,8 +14,11 @@ jobs:
matrix:
php:
- 7.2
- 7.4
- 8.0
- 8.1
- 8.2
- 8.3

name: "Install on PHP ${{ matrix.php }}"

Expand Down
27 changes: 27 additions & 0 deletions README.md
Expand Up @@ -54,3 +54,30 @@ parameters:
class: 50
function: 8
```

<br>

## Detect complex Class Dependency Trees

In classes like controllers, Rector rules, PHPStan rules or other services of specific type, the complexity can be hidden in the __construct() dependencies. Simple class with 10 dependencies is more complex than complex class with 2 dependencies.

That's why there is a rule to detect these dependency trees. It checks:

* complexity of **current class**
* **constructor dependencies and their class complexity** together

Final number is compared and used as a final complexity:

```neon
# phpstan.neon
parameters:
cognitive_complexity:
dependency_tree: 150
dependency_tree_types:
# only these explicit types are checked, nothing else
- Rector\Contract\Rector\RectorInterface
```

<br>

Happy coding!
8 changes: 3 additions & 5 deletions composer.json
Expand Up @@ -4,18 +4,16 @@
"description": "PHPStan rules to measure cognitive complexity of your classes and methods",
"license": "MIT",
"require": {
"php": "^8.1",
"phpstan/phpstan": "^1.10"
"php": "^8.2",
"phpstan/phpstan": "^1.10.50"
},
"require-dev": {
"phpstan/extension-installer": "^1.3",
"phpunit/phpunit": "^10.3",
"symplify/easy-coding-standard": "^12.0",
"rector/rector": "^0.18",
"tracy/tracy": "^2.9",
"php-parallel-lint/php-parallel-lint": "^1.3",
"tomasvotruba/type-coverage": "^0.2",
"tomasvotruba/unused-public": "^0.3"
"php-parallel-lint/php-parallel-lint": "^1.3"
},
"autoload": {
"psr-4": {
Expand Down
6 changes: 6 additions & 0 deletions config/extension.neon
Expand Up @@ -2,13 +2,17 @@ parametersSchema:
cognitive_complexity: structure([
class: int()
function: int()
dependency_tree: int()
dependency_tree_types: array()
])

# default parameters
parameters:
cognitive_complexity:
class: 40
function: 9
dependency_tree: 150
dependency_tree_types: []

services:
- TomasVotruba\CognitiveComplexity\DataCollector\CognitiveComplexityDataCollector
Expand All @@ -17,6 +21,7 @@ services:
- TomasVotruba\CognitiveComplexity\NodeVisitor\NestingNodeVisitor
- TomasVotruba\CognitiveComplexity\NodeVisitor\ComplexityNodeVisitor
- TomasVotruba\CognitiveComplexity\NodeAnalyzer\ComplexityAffectingNodeFinder
- TomasVotruba\CognitiveComplexity\ClassReflectionParser

-
factory: TomasVotruba\CognitiveComplexity\Configuration
Expand All @@ -26,4 +31,5 @@ services:
rules:
- TomasVotruba\CognitiveComplexity\Rules\ClassLikeCognitiveComplexityRule
- TomasVotruba\CognitiveComplexity\Rules\FunctionLikeCognitiveComplexityRule
- TomasVotruba\CognitiveComplexity\Rules\ClassDependencyTreeRule

2 changes: 1 addition & 1 deletion rector.php
Expand Up @@ -16,7 +16,7 @@

$rectorConfig->sets([
\Rector\PHPUnit\Set\PHPUnitSetList::PHPUNIT_100,
LevelSetList::UP_TO_PHP_81,
LevelSetList::UP_TO_PHP_82,
SetList::TYPE_DECLARATION,
SetList::PRIVATIZATION,
SetList::NAMING,
Expand Down
8 changes: 4 additions & 4 deletions src/AstCognitiveComplexityAnalyzer.php
Expand Up @@ -16,12 +16,12 @@
*
* implements the concept described in https://www.sonarsource.com/resources/white-papers/cognitive-complexity/
*/
final class AstCognitiveComplexityAnalyzer
final readonly class AstCognitiveComplexityAnalyzer
{
public function __construct(
private readonly ComplexityNodeTraverserFactory $complexityNodeTraverserFactory,
private readonly CognitiveComplexityDataCollector $cognitiveComplexityDataCollector,
private readonly NestingNodeVisitor $nestingNodeVisitor
private ComplexityNodeTraverserFactory $complexityNodeTraverserFactory,
private CognitiveComplexityDataCollector $cognitiveComplexityDataCollector,
private NestingNodeVisitor $nestingNodeVisitor
) {
}

Expand Down
49 changes: 49 additions & 0 deletions src/ClassReflectionParser.php
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace TomasVotruba\CognitiveComplexity;

use PhpParser\Node\Stmt\Class_;
use PhpParser\NodeFinder;
use PhpParser\Parser;
use PhpParser\ParserFactory;
use PHPStan\Reflection\ClassReflection;

final readonly class ClassReflectionParser
{
private Parser $phpParser;

private NodeFinder $nodeFinder;

public function __construct()
{
$parserFactory = new ParserFactory();
$this->phpParser = $parserFactory->create(ParserFactory::PREFER_PHP7);

$this->nodeFinder = new NodeFinder();
}

public function parse(ClassReflection $classReflection): ?Class_
{
$fileName = $classReflection->getFileName();
if (! is_string($fileName)) {
return null;
}

/** @var string $fileContents */
$fileContents = file_get_contents($fileName);

$stmts = $this->phpParser->parse($fileContents);
if ($stmts === null) {
return null;
}

$foundClass = $this->nodeFinder->findFirstInstanceOf($stmts, Class_::class);
if (! $foundClass instanceof Class_) {
return null;
}

return $foundClass;
}
}
22 changes: 20 additions & 2 deletions src/Configuration.php
Expand Up @@ -4,13 +4,13 @@

namespace TomasVotruba\CognitiveComplexity;

final class Configuration
final readonly class Configuration
{
/**
* @param array<string, mixed> $parameters
*/
public function __construct(
private readonly array $parameters
private array $parameters
) {
}

Expand All @@ -23,4 +23,22 @@ public function getMaxFunctionCognitiveComplexity(): int
{
return $this->parameters['function'];
}

/**
* @return string[]
*/
public function getDependencyTreeTypes(): array
{
return $this->parameters['dependency_tree_types'] ?? [];
}

public function getMaxDependencyTreeComplexity(): int
{
return $this->parameters['dependency_tree'];
}

public function isDependencyTreeEnabled(): bool
{
return $this->getDependencyTreeTypes() !== [];
}
}
6 changes: 3 additions & 3 deletions src/NodeTraverser/ComplexityNodeTraverserFactory.php
Expand Up @@ -8,11 +8,11 @@
use TomasVotruba\CognitiveComplexity\NodeVisitor\ComplexityNodeVisitor;
use TomasVotruba\CognitiveComplexity\NodeVisitor\NestingNodeVisitor;

final class ComplexityNodeTraverserFactory
final readonly class ComplexityNodeTraverserFactory
{
public function __construct(
private readonly NestingNodeVisitor $nestingNodeVisitor,
private readonly ComplexityNodeVisitor $complexityNodeVisitor
private NestingNodeVisitor $nestingNodeVisitor,
private ComplexityNodeVisitor $complexityNodeVisitor
) {
}

Expand Down
126 changes: 126 additions & 0 deletions src/Rules/ClassDependencyTreeRule.php
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace TomasVotruba\CognitiveComplexity\Rules;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Rules\Rule;
use PHPStan\Type\TypeWithClassName;
use TomasVotruba\CognitiveComplexity\AstCognitiveComplexityAnalyzer;
use TomasVotruba\CognitiveComplexity\ClassReflectionParser;
use TomasVotruba\CognitiveComplexity\Configuration;

/**
* @implements Rule<InClassNode>
*
* Find classes with complex constructor dependency tree = current class complexity + complexity of all __construct() dependencies.
*/
final readonly class ClassDependencyTreeRule implements Rule
{
/**
* @var string
*/
public const ERROR_MESSAGE = 'Dependency tree complexity %d is over %d. Refactor __construct() dependencies or split up.';

public function __construct(
private AstCognitiveComplexityAnalyzer $astCognitiveComplexityAnalyzer,
private ClassReflectionParser $classReflectionParser,
private Configuration $configuration
) {
}

public function getNodeType(): string
{
return InClassNode::class;
}

/**
* @param InClassNode $node
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isDependencyTreeEnabled()) {
return [];
}

$classReflection = $node->getClassReflection();

// nothing to check
if (! $classReflection->hasConstructor()) {
return [];
}

// only check
$originalClassLike = $node->getOriginalNode();
if (! $originalClassLike instanceof Class_) {
return [];
}

if (! $this->isTypeToAnalyse($classReflection)) {
return [];
}

$extendedMethodReflection = $classReflection->getConstructor();

$parametersAcceptorWithPhpDocs = ParametersAcceptorSelector::selectSingle(
$extendedMethodReflection->getVariants()
);

$totaDependencyTreeComplexity = $this->astCognitiveComplexityAnalyzer->analyzeClassLike($originalClassLike);

foreach ($parametersAcceptorWithPhpDocs->getParameters() as $parameterReflectionWithPhpDoc) {
$dependencyClass = $this->resolveParameterTypeClass($parameterReflectionWithPhpDoc);
if (! $dependencyClass instanceof Class_) {
continue;
}

$dependencyComplexity = $this->astCognitiveComplexityAnalyzer->analyzeClassLike($dependencyClass);
$totaDependencyTreeComplexity += $dependencyComplexity;
}

if ($totaDependencyTreeComplexity <= $this->configuration->getMaxDependencyTreeComplexity()) {
return [];
}

return [
sprintf(
self::ERROR_MESSAGE,
$totaDependencyTreeComplexity,
$this->configuration->getMaxDependencyTreeComplexity()
),
];
}

private function isTypeToAnalyse(ClassReflection $classReflection): bool
{
foreach ($this->configuration->getDependencyTreeTypes() as $dependencyTreeType) {
if ($classReflection->isSubclassOf($dependencyTreeType)) {
return true;
}
}

return false;
}

private function resolveParameterTypeClass(ParameterReflection $parameterReflection): ?Class_
{
$parameterType = $parameterReflection->getType();
if (! $parameterType instanceof TypeWithClassName) {
return null;
}

$parameterClassReflection = $parameterType->getClassReflection();
if (! $parameterClassReflection instanceof ClassReflection) {
return null;
}

return $this->classReflectionParser->parse($parameterClassReflection);
}
}

0 comments on commit 0362504

Please sign in to comment.