Skip to content

Commit f2a0fc7

Browse files
Adding Understandability (Sonar Cognitive Complexity) (#74)
* Add Understandability Metrics and Reporting (#70) This commit introduces a new set of metrics focused on understandability, enhancing the cognitive analysis capabilities. Key changes include: - Added `UnderstandabilityMetrics` and `UnderstandabilityCalculator` for calculating and summarizing understandability metrics. - Integrated understandability metrics into the existing cognitive metrics framework. - Updated configuration options to enable the display of understandability metrics in reports. - Enhanced the reporting system to include understandability in the output format. - Added tests to ensure the correctness of the new understandability features. These changes aim to provide deeper insights into code complexity and maintainability, aligning with modern coding standards. * Enhance understandability metrics handling and improve type validation - Added new methods in `UnderstandabilityMetrics` for resolving risk levels and count values, improving data handling and robustness. - Updated `Parser` class to include understandability metrics in the analysis process. - Enhanced `CombinedMetricsVisitor` with type annotations for the `getMethodUnderstandability` method. - Added suppression warnings in `UnderstandabilityVisitor` for excessive complexity and method count, promoting cleaner code practices. * Add Understandability documentation for Sonar Cognitive Complexity - Introduced a new document, Understandability.md, detailing the Sonar Cognitive Complexity metric. - Explained the calculation method, risk levels, and configuration options for enabling understandability metrics. - Provided references to relevant literature and resources for further reading on cognitive complexity. * Refactor understandability calculations and visitor logic for improved clarity - Updated `UnderstandabilityCalculator` to skip low complexity methods, enhancing risk categorization. - Refined `UnderstandabilityVisitor` logic to return early for ignored classes and non-relevant nodes, improving code readability and maintainability. - Adjusted nesting and logical operator handling to streamline flow and reduce unnecessary checks. * Update cache versioning in CognitiveMetricsCollector and enhance understandability output - Introduced a new constant `CACHE_VERSION` in `CognitiveMetricsCollector` to manage cache versioning effectively. - Updated cache item versioning to use the new constant, ensuring stale entries are ignored when the analysis result shape changes. - Enhanced the understandability output in various test files by updating complexity ratings, improving clarity in metrics reporting.
1 parent ab84eb2 commit f2a0fc7

23 files changed

Lines changed: 1234 additions & 21 deletions

docs/Understandability.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Understandability (Sonar Cognitive Complexity)
2+
3+
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).
4+
5+
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.
6+
7+
## Why use it?
8+
9+
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.
10+
11+
## How it is calculated
12+
13+
Per method, the score follows three rules from the Sonar spec:
14+
15+
1. **Ignore shorthand** — method calls and null-coalescing are not counted.
16+
2. **Increment for flow breaks** — loops, `if`, ternary, `catch`, `switch`/`match`, logical-operator sequences, recursion, and multi-level `break`/`continue`/`goto`.
17+
3. **Increment for nesting** — each nested flow-breaking structure adds its current nesting depth to the score.
18+
19+
Increments fall into four categories (each adds to the total, but categories clarify nesting behavior):
20+
21+
| Category | Examples |
22+
|-------------|-----------------------------------------------|
23+
| Structural | `if`, loops, ternary, `catch`, `switch` |
24+
| Hybrid | `elseif`, `else` (no nesting penalty, but increase nesting level) |
25+
| Fundamental | Logical-operator sequences, recursion, jumps |
26+
| Nesting | Extra points when structures are nested |
27+
28+
Structural increments use `1 + nestingLevel`; hybrid increments add `1` only.
29+
30+
## Risk levels
31+
32+
| Score | Risk |
33+
|-------|-------------|
34+
| 0–5 | low |
35+
| 6–10 | medium |
36+
| 11–15 | high |
37+
| 16+ | very high |
38+
39+
Console output shows `score (risk)`, for example `7 (medium)`.
40+
41+
## Configuration
42+
43+
Understandability is **off by default**. Enable it in `phpcca.yaml`:
44+
45+
```yaml
46+
cognitive:
47+
showUnderstandability: true
48+
```
49+
50+
When enabled, an **Understandability** column appears in console output. It does not affect baselines, file reports, or sorting unless those features are extended separately.
51+
52+
## Interpretation
53+
54+
- **Low (≤5)** — easy to follow; usually fine as-is.
55+
- **Medium (6–10)** — worth a closer look during review.
56+
- **High / very high (≥11)** — nested or branching logic is taxing; consider extracting methods or simplifying control flow.
57+
58+
Use as an indicator, not an absolute rule. Domain logic, parsers, and constructors may legitimately score higher.
59+
60+
## References
61+
62+
- [Sonar Cognitive Complexity white paper (2023, v1.7)](https://www.sonarsource.com/resources/cognitive-complexity/)
63+
- [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

phpcca.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ cognitive:
55
showOnlyMethodsExceedingThreshold: true
66
showHalsteadComplexity: false
77
showCyclomaticComplexity: false
8+
showUnderstandability: false
89
showDetailedCognitiveMetrics: true
910
groupByClass: true
1011
metrics:

src/Business/Cognitive/CognitiveMetrics.php

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetrics;
88
use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticMetrics;
9+
use Phauthentic\CognitiveCodeAnalysis\Business\Understandability\UnderstandabilityMetrics;
910
use InvalidArgumentException;
1011
use JsonSerializable;
1112

@@ -69,6 +70,7 @@ class CognitiveMetrics implements JsonSerializable
6970

7071
private ?HalsteadMetrics $halstead = null;
7172
private ?CyclomaticMetrics $cyclomatic = null;
73+
private ?UnderstandabilityMetrics $understandability = null;
7274
private ?float $coverage = null;
7375

7476
/**
@@ -105,25 +107,29 @@ public function __construct(array $metrics)
105107
]);
106108
}
107109

108-
if (!isset($metrics['cyclomatic_complexity'])) {
109-
// Handle baseline format with individual cyclomatic fields
110-
if (isset($metrics['cyclomaticComplexity']) && !isset($metrics['cyclomatic_complexity'])) {
111-
$riskLevel = $metrics['cyclomaticRiskLevel'] ?? 'unknown';
112-
$this->cyclomatic = new CyclomaticMetrics([
113-
'complexity' => $this->resolveIntValue($metrics['cyclomaticComplexity'], 1),
114-
'riskLevel' => is_string($riskLevel) ? $riskLevel : 'unknown',
115-
]);
116-
}
117-
return;
110+
if (isset($metrics['cyclomatic_complexity']) && is_array($metrics['cyclomatic_complexity'])) {
111+
/** @var array<string, mixed> $cyclomaticData */
112+
$cyclomaticData = $metrics['cyclomatic_complexity'];
113+
$this->cyclomatic = new CyclomaticMetrics($cyclomaticData);
114+
} elseif (isset($metrics['cyclomaticComplexity'])) {
115+
$riskLevel = $metrics['cyclomaticRiskLevel'] ?? 'unknown';
116+
$this->cyclomatic = new CyclomaticMetrics([
117+
'complexity' => $this->resolveIntValue($metrics['cyclomaticComplexity'], 1),
118+
'riskLevel' => is_string($riskLevel) ? $riskLevel : 'unknown',
119+
]);
118120
}
119121

120-
if (!is_array($metrics['cyclomatic_complexity'])) {
121-
return;
122+
if (isset($metrics['understandability']) && is_array($metrics['understandability'])) {
123+
/** @var array<string, mixed> $understandabilityData */
124+
$understandabilityData = $metrics['understandability'];
125+
$this->understandability = new UnderstandabilityMetrics($understandabilityData);
126+
} elseif (isset($metrics['understandabilityComplexity'])) {
127+
$riskLevel = $metrics['understandabilityRiskLevel'] ?? 'unknown';
128+
$this->understandability = new UnderstandabilityMetrics([
129+
'complexity' => $this->resolveIntValue($metrics['understandabilityComplexity'], 0),
130+
'riskLevel' => is_string($riskLevel) ? $riskLevel : 'unknown',
131+
]);
122132
}
123-
124-
/** @var array<string, mixed> $cyclomaticData */
125-
$cyclomaticData = $metrics['cyclomatic_complexity'];
126-
$this->cyclomatic = new CyclomaticMetrics($cyclomaticData);
127133
}
128134

129135
/**
@@ -537,6 +543,11 @@ public function getCyclomatic(): ?CyclomaticMetrics
537543
return $this->cyclomatic;
538544
}
539545

546+
public function getUnderstandability(): ?UnderstandabilityMetrics
547+
{
548+
return $this->understandability;
549+
}
550+
540551
private function resolveStringValue(mixed $value): string
541552
{
542553
if (!is_string($value)) {

src/Business/Cognitive/CognitiveMetricsCollector.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
*/
2424
class CognitiveMetricsCollector
2525
{
26+
/**
27+
* Bump when cached analysis_result shape changes so stale entries are ignored.
28+
*/
29+
private const CACHE_VERSION = '1.1';
30+
2631
/**
2732
* @var array<string, mixed>
2833
*/
@@ -322,7 +327,7 @@ private function cacheResult(
322327
string $configHash
323328
): void {
324329
$cacheItem->set([
325-
'version' => '1.0',
330+
'version' => self::CACHE_VERSION,
326331
'file_path' => $file->getRealPath(),
327332
'file_mtime' => $file->getMTime(),
328333
'config_hash' => $configHash,
@@ -363,6 +368,14 @@ private function getCachedMetrics(SplFileInfo $file, string $configHash, bool $u
363368
return ['metrics' => null, 'cacheItem' => $cacheItem];
364369
}
365370

371+
if (($cachedData['version'] ?? null) !== self::CACHE_VERSION) {
372+
return ['metrics' => null, 'cacheItem' => $cacheItem];
373+
}
374+
375+
if (($cachedData['config_hash'] ?? null) !== $configHash) {
376+
return ['metrics' => null, 'cacheItem' => $cacheItem];
377+
}
378+
366379
$ignoredItems = $cachedData['ignored_items'] ?? [];
367380
$this->ignoredItems = $this->normalizeIgnoredItems($ignoredItems);
368381
$this->messageBus->dispatch(new FileProcessed($file));

src/Business/Cognitive/Parser.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,15 @@ public function parse(string $code): array
7878
$cyclomaticMetrics = $this->combinedVisitor->getMethodComplexity();
7979
/** @var array<string, mixed> $halsteadMetrics */
8080
$halsteadMetrics = $this->combinedVisitor->getHalsteadMethodMetrics();
81+
/** @var array<string, mixed> $understandabilityMetrics */
82+
$understandabilityMetrics = $this->combinedVisitor->getMethodUnderstandability();
8183

8284
// Now reset the combined visitor
8385
$this->combinedVisitor->resetAll();
8486

8587
$methodMetrics = $this->mergeCyclomaticMetrics($methodMetrics, $cyclomaticMetrics);
8688
$methodMetrics = $this->mergeHalsteadMetrics($methodMetrics, $halsteadMetrics);
89+
$methodMetrics = $this->mergeUnderstandabilityMetrics($methodMetrics, $understandabilityMetrics);
8790

8891
/** @var array<string, array<string, mixed>> $methodMetrics */
8992
return $methodMetrics;
@@ -134,6 +137,32 @@ private function mergeHalsteadMetrics(array $methodMetrics, array $halsteadMetri
134137
return $methodMetrics;
135138
}
136139

140+
/**
141+
* @param array<string, mixed> $methodMetrics
142+
* @param array<string, mixed> $understandabilityMetrics
143+
* @return array<string, mixed>
144+
*/
145+
private function mergeUnderstandabilityMetrics(array $methodMetrics, array $understandabilityMetrics): array
146+
{
147+
foreach ($understandabilityMetrics as $method => $complexityData) {
148+
$methodMetric = $methodMetrics[$method] ?? null;
149+
if (!is_array($methodMetric) || !is_array($complexityData)) {
150+
continue;
151+
}
152+
153+
$complexity = $complexityData['complexity'] ?? $complexityData;
154+
$riskLevel = $complexityData['risk_level'] ?? 'unknown';
155+
$methodMetric['understandability'] = [
156+
'complexity' => $complexity,
157+
'risk_level' => is_string($riskLevel) ? $riskLevel : 'unknown',
158+
'breakdown' => $complexityData['breakdown'] ?? [],
159+
];
160+
$methodMetrics[$method] = $methodMetric;
161+
}
162+
163+
return $methodMetrics;
164+
}
165+
137166
/**
138167
* @return array{complexity: int, risk_level: string}|null
139168
*/
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phauthentic\CognitiveCodeAnalysis\Business\Understandability;
6+
7+
class UnderstandabilityCalculator implements UnderstandabilityCalculatorInterface
8+
{
9+
/**
10+
* @param array<string, int> $incrementCounts
11+
*/
12+
public function calculateComplexity(array $incrementCounts): int
13+
{
14+
return $incrementCounts['total'] ?? 0;
15+
}
16+
17+
/**
18+
* @param array<string, int> $incrementCounts
19+
* @return array<string, int>
20+
*/
21+
public function createBreakdown(array $incrementCounts, int $totalComplexity): array
22+
{
23+
return array_merge(['total' => $totalComplexity], $incrementCounts);
24+
}
25+
26+
public function getRiskLevel(int $complexity): string
27+
{
28+
return match (true) {
29+
$complexity <= 5 => 'low',
30+
$complexity <= 10 => 'medium',
31+
$complexity <= 15 => 'high',
32+
default => 'very_high',
33+
};
34+
}
35+
36+
/**
37+
* @param array<string, int> $methodComplexities
38+
* @param array<string, array<string, int>> $methodBreakdowns
39+
* @return array<string, mixed>
40+
*/
41+
public function createSummary(array $methodComplexities, array $methodBreakdowns): array
42+
{
43+
$summary = [
44+
'methods' => [],
45+
'high_risk_methods' => [],
46+
'very_high_risk_methods' => [],
47+
];
48+
49+
foreach ($methodComplexities as $methodKey => $complexity) {
50+
$riskLevel = $this->getRiskLevel($complexity);
51+
$summary['methods'][$methodKey] = [
52+
'complexity' => $complexity,
53+
'risk_level' => $riskLevel,
54+
'breakdown' => $methodBreakdowns[$methodKey] ?? [],
55+
];
56+
57+
if ($complexity >= 10) {
58+
$summary['high_risk_methods'][$methodKey] = $complexity;
59+
}
60+
61+
if ($complexity < 15) {
62+
continue;
63+
}
64+
65+
$summary['very_high_risk_methods'][$methodKey] = $complexity;
66+
}
67+
68+
return $summary;
69+
}
70+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phauthentic\CognitiveCodeAnalysis\Business\Understandability;
6+
7+
interface UnderstandabilityCalculatorInterface
8+
{
9+
/**
10+
* @param array<string, int> $incrementCounts
11+
*/
12+
public function calculateComplexity(array $incrementCounts): int;
13+
14+
/**
15+
* @param array<string, int> $incrementCounts
16+
* @return array<string, int>
17+
*/
18+
public function createBreakdown(array $incrementCounts, int $totalComplexity): array;
19+
20+
public function getRiskLevel(int $complexity): string;
21+
22+
/**
23+
* @param array<string, int> $methodComplexities
24+
* @param array<string, array<string, int>> $methodBreakdowns
25+
* @return array<string, mixed>
26+
*/
27+
public function createSummary(array $methodComplexities, array $methodBreakdowns): array;
28+
}

0 commit comments

Comments
 (0)