diff --git a/docs/Understandability.md b/docs/Understandability.md new file mode 100644 index 0000000..0b0e2c4 --- /dev/null +++ b/docs/Understandability.md @@ -0,0 +1,63 @@ +# Understandability (Sonar Cognitive Complexity) + +Understandability measures how hard it is for a human to follow a method’s control flow. It implements **Sonar Cognitive Complexity** as described in SonarSource’s 2023 white paper (*Cognitive Complexity: a new way of measuring understandability*, v1.7). + +This metric is **separate** from this tool’s weighted **Cognitive Complexity** score, which sums logarithmic weights over structural metrics (lines, arguments, `if` count, and so on). Understandability follows Sonar’s rule-based control-flow model instead. + +## Why use it? + +Cyclomatic complexity counts paths through code but treats structures like `switch` and nested loops similarly even when one is much harder to read. Sonar Cognitive Complexity is designed to better match maintainer intuition: it penalizes nested flow breaks, treats `switch` as a single decision, and ignores method calls that shorthand logic. + +## How it is calculated + +Per method, the score follows three rules from the Sonar spec: + +1. **Ignore shorthand** — method calls and null-coalescing are not counted. +2. **Increment for flow breaks** — loops, `if`, ternary, `catch`, `switch`/`match`, logical-operator sequences, recursion, and multi-level `break`/`continue`/`goto`. +3. **Increment for nesting** — each nested flow-breaking structure adds its current nesting depth to the score. + +Increments fall into four categories (each adds to the total, but categories clarify nesting behavior): + +| Category | Examples | +|-------------|-----------------------------------------------| +| Structural | `if`, loops, ternary, `catch`, `switch` | +| Hybrid | `elseif`, `else` (no nesting penalty, but increase nesting level) | +| Fundamental | Logical-operator sequences, recursion, jumps | +| Nesting | Extra points when structures are nested | + +Structural increments use `1 + nestingLevel`; hybrid increments add `1` only. + +## Risk levels + +| Score | Risk | +|-------|-------------| +| 0–5 | low | +| 6–10 | medium | +| 11–15 | high | +| 16+ | very high | + +Console output shows `score (risk)`, for example `7 (medium)`. + +## Configuration + +Understandability is **off by default**. Enable it in `phpcca.yaml`: + +```yaml +cognitive: + showUnderstandability: true +``` + +When enabled, an **Understandability** column appears in console output. It does not affect baselines, file reports, or sorting unless those features are extended separately. + +## Interpretation + +- **Low (≤5)** — easy to follow; usually fine as-is. +- **Medium (6–10)** — worth a closer look during review. +- **High / very high (≥11)** — nested or branching logic is taxing; consider extracting methods or simplifying control flow. + +Use as an indicator, not an absolute rule. Domain logic, parsers, and constructors may legitimately score higher. + +## References + +- [Sonar Cognitive Complexity white paper (2023, v1.7)](https://www.sonarsource.com/resources/cognitive-complexity/) +- [An Empirical Validation of Cognitive Complexity as a Measure of Source Code Understandability](https://arxiv.org/pdf/2007.12520) — Muñoz Barón, Wyrich, Wagner diff --git a/phpcca.yaml b/phpcca.yaml index 5ac88b6..f783aef 100644 --- a/phpcca.yaml +++ b/phpcca.yaml @@ -5,6 +5,7 @@ cognitive: showOnlyMethodsExceedingThreshold: true showHalsteadComplexity: false showCyclomaticComplexity: false + showUnderstandability: false showDetailedCognitiveMetrics: true groupByClass: true metrics: diff --git a/src/Business/Cognitive/CognitiveMetrics.php b/src/Business/Cognitive/CognitiveMetrics.php index b3db2b1..0eb9dc2 100644 --- a/src/Business/Cognitive/CognitiveMetrics.php +++ b/src/Business/Cognitive/CognitiveMetrics.php @@ -6,6 +6,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticMetrics; +use Phauthentic\CognitiveCodeAnalysis\Business\Understandability\UnderstandabilityMetrics; use InvalidArgumentException; use JsonSerializable; @@ -69,6 +70,7 @@ class CognitiveMetrics implements JsonSerializable private ?HalsteadMetrics $halstead = null; private ?CyclomaticMetrics $cyclomatic = null; + private ?UnderstandabilityMetrics $understandability = null; private ?float $coverage = null; /** @@ -105,25 +107,29 @@ public function __construct(array $metrics) ]); } - if (!isset($metrics['cyclomatic_complexity'])) { - // Handle baseline format with individual cyclomatic fields - if (isset($metrics['cyclomaticComplexity']) && !isset($metrics['cyclomatic_complexity'])) { - $riskLevel = $metrics['cyclomaticRiskLevel'] ?? 'unknown'; - $this->cyclomatic = new CyclomaticMetrics([ - 'complexity' => $this->resolveIntValue($metrics['cyclomaticComplexity'], 1), - 'riskLevel' => is_string($riskLevel) ? $riskLevel : 'unknown', - ]); - } - return; + if (isset($metrics['cyclomatic_complexity']) && is_array($metrics['cyclomatic_complexity'])) { + /** @var array $cyclomaticData */ + $cyclomaticData = $metrics['cyclomatic_complexity']; + $this->cyclomatic = new CyclomaticMetrics($cyclomaticData); + } elseif (isset($metrics['cyclomaticComplexity'])) { + $riskLevel = $metrics['cyclomaticRiskLevel'] ?? 'unknown'; + $this->cyclomatic = new CyclomaticMetrics([ + 'complexity' => $this->resolveIntValue($metrics['cyclomaticComplexity'], 1), + 'riskLevel' => is_string($riskLevel) ? $riskLevel : 'unknown', + ]); } - if (!is_array($metrics['cyclomatic_complexity'])) { - return; + if (isset($metrics['understandability']) && is_array($metrics['understandability'])) { + /** @var array $understandabilityData */ + $understandabilityData = $metrics['understandability']; + $this->understandability = new UnderstandabilityMetrics($understandabilityData); + } elseif (isset($metrics['understandabilityComplexity'])) { + $riskLevel = $metrics['understandabilityRiskLevel'] ?? 'unknown'; + $this->understandability = new UnderstandabilityMetrics([ + 'complexity' => $this->resolveIntValue($metrics['understandabilityComplexity'], 0), + 'riskLevel' => is_string($riskLevel) ? $riskLevel : 'unknown', + ]); } - - /** @var array $cyclomaticData */ - $cyclomaticData = $metrics['cyclomatic_complexity']; - $this->cyclomatic = new CyclomaticMetrics($cyclomaticData); } /** @@ -537,6 +543,11 @@ public function getCyclomatic(): ?CyclomaticMetrics return $this->cyclomatic; } + public function getUnderstandability(): ?UnderstandabilityMetrics + { + return $this->understandability; + } + private function resolveStringValue(mixed $value): string { if (!is_string($value)) { diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index 202228e..1d7f0c9 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -23,6 +23,11 @@ */ class CognitiveMetricsCollector { + /** + * Bump when cached analysis_result shape changes so stale entries are ignored. + */ + private const CACHE_VERSION = '1.1'; + /** * @var array */ @@ -322,7 +327,7 @@ private function cacheResult( string $configHash ): void { $cacheItem->set([ - 'version' => '1.0', + 'version' => self::CACHE_VERSION, 'file_path' => $file->getRealPath(), 'file_mtime' => $file->getMTime(), 'config_hash' => $configHash, @@ -363,6 +368,14 @@ private function getCachedMetrics(SplFileInfo $file, string $configHash, bool $u return ['metrics' => null, 'cacheItem' => $cacheItem]; } + if (($cachedData['version'] ?? null) !== self::CACHE_VERSION) { + return ['metrics' => null, 'cacheItem' => $cacheItem]; + } + + if (($cachedData['config_hash'] ?? null) !== $configHash) { + return ['metrics' => null, 'cacheItem' => $cacheItem]; + } + $ignoredItems = $cachedData['ignored_items'] ?? []; $this->ignoredItems = $this->normalizeIgnoredItems($ignoredItems); $this->messageBus->dispatch(new FileProcessed($file)); diff --git a/src/Business/Cognitive/Parser.php b/src/Business/Cognitive/Parser.php index e8d6958..8c54ca0 100644 --- a/src/Business/Cognitive/Parser.php +++ b/src/Business/Cognitive/Parser.php @@ -78,12 +78,15 @@ public function parse(string $code): array $cyclomaticMetrics = $this->combinedVisitor->getMethodComplexity(); /** @var array $halsteadMetrics */ $halsteadMetrics = $this->combinedVisitor->getHalsteadMethodMetrics(); + /** @var array $understandabilityMetrics */ + $understandabilityMetrics = $this->combinedVisitor->getMethodUnderstandability(); // Now reset the combined visitor $this->combinedVisitor->resetAll(); $methodMetrics = $this->mergeCyclomaticMetrics($methodMetrics, $cyclomaticMetrics); $methodMetrics = $this->mergeHalsteadMetrics($methodMetrics, $halsteadMetrics); + $methodMetrics = $this->mergeUnderstandabilityMetrics($methodMetrics, $understandabilityMetrics); /** @var array> $methodMetrics */ return $methodMetrics; @@ -134,6 +137,32 @@ private function mergeHalsteadMetrics(array $methodMetrics, array $halsteadMetri return $methodMetrics; } + /** + * @param array $methodMetrics + * @param array $understandabilityMetrics + * @return array + */ + private function mergeUnderstandabilityMetrics(array $methodMetrics, array $understandabilityMetrics): array + { + foreach ($understandabilityMetrics as $method => $complexityData) { + $methodMetric = $methodMetrics[$method] ?? null; + if (!is_array($methodMetric) || !is_array($complexityData)) { + continue; + } + + $complexity = $complexityData['complexity'] ?? $complexityData; + $riskLevel = $complexityData['risk_level'] ?? 'unknown'; + $methodMetric['understandability'] = [ + 'complexity' => $complexity, + 'risk_level' => is_string($riskLevel) ? $riskLevel : 'unknown', + 'breakdown' => $complexityData['breakdown'] ?? [], + ]; + $methodMetrics[$method] = $methodMetric; + } + + return $methodMetrics; + } + /** * @return array{complexity: int, risk_level: string}|null */ diff --git a/src/Business/Understandability/UnderstandabilityCalculator.php b/src/Business/Understandability/UnderstandabilityCalculator.php new file mode 100644 index 0000000..8e66795 --- /dev/null +++ b/src/Business/Understandability/UnderstandabilityCalculator.php @@ -0,0 +1,70 @@ + $incrementCounts + */ + public function calculateComplexity(array $incrementCounts): int + { + return $incrementCounts['total'] ?? 0; + } + + /** + * @param array $incrementCounts + * @return array + */ + public function createBreakdown(array $incrementCounts, int $totalComplexity): array + { + return array_merge(['total' => $totalComplexity], $incrementCounts); + } + + public function getRiskLevel(int $complexity): string + { + return match (true) { + $complexity <= 5 => 'low', + $complexity <= 10 => 'medium', + $complexity <= 15 => 'high', + default => 'very_high', + }; + } + + /** + * @param array $methodComplexities + * @param array> $methodBreakdowns + * @return array + */ + public function createSummary(array $methodComplexities, array $methodBreakdowns): array + { + $summary = [ + 'methods' => [], + 'high_risk_methods' => [], + 'very_high_risk_methods' => [], + ]; + + foreach ($methodComplexities as $methodKey => $complexity) { + $riskLevel = $this->getRiskLevel($complexity); + $summary['methods'][$methodKey] = [ + 'complexity' => $complexity, + 'risk_level' => $riskLevel, + 'breakdown' => $methodBreakdowns[$methodKey] ?? [], + ]; + + if ($complexity >= 10) { + $summary['high_risk_methods'][$methodKey] = $complexity; + } + + if ($complexity < 15) { + continue; + } + + $summary['very_high_risk_methods'][$methodKey] = $complexity; + } + + return $summary; + } +} diff --git a/src/Business/Understandability/UnderstandabilityCalculatorInterface.php b/src/Business/Understandability/UnderstandabilityCalculatorInterface.php new file mode 100644 index 0000000..2b5c3b5 --- /dev/null +++ b/src/Business/Understandability/UnderstandabilityCalculatorInterface.php @@ -0,0 +1,28 @@ + $incrementCounts + */ + public function calculateComplexity(array $incrementCounts): int; + + /** + * @param array $incrementCounts + * @return array + */ + public function createBreakdown(array $incrementCounts, int $totalComplexity): array; + + public function getRiskLevel(int $complexity): string; + + /** + * @param array $methodComplexities + * @param array> $methodBreakdowns + * @return array + */ + public function createSummary(array $methodComplexities, array $methodBreakdowns): array; +} diff --git a/src/Business/Understandability/UnderstandabilityMetrics.php b/src/Business/Understandability/UnderstandabilityMetrics.php new file mode 100644 index 0000000..f5139ff --- /dev/null +++ b/src/Business/Understandability/UnderstandabilityMetrics.php @@ -0,0 +1,75 @@ + $data + */ + public function __construct(array $data) + { + $breakdown = $data['breakdown'] ?? null; + if (!is_array($breakdown)) { + $breakdown = $data; + } + + /** @var array $breakdown */ + $this->complexity = $this->resolveIntValue($data['complexity'] ?? null, 0); + $this->riskLevel = $this->resolveRiskLevel($data); + $this->structuralCount = $this->resolveCountValue($data, $breakdown, 'structuralCount', 'structural', 0); + $this->hybridCount = $this->resolveCountValue($data, $breakdown, 'hybridCount', 'hybrid', 0); + $this->fundamentalCount = $this->resolveCountValue($data, $breakdown, 'fundamentalCount', 'fundamental', 0); + $this->nestingCount = $this->resolveCountValue($data, $breakdown, 'nestingCount', 'nesting', 0); + $this->recursionCount = $this->resolveCountValue($data, $breakdown, 'recursionCount', 'recursion', 0); + } + + /** + * @param array $data + */ + private function resolveRiskLevel(array $data): string + { + $value = $data['risk_level'] ?? $data['riskLevel'] ?? 'unknown'; + + return is_string($value) ? $value : 'unknown'; + } + + /** + * @param array $data + * @param array $breakdown + */ + private function resolveCountValue( + array $data, + array $breakdown, + string $dataKey, + string $breakdownKey, + int $default + ): int { + if (array_key_exists($dataKey, $data)) { + return $this->resolveIntValue($data[$dataKey], $default); + } + + return $this->resolveIntValue($breakdown[$breakdownKey] ?? null, $default); + } + + private function resolveIntValue(mixed $value, int $default): int + { + return is_int($value) ? $value : $default; + } +} diff --git a/src/Command/Presentation/MetricFormatter.php b/src/Command/Presentation/MetricFormatter.php index 55678a0..8981db0 100644 --- a/src/Command/Presentation/MetricFormatter.php +++ b/src/Command/Presentation/MetricFormatter.php @@ -6,6 +6,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticMetrics; +use Phauthentic\CognitiveCodeAnalysis\Business\Understandability\UnderstandabilityMetrics; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; /** @@ -82,11 +83,36 @@ public function formatCyclomaticComplexity(?CyclomaticMetrics $cyclomatic): stri return $complexity . ' (' . $riskColored . ')'; } + public function formatUnderstandability(?UnderstandabilityMetrics $understandability): string + { + if (!$understandability) { + return '-'; + } + + $complexity = $understandability->complexity; + $risk = $understandability->riskLevel; + if ($risk === '' || $risk === 'unknown') { + return (string)$complexity; + } + + return $complexity . ' (' . $this->colorUnderstandabilityRisk($risk) . ')'; + } + private function colorCyclomaticRisk(string $risk): string + { + return $this->colorRisk($risk); + } + + private function colorUnderstandabilityRisk(string $risk): string + { + return $this->colorRisk($risk); + } + + private function colorRisk(string $risk): string { return match (strtolower($risk)) { 'medium' => '' . $risk . '', - 'high' => '' . $risk . '', + 'high', 'very_high' => '' . $risk . '', default => $risk, }; } diff --git a/src/Command/Presentation/TableHeaderBuilder.php b/src/Command/Presentation/TableHeaderBuilder.php index 1fd79ae..75a1be0 100644 --- a/src/Command/Presentation/TableHeaderBuilder.php +++ b/src/Command/Presentation/TableHeaderBuilder.php @@ -37,6 +37,7 @@ public function getGroupedTableHeaders(): array $fields = $this->addHalsteadHeaders($fields); $fields = $this->addCyclomaticHeaders($fields); + $fields = $this->addUnderstandabilityHeaders($fields); $fields = $this->addCoverageHeader($fields); return $fields; @@ -75,6 +76,7 @@ public function getSingleTableHeaders(): array $fields = $this->addHalsteadHeaders($fields); $fields = $this->addCyclomaticHeaders($fields); + $fields = $this->addUnderstandabilityHeaders($fields); if ($this->hasCoverage) { $fields[] = "Coverage"; @@ -115,6 +117,19 @@ private function addCyclomaticHeaders(array $fields): array return $fields; } + /** + * @param array $fields + * @return array + */ + private function addUnderstandabilityHeaders(array $fields): array + { + if ($this->config->showUnderstandability) { + $fields[] = "Understandability"; + } + + return $fields; + } + /** * @param array $fields * @return array diff --git a/src/Command/Presentation/TableRowBuilder.php b/src/Command/Presentation/TableRowBuilder.php index 0cd93ee..8a3a224 100644 --- a/src/Command/Presentation/TableRowBuilder.php +++ b/src/Command/Presentation/TableRowBuilder.php @@ -8,6 +8,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Delta; use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticMetrics; +use Phauthentic\CognitiveCodeAnalysis\Business\Understandability\UnderstandabilityMetrics; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; @@ -139,6 +140,19 @@ private function addCyclomaticFields(array $fields, ?CyclomaticMetrics $cyclomat return $fields; } + /** + * @param array $fields + * @return array + */ + private function addUnderstandabilityFields(array $fields, ?UnderstandabilityMetrics $understandability): array + { + if ($this->config->showUnderstandability) { + $fields['understandability'] = $this->formatter->formatUnderstandability($understandability); + } + + return $fields; + } + /** * Add weighted value to the row * @@ -267,6 +281,7 @@ private function extracted(array $fields, CognitiveMetrics $metrics): array $fields = $this->addHalsteadFields($fields, $metrics->getHalstead()); $fields = $this->addCyclomaticFields($fields, $metrics->getCyclomatic()); + $fields = $this->addUnderstandabilityFields($fields, $metrics->getUnderstandability()); return $fields; } diff --git a/src/Config/CognitiveConfig.php b/src/Config/CognitiveConfig.php index cf8cf06..9542894 100644 --- a/src/Config/CognitiveConfig.php +++ b/src/Config/CognitiveConfig.php @@ -25,6 +25,7 @@ public function __construct( public readonly float $scoreThreshold, public readonly bool $showHalsteadComplexity = false, public readonly bool $showCyclomaticComplexity = false, + public readonly bool $showUnderstandability = false, public readonly bool $groupByClass = false, public readonly bool $showDetailedCognitiveMetrics = true, public readonly ?CacheConfig $cache = null, @@ -53,6 +54,7 @@ public function toArray(): array 'scoreThreshold' => $this->scoreThreshold, 'showHalsteadComplexity' => $this->showHalsteadComplexity, 'showCyclomaticComplexity' => $this->showCyclomaticComplexity, + 'showUnderstandability' => $this->showUnderstandability, 'groupByClass' => $this->groupByClass, 'showDetailedCognitiveMetrics' => $this->showDetailedCognitiveMetrics, 'cache' => $this->cache?->toArray(), diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php index f20e923..e3a4ea6 100644 --- a/src/Config/ConfigFactory.php +++ b/src/Config/ConfigFactory.php @@ -13,8 +13,9 @@ * showOnlyMethodsExceedingThreshold: bool, * scoreThreshold: float, * showHalsteadComplexity?: bool, - * showCyclomaticComplexity?: bool, - * groupByClass?: bool, + * showCyclomaticComplexity?: bool, + * showUnderstandability?: bool, + * groupByClass?: bool, * showDetailedCognitiveMetrics?: bool, * cache?: array{enabled?: bool, directory?: string}, * performance?: array{batchSize?: int}, @@ -62,6 +63,7 @@ public function fromArray(array $config): CognitiveConfig scoreThreshold: $cognitive['scoreThreshold'], showHalsteadComplexity: $cognitive['showHalsteadComplexity'] ?? false, showCyclomaticComplexity: $cognitive['showCyclomaticComplexity'] ?? false, + showUnderstandability: $cognitive['showUnderstandability'] ?? false, groupByClass: $cognitive['groupByClass'] ?? true, showDetailedCognitiveMetrics: $cognitive['showDetailedCognitiveMetrics'] ?? true, cache: $cacheConfig, diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index f36b8ba..c2559c8 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -105,6 +105,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('showCyclomaticComplexity') ->defaultValue(false) ->end() + ->booleanNode('showUnderstandability') + ->defaultValue(false) + ->end() ->booleanNode('groupByClass') ->defaultValue(true) ->end() diff --git a/src/PhpParser/CombinedMetricsVisitor.php b/src/PhpParser/CombinedMetricsVisitor.php index 3e920a6..369bc09 100644 --- a/src/PhpParser/CombinedMetricsVisitor.php +++ b/src/PhpParser/CombinedMetricsVisitor.php @@ -6,6 +6,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticComplexityCalculator; use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetricsCalculator; +use Phauthentic\CognitiveCodeAnalysis\Business\Understandability\UnderstandabilityCalculator; use PhpParser\Node; use PhpParser\NodeVisitor; @@ -22,6 +23,8 @@ class CombinedMetricsVisitor implements NodeVisitor private HalsteadMetricsVisitor $halsteadVisitor; private HalsteadMetricsCalculator $halsteadCalculator; private CyclomaticComplexityCalculator $cyclomaticCalculator; + private UnderstandabilityVisitor $understandabilityVisitor; + private UnderstandabilityCalculator $understandabilityCalculator; public function __construct() { @@ -29,8 +32,12 @@ public function __construct() $this->cognitiveVisitor = new CognitiveMetricsVisitor(); $this->cyclomaticCalculator = new CyclomaticComplexityCalculator(); $this->cyclomaticVisitor = new CyclomaticComplexityVisitor($this->cyclomaticCalculator); + $this->cyclomaticVisitor->setAnnotationVisitor($this->annotationVisitor); $this->halsteadCalculator = new HalsteadMetricsCalculator(); $this->halsteadVisitor = new HalsteadMetricsVisitor($this->halsteadCalculator); + $this->understandabilityCalculator = new UnderstandabilityCalculator(); + $this->understandabilityVisitor = new UnderstandabilityVisitor($this->understandabilityCalculator); + $this->understandabilityVisitor->setAnnotationVisitor($this->annotationVisitor); } /** @@ -51,13 +58,15 @@ public function enterNode(Node $node): int|Node|null $result2 = $this->cognitiveVisitor->enterNode($node); $result3 = $this->cyclomaticVisitor->enterNode($node); $result4 = $this->halsteadVisitor->enterNode($node); + $result5 = $this->understandabilityVisitor->enterNode($node); // If any visitor wants to skip children, respect that if ( $result1 === NodeVisitor::DONT_TRAVERSE_CHILDREN || $result2 === NodeVisitor::DONT_TRAVERSE_CHILDREN || $result3 === NodeVisitor::DONT_TRAVERSE_CHILDREN || - $result4 === NodeVisitor::DONT_TRAVERSE_CHILDREN + $result4 === NodeVisitor::DONT_TRAVERSE_CHILDREN || + $result5 === NodeVisitor::DONT_TRAVERSE_CHILDREN ) { return NodeVisitor::DONT_TRAVERSE_CHILDREN; } @@ -72,6 +81,7 @@ public function leaveNode(Node $node): int|Node|null $this->cognitiveVisitor->leaveNode($node); $this->cyclomaticVisitor->leaveNode($node); $this->halsteadVisitor->leaveNode($node); + $this->understandabilityVisitor->leaveNode($node); return null; } @@ -93,6 +103,7 @@ public function resetAll(): void $this->cognitiveVisitor->resetValues(); $this->cyclomaticVisitor->resetAll(); $this->halsteadVisitor->resetMetrics(); + $this->understandabilityVisitor->resetAll(); } /** @@ -104,6 +115,7 @@ public function resetAllBetweenFiles(): void $this->cognitiveVisitor->resetAll(); $this->cyclomaticVisitor->resetAll(); $this->halsteadVisitor->resetAll(); + $this->understandabilityVisitor->resetAll(); } /** @@ -132,6 +144,18 @@ public function getHalsteadMethodMetrics(): array return $metrics['methods'] ?? []; } + /** + * Get method understandability (Sonar cognitive complexity) from the understandability visitor. + */ + /** + * @return array + */ + public function getMethodUnderstandability(): array + { + $summary = $this->understandabilityVisitor->getComplexitySummary(); + return $summary['methods'] ?? []; + } + /** * Get ignored items from the annotation visitor. */ diff --git a/src/PhpParser/UnderstandabilityVisitor.php b/src/PhpParser/UnderstandabilityVisitor.php new file mode 100644 index 0000000..ced7108 --- /dev/null +++ b/src/PhpParser/UnderstandabilityVisitor.php @@ -0,0 +1,445 @@ + + */ + private array $methodComplexity = []; + + /** + * @var array> + */ + private array $methodBreakdown = []; + + private string $currentNamespace = ''; + private string $currentClassName = ''; + private string $currentMethodName = ''; + private string $currentMethodKey = ''; + + private int $nestingLevel = 0; + private int $score = 0; + private int $structuralCount = 0; + private int $hybridCount = 0; + private int $fundamentalCount = 0; + private int $nestingIncrementCount = 0; + private int $recursionCount = 0; + private bool $hasRecursion = false; + + /** + * @var array + */ + private static array $fqcnCache = []; + + private ?AnnotationVisitor $annotationVisitor = null; + + public function __construct( + private readonly UnderstandabilityCalculatorInterface $calculator, + ) { + } + + public function setAnnotationVisitor(AnnotationVisitor $annotationVisitor): void + { + $this->annotationVisitor = $annotationVisitor; + } + + public function resetMethodCounters(): void + { + $this->nestingLevel = 0; + $this->score = 0; + $this->structuralCount = 0; + $this->hybridCount = 0; + $this->fundamentalCount = 0; + $this->nestingIncrementCount = 0; + $this->recursionCount = 0; + $this->hasRecursion = false; + } + + public function resetAll(): void + { + $this->methodComplexity = []; + $this->methodBreakdown = []; + $this->currentNamespace = ''; + $this->currentClassName = ''; + $this->currentMethodName = ''; + $this->currentMethodKey = ''; + $this->resetMethodCounters(); + } + + public function enterNode(Node $node): void + { + $this->setCurrentNamespaceOnEnterNode($node); + $this->setCurrentClassOnEnterNode($node); + $this->handleClassMethodEnter($node); + + if ($this->currentMethodKey === '') { + return; + } + + $this->countRecursion($node); + $this->countLogicalOperator($node); + $this->countControlFlowIncrements($node); + } + + public function leaveNode(Node $node): void + { + $this->leaveControlFlowNesting($node); + $this->handleClassMethodLeave($node); + $this->checkNamespaceLeave($node); + $this->checkClassLeave($node); + } + + /** + * @return array + */ + public function getMethodComplexity(): array + { + return $this->methodComplexity; + } + + /** + * @return array + */ + public function getComplexitySummary(): array + { + return $this->calculator->createSummary($this->methodComplexity, $this->methodBreakdown); + } + + private function setCurrentNamespaceOnEnterNode(Node $node): void + { + if (!$node instanceof Stmt\Namespace_) { + return; + } + + $this->currentNamespace = $node->name instanceof Node\Name ? $node->name->toString() : ''; + } + + private function setCurrentClassOnEnterNode(Node $node): void + { + if (!$node instanceof Stmt\Class_ && !$node instanceof Stmt\Trait_) { + return; + } + + if ($node->name === null) { + return; + } + + $fqcn = $this->currentNamespace . '\\' . $node->name->toString(); + $this->currentClassName = $this->normalizeFqcn($fqcn); + + if ($this->annotationVisitor === null || !$this->annotationVisitor->isClassIgnored($this->currentClassName)) { + return; + } + + $this->currentClassName = ''; + } + + private function normalizeFqcn(string $fqcn): string + { + if (!isset(self::$fqcnCache[$fqcn])) { + self::$fqcnCache[$fqcn] = str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + } + + return self::$fqcnCache[$fqcn]; + } + + private function handleClassMethodEnter(Node $node): void + { + if (!$node instanceof Stmt\ClassMethod) { + return; + } + + if ($this->currentClassName === '') { + return; + } + + $methodKey = $this->currentClassName . '::' . $node->name->toString(); + + if ($this->annotationVisitor !== null && $this->annotationVisitor->isMethodIgnored($methodKey)) { + return; + } + + $this->currentMethodName = $node->name->toString(); + $this->currentMethodKey = $methodKey; + $this->resetMethodCounters(); + } + + private function handleClassMethodLeave(Node $node): void + { + if (!$node instanceof Stmt\ClassMethod) { + return; + } + + if ($this->currentMethodKey === '') { + return; + } + + if ($this->hasRecursion) { + $this->addFundamental(); + $this->recursionCount++; + } + + $incrementCounts = [ + 'total' => $this->score, + 'structural' => $this->structuralCount, + 'hybrid' => $this->hybridCount, + 'fundamental' => $this->fundamentalCount, + 'nesting' => $this->nestingIncrementCount, + 'recursion' => $this->recursionCount, + ]; + + $this->methodComplexity[$this->currentMethodKey] = $this->score; + $this->methodBreakdown[$this->currentMethodKey] = $this->calculator->createBreakdown( + $incrementCounts, + $this->score, + ); + + $this->currentMethodName = ''; + $this->currentMethodKey = ''; + $this->resetMethodCounters(); + } + + private function countControlFlowIncrements(Node $node): void + { + match (true) { + $node instanceof Stmt\If_ => $this->addStructuralAndIncreaseNesting(), + $node instanceof Stmt\ElseIf_ => $this->addHybridAndIncreaseNesting(), + $node instanceof Stmt\Else_ => $this->addHybridAndIncreaseNesting(), + $node instanceof Expr\Ternary => $this->addStructuralAndIncreaseNesting(), + $node instanceof Stmt\Switch_ => $this->enterSwitch(), + $node instanceof Expr\Match_ => $this->enterMatch(), + $node instanceof Stmt\For_, + $node instanceof Stmt\Foreach_, + $node instanceof Stmt\While_, + $node instanceof Stmt\Do_ => $this->addStructuralAndIncreaseNesting(), + $node instanceof Stmt\Catch_ => $this->addStructuralAndIncreaseNesting(), + $node instanceof Expr\Closure, + $node instanceof Expr\ArrowFunction => $this->enterClosure(), + $node instanceof Stmt\Goto_ => $this->addFundamental(), + $node instanceof Stmt\Break_, + $node instanceof Stmt\Continue_ => $this->countJumpStatement($node), + default => null, + }; + } + + private function leaveControlFlowNesting(Node $node): void + { + match (true) { + $node instanceof Stmt\If_, + $node instanceof Stmt\ElseIf_, + $node instanceof Stmt\Else_, + $node instanceof Expr\Ternary, + $node instanceof Stmt\For_, + $node instanceof Stmt\Foreach_, + $node instanceof Stmt\While_, + $node instanceof Stmt\Do_, + $node instanceof Stmt\Catch_ => $this->decreaseNesting(), + $node instanceof Stmt\Switch_ => $this->leaveSwitch(), + $node instanceof Expr\Match_ => $this->leaveMatch(), + $node instanceof Expr\Closure, + $node instanceof Expr\ArrowFunction => $this->leaveClosure(), + default => null, + }; + } + + private function enterSwitch(): void + { + $this->addStructuralAndIncreaseNesting(); + } + + private function leaveSwitch(): void + { + $this->decreaseNesting(); + } + + private function enterMatch(): void + { + $this->addStructuralAndIncreaseNesting(); + } + + private function leaveMatch(): void + { + $this->decreaseNesting(); + } + + private function enterClosure(): void + { + $this->nestingLevel++; + } + + private function leaveClosure(): void + { + $this->nestingLevel--; + } + + private function addStructuralAndIncreaseNesting(): void + { + $this->score += 1 + $this->nestingLevel; + $this->structuralCount++; + if ($this->nestingLevel > 0) { + $this->nestingIncrementCount++; + } + $this->nestingLevel++; + } + + private function addHybridAndIncreaseNesting(): void + { + $this->score += 1; + $this->hybridCount++; + $this->nestingLevel++; + } + + private function addFundamental(): void + { + $this->score += 1; + $this->fundamentalCount++; + } + + private function decreaseNesting(): void + { + if ($this->nestingLevel <= 0) { + return; + } + + $this->nestingLevel--; + } + + private function countJumpStatement(Node $node): void + { + if ($node instanceof Stmt\Goto_) { + $this->addFundamental(); + return; + } + + if (!$node instanceof Stmt\Break_ && !$node instanceof Stmt\Continue_) { + return; + } + + if ($node->num === null) { + return; + } + + $this->addFundamental(); + } + + private function countLogicalOperator(Node $node): void + { + if (!$this->isLogicalBinaryOp($node)) { + return; + } + + /** @var Expr\BinaryOp $node */ + if ($this->leftOperandIsSameLogicalOp($node)) { + return; + } + + $this->addFundamental(); + } + + private function isLogicalBinaryOp(Node $node): bool + { + return $node instanceof Expr\BinaryOp\BooleanAnd + || $node instanceof Expr\BinaryOp\BooleanOr + || $node instanceof Expr\BinaryOp\LogicalAnd + || $node instanceof Expr\BinaryOp\LogicalOr; + } + + private function leftOperandIsSameLogicalOp(Expr\BinaryOp $node): bool + { + $left = $node->left; + $operatorClass = $node::class; + + return $left::class === $operatorClass; + } + + private function countRecursion(Node $node): void + { + if ($this->hasRecursion || $this->currentMethodName === '') { + return; + } + + if ($node instanceof Expr\MethodCall) { + $name = $this->resolveMethodName($node->name); + if ($name === $this->currentMethodName) { + $this->hasRecursion = true; + } + return; + } + + if (!$node instanceof Expr\StaticCall) { + return; + } + + $name = $this->resolveMethodName($node->name); + if ($name !== $this->currentMethodName) { + return; + } + + if (!$this->isSelfStaticCall($node->class)) { + return; + } + + $this->hasRecursion = true; + } + + private function resolveMethodName(Node\Expr|string|Node\Identifier $name): ?string + { + if ($name instanceof Node\Identifier) { + return $name->toString(); + } + + if (is_string($name)) { + return $name; + } + + return null; + } + + private function isSelfStaticCall(Node\Name|Expr $class): bool + { + if (!$class instanceof Node\Name) { + return false; + } + + $parts = $class->getParts(); + $last = end($parts); + + return in_array($last, ['self', 'static', 'parent'], true); + } + + private function checkNamespaceLeave(Node $node): void + { + if (!($node instanceof Stmt\Namespace_)) { + return; + } + + $this->currentNamespace = ''; + } + + private function checkClassLeave(Node $node): void + { + if (!($node instanceof Stmt\Class_) && !($node instanceof Stmt\Trait_)) { + return; + } + + $this->currentClassName = ''; + } +} diff --git a/stan.json b/stan.json new file mode 100644 index 0000000..5650b5c --- /dev/null +++ b/stan.json @@ -0,0 +1 @@ +{"totals":{"errors":0,"file_errors":0},"files":[],"errors":[]} \ No newline at end of file diff --git a/tests/Fixtures/understandability-config.yml b/tests/Fixtures/understandability-config.yml new file mode 100644 index 0000000..d522d53 --- /dev/null +++ b/tests/Fixtures/understandability-config.yml @@ -0,0 +1,10 @@ +cognitive: + excludeFilePatterns: + excludePatterns: + scoreThreshold: 0.5 + showOnlyMethodsExceedingThreshold: false + showHalsteadComplexity: false + showCyclomaticComplexity: false + showUnderstandability: true + showDetailedCognitiveMetrics: true + groupByClass: true diff --git a/tests/Unit/Business/Churn/Report/TestCognitiveConfig.php b/tests/Unit/Business/Churn/Report/TestCognitiveConfig.php index 3531074..f88b806 100644 --- a/tests/Unit/Business/Churn/Report/TestCognitiveConfig.php +++ b/tests/Unit/Business/Churn/Report/TestCognitiveConfig.php @@ -19,6 +19,7 @@ public function __construct( float $scoreThreshold = 0.5, bool $showHalsteadComplexity = false, bool $showCyclomaticComplexity = false, + bool $showUnderstandability = false, bool $groupByClass = false, bool $showDetailedCognitiveMetrics = true, array $customReporters = [] @@ -31,6 +32,7 @@ public function __construct( scoreThreshold: $scoreThreshold, showHalsteadComplexity: $showHalsteadComplexity, showCyclomaticComplexity: $showCyclomaticComplexity, + showUnderstandability: $showUnderstandability, groupByClass: $groupByClass, showDetailedCognitiveMetrics: $showDetailedCognitiveMetrics, customReporters: $customReporters diff --git a/tests/Unit/Business/Understandability/UnderstandabilityCalculatorTest.php b/tests/Unit/Business/Understandability/UnderstandabilityCalculatorTest.php new file mode 100644 index 0000000..4160dd9 --- /dev/null +++ b/tests/Unit/Business/Understandability/UnderstandabilityCalculatorTest.php @@ -0,0 +1,55 @@ +calculator = new UnderstandabilityCalculator(); + } + + #[DataProvider('riskLevelProvider')] + public function testGetRiskLevel(int $complexity, string $expected): void + { + $this->assertSame($expected, $this->calculator->getRiskLevel($complexity)); + } + + /** + * @return array + */ + public static function riskLevelProvider(): array + { + return [ + 'zero' => [0, 'low'], + 'low boundary' => [5, 'low'], + 'medium' => [10, 'medium'], + 'high' => [15, 'high'], + 'very high' => [16, 'very_high'], + ]; + } + + public function testCreateSummaryFlagsHighRiskMethods(): void + { + $summary = $this->calculator->createSummary( + [ + '\\App\\A::low' => 3, + '\\App\\B::high' => 12, + '\\App\\C::veryHigh' => 20, + ], + [], + ); + + $this->assertArrayHasKey('\\App\\B::high', $summary['high_risk_methods']); + $this->assertArrayHasKey('\\App\\C::veryHigh', $summary['very_high_risk_methods']); + $this->assertSame('high', $summary['methods']['\\App\\B::high']['risk_level']); + } +} diff --git a/tests/Unit/Command/CognitiveMetricsCommandTest.php b/tests/Unit/Command/CognitiveMetricsCommandTest.php index 91650f6..a7529cc 100644 --- a/tests/Unit/Command/CognitiveMetricsCommandTest.php +++ b/tests/Unit/Command/CognitiveMetricsCommandTest.php @@ -228,6 +228,11 @@ public static function configurationOutputProvider(): array 'OutputWithCyclomaticOnly.txt', 'Should show only Cyclomatic complexity metrics' ], + 'understandability only' => [ + 'understandability-config.yml', + 'OutputWithUnderstandability.txt', + 'Should show Sonar-style Understandability metric' + ], 'no detailed metrics' => [ 'no-detailed-metrics-config.yml', 'OutputWithoutDetailedMetrics.txt', diff --git a/tests/Unit/Command/OutputWithUnderstandability.txt b/tests/Unit/Command/OutputWithUnderstandability.txt new file mode 100644 index 0000000..93da945 --- /dev/null +++ b/tests/Unit/Command/OutputWithUnderstandability.txt @@ -0,0 +1,58 @@ +Config: ./tests/Unit/Command/../../../tests/Fixtures/understandability-config.yml +Cache: enabled + +Class: \ClassOne +File: tests/TestCode/FileWithTwoClasses.php +┌─────────────┬───────┬───────────┬─────────┬───────────┬──────────┬───────┬────────────┬───────┬────────────┬───────────────────┐ +│ Method Name │ Lines │ Arguments │ Returns │ Variables │ Property │ If │ If Nesting │ Else │ Cognitive │ Understandability │ +│ │ │ │ │ │ Accesses │ │ Level │ │ Complexity │ │ +├─────────────┼───────┼───────────┼─────────┼───────────┼──────────┼───────┼────────────┼───────┼────────────┼───────────────────┤ +│ add │ 4 (0) │ 2 (0) │ 1 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 0 (low) │ +└─────────────┴───────┴───────────┴─────────┴───────────┴──────────┴───────┴────────────┴───────┴────────────┴───────────────────┘ + +Class: \ClassTwo +File: tests/TestCode/FileWithTwoClasses.php +┌─────────────┬───────┬───────────┬─────────┬───────────┬──────────┬───────┬────────────┬───────┬────────────┬───────────────────┐ +│ Method Name │ Lines │ Arguments │ Returns │ Variables │ Property │ If │ If Nesting │ Else │ Cognitive │ Understandability │ +│ │ │ │ │ │ Accesses │ │ Level │ │ Complexity │ │ +├─────────────┼───────┼───────────┼─────────┼───────────┼──────────┼───────┼────────────┼───────┼────────────┼───────────────────┤ +│ add │ 4 (0) │ 2 (0) │ 1 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 0 (low) │ +└─────────────┴───────┴───────────┴─────────┴───────────┴──────────┴───────┴────────────┴───────┴────────────┴───────────────────┘ + +Class: \Doctrine\ORM\Tools\Pagination\Paginator +File: tests/TestCode/Paginator.php +┌───────────────────────────────────────────┬────────┬───────────┬─────────┬───────────┬──────────┬───────┬────────────┬───────────┬────────────┬───────────────────┐ +│ Method Name │ Lines │ Arguments │ Returns │ Variables │ Property │ If │ If Nesting │ Else │ Cognitive │ Understandability │ +│ │ │ │ │ │ Accesses │ │ Level │ │ Complexity │ │ +├───────────────────────────────────────────┼────────┼───────────┼─────────┼───────────┼──────────┼───────┼────────────┼───────────┼────────────┼───────────────────┤ +│ __construct │ 10 (0) │ 2 (0) │ 0 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 │ 1 (low) │ +│ getQuery │ 4 (0) │ 0 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 0 (low) │ +│ getFetchJoinCollection │ 4 (0) │ 0 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 0 (low) │ +│ getUseOutputWalkers │ 4 (0) │ 0 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 0 (low) │ +│ setUseOutputWalkers │ 6 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 0 (low) │ +│ count │ 12 (0) │ 0 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 │ 3 (low) │ +│ getIterator │ 46 (0) │ 0 (0) │ 2 (0) │ 9 (0.693) │ 2 (0) │ 3 (0) │ 2 (0.693) │ 2 (0.693) │ 2.079 │ 8 (medium) │ +│ cloneQuery │ 13 (0) │ 1 (0) │ 1 (0) │ 3 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 1 (low) │ +│ useOutputWalker │ 8 (0) │ 1 (0) │ 2 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 │ 1 (low) │ +│ appendTreeWalker │ 11 (0) │ 2 (0) │ 0 (0) │ 1 (0) │ 0 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 │ 1 (low) │ +│ getCountQuery │ 25 (0) │ 0 (0) │ 1 (0) │ 4 (0) │ 1 (0) │ 2 (0) │ 1 (0) │ 1 (0) │ 0 │ 3 (low) │ +│ unbindUnusedQueryParams │ 17 (0) │ 1 (0) │ 0 (0) │ 6 (0.336) │ 0 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0.336 │ 4 (low) │ +│ convertWhereInIdentifiersToDatabaseValues │ 11 (0) │ 1 (0) │ 1 (0) │ 5 (0.182) │ 1 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0.182 │ 0 (low) │ +└───────────────────────────────────────────┴────────┴───────────┴─────────┴───────────┴──────────┴───────┴────────────┴───────────┴────────────┴───────────────────┘ + +Class: \WP_Debug_Data +File: tests/TestCode/WpDebugData.php +┌───────────────────┬──────────────┬───────────┬─────────┬─────────────┬────────────┬────────────┬────────────┬────────────┬────────────┬───────────────────┐ +│ Method Name │ Lines │ Arguments │ Returns │ Variables │ Property │ If │ If Nesting │ Else │ Cognitive │ Understandability │ +│ │ │ │ │ │ Accesses │ │ Level │ │ Complexity │ │ +├───────────────────┼──────────────┼───────────┼─────────┼─────────────┼────────────┼────────────┼────────────┼────────────┼────────────┼───────────────────┤ +│ check_for_updates │ 6 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 │ 0 (low) │ +│ debug_data │ 1230 (3.867) │ 0 (0) │ 1 (0) │ 105 (3.054) │ 20 (0.726) │ 58 (4.025) │ 3 (1.099) │ 33 (3.497) │ 16.267 │ 230 (very_high) │ +│ get_wp_constants │ 144 (1.472) │ 0 (0) │ 1 (0) │ 9 (0.693) │ 0 (0) │ 5 (1.099) │ 1 (0) │ 5 (1.609) │ 4.874 │ 36 (very_high) │ +│ get_wp_filesystem │ 60 (0) │ 0 (0) │ 1 (0) │ 9 (0.693) │ 0 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0.693 │ 18 (very_high) │ +│ get_mysql_var │ 15 (0) │ 1 (0) │ 2 (0) │ 2 (0) │ 0 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 │ 2 (low) │ +│ format │ 60 (0) │ 2 (0) │ 1 (0) │ 11 (0.875) │ 0 (0) │ 5 (1.099) │ 1 (0) │ 5 (1.609) │ 3.584 │ 41 (very_high) │ +│ get_database_size │ 14 (0) │ 0 (0) │ 1 (0) │ 4 (0) │ 1 (0) │ 1 (0) │ 1 (0) │ 0 (0) │ 0 │ 3 (low) │ +│ get_sizes │ 125 (1.281) │ 0 (0) │ 1 (0) │ 14 (1.099) │ 0 (0) │ 9 (1.946) │ 2 (0.693) │ 5 (1.609) │ 6.628 │ 23 (very_high) │ +└───────────────────┴──────────────┴───────────┴─────────┴─────────────┴────────────┴────────────┴────────────┴────────────┴────────────┴───────────────────┘ + diff --git a/tests/Unit/PhpParser/UnderstandabilityVisitorTest.php b/tests/Unit/PhpParser/UnderstandabilityVisitorTest.php new file mode 100644 index 0000000..1df8a35 --- /dev/null +++ b/tests/Unit/PhpParser/UnderstandabilityVisitorTest.php @@ -0,0 +1,260 @@ +createForHostVersion(); + $ast = $parser->parse($code); + $calculator = new UnderstandabilityCalculator(); + $visitor = new UnderstandabilityVisitor($calculator); + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + $methodKey = '\\MyNamespace\\MyClass::' . $methodName; + $complexity = $visitor->getMethodComplexity(); + + $this->assertArrayHasKey($methodKey, $complexity, 'Method not found: ' . $methodKey); + + return $complexity[$methodKey]; + } + + public function testSwitchOnlyScoresOneLikeGetWords(): void + { + $code = <<<'CODE' + assertSame(1, $this->analyzeMethod($code, 'getWords')); + } + + public function testNestedLoopsAndContinueMatchSumOfPrimes(): void + { + $code = <<<'CODE' + assertSame(7, $this->analyzeMethod($code, 'sumOfPrimes')); + } + + public function testNestedIfForWhileCatchScoresNine(): void + { + $code = <<<'CODE' + assertSame(9, $this->analyzeMethod($code)); + } + + public function testClosureNestingScoresTwo(): void + { + $code = <<<'CODE' + $condition1 ? 1 : 0; + } + } + CODE; + + // arrow fn with ternary inside: closure nesting + ternary structural (+1+1) + $this->assertSame(2, $this->analyzeMethod($code)); + } + + public function testIfInsideClosureScoresTwo(): void + { + $code = <<<'CODE' + assertSame(2, $this->analyzeMethod($code)); + } + + public function testLogicalOperatorSequencesInCondition(): void + { + $code = <<<'CODE' + assertSame(2, $this->analyzeMethod($code)); + } + + public function testMixedLogicalOperatorsIncrementPerSequence(): void + { + $code = <<<'CODE' + assertSame(4, $this->analyzeMethod($code)); + } + + public function testSwitchLowerThanIfElseIfChain(): void + { + $switchCode = <<<'CODE' + analyzeMethod($switchCode, 'viaSwitch'); + $ifScore = $this->analyzeMethod($ifCode, 'viaIf'); + + $this->assertLessThan($ifScore, $switchScore); + $this->assertSame(1, $switchScore); + } + + public function testDirectRecursionAddsFundamentalIncrement(): void + { + $code = <<<'CODE' + run($n - 1); + } + } + CODE; + + // if +1, recursion +1 + $this->assertSame(2, $this->analyzeMethod($code)); + } + + public function testIgnoredMethodIsSkippedWhenAnnotationVisitorWired(): void + { + $code = <<<'CODE' + createForHostVersion(); + $ast = $parser->parse($code); + + $annotationVisitor = new AnnotationVisitor(); + $annotationTraverser = new NodeTraverser(); + $annotationTraverser->addVisitor($annotationVisitor); + $annotationTraverser->traverse($ast); + + $visitor = new UnderstandabilityVisitor(new UnderstandabilityCalculator()); + $visitor->setAnnotationVisitor($annotationVisitor); + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + $this->assertEmpty($visitor->getMethodComplexity()); + } +}