diff --git a/src/Business/Cognitive/Parser.php b/src/Business/Cognitive/Parser.php index 6430004..356fc57 100644 --- a/src/Business/Cognitive/Parser.php +++ b/src/Business/Cognitive/Parser.php @@ -5,6 +5,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive; use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetricsCalculator; +use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculator; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\PhpParser\AnnotationVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\CognitiveMetricsVisitor; @@ -27,6 +28,7 @@ class Parser protected HalsteadMetricsVisitor $halsteadMetricsVisitor; protected CombinedMetricsVisitor $combinedVisitor; protected HalsteadMetricsCalculator $halsteadCalculator; + protected CyclomaticComplexityCalculator $cyclomaticCalculator; public function __construct( ParserFactory $parserFactory, @@ -42,7 +44,8 @@ public function __construct( $this->cognitiveMetricsVisitor->setAnnotationVisitor($this->annotationVisitor); $this->traverser->addVisitor($this->cognitiveMetricsVisitor); - $this->cyclomaticComplexityVisitor = new CyclomaticComplexityVisitor(); + $this->cyclomaticCalculator = new CyclomaticComplexityCalculator(); + $this->cyclomaticComplexityVisitor = new CyclomaticComplexityVisitor($this->cyclomaticCalculator); $this->cyclomaticComplexityVisitor->setAnnotationVisitor($this->annotationVisitor); $this->traverser->addVisitor($this->cyclomaticComplexityVisitor); diff --git a/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculator.php b/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculator.php new file mode 100644 index 0000000..afe1411 --- /dev/null +++ b/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculator.php @@ -0,0 +1,109 @@ + $decisionPointCounts Array of decision point counts + * @return int Total cyclomatic complexity + */ + public function calculateComplexity(array $decisionPointCounts): int + { + $baseComplexity = 1; // Base complexity for any method + $totalComplexity = $baseComplexity; + + // Add complexity for each decision point type (excluding 'else' which doesn't add complexity) + foreach ($decisionPointCounts as $type => $count) { + if ($type === 'else') { + continue; + } + + $totalComplexity += $count; + } + + return $totalComplexity; + } + + /** + * Create detailed breakdown of complexity factors. + * + * @param array $decisionPointCounts Array of decision point counts + * @param int $totalComplexity Total complexity value + * @return array Detailed breakdown including base complexity + */ + public function createBreakdown(array $decisionPointCounts, int $totalComplexity): array + { + return array_merge([ + 'total' => $totalComplexity, + 'base' => 1, + ], $decisionPointCounts); + } + + /** + * Determine risk level based on complexity value. + * + * @param int $complexity The cyclomatic complexity value + * @return string Risk level: 'low', 'medium', 'high', 'very_high' + */ + public function getRiskLevel(int $complexity): string + { + return match (true) { + $complexity <= 5 => 'low', + $complexity <= 10 => 'medium', + $complexity <= 15 => 'high', + default => 'very_high', + }; + } + + /** + * Create complete summary with risk assessment. + * + * @param array $classComplexities Class complexities indexed by class name + * @param array $methodComplexities Method complexities indexed by "ClassName::methodName" + * @param array> $methodBreakdowns Method breakdowns indexed by "ClassName::methodName" + * @return array Complete summary with risk assessment + */ + public function createSummary(array $classComplexities, array $methodComplexities, array $methodBreakdowns): array + { + $summary = [ + 'classes' => [], + 'methods' => [], + 'high_risk_methods' => [], + 'very_high_risk_methods' => [], + ]; + + // Class summary + foreach ($classComplexities as $className => $complexity) { + $summary['classes'][$className] = [ + 'complexity' => $complexity, + 'risk_level' => $this->getRiskLevel($complexity), + ]; + } + + // Method summary + 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/CyclomaticComplexity/CyclomaticComplexityCalculatorInterface.php b/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorInterface.php new file mode 100644 index 0000000..50bbd30 --- /dev/null +++ b/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorInterface.php @@ -0,0 +1,43 @@ + $decisionPointCounts Array of decision point counts + * @return int Total cyclomatic complexity + */ + public function calculateComplexity(array $decisionPointCounts): int; + + /** + * Create detailed breakdown of complexity factors. + * + * @param array $decisionPointCounts Array of decision point counts + * @param int $totalComplexity Total complexity value + * @return array Detailed breakdown including base complexity + */ + public function createBreakdown(array $decisionPointCounts, int $totalComplexity): array; + + /** + * Determine risk level based on complexity value. + * + * @param int $complexity The cyclomatic complexity value + * @return string Risk level: 'low', 'medium', 'high', 'very_high' + */ + public function getRiskLevel(int $complexity): string; + + /** + * Create complete summary with risk assessment. + * + * @param array $classComplexities Class complexities indexed by class name + * @param array $methodComplexities Method complexities indexed by "ClassName::methodName" + * @param array> $methodBreakdowns Method breakdowns indexed by "ClassName::methodName" + * @return array Complete summary with risk assessment + */ + public function createSummary(array $classComplexities, array $methodComplexities, array $methodBreakdowns): array; +} diff --git a/src/PhpParser/CombinedMetricsVisitor.php b/src/PhpParser/CombinedMetricsVisitor.php index 57e30df..6c4c46a 100644 --- a/src/PhpParser/CombinedMetricsVisitor.php +++ b/src/PhpParser/CombinedMetricsVisitor.php @@ -5,6 +5,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\PhpParser; use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetricsCalculator; +use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculator; use PhpParser\Node; use PhpParser\NodeVisitor; @@ -20,12 +21,14 @@ class CombinedMetricsVisitor implements NodeVisitor private CyclomaticComplexityVisitor $cyclomaticVisitor; private HalsteadMetricsVisitor $halsteadVisitor; private HalsteadMetricsCalculator $halsteadCalculator; + private CyclomaticComplexityCalculator $cyclomaticCalculator; public function __construct() { $this->annotationVisitor = new AnnotationVisitor(); $this->cognitiveVisitor = new CognitiveMetricsVisitor(); - $this->cyclomaticVisitor = new CyclomaticComplexityVisitor(); + $this->cyclomaticCalculator = new CyclomaticComplexityCalculator(); + $this->cyclomaticVisitor = new CyclomaticComplexityVisitor($this->cyclomaticCalculator); $this->halsteadCalculator = new HalsteadMetricsCalculator(); $this->halsteadVisitor = new HalsteadMetricsVisitor($this->halsteadCalculator); } diff --git a/src/PhpParser/CyclomaticComplexityVisitor.php b/src/PhpParser/CyclomaticComplexityVisitor.php index b6a6a3b..a3ee371 100644 --- a/src/PhpParser/CyclomaticComplexityVisitor.php +++ b/src/PhpParser/CyclomaticComplexityVisitor.php @@ -4,6 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\PhpParser; +use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculatorInterface; use PhpParser\Node; use PhpParser\NodeVisitorAbstract; @@ -15,8 +16,7 @@ * - +1 for each decision point (if, while, for, foreach, switch case, catch, etc.) * - +1 for each logical operator (&&, ||, and, or, xor) * - * @SuppressWarnings(TooManyFields) - * @SuppressWarnings(ExcessiveClassComplexity) + * @SuppressWarnings("PHPMD") */ class CyclomaticComplexityVisitor extends NodeVisitorAbstract { @@ -31,7 +31,7 @@ class CyclomaticComplexityVisitor extends NodeVisitorAbstract private array $methodComplexity = []; /** - * @var array Detailed breakdown of complexity factors per method + * @var array> Detailed breakdown of complexity factors per method */ private array $methodComplexityBreakdown = []; @@ -49,6 +49,11 @@ class CyclomaticComplexityVisitor extends NodeVisitorAbstract */ private ?AnnotationVisitor $annotationVisitor = null; + /** + * @var CyclomaticComplexityCalculatorInterface The calculator for cyclomatic complexity + */ + private CyclomaticComplexityCalculatorInterface $calculator; + // Complexity counters for the current method private int $currentMethodComplexity = 1; // Base complexity private int $ifCount = 0; @@ -67,6 +72,16 @@ class CyclomaticComplexityVisitor extends NodeVisitorAbstract private int $logicalXorCount = 0; private int $ternaryCount = 0; + /** + * Constructor for CyclomaticComplexityVisitor. + * + * @param CyclomaticComplexityCalculatorInterface $calculator The calculator for cyclomatic complexity + */ + public function __construct(CyclomaticComplexityCalculatorInterface $calculator) + { + $this->calculator = $calculator; + } + /** * Set the annotation visitor to check for ignored items. */ @@ -311,13 +326,8 @@ private function handleClassMethodLeave(Node $node): void $methodKey = "{$this->currentClassName}::{$this->currentMethod}"; - // Store method complexity - $this->methodComplexity[$methodKey] = $this->currentMethodComplexity; - - // Store detailed breakdown - $this->methodComplexityBreakdown[$methodKey] = [ - 'total' => $this->currentMethodComplexity, - 'base' => 1, + // Create decision point counts array + $decisionPointCounts = [ 'if' => $this->ifCount, 'elseif' => $this->elseIfCount, 'else' => $this->elseCount, @@ -335,6 +345,15 @@ private function handleClassMethodLeave(Node $node): void 'ternary' => $this->ternaryCount, ]; + // Calculate complexity using calculator + $this->currentMethodComplexity = $this->calculator->calculateComplexity($decisionPointCounts); + + // Store method complexity + $this->methodComplexity[$methodKey] = $this->currentMethodComplexity; + + // Store detailed breakdown using calculator + $this->methodComplexityBreakdown[$methodKey] = $this->calculator->createBreakdown($decisionPointCounts, $this->currentMethodComplexity); + // Add method complexity to class complexity if (isset($this->classComplexity[$this->currentClassName])) { $this->classComplexity[$this->currentClassName] += $this->currentMethodComplexity; @@ -394,60 +413,10 @@ public function getMethodComplexityBreakdown(): array /** * Get complexity summary with risk levels. * - * @return array Summary with risk assessment + * @return array Summary with risk assessment */ public function getComplexitySummary(): array { - $summary = [ - 'classes' => [], - 'methods' => [], - 'high_risk_methods' => [], - 'very_high_risk_methods' => [], - ]; - - // Class summary - foreach ($this->classComplexity as $className => $complexity) { - $summary['classes'][$className] = [ - 'complexity' => $complexity, - 'risk_level' => $this->getRiskLevel($complexity), - ]; - } - - // Method summary - foreach ($this->methodComplexity as $methodKey => $complexity) { - $riskLevel = $this->getRiskLevel($complexity); - $summary['methods'][$methodKey] = [ - 'complexity' => $complexity, - 'risk_level' => $riskLevel, - 'breakdown' => $this->methodComplexityBreakdown[$methodKey] ?? [], - ]; - - if ($complexity >= 10) { - $summary['high_risk_methods'][$methodKey] = $complexity; - } - if ($complexity < 15) { - continue; - } - - $summary['very_high_risk_methods'][$methodKey] = $complexity; - } - - return $summary; - } - - /** - * Determine risk level based on complexity. - * - * @param int $complexity The cyclomatic complexity value - * @return string Risk level: 'low', 'medium', 'high', 'very_high' - */ - private function getRiskLevel(int $complexity): string - { - return match (true) { - $complexity <= 5 => 'low', - $complexity <= 10 => 'medium', - $complexity <= 15 => 'high', - default => 'very_high', - }; + return $this->calculator->createSummary($this->classComplexity, $this->methodComplexity, $this->methodComplexityBreakdown); } } diff --git a/tests/Unit/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorTest.php b/tests/Unit/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorTest.php new file mode 100644 index 0000000..f88c02b --- /dev/null +++ b/tests/Unit/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorTest.php @@ -0,0 +1,236 @@ +calculator = new CyclomaticComplexityCalculator(); + } + + public function testCalculateComplexityWithEmptyCounts(): void + { + $decisionPointCounts = []; + $result = $this->calculator->calculateComplexity($decisionPointCounts); + + $this->assertEquals(1, $result, 'Base complexity should be 1'); + } + + public function testCalculateComplexityWithVariousCounts(): void + { + $decisionPointCounts = [ + 'if' => 2, + 'while' => 1, + 'for' => 1, + 'switch' => 1, + 'case' => 3, + 'logical_and' => 2, + 'logical_or' => 1, + ]; + + $result = $this->calculator->calculateComplexity($decisionPointCounts); + + // Base complexity (1) + sum of all counts (2+1+1+1+3+2+1 = 11) = 12 + $this->assertEquals(12, $result, 'Complexity should be base + sum of counts'); + } + + public function testCalculateComplexityWithZeroCounts(): void + { + $decisionPointCounts = [ + 'if' => 0, + 'while' => 0, + 'for' => 0, + 'switch' => 0, + 'case' => 0, + 'logical_and' => 0, + 'logical_or' => 0, + ]; + + $result = $this->calculator->calculateComplexity($decisionPointCounts); + + $this->assertEquals(1, $result, 'Complexity should be base complexity only'); + } + + public function testCreateBreakdown(): void + { + $decisionPointCounts = [ + 'if' => 2, + 'while' => 1, + 'logical_and' => 1, + ]; + $totalComplexity = 5; + + $result = $this->calculator->createBreakdown($decisionPointCounts, $totalComplexity); + + $expected = [ + 'total' => 5, + 'base' => 1, + 'if' => 2, + 'while' => 1, + 'logical_and' => 1, + ]; + + $this->assertEquals($expected, $result, 'Breakdown should include total, base, and all counts'); + } + + public function testCreateBreakdownWithEmptyCounts(): void + { + $decisionPointCounts = []; + $totalComplexity = 1; + + $result = $this->calculator->createBreakdown($decisionPointCounts, $totalComplexity); + + $expected = [ + 'total' => 1, + 'base' => 1, + ]; + + $this->assertEquals($expected, $result, 'Breakdown should include only total and base for empty counts'); + } + + public function testGetRiskLevelLow(): void + { + $this->assertEquals('low', $this->calculator->getRiskLevel(1), 'Complexity 1 should be low risk'); + $this->assertEquals('low', $this->calculator->getRiskLevel(3), 'Complexity 3 should be low risk'); + $this->assertEquals('low', $this->calculator->getRiskLevel(5), 'Complexity 5 should be low risk'); + } + + public function testGetRiskLevelMedium(): void + { + $this->assertEquals('medium', $this->calculator->getRiskLevel(6), 'Complexity 6 should be medium risk'); + $this->assertEquals('medium', $this->calculator->getRiskLevel(8), 'Complexity 8 should be medium risk'); + $this->assertEquals('medium', $this->calculator->getRiskLevel(10), 'Complexity 10 should be medium risk'); + } + + public function testGetRiskLevelHigh(): void + { + $this->assertEquals('high', $this->calculator->getRiskLevel(11), 'Complexity 11 should be high risk'); + $this->assertEquals('high', $this->calculator->getRiskLevel(13), 'Complexity 13 should be high risk'); + $this->assertEquals('high', $this->calculator->getRiskLevel(15), 'Complexity 15 should be high risk'); + } + + public function testGetRiskLevelVeryHigh(): void + { + $this->assertEquals('very_high', $this->calculator->getRiskLevel(16), 'Complexity 16 should be very high risk'); + $this->assertEquals('very_high', $this->calculator->getRiskLevel(25), 'Complexity 25 should be very high risk'); + $this->assertEquals('very_high', $this->calculator->getRiskLevel(100), 'Complexity 100 should be very high risk'); + } + + public function testCreateSummaryWithEmptyData(): void + { + $classComplexities = []; + $methodComplexities = []; + $methodBreakdowns = []; + + $result = $this->calculator->createSummary($classComplexities, $methodComplexities, $methodBreakdowns); + + $expected = [ + 'classes' => [], + 'methods' => [], + 'high_risk_methods' => [], + 'very_high_risk_methods' => [], + ]; + + $this->assertEquals($expected, $result, 'Summary should have empty arrays for empty input'); + } + + public function testCreateSummaryWithClassData(): void + { + $classComplexities = [ + '\\Test\\Class1' => 5, + '\\Test\\Class2' => 12, + ]; + $methodComplexities = []; + $methodBreakdowns = []; + + $result = $this->calculator->createSummary($classComplexities, $methodComplexities, $methodBreakdowns); + + $this->assertArrayHasKey('classes', $result); + $this->assertArrayHasKey('\\Test\\Class1', $result['classes']); + $this->assertArrayHasKey('\\Test\\Class2', $result['classes']); + + $this->assertEquals(5, $result['classes']['\\Test\\Class1']['complexity']); + $this->assertEquals('low', $result['classes']['\\Test\\Class1']['risk_level']); + + $this->assertEquals(12, $result['classes']['\\Test\\Class2']['complexity']); + $this->assertEquals('high', $result['classes']['\\Test\\Class2']['risk_level']); + } + + public function testCreateSummaryWithMethodData(): void + { + $classComplexities = []; + $methodComplexities = [ + '\\Test\\Class::simpleMethod' => 3, + '\\Test\\Class::complexMethod' => 12, + '\\Test\\Class::veryComplexMethod' => 20, + ]; + $methodBreakdowns = [ + '\\Test\\Class::simpleMethod' => ['total' => 3, 'base' => 1, 'if' => 2], + '\\Test\\Class::complexMethod' => ['total' => 12, 'base' => 1, 'if' => 5, 'while' => 3, 'logical_and' => 3], + '\\Test\\Class::veryComplexMethod' => ['total' => 20, 'base' => 1, 'if' => 8, 'for' => 4, 'switch' => 2, 'case' => 5], + ]; + + $result = $this->calculator->createSummary($classComplexities, $methodComplexities, $methodBreakdowns); + + // Check methods + $this->assertArrayHasKey('methods', $result); + $this->assertArrayHasKey('\\Test\\Class::simpleMethod', $result['methods']); + $this->assertArrayHasKey('\\Test\\Class::complexMethod', $result['methods']); + $this->assertArrayHasKey('\\Test\\Class::veryComplexMethod', $result['methods']); + + // Check simple method (low risk) + $this->assertEquals(3, $result['methods']['\\Test\\Class::simpleMethod']['complexity']); + $this->assertEquals('low', $result['methods']['\\Test\\Class::simpleMethod']['risk_level']); + $this->assertEquals(['total' => 3, 'base' => 1, 'if' => 2], $result['methods']['\\Test\\Class::simpleMethod']['breakdown']); + + // Check complex method (high risk) + $this->assertEquals(12, $result['methods']['\\Test\\Class::complexMethod']['complexity']); + $this->assertEquals('high', $result['methods']['\\Test\\Class::complexMethod']['risk_level']); + + // Check very complex method (very high risk) + $this->assertEquals(20, $result['methods']['\\Test\\Class::veryComplexMethod']['complexity']); + $this->assertEquals('very_high', $result['methods']['\\Test\\Class::veryComplexMethod']['risk_level']); + + // Check high risk methods (>= 10) + $this->assertArrayHasKey('high_risk_methods', $result); + $this->assertArrayHasKey('\\Test\\Class::complexMethod', $result['high_risk_methods']); + $this->assertArrayHasKey('\\Test\\Class::veryComplexMethod', $result['high_risk_methods']); + $this->assertEquals(12, $result['high_risk_methods']['\\Test\\Class::complexMethod']); + $this->assertEquals(20, $result['high_risk_methods']['\\Test\\Class::veryComplexMethod']); + + // Check very high risk methods (>= 15) + $this->assertArrayHasKey('very_high_risk_methods', $result); + $this->assertArrayHasKey('\\Test\\Class::veryComplexMethod', $result['very_high_risk_methods']); + $this->assertEquals(20, $result['very_high_risk_methods']['\\Test\\Class::veryComplexMethod']); + + // Simple method should not be in high risk lists + $this->assertArrayNotHasKey('\\Test\\Class::simpleMethod', $result['high_risk_methods']); + $this->assertArrayNotHasKey('\\Test\\Class::simpleMethod', $result['very_high_risk_methods']); + + // Complex method should not be in very high risk list + $this->assertArrayNotHasKey('\\Test\\Class::complexMethod', $result['very_high_risk_methods']); + } + + public function testCreateSummaryWithMissingBreakdown(): void + { + $classComplexities = []; + $methodComplexities = [ + '\\Test\\Class::methodWithoutBreakdown' => 5, + ]; + $methodBreakdowns = []; // Missing breakdown + + $result = $this->calculator->createSummary($classComplexities, $methodComplexities, $methodBreakdowns); + + $this->assertArrayHasKey('methods', $result); + $this->assertArrayHasKey('\\Test\\Class::methodWithoutBreakdown', $result['methods']); + $this->assertEquals([], $result['methods']['\\Test\\Class::methodWithoutBreakdown']['breakdown']); + } +} diff --git a/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php b/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php index 49b1547..d112261 100644 --- a/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php +++ b/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php @@ -4,6 +4,8 @@ namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\PhpParser; +use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculator; +use Phauthentic\CognitiveCodeAnalysis\PhpParser\CyclomaticComplexityVisitor; use PhpParser\ParserFactory; use PhpParser\NodeTraverser; use PHPUnit\Framework\TestCase; @@ -35,7 +37,8 @@ public function foo($a) { $parser = (new ParserFactory())->createForHostVersion(); $ast = $parser->parse($code); $traverser = new NodeTraverser(); - $visitor = new \Phauthentic\CognitiveCodeAnalysis\PhpParser\CyclomaticComplexityVisitor(); + $calculator = new CyclomaticComplexityCalculator(); + $visitor = new CyclomaticComplexityVisitor($calculator); $traverser->addVisitor($visitor); $traverser->traverse($ast); $classKey = '\\MyNamespace\\MyClass';