From 54b12ef53b113e829343592bbaf12d2c1fbebfc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 25 May 2026 01:22:24 +0200 Subject: [PATCH 1/5] Update PHPStan level and refactor application and report handling - Increased PHPStan analysis level from 8 to 9 for stricter type checking and improved code quality. - Refactored `Application` class to use dedicated methods for resolving input and output interfaces, enhancing clarity and error handling. - Improved `ChurnMetrics` and related classes by adding type checks and default value handling for various metrics, ensuring robustness. - Enhanced report generation by implementing consistent value resolution methods across multiple classes, improving data integrity and reducing redundancy. - Updated command classes to format statistic values more reliably, ensuring consistent output across different data types. --- phpstan.neon | 2 +- src/Application.php | 28 ++++- src/Business/Churn/ChurnMetrics.php | 31 +++-- .../Churn/Report/ChurnReportFactory.php | 23 +++- .../Churn/Report/SvgTreemapReport.php | 33 +++-- src/Business/Churn/Report/TreemapMath.php | 13 +- src/Business/CodeCoverage/CloverReader.php | 4 + src/Business/Cognitive/Baseline/Baseline.php | 25 +++- .../Cognitive/Baseline/BaselineFile.php | 18 ++- .../Baseline/BaselineSchemaValidator.php | 8 +- src/Business/Cognitive/CognitiveMetrics.php | 116 +++++++++++++----- .../Cognitive/CognitiveMetricsCollector.php | 21 +++- .../Cognitive/CognitiveMetricsSorter.php | 24 +++- .../Report/CognitiveReportFactory.php | 22 +++- src/Business/Cognitive/Report/JsonReport.php | 79 ++++++------ .../CyclomaticComplexityCalculator.php | 34 ++++- src/Business/Cyclomatic/CyclomaticMetrics.php | 75 ++++++++--- src/Business/Utility/CoverageDataDetector.php | 4 + src/Command/ChurnCommand.php | 15 ++- .../ChurnCommandContext.php | 41 +++++-- .../ChurnSpecifications/CustomExporter.php | 22 ++++ src/Command/CognitiveMetricsCommand.php | 15 ++- .../CognitiveMetricsCommandContext.php | 43 +++++-- .../CustomExporterValidation.php | 22 ++++ src/Command/InitCommand.php | 6 +- .../Pipeline/ChurnExecutionContext.php | 34 ++++- .../ChurnStages/ChurnCalculationStage.php | 2 +- .../Pipeline/ChurnStages/OutputStage.php | 2 +- .../ChurnStages/ReportGenerationStage.php | 2 +- .../BaselineGenerationStage.php | 2 +- .../CognitiveStages/BaselineStage.php | 2 +- .../MetricsCollectionStage.php | 2 +- .../Pipeline/CognitiveStages/OutputStage.php | 4 +- .../CognitiveStages/ReportGenerationStage.php | 2 +- .../Pipeline/CognitiveStages/SortingStage.php | 6 +- src/Command/Pipeline/ExecutionContext.php | 55 ++++++++- src/Command/Presentation/TableRowBuilder.php | 40 ++++-- src/Config/ConfigFactory.php | 82 +++++++++---- src/Config/ConfigInitializer.php | 5 + 39 files changed, 758 insertions(+), 206 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index c308706..0da04a3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 8 + level: 9 paths: - src parallel: diff --git a/src/Application.php b/src/Application.php index 3dcfed7..6fd3973 100644 --- a/src/Application.php +++ b/src/Application.php @@ -258,12 +258,12 @@ private function bootstrapMetricsCollectors(): void private function configureEventBus(): void { $progressbar = new ProgressBarHandler( - $this->get(OutputInterface::class) + $this->resolveOutputInterface() ); $verbose = new VerboseHandler( - $this->get(InputInterface::class), - $this->get(OutputInterface::class) + $this->resolveInputInterface(), + $this->resolveOutputInterface() ); $handlersLocator = $this->setUpEventHandlersLocator($progressbar, $verbose); @@ -502,8 +502,28 @@ private function setUpEventHandlersLocator( $verbose ], ParserFailed::class => [ - new ParserErrorHandler($this->get(OutputInterface::class)) + new ParserErrorHandler($this->resolveOutputInterface()) ], ]); } + + private function resolveInputInterface(): InputInterface + { + $input = $this->get(InputInterface::class); + if (!$input instanceof InputInterface) { + throw new CognitiveAnalysisException('Console input is not configured.'); + } + + return $input; + } + + private function resolveOutputInterface(): OutputInterface + { + $output = $this->get(OutputInterface::class); + if (!$output instanceof OutputInterface) { + throw new CognitiveAnalysisException('Console output is not configured.'); + } + + return $output; + } } diff --git a/src/Business/Churn/ChurnMetrics.php b/src/Business/Churn/ChurnMetrics.php index 032de84..f4bf96f 100644 --- a/src/Business/Churn/ChurnMetrics.php +++ b/src/Business/Churn/ChurnMetrics.php @@ -49,18 +49,35 @@ public function __construct( */ public static function fromArray(string $className, array $data): self { + $file = $data['file'] ?? ''; + $riskLevel = $data['riskLevel'] ?? null; + return new self( className: $className, - file: $data['file'] ?? '', - score: (float)($data['score'] ?? 0), - timesChanged: (int)($data['timesChanged'] ?? 0), - churn: (float)($data['churn'] ?? 0), - coverage: isset($data['coverage']) ? (float)$data['coverage'] : null, - riskChurn: isset($data['riskChurn']) ? (float)$data['riskChurn'] : null, - riskLevel: $data['riskLevel'] ?? null + file: is_string($file) ? $file : '', + score: self::resolveFloatValue($data['score'] ?? null, 0.0), + timesChanged: self::resolveIntValue($data['timesChanged'] ?? null, 0), + churn: self::resolveFloatValue($data['churn'] ?? null, 0.0), + coverage: isset($data['coverage']) ? self::resolveFloatValue($data['coverage'], 0.0) : null, + riskChurn: isset($data['riskChurn']) ? self::resolveFloatValue($data['riskChurn'], 0.0) : null, + riskLevel: is_string($riskLevel) ? $riskLevel : null ); } + private static function resolveIntValue(mixed $value, int $default): int + { + return is_int($value) ? $value : $default; + } + + private static function resolveFloatValue(mixed $value, float $default): float + { + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + return $default; + } + /** * Convert to array format (for backward compatibility). * diff --git a/src/Business/Churn/Report/ChurnReportFactory.php b/src/Business/Churn/Report/ChurnReportFactory.php index 2ab455d..72c8472 100644 --- a/src/Business/Churn/Report/ChurnReportFactory.php +++ b/src/Business/Churn/Report/ChurnReportFactory.php @@ -47,9 +47,14 @@ public function create(string $type): ReportGeneratorInterface return $builtIn; } - // Check custom exporters if (isset($customReporters[$type])) { - return $this->createCustomExporter($customReporters[$type]); + $exporterConfig = $customReporters[$type]; + if (!is_array($exporterConfig)) { + throw new InvalidArgumentException("Invalid custom exporter configuration for type: {$type}"); + } + + /** @var array $exporterConfig */ + return $this->createCustomExporter($exporterConfig); } throw new InvalidArgumentException("Unsupported exporter type: {$type}"); @@ -65,9 +70,19 @@ private function createCustomExporter(array $config): ReportGeneratorInterface { $cognitiveConfig = $this->configService->getConfig(); - $this->registry->loadExporter($config['class'], $config['file'] ?? null); + $class = $config['class'] ?? null; + if (!is_string($class)) { + throw new InvalidArgumentException('Custom exporter must define a "class" string.'); + } + + $file = $config['file'] ?? null; + if ($file !== null && !is_string($file)) { + throw new InvalidArgumentException('Custom exporter "file" must be a string or null.'); + } + + $this->registry->loadExporter($class, $file); $exporter = $this->registry->instantiate( - $config['class'], + $class, $cognitiveConfig ); $this->registry->validateInterface($exporter, ReportGeneratorInterface::class); diff --git a/src/Business/Churn/Report/SvgTreemapReport.php b/src/Business/Churn/Report/SvgTreemapReport.php index 3440c29..3addc9e 100644 --- a/src/Business/Churn/Report/SvgTreemapReport.php +++ b/src/Business/Churn/Report/SvgTreemapReport.php @@ -82,7 +82,12 @@ private function renderSvgRects(array $rects, float $minScore, float $maxScore): { $svgRects = []; foreach ($rects as $rect) { - $normalizedScore = $this->treemapMath->normalizeScore(score: $rect['score'], minScore: $minScore, maxScore: $maxScore); + $score = $this->resolveFloatValue($rect['score'] ?? null, 0.0); + $normalizedScore = $this->treemapMath->normalizeScore( + score: $score, + minScore: $minScore, + maxScore: $maxScore + ); $svgRects[] = $this->renderSvgRect(rect: $rect, normalizedScore: $normalizedScore); } @@ -98,17 +103,18 @@ private function renderSvgRects(array $rects, float $minScore, float $maxScore): */ private function renderSvgRect(array $rect, float $normalizedScore): string { - $x = $rect['x'] + self::PADDING; - $y = $rect['y'] + self::PADDING; - $width = max(0, $rect['width'] - self::PADDING * 2); - $height = max(0, $rect['height'] - self::PADDING * 2); + $x = $this->resolveFloatValue($rect['x'] ?? null, 0.0) + self::PADDING; + $y = $this->resolveFloatValue($rect['y'] ?? null, 0.0) + self::PADDING; + $width = max(0, $this->resolveFloatValue($rect['width'] ?? null, 0.0) - self::PADDING * 2); + $height = max(0, $this->resolveFloatValue($rect['height'] ?? null, 0.0) - self::PADDING * 2); $color = $this->treemapMath->scoreToColor(score: $normalizedScore); - $class = htmlspecialchars($rect['class']); - $churn = $rect['churn']; - $score = $rect['score']; + $className = is_string($rect['class'] ?? null) ? $rect['class'] : ''; + $class = htmlspecialchars($className); + $churn = $this->resolveFloatValue($rect['churn'] ?? null, 0.0); + $score = $this->resolveFloatValue($rect['score'] ?? null, 0.0); $textX = $x + 4; $textY = $y + 18; - $label = htmlspecialchars(mb_strimwidth($rect['class'], 0, 40, '…')); + $label = htmlspecialchars(mb_strimwidth($className, 0, 40, '…')); return sprintf( '%s Churn: %s Score: %s%s', @@ -153,4 +159,13 @@ private function wrapSvg(string $rectsSvg): string SVG; } + + private function resolveFloatValue(mixed $value, float $default): float + { + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + return $default; + } } diff --git a/src/Business/Churn/Report/TreemapMath.php b/src/Business/Churn/Report/TreemapMath.php index e050461..027da00 100644 --- a/src/Business/Churn/Report/TreemapMath.php +++ b/src/Business/Churn/Report/TreemapMath.php @@ -29,8 +29,8 @@ public function prepareItems(array $classes): array { $items = []; foreach ($classes as $class => $data) { - $churn = (float)($data['churn'] ?? 0); - $score = (float)($data['score'] ?? 0); + $churn = $this->resolveFloatValue($data['churn'] ?? null, 0.0); + $score = $this->resolveFloatValue($data['score'] ?? null, 0.0); if ($churn <= 0) { continue; } @@ -261,4 +261,13 @@ private function findSplitIndex(array $items, float $sum): int return 1; } + + private function resolveFloatValue(mixed $value, float $default): float + { + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + return $default; + } } diff --git a/src/Business/CodeCoverage/CloverReader.php b/src/Business/CodeCoverage/CloverReader.php index 18e4dc9..5c04fe5 100644 --- a/src/Business/CodeCoverage/CloverReader.php +++ b/src/Business/CodeCoverage/CloverReader.php @@ -220,6 +220,10 @@ private function extractMethodCoverageFromLines(mixed $allLines, int $methodLine $coveredStatements = 0; $inMethod = false; + if (!is_iterable($allLines)) { + return ['statements' => 0, 'covered' => 0]; + } + foreach ($allLines as $line) { if (!$line instanceof DOMElement) { continue; diff --git a/src/Business/Cognitive/Baseline/Baseline.php b/src/Business/Cognitive/Baseline/Baseline.php index c81e7f8..12eec01 100644 --- a/src/Business/Cognitive/Baseline/Baseline.php +++ b/src/Business/Cognitive/Baseline/Baseline.php @@ -23,7 +23,17 @@ public function calculateDeltas( $warnings = []; foreach ($baseline as $class => $data) { - foreach ($data['methods'] as $methodName => $methodData) { + $methods = $data['methods'] ?? null; + if (!is_array($methods)) { + continue; + } + + foreach ($methods as $methodName => $methodData) { + if (!is_string($methodName) || !is_array($methodData)) { + continue; + } + + /** @var array $methodData */ $metrics = $metricsCollection->getClassWithMethod($class, $methodName); if (!$metrics) { continue; @@ -57,6 +67,11 @@ public function loadBaseline(string $baselineFile): array } $data = json_decode($baseline, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new CognitiveAnalysisException('Baseline file must contain a JSON object.'); + } + + /** @var array $data */ // Validate against JSON schema $validator = new BaselineSchemaValidator(); @@ -68,9 +83,11 @@ public function loadBaseline(string $baselineFile): array } $result = BaselineFile::fromJson($data); + /** @var array> $metrics */ + $metrics = $result['metrics']; return [ - 'metrics' => $result['metrics'], + 'metrics' => $metrics, 'baselineFile' => $result['baselineFile'], 'warnings' => [] ]; @@ -182,10 +199,12 @@ public function isValidBaselineFile(string $filePath): bool } $data = json_decode($content, true); - if ($data === null) { + if (!is_array($data)) { return false; } + /** @var array $data */ + // Use schema validator for comprehensive validation $validator = new BaselineSchemaValidator(); return $validator->isValidBaseline($data); diff --git a/src/Business/Cognitive/Baseline/BaselineFile.php b/src/Business/Cognitive/Baseline/BaselineFile.php index b4e9156..6a15e24 100644 --- a/src/Business/Cognitive/Baseline/BaselineFile.php +++ b/src/Business/Cognitive/Baseline/BaselineFile.php @@ -51,19 +51,29 @@ public static function fromJson(array $data): array { // Check if this is the new format (has version field) if (isset($data['version']) && $data['version'] === self::VERSION) { + $createdAt = $data['createdAt'] ?? null; + $configHash = $data['configHash'] ?? null; + $metrics = $data['metrics'] ?? null; + + if (!is_string($createdAt) || !is_string($configHash) || !is_array($metrics)) { + throw new \InvalidArgumentException('Invalid baseline file metadata.'); + } + + /** @var array> $metrics */ $baselineFile = new self( - $data['createdAt'], - $data['configHash'], - $data['metrics'] + $createdAt, + $configHash, + $metrics ); return [ 'baselineFile' => $baselineFile, - 'metrics' => $data['metrics'] + 'metrics' => $metrics ]; } // Old format - return null for baselineFile, data as metrics + /** @var array $data */ return [ 'baselineFile' => null, 'metrics' => $data diff --git a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php index b247e6a..e60dded 100644 --- a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php +++ b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php @@ -57,7 +57,8 @@ private function validateNewFormat(array $data): array // Validate version if ($data['version'] !== '2.0') { - $errors[] = "Invalid version: {$data['version']}. Expected: 2.0"; + $version = is_string($data['version']) ? $data['version'] : gettype($data['version']); + $errors[] = "Invalid version: {$version}. Expected: 2.0"; } // Validate createdAt format @@ -70,10 +71,13 @@ private function validateNewFormat(array $data): array $errors[] = "Invalid configHash. Must be a non-empty string"; } - $errors = array_merge($errors, $this->validateMetrics($data['metrics'])); // Validate metrics structure if (!is_array($data['metrics'])) { $errors[] = "Invalid metrics. Must be an object"; + } else { + /** @var array $metrics */ + $metrics = $data['metrics']; + $errors = array_merge($errors, $this->validateMetrics($metrics)); } // Check for additional properties diff --git a/src/Business/Cognitive/CognitiveMetrics.php b/src/Business/Cognitive/CognitiveMetrics.php index ce58123..3828422 100644 --- a/src/Business/Cognitive/CognitiveMetrics.php +++ b/src/Business/Cognitive/CognitiveMetrics.php @@ -79,16 +79,18 @@ public function __construct(array $metrics) $this->assertArrayKeyIsPresent($metrics, 'class'); $this->assertArrayKeyIsPresent($metrics, 'method'); - $this->method = $metrics['method']; - $this->class = $metrics['class']; - $this->file = $metrics['file'] ?? null; - $this->line = $metrics['line'] ?? 0; + $this->method = $this->resolveStringValue($metrics['method']); + $this->class = $this->resolveStringValue($metrics['class']); + $this->file = isset($metrics['file']) ? $this->resolveStringValue($metrics['file']) : ''; + $this->line = isset($metrics['line']) ? $this->resolveIntValue($metrics['line'], 0) : 0; $this->setRequiredMetricProperties($metrics); $this->setOptionalMetricProperties($metrics); - if (isset($metrics['halstead'])) { - $this->halstead = new HalsteadMetrics($metrics['halstead']); + if (isset($metrics['halstead']) && is_array($metrics['halstead'])) { + /** @var array $halsteadData */ + $halsteadData = $metrics['halstead']; + $this->halstead = new HalsteadMetrics($this->resolveHalsteadData($halsteadData)); } // Handle baseline format with individual halstead fields @@ -96,25 +98,30 @@ public function __construct(array $metrics) $this->halstead = new HalsteadMetrics([ 'n1' => 0, 'n2' => 0, 'N1' => 0, 'N2' => 0, 'programLength' => 0, 'programVocabulary' => 0, - 'volume' => $metrics['halsteadVolume'], - 'difficulty' => $metrics['halsteadDifficulty'] ?? 0.0, - 'effort' => $metrics['halsteadEffort'] ?? 0.0, - 'fqName' => $metrics['class'] . '::' . $metrics['method'] + 'volume' => $this->resolveFloatValue($metrics['halsteadVolume'], 0.0), + 'difficulty' => $this->resolveFloatValue($metrics['halsteadDifficulty'] ?? null, 0.0), + 'effort' => $this->resolveFloatValue($metrics['halsteadEffort'] ?? null, 0.0), + 'fqName' => $this->class . '::' . $this->method, ]); } 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' => $metrics['cyclomaticComplexity'], - 'riskLevel' => $metrics['cyclomaticRiskLevel'] ?? 'unknown' + 'complexity' => $this->resolveIntValue($metrics['cyclomaticComplexity'], 1), + 'riskLevel' => is_string($riskLevel) ? $riskLevel : 'unknown', ]); } return; } - $this->cyclomatic = new CyclomaticMetrics($metrics['cyclomatic_complexity']); + if (is_array($metrics['cyclomatic_complexity'])) { + /** @var array $cyclomaticData */ + $cyclomaticData = $metrics['cyclomatic_complexity']; + $this->cyclomatic = new CyclomaticMetrics($cyclomaticData); + } } /** @@ -125,9 +132,9 @@ private function setRequiredMetricProperties(array $metrics): void { $missingKeys = array_diff_key($this->metrics, $metrics); if (!empty($missingKeys)) { - $class = $metrics['class'] ?? 'Unknown'; - $method = $metrics['method'] ?? 'Unknown'; - $file = $metrics['file'] ?? 'Unknown'; + $class = is_string($metrics['class'] ?? null) ? $metrics['class'] : 'Unknown'; + $method = is_string($metrics['method'] ?? null) ? $metrics['method'] : 'Unknown'; + $file = is_string($metrics['file'] ?? null) ? $metrics['file'] : 'Unknown'; $errorMessage = sprintf( 'Missing required keys for %s::%s in file %s: %s. Available keys: %s', @@ -142,14 +149,14 @@ private function setRequiredMetricProperties(array $metrics): void } // Not pretty to set each but more efficient than using a loop and $this->metrics - $this->lineCount = $metrics['lineCount']; - $this->argCount = $metrics['argCount']; - $this->returnCount = $metrics['returnCount']; - $this->variableCount = $metrics['variableCount']; - $this->propertyCallCount = $metrics['propertyCallCount']; - $this->ifCount = $metrics['ifCount']; - $this->ifNestingLevel = $metrics['ifNestingLevel']; - $this->elseCount = $metrics['elseCount']; + $this->lineCount = $this->resolveIntValue($metrics['lineCount'], 0); + $this->argCount = $this->resolveIntValue($metrics['argCount'], 0); + $this->returnCount = $this->resolveIntValue($metrics['returnCount'], 0); + $this->variableCount = $this->resolveIntValue($metrics['variableCount'], 0); + $this->propertyCallCount = $this->resolveIntValue($metrics['propertyCallCount'], 0); + $this->ifCount = $this->resolveIntValue($metrics['ifCount'], 0); + $this->ifNestingLevel = $this->resolveIntValue($metrics['ifNestingLevel'], 0); + $this->elseCount = $this->resolveIntValue($metrics['elseCount'], 0); } /** @@ -159,14 +166,14 @@ private function setRequiredMetricProperties(array $metrics): void private function setOptionalMetricProperties(array $metrics): void { // Not pretty to set each but more efficient than using a loop and $this->metrics - $this->lineCountWeight = $metrics['lineCountWeight'] ?? 0.0; - $this->argCountWeight = $metrics['argCountWeight'] ?? 0.0; - $this->returnCountWeight = $metrics['returnCountWeight'] ?? 0.0; - $this->variableCountWeight = $metrics['variableCountWeight'] ?? 0.0; - $this->propertyCallCountWeight = $metrics['propertyCallCountWeight'] ?? 0.0; - $this->ifCountWeight = $metrics['ifCountWeight'] ?? 0.0; - $this->ifNestingLevelWeight = $metrics['ifNestingLevelWeight'] ?? 0.0; - $this->elseCountWeight = $metrics['elseCountWeight'] ?? 0.0; + $this->lineCountWeight = $this->resolveFloatValue($metrics['lineCountWeight'] ?? null, 0.0); + $this->argCountWeight = $this->resolveFloatValue($metrics['argCountWeight'] ?? null, 0.0); + $this->returnCountWeight = $this->resolveFloatValue($metrics['returnCountWeight'] ?? null, 0.0); + $this->variableCountWeight = $this->resolveFloatValue($metrics['variableCountWeight'] ?? null, 0.0); + $this->propertyCallCountWeight = $this->resolveFloatValue($metrics['propertyCallCountWeight'] ?? null, 0.0); + $this->ifCountWeight = $this->resolveFloatValue($metrics['ifCountWeight'] ?? null, 0.0); + $this->ifNestingLevelWeight = $this->resolveFloatValue($metrics['ifNestingLevelWeight'] ?? null, 0.0); + $this->elseCountWeight = $this->resolveFloatValue($metrics['elseCountWeight'] ?? null, 0.0); } public function setTimesChanged(int $timesChanged): void @@ -527,4 +534,49 @@ public function getCyclomatic(): ?CyclomaticMetrics { return $this->cyclomatic; } + + private function resolveStringValue(mixed $value): string + { + if (!is_string($value)) { + throw new InvalidArgumentException('Expected string value.'); + } + + return $value; + } + + private function resolveIntValue(mixed $value, int $default): int + { + return is_int($value) ? $value : $default; + } + + private function resolveFloatValue(mixed $value, float $default): float + { + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + return $default; + } + + /** + * @param array $data + * @return array + */ + private function resolveHalsteadData(array $data): array + { + $fqName = $data['fqName'] ?? ''; + + return [ + 'n1' => $this->resolveIntValue($data['n1'] ?? null, 0), + 'n2' => $this->resolveIntValue($data['n2'] ?? null, 0), + 'N1' => $this->resolveIntValue($data['N1'] ?? null, 0), + 'N2' => $this->resolveIntValue($data['N2'] ?? null, 0), + 'programLength' => $this->resolveIntValue($data['programLength'] ?? null, 0), + 'programVocabulary' => $this->resolveIntValue($data['programVocabulary'] ?? null, 0), + 'volume' => $this->resolveFloatValue($data['volume'] ?? null, 0.0), + 'difficulty' => $this->resolveFloatValue($data['difficulty'] ?? null, 0.0), + 'effort' => $this->resolveFloatValue($data['effort'] ?? null, 0.0), + 'fqName' => is_string($fqName) ? $fqName : '', + ]; + } } diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index bfae4a4..889866f 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -233,12 +233,17 @@ private function processMethodMetrics( string $file ): CognitiveMetricsCollection { foreach ($methodMetrics as $classAndMethod => $metrics) { + if (!is_array($metrics)) { + continue; + } + if ($this->isExcluded($classAndMethod)) { continue; } [$class, $method] = explode('::', $classAndMethod); + /** @var array $metricsArray */ $metricsArray = array_merge($metrics, [ 'class' => $class, 'method' => $method, @@ -354,10 +359,22 @@ private function getCachedMetrics(SplFileInfo $file, string $configHash, bool $u } $cachedData = $cacheItem->get(); - $this->ignoredItems = $cachedData['ignored_items'] ?? []; + if (!is_array($cachedData)) { + return ['metrics' => null, 'cacheItem' => $cacheItem]; + } + + $ignoredItems = $cachedData['ignored_items'] ?? []; + $this->ignoredItems = is_array($ignoredItems) ? $ignoredItems : []; $this->messageBus->dispatch(new FileProcessed($file)); - return ['metrics' => $cachedData['analysis_result'], 'cacheItem' => $cacheItem]; + $analysisResult = $cachedData['analysis_result'] ?? null; + if (!is_array($analysisResult)) { + return ['metrics' => null, 'cacheItem' => $cacheItem]; + } + + /** @var array $analysisResult */ + + return ['metrics' => $analysisResult, 'cacheItem' => $cacheItem]; } /** diff --git a/src/Business/Cognitive/CognitiveMetricsSorter.php b/src/Business/Cognitive/CognitiveMetricsSorter.php index 1797ded..6902871 100644 --- a/src/Business/Cognitive/CognitiveMetricsSorter.php +++ b/src/Business/Cognitive/CognitiveMetricsSorter.php @@ -143,8 +143,28 @@ private function compareValues(mixed $alpha, mixed $beta): int return strcasecmp($alpha, $beta); } - // Handle mixed types by converting to string - return strcasecmp((string) $alpha, (string) $beta); + return strcasecmp($this->valueToSortString($alpha), $this->valueToSortString($beta)); + } + + private function valueToSortString(mixed $value): string + { + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if ($value === null) { + return ''; + } + + throw new InvalidArgumentException('Unsupported sort value type.'); } /** diff --git a/src/Business/Cognitive/Report/CognitiveReportFactory.php b/src/Business/Cognitive/Report/CognitiveReportFactory.php index c8511d7..302844d 100644 --- a/src/Business/Cognitive/Report/CognitiveReportFactory.php +++ b/src/Business/Cognitive/Report/CognitiveReportFactory.php @@ -52,7 +52,13 @@ public function create(string $type): ReportGeneratorInterface } if (isset($customReporters[$type])) { - return $this->createCustomExporter($customReporters[$type]); + $exporterConfig = $customReporters[$type]; + if (!is_array($exporterConfig)) { + throw new InvalidArgumentException("Invalid custom exporter configuration for type: {$type}"); + } + + /** @var array $exporterConfig */ + return $this->createCustomExporter($exporterConfig); } throw new InvalidArgumentException("Unsupported exporter type: {$type}"); @@ -68,9 +74,19 @@ private function createCustomExporter(array $config): ReportGeneratorInterface { $cognitiveConfig = $this->configService->getConfig(); - $this->registry->loadExporter($config['class'], $config['file'] ?? null); + $class = $config['class'] ?? null; + if (!is_string($class)) { + throw new InvalidArgumentException('Custom exporter must define a "class" string.'); + } + + $file = $config['file'] ?? null; + if ($file !== null && !is_string($file)) { + throw new InvalidArgumentException('Custom exporter "file" must be a string or null.'); + } + + $this->registry->loadExporter($class, $file); $exporter = $this->registry->instantiate( - $config['class'], + $class, $cognitiveConfig ); $this->registry->validateInterface($exporter, ReportGeneratorInterface::class); diff --git a/src/Business/Cognitive/Report/JsonReport.php b/src/Business/Cognitive/Report/JsonReport.php index 7bfba59..e6b2a35 100644 --- a/src/Business/Cognitive/Report/JsonReport.php +++ b/src/Business/Cognitive/Report/JsonReport.php @@ -4,13 +4,14 @@ namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; class JsonReport implements ReportGeneratorInterface, StreamableReportInterface { private ?string $filename = null; - /** @var array */ + /** @var array>}> */ private array $jsonData = []; private bool $isStreaming = false; @@ -25,27 +26,7 @@ public function export(CognitiveMetricsCollection $metricsCollection, string $fi foreach ($groupedByClass as $class => $methods) { foreach ($methods as $metrics) { - $jsonData[$class]['methods'][$metrics->getMethod()] = [ - 'class' => $metrics->getClass(), - 'method' => $metrics->getMethod(), - 'lineCount' => $metrics->getLineCount(), - 'lineCountWeight' => $metrics->getLineCountWeight(), - 'argCount' => $metrics->getArgCount(), - 'argCountWeight' => $metrics->getArgCountWeight(), - 'returnCount' => $metrics->getReturnCount(), - 'returnCountWeight' => $metrics->getReturnCountWeight(), - 'variableCount' => $metrics->getVariableCount(), - 'variableCountWeight' => $metrics->getVariableCountWeight(), - 'propertyCallCount' => $metrics->getPropertyCallCount(), - 'propertyCallCountWeight' => $metrics->getPropertyCallCountWeight(), - 'ifCount' => $metrics->getIfCount(), - 'ifCountWeight' => $metrics->getIfCountWeight(), - 'ifNestingLevel' => $metrics->getIfNestingLevel(), - 'ifNestingLevelWeight' => $metrics->getIfNestingLevelWeight(), - 'elseCount' => $metrics->getElseCount(), - 'elseCountWeight' => $metrics->getElseCountWeight(), - 'score' => $metrics->getScore() - ]; + $this->addClassMethodMetrics($jsonData, (string) $class, $metrics); } } @@ -76,31 +57,43 @@ public function writeMetricBatch(CognitiveMetricsCollection $batch): void foreach ($groupedByClass as $class => $methods) { foreach ($methods as $metrics) { - $this->jsonData[$class]['methods'][$metrics->getMethod()] = [ - 'class' => $metrics->getClass(), - 'method' => $metrics->getMethod(), - 'lineCount' => $metrics->getLineCount(), - 'lineCountWeight' => $metrics->getLineCountWeight(), - 'argCount' => $metrics->getArgCount(), - 'argCountWeight' => $metrics->getArgCountWeight(), - 'returnCount' => $metrics->getReturnCount(), - 'returnCountWeight' => $metrics->getReturnCountWeight(), - 'variableCount' => $metrics->getVariableCount(), - 'variableCountWeight' => $metrics->getVariableCountWeight(), - 'propertyCallCount' => $metrics->getPropertyCallCount(), - 'propertyCallCountWeight' => $metrics->getPropertyCallCountWeight(), - 'ifCount' => $metrics->getIfCount(), - 'ifCountWeight' => $metrics->getIfCountWeight(), - 'ifNestingLevel' => $metrics->getIfNestingLevel(), - 'ifNestingLevelWeight' => $metrics->getIfNestingLevelWeight(), - 'elseCount' => $metrics->getElseCount(), - 'elseCountWeight' => $metrics->getElseCountWeight(), - 'score' => $metrics->getScore() - ]; + $this->addClassMethodMetrics($this->jsonData, (string) $class, $metrics); } } } + /** + * @param array>}> $jsonData + */ + private function addClassMethodMetrics(array &$jsonData, string $class, CognitiveMetrics $metrics): void + { + if (!isset($jsonData[$class])) { + $jsonData[$class] = ['methods' => []]; + } + + $jsonData[$class]['methods'][$metrics->getMethod()] = [ + 'class' => $metrics->getClass(), + 'method' => $metrics->getMethod(), + 'lineCount' => $metrics->getLineCount(), + 'lineCountWeight' => $metrics->getLineCountWeight(), + 'argCount' => $metrics->getArgCount(), + 'argCountWeight' => $metrics->getArgCountWeight(), + 'returnCount' => $metrics->getReturnCount(), + 'returnCountWeight' => $metrics->getReturnCountWeight(), + 'variableCount' => $metrics->getVariableCount(), + 'variableCountWeight' => $metrics->getVariableCountWeight(), + 'propertyCallCount' => $metrics->getPropertyCallCount(), + 'propertyCallCountWeight' => $metrics->getPropertyCallCountWeight(), + 'ifCount' => $metrics->getIfCount(), + 'ifCountWeight' => $metrics->getIfCountWeight(), + 'ifNestingLevel' => $metrics->getIfNestingLevel(), + 'ifNestingLevelWeight' => $metrics->getIfNestingLevelWeight(), + 'elseCount' => $metrics->getElseCount(), + 'elseCountWeight' => $metrics->getElseCountWeight(), + 'score' => $metrics->getScore(), + ]; + } + /** * @throws \JsonException|CognitiveAnalysisException */ diff --git a/src/Business/Cyclomatic/CyclomaticComplexityCalculator.php b/src/Business/Cyclomatic/CyclomaticComplexityCalculator.php index 1176e73..1eb9067 100644 --- a/src/Business/Cyclomatic/CyclomaticComplexityCalculator.php +++ b/src/Business/Cyclomatic/CyclomaticComplexityCalculator.php @@ -93,25 +93,44 @@ public function createSummary(array $classComplexities, array $methodComplexitie */ public function methodSummary(array $methodComplexities, array $methodBreakdowns, array $summary): array { + $methods = $summary['methods'] ?? []; + if (!is_array($methods)) { + $methods = []; + } + + $highRiskMethods = $summary['high_risk_methods'] ?? []; + if (!is_array($highRiskMethods)) { + $highRiskMethods = []; + } + + $veryHighRiskMethods = $summary['very_high_risk_methods'] ?? []; + if (!is_array($veryHighRiskMethods)) { + $veryHighRiskMethods = []; + } + foreach ($methodComplexities as $methodKey => $complexity) { $riskLevel = $this->getRiskLevel($complexity); - $summary['methods'][$methodKey] = [ + $methods[$methodKey] = [ 'complexity' => $complexity, 'risk_level' => $riskLevel, 'breakdown' => $methodBreakdowns[$methodKey] ?? [], ]; if ($complexity >= 10) { - $summary['high_risk_methods'][$methodKey] = $complexity; + $highRiskMethods[$methodKey] = $complexity; } if ($complexity < 15) { continue; } - $summary['very_high_risk_methods'][$methodKey] = $complexity; + $veryHighRiskMethods[$methodKey] = $complexity; } + $summary['methods'] = $methods; + $summary['high_risk_methods'] = $highRiskMethods; + $summary['very_high_risk_methods'] = $veryHighRiskMethods; + return $summary; } @@ -124,13 +143,20 @@ public function methodSummary(array $methodComplexities, array $methodBreakdowns */ public function classSummary(array $classComplexities, array $summary): array { + $classes = $summary['classes'] ?? []; + if (!is_array($classes)) { + $classes = []; + } + foreach ($classComplexities as $className => $complexity) { - $summary['classes'][$className] = [ + $classes[$className] = [ 'complexity' => $complexity, 'risk_level' => $this->getRiskLevel($complexity), ]; } + $summary['classes'] = $classes; + return $summary; } } diff --git a/src/Business/Cyclomatic/CyclomaticMetrics.php b/src/Business/Cyclomatic/CyclomaticMetrics.php index 155b799..f37b43d 100644 --- a/src/Business/Cyclomatic/CyclomaticMetrics.php +++ b/src/Business/Cyclomatic/CyclomaticMetrics.php @@ -128,25 +128,62 @@ class CyclomaticMetrics */ public function __construct(array $data) { + $breakdown = $data['breakdown'] ?? null; + if (!is_array($breakdown)) { + $breakdown = []; + } + + $this->complexity = $this->resolveIntValue($data['complexity'] ?? null, 1); + $this->riskLevel = $this->resolveRiskLevel($data); + $this->totalCount = $this->resolveCountValue($data, $breakdown, 'totalCount', 'total', 0); + $this->baseCount = $this->resolveCountValue($data, $breakdown, 'baseCount', 'base', 1); + $this->ifCount = $this->resolveCountValue($data, $breakdown, 'ifCount', 'if', 0); + $this->elseifCount = $this->resolveCountValue($data, $breakdown, 'elseifCount', 'elseif', 0); + $this->elseCount = $this->resolveCountValue($data, $breakdown, 'elseCount', 'else', 0); + $this->switchCount = $this->resolveCountValue($data, $breakdown, 'switchCount', 'switch', 0); + $this->caseCount = $this->resolveCountValue($data, $breakdown, 'caseCount', 'case', 0); + $this->defaultCount = $this->resolveCountValue($data, $breakdown, 'defaultCount', 'default', 0); + $this->whileCount = $this->resolveCountValue($data, $breakdown, 'whileCount', 'while', 0); + $this->doWhileCount = $this->resolveCountValue($data, $breakdown, 'doWhileCount', 'do_while', 0); + $this->forCount = $this->resolveCountValue($data, $breakdown, 'forCount', 'for', 0); + $this->foreachCount = $this->resolveCountValue($data, $breakdown, 'foreachCount', 'foreach', 0); + $this->catchCount = $this->resolveCountValue($data, $breakdown, 'catchCount', 'catch', 0); + $this->logicalAndCount = $this->resolveCountValue($data, $breakdown, 'logicalAndCount', 'logical_and', 0); + $this->logicalOrCount = $this->resolveCountValue($data, $breakdown, 'logicalOrCount', 'logical_or', 0); + $this->logicalXorCount = $this->resolveCountValue($data, $breakdown, 'logicalXorCount', 'logical_xor', 0); + $this->ternaryCount = $this->resolveCountValue($data, $breakdown, 'ternaryCount', 'ternary', 0); + } - $this->complexity = $data['complexity'] ?? 1; - $this->riskLevel = (string)($data['risk_level'] ?? $data['riskLevel'] ?? 'unknown'); - $this->totalCount = $data['totalCount'] ?? $data['breakdown']['total'] ?? 0; - $this->baseCount = $data['baseCount'] ?? $data['breakdown']['base'] ?? 1; - $this->ifCount = $data['ifCount'] ?? $data['breakdown']['if'] ?? 0; - $this->elseifCount = $data['elseifCount'] ?? $data['breakdown']['elseif'] ?? 0; - $this->elseCount = $data['elseCount'] ?? $data['breakdown']['else'] ?? 0; - $this->switchCount = $data['switchCount'] ?? $data['breakdown']['switch'] ?? 0; - $this->caseCount = $data['caseCount'] ?? $data['breakdown']['case'] ?? 0; - $this->defaultCount = $data['defaultCount'] ?? $data['breakdown']['default'] ?? 0; - $this->whileCount = $data['whileCount'] ?? $data['breakdown']['while'] ?? 0; - $this->doWhileCount = $data['doWhileCount'] ?? $data['breakdown']['do_while'] ?? 0; - $this->forCount = $data['forCount'] ?? $data['breakdown']['for'] ?? 0; - $this->foreachCount = $data['foreachCount'] ?? $data['breakdown']['foreach'] ?? 0; - $this->catchCount = $data['catchCount'] ?? $data['breakdown']['catch'] ?? 0; - $this->logicalAndCount = $data['logicalAndCount'] ?? $data['breakdown']['logical_and'] ?? 0; - $this->logicalOrCount = $data['logicalOrCount'] ?? $data['breakdown']['logical_or'] ?? 0; - $this->logicalXorCount = $data['logicalXorCount'] ?? $data['breakdown']['logical_xor'] ?? 0; - $this->ternaryCount = $data['ternaryCount'] ?? $data['breakdown']['ternary'] ?? 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/Business/Utility/CoverageDataDetector.php b/src/Business/Utility/CoverageDataDetector.php index d4ca520..cf7979b 100644 --- a/src/Business/Utility/CoverageDataDetector.php +++ b/src/Business/Utility/CoverageDataDetector.php @@ -18,6 +18,10 @@ trait CoverageDataDetector protected function hasCoverageData(array $classes): bool { foreach ($classes as $data) { + if (!is_array($data)) { + continue; + } + if (array_key_exists('coverage', $data) && $data['coverage'] !== null) { return true; } diff --git a/src/Command/ChurnCommand.php b/src/Command/ChurnCommand.php index 2d05999..46169e7 100644 --- a/src/Command/ChurnCommand.php +++ b/src/Command/ChurnCommand.php @@ -155,7 +155,20 @@ private function outputExecutionSummary(ChurnExecutionContext $context, OutputIn $output->writeln('Statistics:'); foreach ($statistics as $key => $value) { - $output->writeln(sprintf(' %s: %s', $key, $value)); + $output->writeln(sprintf(' %s: %s', $key, $this->formatStatisticValue($value))); } } + + private function formatStatisticValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_string($value) || is_int($value) || is_float($value) || $value === null) { + return (string) $value; + } + + return json_encode($value, JSON_THROW_ON_ERROR); + } } diff --git a/src/Command/ChurnSpecifications/ChurnCommandContext.php b/src/Command/ChurnSpecifications/ChurnCommandContext.php index 92e558b..0c8cade 100644 --- a/src/Command/ChurnSpecifications/ChurnCommandContext.php +++ b/src/Command/ChurnSpecifications/ChurnCommandContext.php @@ -4,6 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications; +use InvalidArgumentException; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigFileResolver; use Symfony\Component\Console\Input\InputInterface; @@ -17,7 +18,7 @@ public function __construct( public function getConfigFile(): ?string { - return $this->configFileResolver->resolve($this->input->getOption('config')); + return $this->configFileResolver->resolve($this->getOptionalStringOption('config')); } public function hasConfigFile(): bool @@ -27,12 +28,12 @@ public function hasConfigFile(): bool public function getCoberturaFile(): ?string { - return $this->input->getOption('coverage-cobertura'); + return $this->getOptionalStringOption('coverage-cobertura'); } public function getCloverFile(): ?string { - return $this->input->getOption('coverage-clover'); + return $this->getOptionalStringOption('coverage-clover'); } public function hasCoberturaFile(): bool @@ -63,12 +64,12 @@ public function getCoverageFormat(): ?string public function getReportType(): ?string { - return $this->input->getOption('report-type'); + return $this->getOptionalStringOption('report-type'); } public function getReportFile(): ?string { - return $this->input->getOption('report-file'); + return $this->getOptionalStringOption('report-file'); } public function hasReportOptions(): bool @@ -78,16 +79,40 @@ public function hasReportOptions(): bool public function getPath(): string { - return $this->input->getArgument('path'); + return $this->getRequiredStringArgument('path'); } public function getVcsType(): string { - return $this->input->getOption('vcs') ?? 'git'; + return $this->getStringOptionWithDefault('vcs', 'git'); } public function getSince(): string { - return $this->input->getOption('since') ?? '2000-01-01'; + return $this->getStringOptionWithDefault('since', '2000-01-01'); + } + + private function getOptionalStringOption(string $name): ?string + { + $value = $this->input->getOption($name); + + return is_string($value) ? $value : null; + } + + private function getStringOptionWithDefault(string $name, string $default): string + { + $value = $this->input->getOption($name); + + return is_string($value) ? $value : $default; + } + + private function getRequiredStringArgument(string $name): string + { + $value = $this->input->getArgument($name); + if (!is_string($value)) { + throw new InvalidArgumentException(sprintf('Argument "%s" must be a string.', $name)); + } + + return $value; } } diff --git a/src/Command/ChurnSpecifications/CustomExporter.php b/src/Command/ChurnSpecifications/CustomExporter.php index 1395b97..bb1521e 100644 --- a/src/Command/ChurnSpecifications/CustomExporter.php +++ b/src/Command/ChurnSpecifications/CustomExporter.php @@ -62,8 +62,19 @@ public function getErrorMessageWithContext(ChurnCommandContext $context): string } $exporterConfig = $customReporters[$reportType]; + if (!is_array($exporterConfig)) { + return "Custom exporter `{$reportType}` has invalid configuration."; + } + $class = $exporterConfig['class'] ?? ''; $file = $exporterConfig['file'] ?? null; + if (!is_string($class)) { + return "Custom exporter `{$reportType}` must define a string 'class'."; + } + + if ($file !== null && !is_string($file)) { + return "Custom exporter `{$reportType}` must define a string or null 'file'."; + } if ($file !== null && !file_exists($file)) { return "Exporter file not found: {$file}"; @@ -87,8 +98,19 @@ private function validateCustomExporter(string $reportType): bool } $exporterConfig = $customReporters[$reportType]; + if (!is_array($exporterConfig)) { + return false; + } + $class = $exporterConfig['class'] ?? ''; $file = $exporterConfig['file'] ?? null; + if (!is_string($class)) { + return false; + } + + if ($file !== null && !is_string($file)) { + return false; + } // Validate file exists if specified if ($file !== null && !file_exists($file)) { diff --git a/src/Command/CognitiveMetricsCommand.php b/src/Command/CognitiveMetricsCommand.php index 85ec129..4fbb0e1 100644 --- a/src/Command/CognitiveMetricsCommand.php +++ b/src/Command/CognitiveMetricsCommand.php @@ -174,7 +174,20 @@ private function outputExecutionSummary(ExecutionContext $context, OutputInterfa $output->writeln('Statistics:'); foreach ($statistics as $key => $value) { - $output->writeln(sprintf(' %s: %s', $key, $value)); + $output->writeln(sprintf(' %s: %s', $key, $this->formatStatisticValue($value))); } } + + private function formatStatisticValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_string($value) || is_int($value) || is_float($value) || $value === null) { + return (string) $value; + } + + return json_encode($value, JSON_THROW_ON_ERROR); + } } diff --git a/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php index d2c204e..69a0a9a 100644 --- a/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php +++ b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php @@ -4,6 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications; +use InvalidArgumentException; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigFileResolver; use Symfony\Component\Console\Input\InputInterface; @@ -17,7 +18,7 @@ public function __construct( public function getConfigFile(): ?string { - return $this->configFileResolver->resolve($this->input->getOption('config')); + return $this->configFileResolver->resolve($this->getOptionalStringOption('config')); } public function hasConfigFile(): bool @@ -27,12 +28,12 @@ public function hasConfigFile(): bool public function getCoberturaFile(): ?string { - return $this->input->getOption('coverage-cobertura'); + return $this->getOptionalStringOption('coverage-cobertura'); } public function getCloverFile(): ?string { - return $this->input->getOption('coverage-clover'); + return $this->getOptionalStringOption('coverage-clover'); } public function hasCoberturaFile(): bool @@ -63,12 +64,12 @@ public function getCoverageFormat(): ?string public function getReportType(): ?string { - return $this->input->getOption('report-type'); + return $this->getOptionalStringOption('report-type'); } public function getReportFile(): ?string { - return $this->input->getOption('report-file'); + return $this->getOptionalStringOption('report-file'); } public function hasReportOptions(): bool @@ -78,12 +79,12 @@ public function hasReportOptions(): bool public function getSortBy(): ?string { - return $this->input->getOption('sort-by'); + return $this->getOptionalStringOption('sort-by'); } public function getSortOrder(): string { - return $this->input->getOption('sort-order') ?? 'asc'; + return $this->getStringOptionWithDefault('sort-order', 'asc'); } public function hasSortingOptions(): bool @@ -93,7 +94,7 @@ public function hasSortingOptions(): bool public function getBaselineFile(): ?string { - return $this->input->getOption('baseline'); + return $this->getOptionalStringOption('baseline'); } public function hasBaselineFile(): bool @@ -139,7 +140,7 @@ public function getBaselineOutputPath(): string */ public function getPaths(): array { - $pathInput = $this->input->getArgument('path'); + $pathInput = $this->getRequiredStringArgument('path'); return array_map('trim', explode(',', $pathInput)); } @@ -147,4 +148,28 @@ public function getDebug(): bool { return (bool) $this->input->getOption('debug'); } + + private function getOptionalStringOption(string $name): ?string + { + $value = $this->input->getOption($name); + + return is_string($value) ? $value : null; + } + + private function getStringOptionWithDefault(string $name, string $default): string + { + $value = $this->input->getOption($name); + + return is_string($value) ? $value : $default; + } + + private function getRequiredStringArgument(string $name): string + { + $value = $this->input->getArgument($name); + if (!is_string($value)) { + throw new InvalidArgumentException(sprintf('Argument "%s" must be a string.', $name)); + } + + return $value; + } } diff --git a/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php b/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php index 9dcf04d..ec20a49 100644 --- a/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php +++ b/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php @@ -62,8 +62,19 @@ public function getErrorMessageWithContext(CognitiveMetricsCommandContext $conte } $exporterConfig = $customReporters[$reportType]; + if (!is_array($exporterConfig)) { + return "Custom exporter `{$reportType}` has invalid configuration."; + } + $class = $exporterConfig['class'] ?? ''; $file = $exporterConfig['file'] ?? null; + if (!is_string($class)) { + return "Custom exporter `{$reportType}` must define a string 'class'."; + } + + if ($file !== null && !is_string($file)) { + return "Custom exporter `{$reportType}` must define a string or null 'file'."; + } if ($file !== null && !file_exists($file)) { return "Exporter file not found: {$file}"; @@ -87,8 +98,19 @@ private function validateCustomExporter(string $reportType): bool } $exporterConfig = $customReporters[$reportType]; + if (!is_array($exporterConfig)) { + return false; + } + $class = $exporterConfig['class'] ?? ''; $file = $exporterConfig['file'] ?? null; + if (!is_string($class)) { + return false; + } + + if ($file !== null && !is_string($file)) { + return false; + } // Validate file exists if specified if ($file !== null && !file_exists($file)) { diff --git a/src/Command/InitCommand.php b/src/Command/InitCommand.php index 8e02537..4ffbafd 100644 --- a/src/Command/InitCommand.php +++ b/src/Command/InitCommand.php @@ -94,7 +94,7 @@ private function resolveTargetPath(InputInterface $input): string { $path = $input->getOption(self::OPTION_PATH); - if ($path !== null) { + if (is_string($path)) { return $path; } @@ -194,6 +194,10 @@ static function (mixed $answer): float { } ); + if (!is_float($value) && !is_int($value)) { + throw new CognitiveAnalysisException('Score threshold must be a number.'); + } + return (float) $value; } diff --git a/src/Command/Pipeline/ChurnExecutionContext.php b/src/Command/Pipeline/ChurnExecutionContext.php index b1a0dfa..a93c853 100644 --- a/src/Command/Pipeline/ChurnExecutionContext.php +++ b/src/Command/Pipeline/ChurnExecutionContext.php @@ -4,6 +4,9 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline; +use InvalidArgumentException; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; +use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoverageReportReaderInterface; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnCommandContext; use Symfony\Component\Console\Output\OutputInterface; @@ -100,12 +103,41 @@ public function hasData(string $key): bool return array_key_exists($key, $this->data); } + public function getCoverageReader(): ?CoverageReportReaderInterface + { + $value = $this->data['coverageReader'] ?? null; + if ($value === null) { + return null; + } + + if (!$value instanceof CoverageReportReaderInterface) { + throw new InvalidArgumentException('Invalid coverage reader in execution context.'); + } + + return $value; + } + + public function getChurnMetrics(): ?ChurnMetricsCollection + { + $value = $this->data['churnMetrics'] ?? null; + if ($value === null) { + return null; + } + + if (!$value instanceof ChurnMetricsCollection) { + throw new InvalidArgumentException('Invalid churn metrics in execution context.'); + } + + return $value; + } + /** * Increment a statistic counter. */ public function incrementStatistic(string $key, int $amount = 1): void { - $this->statistics[$key] = ($this->statistics[$key] ?? 0) + $amount; + $current = $this->statistics[$key] ?? 0; + $this->statistics[$key] = (is_int($current) ? $current : 0) + $amount; } /** diff --git a/src/Command/Pipeline/ChurnStages/ChurnCalculationStage.php b/src/Command/Pipeline/ChurnStages/ChurnCalculationStage.php index 7c4fd2a..6c9a4ed 100644 --- a/src/Command/Pipeline/ChurnStages/ChurnCalculationStage.php +++ b/src/Command/Pipeline/ChurnStages/ChurnCalculationStage.php @@ -24,7 +24,7 @@ public function execute(ChurnExecutionContext $context): OperationResult $commandContext = $context->getCommandContext(); // Get coverage data from previous stage - $coverageReader = $context->getData('coverageReader'); + $coverageReader = $context->getCoverageReader(); // Calculate churn metrics $metrics = $this->metricsFacade->calculateChurn( diff --git a/src/Command/Pipeline/ChurnStages/OutputStage.php b/src/Command/Pipeline/ChurnStages/OutputStage.php index 9838a04..7d3c2a9 100644 --- a/src/Command/Pipeline/ChurnStages/OutputStage.php +++ b/src/Command/Pipeline/ChurnStages/OutputStage.php @@ -21,7 +21,7 @@ public function __construct( public function execute(ChurnExecutionContext $context): OperationResult { - $churnMetrics = $context->getData('churnMetrics'); + $churnMetrics = $context->getChurnMetrics(); if ($churnMetrics === null) { return OperationResult::failure('Churn metrics not available for console output.'); diff --git a/src/Command/Pipeline/ChurnStages/ReportGenerationStage.php b/src/Command/Pipeline/ChurnStages/ReportGenerationStage.php index a25afb2..20657fc 100644 --- a/src/Command/Pipeline/ChurnStages/ReportGenerationStage.php +++ b/src/Command/Pipeline/ChurnStages/ReportGenerationStage.php @@ -25,7 +25,7 @@ public function __construct( public function execute(ChurnExecutionContext $context): OperationResult { $commandContext = $context->getCommandContext(); - $churnMetrics = $context->getData('churnMetrics'); + $churnMetrics = $context->getChurnMetrics(); if ($churnMetrics === null) { return OperationResult::failure('Churn metrics not available for report generation.'); diff --git a/src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php b/src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php index 3bc070b..068d03d 100644 --- a/src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php +++ b/src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php @@ -26,7 +26,7 @@ public function __construct( public function execute(ExecutionContext $context): OperationResult { $commandContext = $context->getCommandContext(); - $metricsCollection = $context->getData('metricsCollection'); + $metricsCollection = $context->getMetricsCollection(); if (!$commandContext->hasGenerateBaseline()) { return OperationResult::success(); diff --git a/src/Command/Pipeline/CognitiveStages/BaselineStage.php b/src/Command/Pipeline/CognitiveStages/BaselineStage.php index baad6bf..6c9ad98 100644 --- a/src/Command/Pipeline/CognitiveStages/BaselineStage.php +++ b/src/Command/Pipeline/CognitiveStages/BaselineStage.php @@ -26,7 +26,7 @@ public function __construct( public function execute(ExecutionContext $context): OperationResult { $commandContext = $context->getCommandContext(); - $metricsCollection = $context->getData('metricsCollection'); + $metricsCollection = $context->getMetricsCollection(); if ($metricsCollection === null) { return OperationResult::failure('Metrics collection not available for baseline.'); diff --git a/src/Command/Pipeline/CognitiveStages/MetricsCollectionStage.php b/src/Command/Pipeline/CognitiveStages/MetricsCollectionStage.php index 810b6cb..106d168 100644 --- a/src/Command/Pipeline/CognitiveStages/MetricsCollectionStage.php +++ b/src/Command/Pipeline/CognitiveStages/MetricsCollectionStage.php @@ -24,7 +24,7 @@ public function execute(ExecutionContext $context): OperationResult $commandContext = $context->getCommandContext(); // Get coverage data from previous stage - $coverageData = $context->getData('coverageReader'); + $coverageData = $context->getCoverageReader(); // Get metrics $metricsCollection = $this->metricsFacade->getCognitiveMetricsFromPaths( diff --git a/src/Command/Pipeline/CognitiveStages/OutputStage.php b/src/Command/Pipeline/CognitiveStages/OutputStage.php index 87bbdf3..008cf94 100644 --- a/src/Command/Pipeline/CognitiveStages/OutputStage.php +++ b/src/Command/Pipeline/CognitiveStages/OutputStage.php @@ -22,7 +22,7 @@ public function __construct( public function execute(ExecutionContext $context): OperationResult { - $sortedMetricsCollection = $context->getData('sortedMetricsCollection'); + $sortedMetricsCollection = $context->getSortedMetricsCollection(); if ($sortedMetricsCollection === null) { return OperationResult::failure('Metrics collection not available for console output.'); @@ -39,7 +39,7 @@ public function execute(ExecutionContext $context): OperationResult } // Display baseline generation message if generated - $baselineGenerated = $context->getData('baselineGenerated'); + $baselineGenerated = $context->getBaselineGeneratedPath(); if ($baselineGenerated !== null) { $context->getOutput()->writeln('Baseline file generated: ' . $baselineGenerated . ''); } diff --git a/src/Command/Pipeline/CognitiveStages/ReportGenerationStage.php b/src/Command/Pipeline/CognitiveStages/ReportGenerationStage.php index 3ec44ae..8a78877 100644 --- a/src/Command/Pipeline/CognitiveStages/ReportGenerationStage.php +++ b/src/Command/Pipeline/CognitiveStages/ReportGenerationStage.php @@ -26,7 +26,7 @@ public function __construct( public function execute(ExecutionContext $context): OperationResult { $commandContext = $context->getCommandContext(); - $sortedMetricsCollection = $context->getData('sortedMetricsCollection'); + $sortedMetricsCollection = $context->getSortedMetricsCollection(); if ($sortedMetricsCollection === null) { return OperationResult::failure('Metrics collection not available for report generation.'); diff --git a/src/Command/Pipeline/CognitiveStages/SortingStage.php b/src/Command/Pipeline/CognitiveStages/SortingStage.php index ecb952d..1f78850 100644 --- a/src/Command/Pipeline/CognitiveStages/SortingStage.php +++ b/src/Command/Pipeline/CognitiveStages/SortingStage.php @@ -23,7 +23,7 @@ public function __construct( public function execute(ExecutionContext $context): OperationResult { $commandContext = $context->getCommandContext(); - $metricsCollection = $context->getData('metricsCollection'); + $metricsCollection = $context->getMetricsCollection(); $sortBy = $commandContext->getSortBy(); $sortOrder = $commandContext->getSortOrder(); @@ -34,6 +34,10 @@ public function execute(ExecutionContext $context): OperationResult return OperationResult::success(); } + if ($metricsCollection === null) { + return OperationResult::failure('Metrics collection not available for sorting.'); + } + try { $sorted = $this->sorter->sort($metricsCollection, $sortBy, $sortOrder); // Store sorted metrics in context diff --git a/src/Command/Pipeline/ExecutionContext.php b/src/Command/Pipeline/ExecutionContext.php index 73b3a32..664233e 100644 --- a/src/Command/Pipeline/ExecutionContext.php +++ b/src/Command/Pipeline/ExecutionContext.php @@ -4,6 +4,9 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline; +use InvalidArgumentException; +use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoverageReportReaderInterface; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsCommandContext; use Symfony\Component\Console\Output\OutputInterface; @@ -102,12 +105,62 @@ public function hasData(string $key): bool return array_key_exists($key, $this->data); } + public function getCoverageReader(): ?CoverageReportReaderInterface + { + $value = $this->data['coverageReader'] ?? null; + if ($value === null) { + return null; + } + + if (!$value instanceof CoverageReportReaderInterface) { + throw new InvalidArgumentException('Invalid coverage reader in execution context.'); + } + + return $value; + } + + public function getMetricsCollection(): ?CognitiveMetricsCollection + { + $value = $this->data['metricsCollection'] ?? null; + if ($value === null) { + return null; + } + + if (!$value instanceof CognitiveMetricsCollection) { + throw new InvalidArgumentException('Invalid metrics collection in execution context.'); + } + + return $value; + } + + public function getSortedMetricsCollection(): ?CognitiveMetricsCollection + { + $value = $this->data['sortedMetricsCollection'] ?? null; + if ($value === null) { + return null; + } + + if (!$value instanceof CognitiveMetricsCollection) { + throw new InvalidArgumentException('Invalid sorted metrics collection in execution context.'); + } + + return $value; + } + + public function getBaselineGeneratedPath(): ?string + { + $value = $this->data['baselineGenerated'] ?? null; + + return is_string($value) ? $value : null; + } + /** * Increment a statistic counter. */ public function incrementStatistic(string $key, int $amount = 1): void { - $this->statistics[$key] = ($this->statistics[$key] ?? 0) + $amount; + $current = $this->statistics[$key] ?? 0; + $this->statistics[$key] = (is_int($current) ? $current : 0) + $amount; } /** diff --git a/src/Command/Presentation/TableRowBuilder.php b/src/Command/Presentation/TableRowBuilder.php index 795342a..831fdd8 100644 --- a/src/Command/Presentation/TableRowBuilder.php +++ b/src/Command/Presentation/TableRowBuilder.php @@ -173,14 +173,18 @@ private function addDelta(string $key, CognitiveMetrics $metrics, array $row): a } if ($delta->hasIncreased()) { - $row[$key] .= PHP_EOL . 'Δ +' . round($delta->getValue(), 3) . ''; - - return $row; + return $this->appendToRowCell( + $row, + $key, + PHP_EOL . 'Δ +' . round($delta->getValue(), 3) . '' + ); } - $row[$key] .= PHP_EOL . 'Δ -' . $delta->getValue() . ''; - - return $row; + return $this->appendToRowCell( + $row, + $key, + PHP_EOL . 'Δ -' . $delta->getValue() . '' + ); } /** @@ -272,17 +276,17 @@ private function addHalsteadDeltas(CognitiveMetrics $metrics, array $row): array $volumeDelta = $metrics->getHalsteadVolumeDelta(); if ($volumeDelta !== null && !$volumeDelta->hasNotChanged()) { - $row['halsteadVolume'] .= $this->formatDelta($volumeDelta); + $row = $this->appendToRowCell($row, 'halsteadVolume', $this->formatDelta($volumeDelta)); } $difficultyDelta = $metrics->getHalsteadDifficultyDelta(); if ($difficultyDelta !== null && !$difficultyDelta->hasNotChanged()) { - $row['halsteadDifficulty'] .= $this->formatDelta($difficultyDelta); + $row = $this->appendToRowCell($row, 'halsteadDifficulty', $this->formatDelta($difficultyDelta)); } $effortDelta = $metrics->getHalsteadEffortDelta(); if ($effortDelta !== null && !$effortDelta->hasNotChanged()) { - $row['halsteadEffort'] .= $this->formatDelta($effortDelta); + $row = $this->appendToRowCell($row, 'halsteadEffort', $this->formatDelta($effortDelta)); } return $row; @@ -300,7 +304,7 @@ private function addCyclomaticDeltas(CognitiveMetrics $metrics, array $row): arr $complexityDelta = $metrics->getCyclomaticComplexityDelta(); if ($complexityDelta !== null && !$complexityDelta->hasNotChanged()) { - $row['cyclomaticComplexity'] .= $this->formatDelta($complexityDelta); + $row = $this->appendToRowCell($row, 'cyclomaticComplexity', $this->formatDelta($complexityDelta)); } return $row; @@ -313,4 +317,20 @@ private function formatDelta(Delta $delta): string } return PHP_EOL . 'Δ ' . round($delta->getValue(), 3) . ''; } + + /** + * @param array $row + * @return array + */ + private function appendToRowCell(array $row, string $key, string $suffix): array + { + $value = $row[$key] ?? ''; + if (!is_string($value)) { + throw new CognitiveAnalysisException(sprintf('Expected string value for row key "%s".', $key)); + } + + $row[$key] = $value . $suffix; + + return $row; + } } diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php index fe4055f..cd96d38 100644 --- a/src/Config/ConfigFactory.php +++ b/src/Config/ConfigFactory.php @@ -4,50 +4,86 @@ namespace Phauthentic\CognitiveCodeAnalysis\Config; +use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; + +/** + * @phpstan-type MetricConfigArray array{threshold: int|float, scale: float, enabled: bool} + * @phpstan-type CognitiveSectionArray array{ + * excludeFilePatterns: list, + * excludePatterns: list, + * metrics: array, + * showOnlyMethodsExceedingThreshold: bool, + * scoreThreshold: float, + * showHalsteadComplexity?: bool, + * showCyclomaticComplexity?: bool, + * groupByClass?: bool, + * showDetailedCognitiveMetrics?: bool, + * cache?: array{enabled?: bool, directory?: string}, + * performance?: array{batchSize?: int}, + * customReporters?: array> + * } + */ class ConfigFactory { /** * @param array $config - * @return CognitiveConfig */ public function fromArray(array $config): CognitiveConfig { - $metrics = array_map(function ($metric) { - return new MetricsConfig( - $metric['threshold'], + $cognitive = $this->resolveCognitiveSection($config); + + $metrics = array_map( + fn (array $metric): MetricsConfig => new MetricsConfig( + (int) $metric['threshold'], $metric['scale'], - $metric['enabled'] - ); - }, $config['cognitive']['metrics']); + $metric['enabled'], + ), + $cognitive['metrics'], + ); $cacheConfig = null; - if (isset($config['cognitive']['cache'])) { - $cacheConfig = new CacheConfig( - enabled: $config['cognitive']['cache']['enabled'] ?? true, - directory: $config['cognitive']['cache']['directory'] ?? './.phpcca.cache', - ); + if (isset($cognitive['cache'])) { + $cacheConfig = new CacheConfig( + enabled: $cognitive['cache']['enabled'] ?? true, + directory: $cognitive['cache']['directory'] ?? './.phpcca.cache', + ); } $performanceConfig = null; - if (isset($config['cognitive']['performance'])) { + if (isset($cognitive['performance'])) { $performanceConfig = new PerformanceConfig( - batchSize: $config['cognitive']['performance']['batchSize'] ?? 100 + batchSize: $cognitive['performance']['batchSize'] ?? 100 ); } return new CognitiveConfig( - excludeFilePatterns: $config['cognitive']['excludeFilePatterns'], - excludePatterns: $config['cognitive']['excludePatterns'], + excludeFilePatterns: $cognitive['excludeFilePatterns'], + excludePatterns: $cognitive['excludePatterns'], metrics: $metrics, - showOnlyMethodsExceedingThreshold: $config['cognitive']['showOnlyMethodsExceedingThreshold'], - scoreThreshold: $config['cognitive']['scoreThreshold'], - showHalsteadComplexity: $config['cognitive']['showHalsteadComplexity'] ?? false, - showCyclomaticComplexity: $config['cognitive']['showCyclomaticComplexity'] ?? false, - groupByClass: $config['cognitive']['groupByClass'] ?? true, - showDetailedCognitiveMetrics: $config['cognitive']['showDetailedCognitiveMetrics'] ?? true, + showOnlyMethodsExceedingThreshold: $cognitive['showOnlyMethodsExceedingThreshold'], + scoreThreshold: $cognitive['scoreThreshold'], + showHalsteadComplexity: $cognitive['showHalsteadComplexity'] ?? false, + showCyclomaticComplexity: $cognitive['showCyclomaticComplexity'] ?? false, + groupByClass: $cognitive['groupByClass'] ?? true, + showDetailedCognitiveMetrics: $cognitive['showDetailedCognitiveMetrics'] ?? true, cache: $cacheConfig, performance: $performanceConfig, - customReporters: $config['cognitive']['customReporters'] ?? [] + customReporters: $cognitive['customReporters'] ?? [] ); } + + /** + * @param array $config + * @return CognitiveSectionArray + */ + private function resolveCognitiveSection(array $config): array + { + $cognitive = $config['cognitive'] ?? null; + if (!is_array($cognitive)) { + throw new CognitiveAnalysisException('Configuration must contain a "cognitive" section.'); + } + + /** @var CognitiveSectionArray $cognitive */ + return $cognitive; + } } diff --git a/src/Config/ConfigInitializer.php b/src/Config/ConfigInitializer.php index e404cbd..b75d682 100644 --- a/src/Config/ConfigInitializer.php +++ b/src/Config/ConfigInitializer.php @@ -24,6 +24,11 @@ public function __construct( public function createDefaultConfig(array $overrides = []): array { $defaultConfig = Yaml::parseFile($this->bundledConfigPath); + if (!is_array($defaultConfig)) { + throw new CognitiveAnalysisException( + sprintf('Bundled configuration file is invalid: %s', $this->bundledConfigPath) + ); + } if ($overrides !== []) { $defaultConfig = array_replace_recursive($defaultConfig, $overrides); From 644ec7f5984bb8d652248bc33d76dd4926f71488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 25 May 2026 01:28:40 +0200 Subject: [PATCH 2/5] Update PHPStan level and enhance cognitive metrics handling - Increased PHPStan analysis level from 9 to 10 for stricter type checking and improved code quality. - Refactored `ChurnMetricsCollection` to ensure class names are cast to strings when converting metrics to arrays. - Updated `CognitiveMetricsCollection` to return values as an indexed array for consistency. - Improved `CognitiveMetricsCollector` by normalizing ignored items and adding a new method for better handling of ignored items. - Enhanced `Parser` class with better type handling for metrics and added methods to merge cyclomatic and Halstead metrics. - Introduced `CustomExporterConfigValidator` to streamline validation of custom exporter configurations. - Refactored `CompositeChurnSpecification` and `CustomExporter` to utilize the new validator for improved error handling. - Added `ConfigException` for better error management in configuration handling. --- phpstan.neon | 2 +- src/Business/Churn/ChurnMetricsCollection.php | 2 +- .../Cognitive/Baseline/BaselineFile.php | 3 +- .../Cognitive/CognitiveMetricsCollection.php | 2 +- .../Cognitive/CognitiveMetricsCollector.php | 25 +++- src/Business/Cognitive/Parser.php | 81 +++++++++++-- src/Business/Cognitive/ScoreCalculator.php | 14 ++- src/Business/Cyclomatic/CyclomaticMetrics.php | 1 + .../CustomExporterConfigValidator.php | 96 +++++++++++++++ .../CompositeChurnSpecification.php | 18 ++- .../ChurnSpecifications/CustomExporter.php | 112 ++++++------------ ...ognitiveMetricsValidationSpecification.php | 20 +++- .../CustomExporterValidation.php | 112 ++++++------------ src/Command/Presentation/TableRowBuilder.php | 13 +- src/Config/ConfigException.php | 11 ++ src/Config/ConfigFactory.php | 3 +- src/Config/ConfigInitializer.php | 12 +- src/Config/ConfigLoader.php | 7 +- src/Config/ConfigService.php | 12 +- 19 files changed, 357 insertions(+), 189 deletions(-) create mode 100644 src/Business/Reporter/CustomExporterConfigValidator.php create mode 100644 src/Config/ConfigException.php diff --git a/phpstan.neon b/phpstan.neon index 0da04a3..3dd5988 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 9 + level: 10 paths: - src parallel: diff --git a/src/Business/Churn/ChurnMetricsCollection.php b/src/Business/Churn/ChurnMetricsCollection.php index 67292b3..e8c94c2 100644 --- a/src/Business/Churn/ChurnMetricsCollection.php +++ b/src/Business/Churn/ChurnMetricsCollection.php @@ -173,7 +173,7 @@ public function toArray(): array { $result = []; foreach ($this->metrics as $className => $metric) { - $result[$className] = $metric->toArray(); + $result[(string) $className] = $metric->toArray(); } return $result; } diff --git a/src/Business/Cognitive/Baseline/BaselineFile.php b/src/Business/Cognitive/Baseline/BaselineFile.php index 6a15e24..547c728 100644 --- a/src/Business/Cognitive/Baseline/BaselineFile.php +++ b/src/Business/Cognitive/Baseline/BaselineFile.php @@ -4,6 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; +use InvalidArgumentException; use JsonSerializable; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; @@ -56,7 +57,7 @@ public static function fromJson(array $data): array $metrics = $data['metrics'] ?? null; if (!is_string($createdAt) || !is_string($configHash) || !is_array($metrics)) { - throw new \InvalidArgumentException('Invalid baseline file metadata.'); + throw new InvalidArgumentException('Invalid baseline file metadata.'); } /** @var array> $metrics */ diff --git a/src/Business/Cognitive/CognitiveMetricsCollection.php b/src/Business/Cognitive/CognitiveMetricsCollection.php index 0f6c601..1ac7d87 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollection.php +++ b/src/Business/Cognitive/CognitiveMetricsCollection.php @@ -150,6 +150,6 @@ public function groupBy(string $property): array */ public function jsonSerialize(): array { - return $this->metrics; + return array_values($this->metrics); } } diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index 889866f..202228e 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -279,7 +279,7 @@ private function isExcluded(string $classAndMethod): bool * Find source files using DirectoryScanner * * @param string $path Path to the directory or file to scan - * @param array $exclude List of regx to exclude + * @param array $exclude List of regx to exclude * @return iterable An iterable of SplFileInfo objects * @throws CognitiveAnalysisException */ @@ -364,7 +364,7 @@ private function getCachedMetrics(SplFileInfo $file, string $configHash, bool $u } $ignoredItems = $cachedData['ignored_items'] ?? []; - $this->ignoredItems = is_array($ignoredItems) ? $ignoredItems : []; + $this->ignoredItems = $this->normalizeIgnoredItems($ignoredItems); $this->messageBus->dispatch(new FileProcessed($file)); $analysisResult = $cachedData['analysis_result'] ?? null; @@ -420,4 +420,25 @@ private function processFile( return null; } } + + /** + * @return array + */ + private function normalizeIgnoredItems(mixed $ignoredItems): array + { + if (!is_array($ignoredItems)) { + return []; + } + + $normalized = []; + foreach ($ignoredItems as $key => $value) { + if (!is_string($key)) { + continue; + } + + $normalized[$key] = $value; + } + + return $normalized; + } } diff --git a/src/Business/Cognitive/Parser.php b/src/Business/Cognitive/Parser.php index c256c8a..e8d6958 100644 --- a/src/Business/Cognitive/Parser.php +++ b/src/Business/Cognitive/Parser.php @@ -60,7 +60,7 @@ public function __construct( } /** - * @return array> + * @return array> * @throws CognitiveAnalysisException */ public function parse(string $code): array @@ -72,39 +72,96 @@ public function parse(string $code): array $this->traverseAbstractSyntaxTreeWithCombinedVisitor($code); // Get all metrics before resetting + /** @var array $methodMetrics */ $methodMetrics = $this->combinedVisitor->getMethodMetrics(); + /** @var array $cyclomaticMetrics */ $cyclomaticMetrics = $this->combinedVisitor->getMethodComplexity(); + /** @var array $halsteadMetrics */ $halsteadMetrics = $this->combinedVisitor->getHalsteadMethodMetrics(); // Now reset the combined visitor $this->combinedVisitor->resetAll(); - // Add cyclomatic complexity to method metrics + $methodMetrics = $this->mergeCyclomaticMetrics($methodMetrics, $cyclomaticMetrics); + $methodMetrics = $this->mergeHalsteadMetrics($methodMetrics, $halsteadMetrics); + + /** @var array> $methodMetrics */ + return $methodMetrics; + } + + /** + * @param array $methodMetrics + * @param array $cyclomaticMetrics + * @return array + */ + private function mergeCyclomaticMetrics(array $methodMetrics, array $cyclomaticMetrics): array + { foreach ($cyclomaticMetrics as $method => $complexityData) { - if (!isset($methodMetrics[$method])) { + $methodMetric = $methodMetrics[$method] ?? null; + if (!is_array($methodMetric)) { continue; } - $complexity = $complexityData['complexity'] ?? $complexityData; - $riskLevel = $complexityData['risk_level'] ?? $this->getRiskLevel($complexity); - $methodMetrics[$method]['cyclomatic_complexity'] = [ - 'complexity' => $complexity, - 'risk_level' => $riskLevel - ]; + $resolved = $this->resolveCyclomaticData($complexityData); + if ($resolved === null) { + continue; + } + + $methodMetric['cyclomatic_complexity'] = $resolved; + $methodMetrics[$method] = $methodMetric; } - // Add Halstead metrics to method metrics + return $methodMetrics; + } + + /** + * @param array $methodMetrics + * @param array $halsteadMetrics + * @return array + */ + private function mergeHalsteadMetrics(array $methodMetrics, array $halsteadMetrics): array + { foreach ($halsteadMetrics as $method => $metrics) { - if (!isset($methodMetrics[$method])) { + $methodMetric = $methodMetrics[$method] ?? null; + if (!is_array($methodMetric) || !is_array($metrics)) { continue; } - $methodMetrics[$method]['halstead'] = $metrics; + $methodMetric['halstead'] = $metrics; + $methodMetrics[$method] = $methodMetric; } return $methodMetrics; } + /** + * @return array{complexity: int, risk_level: string}|null + */ + private function resolveCyclomaticData(mixed $complexityData): ?array + { + if (is_int($complexityData)) { + return [ + 'complexity' => $complexityData, + 'risk_level' => $this->getRiskLevel($complexityData), + ]; + } + + if (!is_array($complexityData)) { + return null; + } + + $complexity = is_int($complexityData['complexity'] ?? null) + ? $complexityData['complexity'] + : 1; + $riskLevelValue = $complexityData['risk_level'] ?? $this->getRiskLevel($complexity); + $riskLevel = is_string($riskLevelValue) ? $riskLevelValue : $this->getRiskLevel($complexity); + + return [ + 'complexity' => $complexity, + 'risk_level' => $riskLevel, + ]; + } + /** * Scan the code for annotations to collect ignored items. */ diff --git a/src/Business/Cognitive/ScoreCalculator.php b/src/Business/Cognitive/ScoreCalculator.php index 4379c81..c4739cd 100644 --- a/src/Business/Cognitive/ScoreCalculator.php +++ b/src/Business/Cognitive/ScoreCalculator.php @@ -44,10 +44,13 @@ public function calculate(CognitiveMetrics $metrics, CognitiveConfig $config): v private function calculateScore(CognitiveMetrics $metrics): void { - $score = 0; + $score = 0.0; foreach ($this->combinedMetrics as $metric) { $methodName = 'get' . $metric . 'Weight'; - $score += $metrics->{$methodName}(); + $weight = $metrics->{$methodName}(); + if (is_int($weight) || is_float($weight)) { + $score += $weight; + } } $metrics->setScore(round($score, 3)); @@ -70,9 +73,14 @@ private function calculateMetricWeights( $getMethod = 'get' . $methodSuffix; $setMethod = 'set' . $methodSuffix . 'Weight'; + $metricValue = $metrics->{$getMethod}(); + if (!is_int($metricValue) && !is_float($metricValue)) { + $metricValue = 0; + } + $metrics->{$setMethod}( $this->calculateLogWeight( - $metrics->{$getMethod}(), + (float) $metricValue, $metricConfigs[$configKey]->threshold, $metricConfigs[$configKey]->scale, ) diff --git a/src/Business/Cyclomatic/CyclomaticMetrics.php b/src/Business/Cyclomatic/CyclomaticMetrics.php index f37b43d..5040792 100644 --- a/src/Business/Cyclomatic/CyclomaticMetrics.php +++ b/src/Business/Cyclomatic/CyclomaticMetrics.php @@ -133,6 +133,7 @@ public function __construct(array $data) $breakdown = []; } + /** @var array $breakdown */ $this->complexity = $this->resolveIntValue($data['complexity'] ?? null, 1); $this->riskLevel = $this->resolveRiskLevel($data); $this->totalCount = $this->resolveCountValue($data, $breakdown, 'totalCount', 'total', 0); diff --git a/src/Business/Reporter/CustomExporterConfigValidator.php b/src/Business/Reporter/CustomExporterConfigValidator.php new file mode 100644 index 0000000..3ca75f2 --- /dev/null +++ b/src/Business/Reporter/CustomExporterConfigValidator.php @@ -0,0 +1,96 @@ + $exporterConfig + * @return array{class: string, file: string|null}|null + */ + public function parseClassAndFile(array $exporterConfig): ?array + { + $class = $exporterConfig['class'] ?? null; + if (!is_string($class)) { + return null; + } + + $file = $exporterConfig['file'] ?? null; + if ($file !== null && !is_string($file)) { + return null; + } + + return ['class' => $class, 'file' => $file]; + } + + /** + * @param array{class: string, file: string|null} $parsed + */ + public function isLoadable(array $parsed): bool + { + if ($parsed['file'] !== null) { + return $this->isLoadableFromFile($parsed['class'], $parsed['file']); + } + + return class_exists($parsed['class']); + } + + /** + * @param array $exporterConfig + */ + public function getConfigurationError(string $reportType, array $exporterConfig): string + { + $parsed = $this->parseClassAndFile($exporterConfig); + if ($parsed === null) { + return $this->resolveParseFailureMessage($reportType, $exporterConfig); + } + + if ($parsed['file'] !== null && !file_exists($parsed['file'])) { + return "Exporter file not found: {$parsed['file']}"; + } + + if ($parsed['file'] === null && !class_exists($parsed['class'])) { + return "Exporter class not found: {$parsed['class']}"; + } + + return "Custom exporter `{$reportType}` validation failed"; + } + + /** + * @param array $exporterConfig + */ + private function resolveParseFailureMessage(string $reportType, array $exporterConfig): string + { + if (!is_string($exporterConfig['class'] ?? null)) { + return "Custom exporter `{$reportType}` must define a string 'class'."; + } + + if (($exporterConfig['file'] ?? null) !== null && !is_string($exporterConfig['file'])) { + return "Custom exporter `{$reportType}` must define a string or null 'file'."; + } + + return "Custom exporter `{$reportType}` has invalid configuration."; + } + + private function isLoadableFromFile(string $class, string $file): bool + { + if (!file_exists($file)) { + return false; + } + + try { + $content = file_get_contents($file); + if ($content === false) { + return false; + } + + $className = basename(str_replace('\\', '/', $class)); + + return str_contains($content, $className); + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Command/ChurnSpecifications/CompositeChurnSpecification.php b/src/Command/ChurnSpecifications/CompositeChurnSpecification.php index 9ecd872..3cd67e9 100644 --- a/src/Command/ChurnSpecifications/CompositeChurnSpecification.php +++ b/src/Command/ChurnSpecifications/CompositeChurnSpecification.php @@ -37,10 +37,7 @@ public function getDetailedErrorMessage(ChurnCommandContext $context): string foreach ($this->specifications as $specification) { if (!$specification->isSatisfiedBy($context)) { // Use context-specific error message if available - if (method_exists($specification, 'getErrorMessageWithContext')) { - return $specification->getErrorMessageWithContext($context); - } - return $specification->getErrorMessage(); + return $this->resolveSpecificationErrorMessage($specification, $context); } } return ''; @@ -55,4 +52,17 @@ public function getFirstFailedSpecification(ChurnCommandContext $context): ?Chur } return null; } + + private function resolveSpecificationErrorMessage( + ChurnCommandSpecification $specification, + ChurnCommandContext $context + ): string { + return match (true) { + $specification instanceof CustomExporter, + $specification instanceof CoverageFileExists, + $specification instanceof CoverageFormatSupported => + $specification->getErrorMessageWithContext($context), + default => $specification->getErrorMessage(), + }; + } } diff --git a/src/Command/ChurnSpecifications/CustomExporter.php b/src/Command/ChurnSpecifications/CustomExporter.php index bb1521e..56d21b2 100644 --- a/src/Command/ChurnSpecifications/CustomExporter.php +++ b/src/Command/ChurnSpecifications/CustomExporter.php @@ -5,6 +5,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\ChurnReportFactoryInterface; +use Phauthentic\CognitiveCodeAnalysis\Business\Reporter\CustomExporterConfigValidator; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; /** @@ -15,13 +16,13 @@ class CustomExporter implements ChurnCommandSpecification { public function __construct( private readonly ChurnReportFactoryInterface $reportFactory, - private readonly ConfigService $configService + private readonly ConfigService $configService, + private readonly CustomExporterConfigValidator $validator = new CustomExporterConfigValidator(), ) { } public function isSatisfiedBy(ChurnCommandContext $context): bool { - // Only validate if report options are provided if (!$context->hasReportOptions()) { return true; } @@ -31,13 +32,10 @@ public function isSatisfiedBy(ChurnCommandContext $context): bool return true; } - // Check if it's a built-in type (always valid) - $builtInTypes = ['json', 'csv', 'html', 'markdown', 'svg-treemap', 'svg']; - if (in_array($reportType, $builtInTypes, true)) { + if ($this->isBuiltInReportType($reportType)) { return true; } - // For custom exporters, validate they can be loaded return $this->validateCustomExporter($reportType); } @@ -53,93 +51,59 @@ public function getErrorMessageWithContext(ChurnCommandContext $context): string return 'Report type is required for validation'; } - $config = $this->configService->getConfig(); - $customReporters = $config->customReporters['churn'] ?? []; - - if (!isset($customReporters[$reportType])) { + $exporterConfig = $this->resolveExporterConfig($reportType); + if ($exporterConfig === null) { $supportedTypes = implode('`, `', $this->reportFactory->getSupportedTypes()); - return "Custom exporter `{$reportType}` not found in configuration. Supported types: `{$supportedTypes}`"; - } - $exporterConfig = $customReporters[$reportType]; - if (!is_array($exporterConfig)) { - return "Custom exporter `{$reportType}` has invalid configuration."; - } - - $class = $exporterConfig['class'] ?? ''; - $file = $exporterConfig['file'] ?? null; - if (!is_string($class)) { - return "Custom exporter `{$reportType}` must define a string 'class'."; - } - - if ($file !== null && !is_string($file)) { - return "Custom exporter `{$reportType}` must define a string or null 'file'."; - } - - if ($file !== null && !file_exists($file)) { - return "Exporter file not found: {$file}"; - } - - if ($file === null && !class_exists($class)) { - return "Exporter class not found: {$class}"; + return "Custom exporter `{$reportType}` not found in configuration. Supported types: `{$supportedTypes}`"; } - return "Custom exporter `{$reportType}` validation failed"; + return $this->validator->getConfigurationError($reportType, $exporterConfig); } private function validateCustomExporter(string $reportType): bool { try { - $config = $this->configService->getConfig(); - $customReporters = $config->customReporters['churn'] ?? []; - - if (!isset($customReporters[$reportType])) { + $exporterConfig = $this->resolveExporterConfig($reportType); + if ($exporterConfig === null) { return false; } - $exporterConfig = $customReporters[$reportType]; - if (!is_array($exporterConfig)) { + $parsed = $this->validator->parseClassAndFile($exporterConfig); + if ($parsed === null) { return false; } - $class = $exporterConfig['class'] ?? ''; - $file = $exporterConfig['file'] ?? null; - if (!is_string($class)) { - return false; - } + return $this->validator->isLoadable($parsed); + } catch (\Exception) { + return false; + } + } - if ($file !== null && !is_string($file)) { - return false; - } + /** + * @return array|null + */ + private function resolveExporterConfig(string $reportType): ?array + { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['churn'] ?? []; - // Validate file exists if specified - if ($file !== null && !file_exists($file)) { - return false; - } + if (!isset($customReporters[$reportType])) { + return null; + } - // For file-based exporters, we'll do basic validation - // The actual class loading will happen later with proper autoloading - if ($file !== null) { - // Check if the file is readable - try { - $content = file_get_contents($file); - if ($content === false) { - return false; - } - - // Basic check: does the file contain a class with the expected name? - // We'll look for the class name without the namespace prefix - $className = basename(str_replace('\\', '/', $class)); - return strpos($content, $className) !== false; - } catch (\Throwable) { - return false; - } - } + $exporterConfig = $customReporters[$reportType]; - // If no file specified, class should be autoloadable - return class_exists($class); - } catch (\Exception) { - return false; + if (!is_array($exporterConfig)) { + return null; } + + /** @var array $exporterConfig */ + return $exporterConfig; + } + + private function isBuiltInReportType(string $reportType): bool + { + return in_array($reportType, ['json', 'csv', 'html', 'markdown', 'svg-treemap', 'svg'], true); } } diff --git a/src/Command/CognitiveMetricsSpecifications/CompositeCognitiveMetricsValidationSpecification.php b/src/Command/CognitiveMetricsSpecifications/CompositeCognitiveMetricsValidationSpecification.php index f363f68..568d1d0 100644 --- a/src/Command/CognitiveMetricsSpecifications/CompositeCognitiveMetricsValidationSpecification.php +++ b/src/Command/CognitiveMetricsSpecifications/CompositeCognitiveMetricsValidationSpecification.php @@ -48,12 +48,24 @@ public function getDetailedErrorMessage(CognitiveMetricsCommandContext $context) foreach ($this->specifications as $specification) { if (!$specification->isSatisfiedBy($context)) { // Use context-specific error message if available - if (method_exists($specification, 'getErrorMessageWithContext')) { - return $specification->getErrorMessageWithContext($context); - } - return $specification->getErrorMessage(); + return $this->resolveSpecificationErrorMessage($specification, $context); } } return ''; } + + private function resolveSpecificationErrorMessage( + CognitiveMetricsSpecification $specification, + CognitiveMetricsCommandContext $context + ): string { + return match (true) { + $specification instanceof CustomExporterValidation, + $specification instanceof CoverageFileExists, + $specification instanceof CoverageFormatSupported, + $specification instanceof SortFieldValid, + $specification instanceof SortOrderValid => + $specification->getErrorMessageWithContext($context), + default => $specification->getErrorMessage(), + }; + } } diff --git a/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php b/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php index ec20a49..9214b15 100644 --- a/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php +++ b/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php @@ -5,6 +5,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\CognitiveReportFactoryInterface; +use Phauthentic\CognitiveCodeAnalysis\Business\Reporter\CustomExporterConfigValidator; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; /** @@ -15,13 +16,13 @@ class CustomExporterValidation implements CognitiveMetricsSpecification { public function __construct( private readonly CognitiveReportFactoryInterface $reportFactory, - private readonly ConfigService $configService + private readonly ConfigService $configService, + private readonly CustomExporterConfigValidator $validator = new CustomExporterConfigValidator(), ) { } public function isSatisfiedBy(CognitiveMetricsCommandContext $context): bool { - // Only validate if report options are provided if (!$context->hasReportOptions()) { return true; } @@ -31,13 +32,10 @@ public function isSatisfiedBy(CognitiveMetricsCommandContext $context): bool return true; } - // Check if it's a built-in type (always valid) - $builtInTypes = ['json', 'csv', 'html', 'markdown']; - if (in_array($reportType, $builtInTypes, true)) { + if ($this->isBuiltInReportType($reportType)) { return true; } - // For custom exporters, validate they can be loaded return $this->validateCustomExporter($reportType); } @@ -53,93 +51,59 @@ public function getErrorMessageWithContext(CognitiveMetricsCommandContext $conte return 'Report type is required for validation'; } - $config = $this->configService->getConfig(); - $customReporters = $config->customReporters['cognitive'] ?? []; - - if (!isset($customReporters[$reportType])) { + $exporterConfig = $this->resolveExporterConfig($reportType); + if ($exporterConfig === null) { $supportedTypes = implode('`, `', $this->reportFactory->getSupportedTypes()); - return "Custom exporter `{$reportType}` not found in configuration. Supported types: `{$supportedTypes}`"; - } - $exporterConfig = $customReporters[$reportType]; - if (!is_array($exporterConfig)) { - return "Custom exporter `{$reportType}` has invalid configuration."; - } - - $class = $exporterConfig['class'] ?? ''; - $file = $exporterConfig['file'] ?? null; - if (!is_string($class)) { - return "Custom exporter `{$reportType}` must define a string 'class'."; - } - - if ($file !== null && !is_string($file)) { - return "Custom exporter `{$reportType}` must define a string or null 'file'."; - } - - if ($file !== null && !file_exists($file)) { - return "Exporter file not found: {$file}"; - } - - if ($file === null && !class_exists($class)) { - return "Exporter class not found: {$class}"; + return "Custom exporter `{$reportType}` not found in configuration. Supported types: `{$supportedTypes}`"; } - return "Custom exporter `{$reportType}` validation failed"; + return $this->validator->getConfigurationError($reportType, $exporterConfig); } private function validateCustomExporter(string $reportType): bool { try { - $config = $this->configService->getConfig(); - $customReporters = $config->customReporters['cognitive'] ?? []; - - if (!isset($customReporters[$reportType])) { + $exporterConfig = $this->resolveExporterConfig($reportType); + if ($exporterConfig === null) { return false; } - $exporterConfig = $customReporters[$reportType]; - if (!is_array($exporterConfig)) { + $parsed = $this->validator->parseClassAndFile($exporterConfig); + if ($parsed === null) { return false; } - $class = $exporterConfig['class'] ?? ''; - $file = $exporterConfig['file'] ?? null; - if (!is_string($class)) { - return false; - } + return $this->validator->isLoadable($parsed); + } catch (\Exception) { + return false; + } + } - if ($file !== null && !is_string($file)) { - return false; - } + /** + * @return array|null + */ + private function resolveExporterConfig(string $reportType): ?array + { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['cognitive'] ?? []; - // Validate file exists if specified - if ($file !== null && !file_exists($file)) { - return false; - } + if (!isset($customReporters[$reportType])) { + return null; + } - // For file-based exporters, we'll do basic validation - // The actual class loading will happen later with proper autoloading - if ($file !== null) { - // Check if the file is readable - try { - $content = file_get_contents($file); - if ($content === false) { - return false; - } - - // Basic check: does the file contain a class with the expected name? - // We'll look for the class name without the namespace prefix - $className = basename(str_replace('\\', '/', $class)); - return strpos($content, $className) !== false; - } catch (\Throwable) { - return false; - } - } + $exporterConfig = $customReporters[$reportType]; - // If no file specified, class should be autoloadable - return class_exists($class); - } catch (\Exception) { - return false; + if (!is_array($exporterConfig)) { + return null; } + + /** @var array $exporterConfig */ + return $exporterConfig; + } + + private function isBuiltInReportType(string $reportType): bool + { + return in_array($reportType, ['json', 'csv', 'html', 'markdown'], true); } } diff --git a/src/Command/Presentation/TableRowBuilder.php b/src/Command/Presentation/TableRowBuilder.php index 831fdd8..0cd93ee 100644 --- a/src/Command/Presentation/TableRowBuilder.php +++ b/src/Command/Presentation/TableRowBuilder.php @@ -150,8 +150,11 @@ private function addWeightedValue(string $key, CognitiveMetrics $metrics, array $getMethod = 'get' . $key; $getMethodWeight = 'get' . $key . 'Weight'; - $weight = (float)$metrics->{$getMethodWeight}(); - $row[$key] = $metrics->{$getMethod}() . ' (' . round($weight, 3) . ')'; + $weightValue = $metrics->{$getMethodWeight}(); + $weight = is_int($weightValue) || is_float($weightValue) ? (float) $weightValue : 0.0; + $countValue = $metrics->{$getMethod}(); + $count = is_int($countValue) ? (string) $countValue : '0'; + $row[$key] = $count . ' (' . round($weight, 3) . ')'; return $row; } @@ -168,7 +171,11 @@ private function addDelta(string $key, CognitiveMetrics $metrics, array $row): a $this->assertDeltaMethodExists($metrics, $getDeltaMethod); $delta = $metrics->{$getDeltaMethod}(); - if ($delta === null || $delta->hasNotChanged()) { + if (!$delta instanceof Delta) { + return $row; + } + + if ($delta->hasNotChanged()) { return $row; } diff --git a/src/Config/ConfigException.php b/src/Config/ConfigException.php new file mode 100644 index 0000000..8151fb0 --- /dev/null +++ b/src/Config/ConfigException.php @@ -0,0 +1,11 @@ +bundledConfigPath); if (!is_array($defaultConfig)) { - throw new CognitiveAnalysisException( + throw new ConfigException( sprintf('Bundled configuration file is invalid: %s', $this->bundledConfigPath) ); } @@ -34,7 +33,10 @@ public function createDefaultConfig(array $overrides = []): array $defaultConfig = array_replace_recursive($defaultConfig, $overrides); } - return $this->processor->processConfiguration($this->configLoader, [$defaultConfig]); + /** @var array $config */ + $config = $this->processor->processConfiguration($this->configLoader, [$defaultConfig]); + + return $config; } /** @@ -44,12 +46,12 @@ public function writeConfigFile(string $path, array $config): void { $directory = dirname($path); if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { - throw new CognitiveAnalysisException("Failed to create directory: {$directory}"); + throw new ConfigException("Failed to create directory: {$directory}"); } $yaml = Yaml::dump($config, 4, 2); if (file_put_contents($path, $yaml) === false) { - throw new CognitiveAnalysisException("Failed to write config file: {$path}"); + throw new ConfigException("Failed to write config file: {$path}"); } } } diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index 29cbbf0..f36b8ba 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -127,7 +127,12 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->beforeNormalization() ->ifArray() - ->then(function ($mapping) { + ->then(function (mixed $mapping): array { + if (!is_array($mapping)) { + return $this->getCognitiveMetricDefaults(); + } + + /** @var array> $mapping */ return $mapping + $this->getCognitiveMetricDefaults(); }) ->end() diff --git a/src/Config/ConfigService.php b/src/Config/ConfigService.php index f645b88..1294a7e 100644 --- a/src/Config/ConfigService.php +++ b/src/Config/ConfigService.php @@ -26,8 +26,14 @@ public function __construct( */ private function loadDefaultConfig(): void { + $defaultConfig = Yaml::parseFile(__DIR__ . '/../../phpcca.yaml'); + if (!is_array($defaultConfig)) { + throw new ConfigException('Default configuration file is invalid.'); + } + + /** @var array $config */ $config = $this->processor->processConfiguration($this->configuration, [ - Yaml::parseFile(__DIR__ . '/../../phpcca.yaml'), + $defaultConfig, ]); $this->config = (new ConfigFactory())->fromArray($config); @@ -40,7 +46,11 @@ public function loadConfig(string $configFilePath): void { $defaultConfig = Yaml::parseFile(__DIR__ . '/../../phpcca.yaml'); $providedConfig = Yaml::parseFile($configFilePath); + if (!is_array($defaultConfig) || !is_array($providedConfig)) { + throw new ConfigException('Configuration file is invalid.'); + } + /** @var array $config */ $config = $this->processor->processConfiguration($this->configuration, [ $defaultConfig, $providedConfig, From 834f3e2bba1d8c33b64b2b05a6ccce8b8e1f8ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 25 May 2026 01:32:18 +0200 Subject: [PATCH 3/5] Refactor cognitive metrics handling for improved type validation - Updated `CognitiveMetrics` to return early if `cyclomatic_complexity` is not an array, enhancing robustness. - Modified `ScoreCalculator` to skip non-numeric weights, ensuring only valid scores are accumulated. - Cleaned up unnecessary whitespace in `ConfigFactory` for better code clarity. --- src/Business/Cognitive/CognitiveMetrics.php | 10 ++++++---- src/Business/Cognitive/ScoreCalculator.php | 6 ++++-- src/Config/ConfigFactory.php | 1 - 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Business/Cognitive/CognitiveMetrics.php b/src/Business/Cognitive/CognitiveMetrics.php index 3828422..b3db2b1 100644 --- a/src/Business/Cognitive/CognitiveMetrics.php +++ b/src/Business/Cognitive/CognitiveMetrics.php @@ -117,11 +117,13 @@ public function __construct(array $metrics) return; } - if (is_array($metrics['cyclomatic_complexity'])) { - /** @var array $cyclomaticData */ - $cyclomaticData = $metrics['cyclomatic_complexity']; - $this->cyclomatic = new CyclomaticMetrics($cyclomaticData); + if (!is_array($metrics['cyclomatic_complexity'])) { + return; } + + /** @var array $cyclomaticData */ + $cyclomaticData = $metrics['cyclomatic_complexity']; + $this->cyclomatic = new CyclomaticMetrics($cyclomaticData); } /** diff --git a/src/Business/Cognitive/ScoreCalculator.php b/src/Business/Cognitive/ScoreCalculator.php index c4739cd..55113da 100644 --- a/src/Business/Cognitive/ScoreCalculator.php +++ b/src/Business/Cognitive/ScoreCalculator.php @@ -48,9 +48,11 @@ private function calculateScore(CognitiveMetrics $metrics): void foreach ($this->combinedMetrics as $metric) { $methodName = 'get' . $metric . 'Weight'; $weight = $metrics->{$methodName}(); - if (is_int($weight) || is_float($weight)) { - $score += $weight; + if (!is_int($weight) && !is_float($weight)) { + continue; } + + $score += $weight; } $metrics->setScore(round($score, 3)); diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php index 69ca56b..f20e923 100644 --- a/src/Config/ConfigFactory.php +++ b/src/Config/ConfigFactory.php @@ -4,7 +4,6 @@ namespace Phauthentic\CognitiveCodeAnalysis\Config; - /** * @phpstan-type MetricConfigArray array{threshold: int|float, scale: float, enabled: bool} * @phpstan-type CognitiveSectionArray array{ From a8231979e8764b1ba80244af7d62ef62b403cf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 25 May 2026 01:38:02 +0200 Subject: [PATCH 4/5] Fix empty return value in CacheConfig class - Added an empty string to the return array in the `CacheConfig` class to ensure consistent return structure. - This change improves clarity and maintains the expected format for configuration data. --- src/Config/CacheConfig.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Config/CacheConfig.php b/src/Config/CacheConfig.php index 78296a4..6ac7ed1 100644 --- a/src/Config/CacheConfig.php +++ b/src/Config/CacheConfig.php @@ -25,6 +25,7 @@ public function toArray(): array return [ 'enabled' => $this->enabled, 'directory' => $this->directory, + '' ]; } } From c517dba990282ef5ce76d2e5e1c736dbe8256b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 25 May 2026 01:38:12 +0200 Subject: [PATCH 5/5] Remove empty string from return array in CacheConfig class to ensure consistent return structure. --- src/Config/CacheConfig.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Config/CacheConfig.php b/src/Config/CacheConfig.php index 6ac7ed1..78296a4 100644 --- a/src/Config/CacheConfig.php +++ b/src/Config/CacheConfig.php @@ -25,7 +25,6 @@ public function toArray(): array return [ 'enabled' => $this->enabled, 'directory' => $this->directory, - '' ]; } }