Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/Understandability.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions phpcca.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ cognitive:
showOnlyMethodsExceedingThreshold: true
showHalsteadComplexity: false
showCyclomaticComplexity: false
showUnderstandability: false
showDetailedCognitiveMetrics: true
groupByClass: true
metrics:
Expand Down
43 changes: 27 additions & 16 deletions src/Business/Cognitive/CognitiveMetrics.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -69,6 +70,7 @@ class CognitiveMetrics implements JsonSerializable

private ?HalsteadMetrics $halstead = null;
private ?CyclomaticMetrics $cyclomatic = null;
private ?UnderstandabilityMetrics $understandability = null;
private ?float $coverage = null;

/**
Expand Down Expand Up @@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $cyclomaticData */
$cyclomaticData = $metrics['cyclomatic_complexity'];
$this->cyclomatic = new CyclomaticMetrics($cyclomaticData);
}

/**
Expand Down Expand Up @@ -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)) {
Expand Down
15 changes: 14 additions & 1 deletion src/Business/Cognitive/CognitiveMetricsCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>
*/
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
29 changes: 29 additions & 0 deletions src/Business/Cognitive/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,15 @@ public function parse(string $code): array
$cyclomaticMetrics = $this->combinedVisitor->getMethodComplexity();
/** @var array<string, mixed> $halsteadMetrics */
$halsteadMetrics = $this->combinedVisitor->getHalsteadMethodMetrics();
/** @var array<string, mixed> $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<string, array<string, mixed>> $methodMetrics */
return $methodMetrics;
Expand Down Expand Up @@ -134,6 +137,32 @@ private function mergeHalsteadMetrics(array $methodMetrics, array $halsteadMetri
return $methodMetrics;
}

/**
* @param array<string, mixed> $methodMetrics
* @param array<string, mixed> $understandabilityMetrics
* @return array<string, mixed>
*/
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
*/
Expand Down
70 changes: 70 additions & 0 deletions src/Business/Understandability/UnderstandabilityCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Phauthentic\CognitiveCodeAnalysis\Business\Understandability;

class UnderstandabilityCalculator implements UnderstandabilityCalculatorInterface
{
/**
* @param array<string, int> $incrementCounts
*/
public function calculateComplexity(array $incrementCounts): int
{
return $incrementCounts['total'] ?? 0;
}

/**
* @param array<string, int> $incrementCounts
* @return array<string, int>
*/
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<string, int> $methodComplexities
* @param array<string, array<string, int>> $methodBreakdowns
* @return array<string, mixed>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Phauthentic\CognitiveCodeAnalysis\Business\Understandability;

interface UnderstandabilityCalculatorInterface
{
/**
* @param array<string, int> $incrementCounts
*/
public function calculateComplexity(array $incrementCounts): int;

/**
* @param array<string, int> $incrementCounts
* @return array<string, int>
*/
public function createBreakdown(array $incrementCounts, int $totalComplexity): array;

public function getRiskLevel(int $complexity): string;

/**
* @param array<string, int> $methodComplexities
* @param array<string, array<string, int>> $methodBreakdowns
* @return array<string, mixed>
*/
public function createSummary(array $methodComplexities, array $methodBreakdowns): array;
}
Loading
Loading