From 5be5cb32ba2fe0872113b99e13f0db5b43a6f208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Fri, 17 Oct 2025 22:54:38 +0200 Subject: [PATCH 1/8] Refactoring --- config.yml | 2 + src/Application.php | 128 +++++++++++------ .../Cognitive/CognitiveMetricsCollection.php | 10 ++ .../Cognitive/CognitiveMetricsCollector.php | 95 ++++++++++++ src/Business/Cognitive/Report/CsvReport.php | 105 +++++++++++++- src/Business/Cognitive/Report/JsonReport.php | 72 +++++++++- .../Cognitive/Report/MarkdownReport.php | 109 +++++++++++++- .../Report/StreamableReportInterface.php | 37 +++++ src/Command/CognitiveMetricsCommand.php | 126 +++++----------- .../CognitiveAnalysis/BaselineHandler.php | 50 ------- .../CognitiveAnalysis/SortingHandler.php | 49 ------- .../Handler/CognitiveMetricsReportHandler.php | 100 ------------- src/Command/Pipeline/CommandPipeline.php | 50 +++++++ .../Pipeline/CommandPipelineFactory.php | 49 +++++++ .../Pipeline/CommandPipelineInterface.php | 22 +++ src/Command/Pipeline/ExecutionContext.php | 136 ++++++++++++++++++ src/Command/Pipeline/PipelineStage.php | 41 ++++++ src/Command/Pipeline/Stages/BaselineStage.php | 57 ++++++++ .../Stages/ConfigurationStage.php} | 27 ++-- .../Stages/CoverageStage.php} | 36 +++-- .../Stages/MetricsCollectionStage.php | 49 +++++++ src/Command/Pipeline/Stages/OutputStage.php | 51 +++++++ .../Pipeline/Stages/ReportGenerationStage.php | 112 +++++++++++++++ src/Command/Pipeline/Stages/SortingStage.php | 54 +++++++ .../Pipeline/Stages/ValidationStage.php | 39 +++++ src/Config/CognitiveConfig.php | 2 + src/Config/ConfigFactory.php | 8 ++ src/Config/ConfigLoader.php | 8 ++ src/Config/PerformanceConfig.php | 28 ++++ 29 files changed, 1290 insertions(+), 362 deletions(-) create mode 100644 src/Business/Cognitive/Report/StreamableReportInterface.php delete mode 100644 src/Command/Handler/CognitiveAnalysis/BaselineHandler.php delete mode 100644 src/Command/Handler/CognitiveAnalysis/SortingHandler.php delete mode 100644 src/Command/Handler/CognitiveMetricsReportHandler.php create mode 100644 src/Command/Pipeline/CommandPipeline.php create mode 100644 src/Command/Pipeline/CommandPipelineFactory.php create mode 100644 src/Command/Pipeline/CommandPipelineInterface.php create mode 100644 src/Command/Pipeline/ExecutionContext.php create mode 100644 src/Command/Pipeline/PipelineStage.php create mode 100644 src/Command/Pipeline/Stages/BaselineStage.php rename src/Command/{Handler/CognitiveAnalysis/ConfigurationLoadHandler.php => Pipeline/Stages/ConfigurationStage.php} (54%) rename src/Command/{Handler/CognitiveAnalysis/CoverageLoadHandler.php => Pipeline/Stages/CoverageStage.php} (65%) create mode 100644 src/Command/Pipeline/Stages/MetricsCollectionStage.php create mode 100644 src/Command/Pipeline/Stages/OutputStage.php create mode 100644 src/Command/Pipeline/Stages/ReportGenerationStage.php create mode 100644 src/Command/Pipeline/Stages/SortingStage.php create mode 100644 src/Command/Pipeline/Stages/ValidationStage.php create mode 100644 src/Config/PerformanceConfig.php diff --git a/config.yml b/config.yml index ac537b5..2064475 100644 --- a/config.yml +++ b/config.yml @@ -43,6 +43,8 @@ cognitive: cache: enabled: false directory: './.phpcca.cache' + performance: + batchSize: 100 # Files to process before flushing to report # Example of custom reporters: # customReporters: # cognitive: diff --git a/src/Application.php b/src/Application.php index 0809b89..a78cbdc 100644 --- a/src/Application.php +++ b/src/Application.php @@ -26,15 +26,20 @@ use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnValidationSpecificationFactory; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsCommand; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsValidationSpecificationFactory; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CompositeCognitiveMetricsValidationSpecification; use Phauthentic\CognitiveCodeAnalysis\Command\EventHandler\ParserErrorHandler; use Phauthentic\CognitiveCodeAnalysis\Command\EventHandler\ProgressBarHandler; use Phauthentic\CognitiveCodeAnalysis\Command\EventHandler\VerboseHandler; use Phauthentic\CognitiveCodeAnalysis\Command\Handler\ChurnReportHandler; -use Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveAnalysis\BaselineHandler; -use Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveAnalysis\ConfigurationLoadHandler; -use Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveAnalysis\CoverageLoadHandler; -use Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveAnalysis\SortingHandler; -use Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveMetricsReportHandler; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CommandPipelineFactory; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\BaselineStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ConfigurationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\CoverageStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\MetricsCollectionStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\OutputStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ReportGenerationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\SortingStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ValidationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\ChurnTextRenderer; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRenderer; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRendererInterface; @@ -119,6 +124,10 @@ private function registerCoreServices(): void $this->containerBuilder->register(CognitiveMetricsValidationSpecificationFactory::class, CognitiveMetricsValidationSpecificationFactory::class) ->setPublic(true); + $this->containerBuilder->register(CompositeCognitiveMetricsValidationSpecification::class, CompositeCognitiveMetricsValidationSpecification::class) + ->setFactory([new Reference(CognitiveMetricsValidationSpecificationFactory::class), 'create']) + ->setPublic(true); + $this->containerBuilder->register(ChurnValidationSpecificationFactory::class, ChurnValidationSpecificationFactory::class) ->setPublic(true); } @@ -197,39 +206,6 @@ private function registerCommandHandlers(): void new Reference(ChurnReportFactoryInterface::class), ]) ->setPublic(true); - - $this->containerBuilder->register(CognitiveMetricsReportHandler::class, CognitiveMetricsReportHandler::class) - ->setArguments([ - new Reference(MetricsFacade::class), - new Reference(OutputInterface::class), - new Reference(CognitiveReportFactoryInterface::class), - ]) - ->setPublic(true); - - // Register cognitive analysis handlers - $this->containerBuilder->register(ConfigurationLoadHandler::class, ConfigurationLoadHandler::class) - ->setArguments([ - new Reference(MetricsFacade::class), - ]) - ->setPublic(true); - - $this->containerBuilder->register(CoverageLoadHandler::class, CoverageLoadHandler::class) - ->setArguments([ - new Reference(CodeCoverageFactory::class), - ]) - ->setPublic(true); - - $this->containerBuilder->register(BaselineHandler::class, BaselineHandler::class) - ->setArguments([ - new Reference(Baseline::class), - ]) - ->setPublic(true); - - $this->containerBuilder->register(SortingHandler::class, SortingHandler::class) - ->setArguments([ - new Reference(CognitiveMetricsSorter::class), - ]) - ->setPublic(true); } private function bootstrap(): void @@ -239,6 +215,7 @@ private function bootstrap(): void $this->bootstrapMetricsCollectors(); $this->configureConfigService(); $this->registerMetricsFacade(); + $this->registerPipelineStages(); $this->registerCommands(); $this->configureApplication(); } @@ -301,18 +278,77 @@ private function registerMetricsFacade(): void ->setPublic(true); } - private function registerCommands(): void + private function registerPipelineStages(): void { - $this->containerBuilder->register(CognitiveMetricsCommand::class, CognitiveMetricsCommand::class) + // Register pipeline stages + $this->containerBuilder->register(ValidationStage::class, ValidationStage::class) + ->setArguments([ + new Reference(CompositeCognitiveMetricsValidationSpecification::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(ConfigurationStage::class, ConfigurationStage::class) ->setArguments([ new Reference(MetricsFacade::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(CoverageStage::class, CoverageStage::class) + ->setArguments([ + new Reference(CodeCoverageFactory::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(MetricsCollectionStage::class, MetricsCollectionStage::class) + ->setArguments([ + new Reference(MetricsFacade::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(BaselineStage::class, BaselineStage::class) + ->setArguments([ + new Reference(Baseline::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(SortingStage::class, SortingStage::class) + ->setArguments([ + new Reference(CognitiveMetricsSorter::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(ReportGenerationStage::class, ReportGenerationStage::class) + ->setArguments([ + new Reference(MetricsFacade::class), + new Reference(CognitiveReportFactoryInterface::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(OutputStage::class, OutputStage::class) + ->setArguments([ new Reference(CognitiveMetricTextRendererInterface::class), - new Reference(CognitiveMetricsReportHandler::class), - new Reference(ConfigurationLoadHandler::class), - new Reference(CoverageLoadHandler::class), - new Reference(BaselineHandler::class), - new Reference(SortingHandler::class), - new Reference(CognitiveMetricsValidationSpecificationFactory::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(CommandPipelineFactory::class, CommandPipelineFactory::class) + ->setArguments([ + new Reference(ValidationStage::class), + new Reference(ConfigurationStage::class), + new Reference(CoverageStage::class), + new Reference(MetricsCollectionStage::class), + new Reference(BaselineStage::class), + new Reference(SortingStage::class), + new Reference(ReportGenerationStage::class), + new Reference(OutputStage::class), + ]) + ->setPublic(true); + } + + private function registerCommands(): void + { + $this->containerBuilder->register(CognitiveMetricsCommand::class, CognitiveMetricsCommand::class) + ->setArguments([ + new Reference(CommandPipelineFactory::class), ]) ->setPublic(true); diff --git a/src/Business/Cognitive/CognitiveMetricsCollection.php b/src/Business/Cognitive/CognitiveMetricsCollection.php index 1c24d9a..0f6c601 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollection.php +++ b/src/Business/Cognitive/CognitiveMetricsCollection.php @@ -32,6 +32,16 @@ public function add(CognitiveMetrics $metric): void $this->metrics[$metric->getClass() . '::' . $metric->getMethod()] = $metric; } + /** + * Merge another collection into this one + */ + public function merge(CognitiveMetricsCollection $other): void + { + foreach ($other->metrics as $metric) { + $this->add($metric); + } + } + /** * Filter the collection using a callback function * diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index aed0258..bfae4a4 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -72,6 +72,35 @@ public function collectFromPaths(array $paths, CognitiveConfig $config): Cogniti return $this->findMetrics($allFiles); } + /** + * Collect cognitive metrics from multiple paths in batches. + * Yields batches of metrics for streaming report generation. + * + * @param array $paths Array of paths to process + * @param CognitiveConfig $config + * @param callable(CognitiveMetricsCollection): void $batchCallback Callback to process each batch + * @param int $batchSize Number of files to process before yielding a batch + * @return CognitiveMetricsCollection Final merged collection + * @throws \Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException|\InvalidArgumentException + */ + public function collectFromPathsInBatches( + array $paths, + CognitiveConfig $config, + callable $batchCallback, + int $batchSize = 100 + ): CognitiveMetricsCollection { + $allFiles = []; + + foreach ($paths as $path) { + $files = $this->findSourceFiles($path, $config->excludeFilePatterns); + $allFiles = array_merge($allFiles, iterator_to_array($files)); + } + + $this->messageBus->dispatch(new SourceFilesFound(array_values($allFiles))); + + return $this->findMetricsInBatches($allFiles, $batchCallback, $batchSize); + } + /** * @throws CognitiveAnalysisException */ @@ -126,6 +155,72 @@ private function findMetrics(iterable $files): CognitiveMetricsCollection return $metricsCollection; } + /** + * Collect metrics from the found source files in batches + * + * @param iterable $files + * @param callable(CognitiveMetricsCollection): void $batchCallback + * @param int $batchSize + * @return CognitiveMetricsCollection + * @throws \InvalidArgumentException + */ + private function findMetricsInBatches( + iterable $files, + callable $batchCallback, + int $batchSize + ): CognitiveMetricsCollection { + $finalMetricsCollection = new CognitiveMetricsCollection(); + $currentBatch = new CognitiveMetricsCollection(); + $fileCount = 0; + $processedFiles = 0; + $config = $this->configService->getConfig(); + $configHash = $this->generateConfigHash($config); + $useCache = $config->cache?->enabled === true; + + foreach ($files as $file) { + // Try to get cached metrics + $cached = $this->getCachedMetrics($file, $configHash, $useCache); + $metrics = $cached['metrics']; + + // If not cached, process the file + if ($metrics === null) { + $metrics = $this->processFile($file, $fileCount, $cached['cacheItem'], $useCache, $configHash); + + if ($metrics === null) { + continue; + } + } + + $currentBatch = $this->processMethodMetrics( + $metrics, + $currentBatch, + FilenameNormalizer::normalize($file) + ); + + $processedFiles++; + + // If we've processed enough files, yield the batch + if ($processedFiles < $batchSize) { + continue; + } + + if (count($currentBatch) > 0) { + $batchCallback($currentBatch); + $finalMetricsCollection->merge($currentBatch); + $currentBatch = new CognitiveMetricsCollection(); + } + $processedFiles = 0; + } + + // Process any remaining files in the final batch + if (count($currentBatch) > 0) { + $batchCallback($currentBatch); + $finalMetricsCollection->merge($currentBatch); + } + + return $finalMetricsCollection; + } + /** * @param array $methodMetrics * @param CognitiveMetricsCollection $metricsCollection diff --git a/src/Business/Cognitive/Report/CsvReport.php b/src/Business/Cognitive/Report/CsvReport.php index 64dc0ac..9ee994a 100644 --- a/src/Business/Cognitive/Report/CsvReport.php +++ b/src/Business/Cognitive/Report/CsvReport.php @@ -7,7 +7,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; -class CsvReport implements ReportGeneratorInterface +class CsvReport implements ReportGeneratorInterface, StreamableReportInterface { /** * @var array @@ -37,6 +37,11 @@ class CsvReport implements ReportGeneratorInterface 'Combined Cognitive Complexity' ]; + /** @var resource|false|null */ + private $fileHandle = null; + private bool $isStreaming = false; + private bool $headerWritten = false; + /** * @throws CognitiveAnalysisException */ @@ -97,4 +102,102 @@ public function export(CognitiveMetricsCollection $metrics, string $filename): v fclose($file); } + + /** + * @throws CognitiveAnalysisException + */ + public function startReport(string $filename): void + { + $basename = dirname($filename); + if (!is_dir($basename)) { + throw new CognitiveAnalysisException(sprintf('Directory %s does not exist', $basename)); + } + + $this->fileHandle = fopen($filename, 'wb'); + if ($this->fileHandle === false) { + throw new CognitiveAnalysisException(sprintf('Could not open file %s for writing', $filename)); + } + + $this->isStreaming = true; + $this->headerWritten = false; + } + + /** + * @throws CognitiveAnalysisException + */ + public function writeMetricBatch(CognitiveMetricsCollection $batch): void + { + if (!$this->isStreaming || $this->fileHandle === null) { + throw new CognitiveAnalysisException('Streaming not started. Call startReport() first.'); + } + + // Type guard: fileHandle is guaranteed to be resource at this point + assert($this->fileHandle !== false); + + // Write header only once + if (!$this->headerWritten) { + fputcsv($this->fileHandle, $this->header, ',', '"', '\\'); + $this->headerWritten = true; + } + + $groupedByClass = $batch->groupBy('class'); + + foreach ($groupedByClass as $methods) { + foreach ($methods as $data) { + fputcsv($this->fileHandle, [ + $data->getClass(), + $data->getMethod(), + + $data->getLineCount(), + $data->getLineCountWeight(), + (string)$data->getLineCountWeightDelta(), + + $data->getArgCount(), + $data->getArgCountWeight(), + (string)$data->getArgCountWeightDelta(), + + $data->getReturnCount(), + $data->getReturnCountWeight(), + (string)$data->getReturnCountWeightDelta(), + + $data->getVariableCount(), + $data->getVariableCountWeight(), + (string)$data->getVariableCountWeightDelta(), + + $data->getPropertyCallCount(), + $data->getPropertyCallCountWeight(), + (string)$data->getPropertyCallCountWeightDelta(), + + $data->getIfNestingLevel(), + $data->getIfNestingLevelWeight(), + (string)$data->getIfNestingLevelWeightDelta(), + + $data->getElseCount(), + $data->getElseCountWeight(), + (string)$data->getElseCountWeightDelta(), + + $data->getScore() + ], ',', '"', '\\'); + } + } + } + + /** + * @throws CognitiveAnalysisException + */ + public function finalizeReport(): void + { + if (!$this->isStreaming || $this->fileHandle === null) { + throw new CognitiveAnalysisException('Streaming not started or file handle not available.'); + } + + // Type guard: fileHandle is guaranteed to be resource at this point + assert($this->fileHandle !== false); + + fclose($this->fileHandle); + + $this->isStreaming = false; + $this->fileHandle = null; + $this->headerWritten = false; + } } diff --git a/src/Business/Cognitive/Report/JsonReport.php b/src/Business/Cognitive/Report/JsonReport.php index 231a369..7bfba59 100644 --- a/src/Business/Cognitive/Report/JsonReport.php +++ b/src/Business/Cognitive/Report/JsonReport.php @@ -7,8 +7,13 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; -class JsonReport implements ReportGeneratorInterface +class JsonReport implements ReportGeneratorInterface, StreamableReportInterface { + private ?string $filename = null; + /** @var array */ + private array $jsonData = []; + private bool $isStreaming = false; + /** * @throws \JsonException|\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException */ @@ -50,4 +55,69 @@ public function export(CognitiveMetricsCollection $metricsCollection, string $fi throw new CognitiveAnalysisException("Unable to write to file: $filename"); } } + + public function startReport(string $filename): void + { + $this->filename = $filename; + $this->jsonData = []; + $this->isStreaming = true; + } + + /** + * @throws CognitiveAnalysisException + */ + public function writeMetricBatch(CognitiveMetricsCollection $batch): void + { + if (!$this->isStreaming) { + throw new CognitiveAnalysisException('Streaming not started. Call startReport() first.'); + } + + $groupedByClass = $batch->groupBy('class'); + + 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() + ]; + } + } + } + + /** + * @throws \JsonException|CognitiveAnalysisException + */ + public function finalizeReport(): void + { + if (!$this->isStreaming || $this->filename === null) { + throw new CognitiveAnalysisException('Streaming not started or filename not set.'); + } + + $jsonData = json_encode($this->jsonData, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + + if (file_put_contents($this->filename, $jsonData) === false) { + throw new CognitiveAnalysisException("Unable to write to file: {$this->filename}"); + } + + $this->isStreaming = false; + $this->filename = null; + $this->jsonData = []; + } } diff --git a/src/Business/Cognitive/Report/MarkdownReport.php b/src/Business/Cognitive/Report/MarkdownReport.php index 528e1a5..1fdb09f 100644 --- a/src/Business/Cognitive/Report/MarkdownReport.php +++ b/src/Business/Cognitive/Report/MarkdownReport.php @@ -15,11 +15,16 @@ /** * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ -class MarkdownReport implements ReportGeneratorInterface +class MarkdownReport implements ReportGeneratorInterface, StreamableReportInterface { use MarkdownFormatterTrait; private CognitiveConfig $config; + /** @var resource|false|null */ + private $fileHandle = null; + private bool $isStreaming = false; + /** @var array */ + private array $processedClasses = []; public function __construct(CognitiveConfig $config) { @@ -417,4 +422,106 @@ private function generateSingleTableMarkdown(CognitiveMetricsCollection $metrics return $markdown; } + + /** + * @throws CognitiveAnalysisException + */ + public function startReport(string $filename): void + { + $this->fileHandle = fopen($filename, 'wb'); + if ($this->fileHandle === false) { + throw new CognitiveAnalysisException(sprintf('Could not open file %s for writing', $filename)); + } + + $this->isStreaming = true; + $this->processedClasses = []; + + // Write initial header + $datetime = (new Datetime())->format('Y-m-d H:i:s'); + $header = "# Cognitive Metrics Report\n\n"; + $header .= "**Generated:** {$datetime}\n\n"; + + if ($this->config->showOnlyMethodsExceedingThreshold && $this->config->scoreThreshold > 0) { + $header .= "**Note:** Only showing methods exceeding threshold of " . $this->formatNumber($this->config->scoreThreshold) . "\n\n"; + } + + $header .= "---\n\n"; + + fwrite($this->fileHandle, $header); + } + + /** + * @throws CognitiveAnalysisException + */ + public function writeMetricBatch(CognitiveMetricsCollection $batch): void + { + if (!$this->isStreaming || $this->fileHandle === null) { + throw new CognitiveAnalysisException('Streaming not started. Call startReport() first.'); + } + + // Type guard: fileHandle is guaranteed to be resource at this point + assert($this->fileHandle !== false); + + $groupedByClass = $batch->groupBy('class'); + + foreach ($groupedByClass as $class => $methods) { + $filteredMethods = $this->filterMetrics($methods); + + if (count($filteredMethods) === 0) { + continue; + } + + // Check if we've already processed this class + if (in_array($class, $this->processedClasses, true)) { + continue; + } + + $this->processedClasses[] = $class; + + // Get file path from first method in the collection + $firstMethod = null; + foreach ($filteredMethods as $method) { + $firstMethod = $method; + break; + } + + $classSection = "* **Class:** " . $this->escape((string)$class) . "\n"; + if ($firstMethod !== null) { + $classSection .= "* **File:** " . $this->escape($firstMethod->getFileName()) . "\n"; + } + $classSection .= "\n"; + + // Table header and separator + $classSection .= $this->buildTableHeader() . "\n"; + $classSection .= $this->buildTableSeparator() . "\n"; + + // Table rows + foreach ($filteredMethods as $data) { + $classSection .= $this->buildTableRow($data) . "\n"; + } + + $classSection .= "\n---\n\n"; + + fwrite($this->fileHandle, $classSection); + } + } + + /** + * @throws CognitiveAnalysisException + */ + public function finalizeReport(): void + { + if (!$this->isStreaming || $this->fileHandle === null) { + throw new CognitiveAnalysisException('Streaming not started or file handle not available.'); + } + + // Type guard: fileHandle is guaranteed to be resource at this point + assert($this->fileHandle !== false); + + fclose($this->fileHandle); + + $this->isStreaming = false; + $this->fileHandle = null; + $this->processedClasses = []; + } } diff --git a/src/Business/Cognitive/Report/StreamableReportInterface.php b/src/Business/Cognitive/Report/StreamableReportInterface.php new file mode 100644 index 0000000..4787695 --- /dev/null +++ b/src/Business/Cognitive/Report/StreamableReportInterface.php @@ -0,0 +1,37 @@ +specification = $this->specificationFactory->create(); } @@ -135,84 +117,54 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $context = new CognitiveMetricsCommandContext($input); - - // Validate all specifications - if (!$this->specification->isSatisfiedBy($context)) { - return $this->handleValidationError($context, $output); - } + $commandContext = new CognitiveMetricsCommandContext($input); + $executionContext = new ExecutionContext($commandContext, $output); - // Load configuration - $configResult = $this->configHandler->load($context); - if ($configResult->isFailure()) { - return $configResult->toCommandStatus($output); - } + // Build pipeline with stages + $pipeline = $this->pipelineFactory->createPipeline(); - // Validate custom exporters after config is loaded - if ($context->hasReportOptions()) { - $customExporterValidation = new CustomExporterValidation( - $this->reportHandler->getReportFactory(), - $this->reportHandler->getConfigService() - ); + // Execute pipeline + $result = $pipeline->execute($executionContext); - if (!$customExporterValidation->isSatisfiedBy($context)) { - return $this->handleValidationError($context, $output, $customExporterValidation); - } + // Output execution summary if debug mode + if ($input->getOption(self::OPTION_DEBUG)) { + $this->outputExecutionSummary($executionContext, $output); } - // Load coverage reader - $coverageResult = $this->coverageHandler->load($context); - if ($coverageResult->isFailure()) { - return $coverageResult->toCommandStatus($output); + // Display error message if pipeline failed + if ($result->isFailure()) { + $output->writeln('' . $result->getErrorMessage() . ''); + return Command::FAILURE; } - // Get metrics - $metricsCollection = $this->metricsFacade->getCognitiveMetricsFromPaths( - $context->getPaths(), - $coverageResult->getData() - ); + return Command::SUCCESS; + } - // Apply baseline - $baselineResult = $this->baselineHandler->apply($context, $metricsCollection); - if ($baselineResult->isFailure()) { - return $baselineResult->toCommandStatus($output); - } + /** + * Output execution summary with timing information. + */ + private function outputExecutionSummary(ExecutionContext $context, OutputInterface $output): void + { + $timings = $context->getTimings(); + $statistics = $context->getStatistics(); - // Apply sorting - $sortResult = $this->sortingHandler->sort($context, $metricsCollection); - if ($sortResult->isFailure()) { - return $sortResult->toCommandStatus($output); - } + $output->writeln('Execution Summary:'); + $output->writeln(sprintf(' Total execution time: %.3fs', $context->getTotalTime())); - // Generate report or display results - if ($context->hasReportOptions()) { - return $this->reportHandler->handle( - $sortResult->getData(), - $context->getReportType(), - $context->getReportFile() - ); + if (!empty($timings)) { + $output->writeln('Stage timings:'); + foreach ($timings as $stage => $duration) { + $output->writeln(sprintf(' %s: %.3fs', $stage, $duration)); + } } - $this->renderer->render($sortResult->getData(), $output); - - return Command::SUCCESS; - } - + if (empty($statistics)) { + return; + } - /** - * Handle validation errors with consistent error output. - */ - private function handleValidationError( - CognitiveMetricsCommandContext $context, - OutputInterface $output, - ?CustomExporterValidation $customExporterValidation = null - ): int { - $errorMessage = $customExporterValidation !== null - ? $customExporterValidation->getErrorMessageWithContext($context) - : $this->specification->getDetailedErrorMessage($context); - - $output->writeln('' . $errorMessage . ''); - - return Command::FAILURE; + $output->writeln('Statistics:'); + foreach ($statistics as $key => $value) { + $output->writeln(sprintf(' %s: %s', $key, $value)); + } } } diff --git a/src/Command/Handler/CognitiveAnalysis/BaselineHandler.php b/src/Command/Handler/CognitiveAnalysis/BaselineHandler.php deleted file mode 100644 index 9ed0bd4..0000000 --- a/src/Command/Handler/CognitiveAnalysis/BaselineHandler.php +++ /dev/null @@ -1,50 +0,0 @@ -hasBaselineFile()) { - return OperationResult::success(); - } - - $baselineFile = $context->getBaselineFile(); - if ($baselineFile === null) { - return OperationResult::success(); - } - - try { - $baseline = $this->baselineService->loadBaseline($baselineFile); - $this->baselineService->calculateDeltas($metricsCollection, $baseline); - return OperationResult::success(); - } catch (Exception $e) { - return OperationResult::failure('Failed to process baseline: ' . $e->getMessage()); - } - } -} diff --git a/src/Command/Handler/CognitiveAnalysis/SortingHandler.php b/src/Command/Handler/CognitiveAnalysis/SortingHandler.php deleted file mode 100644 index 31304bc..0000000 --- a/src/Command/Handler/CognitiveAnalysis/SortingHandler.php +++ /dev/null @@ -1,49 +0,0 @@ -getSortBy(); - $sortOrder = $context->getSortOrder(); - - if ($sortBy === null) { - return OperationResult::success($metricsCollection); - } - - try { - $sorted = $this->sorter->sort($metricsCollection, $sortBy, $sortOrder); - return OperationResult::success($sorted); - } catch (\InvalidArgumentException $e) { - $availableFields = implode(', ', $this->sorter->getSortableFields()); - return OperationResult::failure( - "Sorting error: {$e->getMessage()}. Available sort fields: {$availableFields}" - ); - } - } -} diff --git a/src/Command/Handler/CognitiveMetricsReportHandler.php b/src/Command/Handler/CognitiveMetricsReportHandler.php deleted file mode 100644 index 0051bb3..0000000 --- a/src/Command/Handler/CognitiveMetricsReportHandler.php +++ /dev/null @@ -1,100 +0,0 @@ -hasIncompleteReportOptions($reportType, $reportFile)) { - $this->output->writeln('Both report type and file must be provided.'); - - return Command::FAILURE; - } - - if (!$this->isValidReportType($reportType)) { - return $this->handleInvalidReporType($reportType); - } - - try { - $this->metricsFacade->exportMetricsReport( - metricsCollection: $metricsCollection, - reportType: (string)$reportType, - filename: (string)$reportFile - ); - - return Command::SUCCESS; - } catch (Exception $exception) { - return $this->handleExceptions($exception); - } - } - - private function hasIncompleteReportOptions(?string $reportType, ?string $reportFile): bool - { - return ($reportType === null && $reportFile !== null) || ($reportType !== null && $reportFile === null); - } - - private function isValidReportType(?string $reportType): bool - { - if ($reportType === null) { - return false; - } - return $this->reportFactory->isSupported($reportType); - } - - private function handleExceptions(Exception $exception): int - { - $this->output->writeln(sprintf( - 'Error generating report: %s', - $exception->getMessage() - )); - - return Command::FAILURE; - } - - public function handleInvalidReporType(?string $reportType): int - { - $supportedTypes = $this->reportFactory->getSupportedTypes(); - - $this->output->writeln(sprintf( - 'Invalid report type `%s` provided. Supported types: %s', - $reportType, - implode(', ', $supportedTypes) - )); - - return Command::FAILURE; - } - - public function getReportFactory(): CognitiveReportFactoryInterface - { - return $this->reportFactory; - } - - public function getConfigService(): ConfigService - { - return $this->metricsFacade->getConfigService(); - } -} diff --git a/src/Command/Pipeline/CommandPipeline.php b/src/Command/Pipeline/CommandPipeline.php new file mode 100644 index 0000000..5b3c9ce --- /dev/null +++ b/src/Command/Pipeline/CommandPipeline.php @@ -0,0 +1,50 @@ +stages as $stage) { + if ($stage->shouldSkip($context)) { + continue; + } + + $startTime = microtime(true); + + $result = $stage->execute($context); + + $duration = microtime(true) - $startTime; + $context->recordTiming($stage->getStageName(), $duration); + + if ($result->isFailure()) { + return $result; + } + } + + return OperationResult::success(); + } +} diff --git a/src/Command/Pipeline/CommandPipelineFactory.php b/src/Command/Pipeline/CommandPipelineFactory.php new file mode 100644 index 0000000..4fa167f --- /dev/null +++ b/src/Command/Pipeline/CommandPipelineFactory.php @@ -0,0 +1,49 @@ +validationStage, + $this->configurationStage, + $this->coverageStage, + $this->metricsCollectionStage, + $this->baselineStage, + $this->sortingStage, + $this->reportGenerationStage, + $this->outputStage, + ]); + } +} diff --git a/src/Command/Pipeline/CommandPipelineInterface.php b/src/Command/Pipeline/CommandPipelineInterface.php new file mode 100644 index 0000000..52af3f7 --- /dev/null +++ b/src/Command/Pipeline/CommandPipelineInterface.php @@ -0,0 +1,22 @@ + */ + private array $timings = []; + /** @var array */ + private array $statistics = []; + /** @var array */ + private array $data = []; + + public function __construct( + private readonly CognitiveMetricsCommandContext $commandContext, + private readonly OutputInterface $output + ) { + } + + /** + * Get the command context. + */ + public function getCommandContext(): CognitiveMetricsCommandContext + { + return $this->commandContext; + } + + /** + * Get the output interface. + */ + public function getOutput(): OutputInterface + { + return $this->output; + } + + /** + * Record timing for a stage. + */ + public function recordTiming(string $stageName, float $duration): void + { + $this->timings[$stageName] = $duration; + } + + /** + * Get timing for a specific stage. + */ + public function getTiming(string $stageName): ?float + { + return $this->timings[$stageName] ?? null; + } + + /** + * Get all timings. + * + * @return array + */ + public function getTimings(): array + { + return $this->timings; + } + + /** + * Get total execution time. + */ + public function getTotalTime(): float + { + return array_sum($this->timings); + } + + /** + * Set a data value in the context. + */ + public function setData(string $key, mixed $value): void + { + $this->data[$key] = $value; + } + + /** + * Get a data value from the context. + */ + public function getData(string $key): mixed + { + return $this->data[$key] ?? null; + } + + /** + * Check if a data key exists in the context. + */ + public function hasData(string $key): bool + { + return array_key_exists($key, $this->data); + } + + /** + * Increment a statistic counter. + */ + public function incrementStatistic(string $key, int $amount = 1): void + { + $this->statistics[$key] = ($this->statistics[$key] ?? 0) + $amount; + } + + /** + * Set a statistic value. + */ + public function setStatistic(string $key, mixed $value): void + { + $this->statistics[$key] = $value; + } + + /** + * Get a statistic value. + */ + public function getStatistic(string $key): mixed + { + return $this->statistics[$key] ?? null; + } + + /** + * Get all statistics. + * + * @return array + */ + public function getStatistics(): array + { + return $this->statistics; + } +} diff --git a/src/Command/Pipeline/PipelineStage.php b/src/Command/Pipeline/PipelineStage.php new file mode 100644 index 0000000..20e9c32 --- /dev/null +++ b/src/Command/Pipeline/PipelineStage.php @@ -0,0 +1,41 @@ +getCommandContext(); + $metricsCollection = $context->getData('metricsCollection'); + + if ($metricsCollection === null) { + return OperationResult::failure('Metrics collection not available for baseline.'); + } + + $baselineFile = $commandContext->getBaselineFile(); + if ($baselineFile === null) { + return OperationResult::success(); + } + + try { + $baseline = $this->baselineService->loadBaseline($baselineFile); + $this->baselineService->calculateDeltas($metricsCollection, $baseline); + return OperationResult::success(); + } catch (Exception $e) { + return OperationResult::failure('Failed to process baseline: ' . $e->getMessage()); + } + } + + public function shouldSkip(ExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + return !$commandContext->hasBaselineFile(); + } + + public function getStageName(): string + { + return 'Baseline'; + } +} diff --git a/src/Command/Handler/CognitiveAnalysis/ConfigurationLoadHandler.php b/src/Command/Pipeline/Stages/ConfigurationStage.php similarity index 54% rename from src/Command/Handler/CognitiveAnalysis/ConfigurationLoadHandler.php rename to src/Command/Pipeline/Stages/ConfigurationStage.php index 657de13..c20c7a0 100644 --- a/src/Command/Handler/CognitiveAnalysis/ConfigurationLoadHandler.php +++ b/src/Command/Pipeline/Stages/ConfigurationStage.php @@ -2,36 +2,34 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveAnalysis; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; use Exception; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; -use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsCommandContext; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\PipelineStage; use Phauthentic\CognitiveCodeAnalysis\Command\Result\OperationResult; /** - * Handler for loading configuration files in cognitive metrics command. + * Pipeline stage for loading configuration files. * Encapsulates configuration loading logic and error handling. */ -class ConfigurationLoadHandler +class ConfigurationStage extends PipelineStage { public function __construct( private readonly MetricsFacade $metricsFacade ) { } - /** - * Load configuration from the context. - * Returns success result if no config file is provided or loading succeeds. - * Returns failure result if loading fails. - */ - public function load(CognitiveMetricsCommandContext $context): OperationResult + public function execute(ExecutionContext $context): OperationResult { - if (!$context->hasConfigFile()) { + $commandContext = $context->getCommandContext(); + + if (!$commandContext->hasConfigFile()) { return OperationResult::success(); } - $configFile = $context->getConfigFile(); + $configFile = $commandContext->getConfigFile(); if ($configFile === null) { return OperationResult::success(); } @@ -43,4 +41,9 @@ public function load(CognitiveMetricsCommandContext $context): OperationResult return OperationResult::failure('Failed to load configuration: ' . $e->getMessage()); } } + + public function getStageName(): string + { + return 'Configuration'; + } } diff --git a/src/Command/Handler/CognitiveAnalysis/CoverageLoadHandler.php b/src/Command/Pipeline/Stages/CoverageStage.php similarity index 65% rename from src/Command/Handler/CognitiveAnalysis/CoverageLoadHandler.php rename to src/Command/Pipeline/Stages/CoverageStage.php index 86ce40c..db3d7bf 100644 --- a/src/Command/Handler/CognitiveAnalysis/CoverageLoadHandler.php +++ b/src/Command/Pipeline/Stages/CoverageStage.php @@ -2,41 +2,41 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveAnalysis; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CodeCoverageFactory; -use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsCommandContext; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\PipelineStage; use Phauthentic\CognitiveCodeAnalysis\Command\Result\OperationResult; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; /** - * Handler for loading coverage files in cognitive metrics command. + * Pipeline stage for loading coverage files. * Encapsulates coverage loading logic, format detection, and error handling. */ -class CoverageLoadHandler +class CoverageStage extends PipelineStage { public function __construct( private readonly CodeCoverageFactory $coverageFactory ) { } - /** - * Load coverage reader from the context. - * Returns success result with reader if file is provided and loading succeeds. - * Returns success result with null if no file is provided. - * Returns failure result if loading fails. - */ - public function load(CognitiveMetricsCommandContext $context): OperationResult + public function execute(ExecutionContext $context): OperationResult { - $coverageFile = $context->getCoverageFile(); - $format = $context->getCoverageFormat(); + $commandContext = $context->getCommandContext(); + $coverageFile = $commandContext->getCoverageFile(); + $format = $commandContext->getCoverageFormat(); if ($coverageFile === null) { - return OperationResult::success(null); + return OperationResult::success(); } // Auto-detect format if not specified if ($format === null) { + if (!file_exists($coverageFile)) { + return OperationResult::failure('Coverage file not found: ' . $coverageFile); + } + $format = $this->detectCoverageFormat($coverageFile); if ($format === null) { return OperationResult::failure('Unable to detect coverage file format. Please specify format explicitly.'); @@ -45,7 +45,8 @@ public function load(CognitiveMetricsCommandContext $context): OperationResult try { $reader = $this->coverageFactory->createFromName($format, $coverageFile); - return OperationResult::success($reader); + $context->setData('coverageReader', $reader); + return OperationResult::success(); } catch (CognitiveAnalysisException $e) { return OperationResult::failure('Failed to load coverage file: ' . $e->getMessage()); } @@ -73,4 +74,9 @@ private function detectCoverageFormat(string $coverageFile): ?string return null; } + + public function getStageName(): string + { + return 'Coverage'; + } } diff --git a/src/Command/Pipeline/Stages/MetricsCollectionStage.php b/src/Command/Pipeline/Stages/MetricsCollectionStage.php new file mode 100644 index 0000000..67f1600 --- /dev/null +++ b/src/Command/Pipeline/Stages/MetricsCollectionStage.php @@ -0,0 +1,49 @@ +getCommandContext(); + + // Get coverage data from previous stage + $coverageData = $context->getData('coverageReader'); + + // Get metrics + $metricsCollection = $this->metricsFacade->getCognitiveMetricsFromPaths( + $commandContext->getPaths(), + $coverageData + ); + + // Store metrics collection in context + $context->setData('metricsCollection', $metricsCollection); + + // Record statistics + $context->setStatistic('filesProcessed', count($commandContext->getPaths())); + $context->setStatistic('metricsCollected', count($metricsCollection)); + + return OperationResult::success(); + } + + public function getStageName(): string + { + return 'MetricsCollection'; + } +} diff --git a/src/Command/Pipeline/Stages/OutputStage.php b/src/Command/Pipeline/Stages/OutputStage.php new file mode 100644 index 0000000..bfc68e3 --- /dev/null +++ b/src/Command/Pipeline/Stages/OutputStage.php @@ -0,0 +1,51 @@ +getData('sortedMetricsCollection'); + + if ($sortedMetricsCollection === null) { + return OperationResult::failure('Metrics collection not available for console output.'); + } + + // Render to console + $this->renderer->render($sortedMetricsCollection, $context->getOutput()); + + // Record statistics + $context->setStatistic('consoleOutputRendered', true); + + return OperationResult::success(); + } + + public function shouldSkip(ExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + // Skip console output if report generation was requested + return $commandContext->hasReportOptions(); + } + + public function getStageName(): string + { + return 'ConsoleOutput'; + } +} diff --git a/src/Command/Pipeline/Stages/ReportGenerationStage.php b/src/Command/Pipeline/Stages/ReportGenerationStage.php new file mode 100644 index 0000000..b64030d --- /dev/null +++ b/src/Command/Pipeline/Stages/ReportGenerationStage.php @@ -0,0 +1,112 @@ +getCommandContext(); + $sortedMetricsCollection = $context->getData('sortedMetricsCollection'); + + if ($sortedMetricsCollection === null) { + return OperationResult::failure('Metrics collection not available for report generation.'); + } + + $reportType = $commandContext->getReportType(); + $reportFile = $commandContext->getReportFile(); + + // Validate report options + if ($this->hasIncompleteReportOptions($reportType, $reportFile)) { + $context->getOutput()->writeln('Both report type and file must be provided.'); + return OperationResult::failure('Incomplete report options provided.'); + } + + if (!$this->isValidReportType($reportType)) { + return $this->handleInvalidReportType($context, $reportType); + } + + try { + $this->metricsFacade->exportMetricsReport( + metricsCollection: $sortedMetricsCollection, + reportType: (string)$reportType, + filename: (string)$reportFile + ); + + // Record success statistics + $context->setStatistic('reportGenerated', true); + $context->setStatistic('reportType', $reportType); + $context->setStatistic('reportFile', $reportFile); + + return OperationResult::success(); + } catch (Exception $exception) { + return $this->handleExceptions($context, $exception); + } + } + + public function shouldSkip(ExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + return !$commandContext->hasReportOptions(); + } + + public function getStageName(): string + { + return 'ReportGeneration'; + } + + private function hasIncompleteReportOptions(?string $reportType, ?string $reportFile): bool + { + return ($reportType === null && $reportFile !== null) || ($reportType !== null && $reportFile === null); + } + + private function isValidReportType(?string $reportType): bool + { + if ($reportType === null) { + return false; + } + return $this->reportFactory->isSupported($reportType); + } + + private function handleExceptions(ExecutionContext $context, Exception $exception): OperationResult + { + $context->getOutput()->writeln(sprintf( + 'Error generating report: %s', + $exception->getMessage() + )); + + return OperationResult::failure('Report generation failed: ' . $exception->getMessage()); + } + + private function handleInvalidReportType(ExecutionContext $context, ?string $reportType): OperationResult + { + $supportedTypes = $this->reportFactory->getSupportedTypes(); + + $context->getOutput()->writeln(sprintf( + 'Invalid report type `%s` provided. Supported types: %s', + $reportType, + implode(', ', $supportedTypes) + )); + + return OperationResult::failure('Invalid report type provided.'); + } +} diff --git a/src/Command/Pipeline/Stages/SortingStage.php b/src/Command/Pipeline/Stages/SortingStage.php new file mode 100644 index 0000000..b0b3506 --- /dev/null +++ b/src/Command/Pipeline/Stages/SortingStage.php @@ -0,0 +1,54 @@ +getCommandContext(); + $metricsCollection = $context->getData('metricsCollection'); + + $sortBy = $commandContext->getSortBy(); + $sortOrder = $commandContext->getSortOrder(); + + if ($sortBy === null) { + // Store unsorted metrics in context + $context->setData('sortedMetricsCollection', $metricsCollection); + return OperationResult::success(); + } + + try { + $sorted = $this->sorter->sort($metricsCollection, $sortBy, $sortOrder); + // Store sorted metrics in context + $context->setData('sortedMetricsCollection', $sorted); + return OperationResult::success(); + } catch (\InvalidArgumentException $e) { + $availableFields = implode(', ', $this->sorter->getSortableFields()); + return OperationResult::failure( + "Sorting error: {$e->getMessage()}. Available sort fields: {$availableFields}" + ); + } + } + + public function getStageName(): string + { + return 'Sorting'; + } +} diff --git a/src/Command/Pipeline/Stages/ValidationStage.php b/src/Command/Pipeline/Stages/ValidationStage.php new file mode 100644 index 0000000..e3f6f0a --- /dev/null +++ b/src/Command/Pipeline/Stages/ValidationStage.php @@ -0,0 +1,39 @@ +getCommandContext(); + + // Validate all specifications + if (!$this->specification->isSatisfiedBy($commandContext)) { + $errorMessage = $this->specification->getDetailedErrorMessage($commandContext); + return OperationResult::failure($errorMessage); + } + + return OperationResult::success(); + } + + public function getStageName(): string + { + return 'Validation'; + } +} diff --git a/src/Config/CognitiveConfig.php b/src/Config/CognitiveConfig.php index 8f38f14..cf8cf06 100644 --- a/src/Config/CognitiveConfig.php +++ b/src/Config/CognitiveConfig.php @@ -28,6 +28,7 @@ public function __construct( public readonly bool $groupByClass = false, public readonly bool $showDetailedCognitiveMetrics = true, public readonly ?CacheConfig $cache = null, + public readonly ?PerformanceConfig $performance = null, public readonly array $customReporters = [], ) { } @@ -55,6 +56,7 @@ public function toArray(): array 'groupByClass' => $this->groupByClass, 'showDetailedCognitiveMetrics' => $this->showDetailedCognitiveMetrics, 'cache' => $this->cache?->toArray(), + 'performance' => $this->performance?->toArray(), 'customReporters' => $this->customReporters, ]; } diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php index 6cf98ba..fe4055f 100644 --- a/src/Config/ConfigFactory.php +++ b/src/Config/ConfigFactory.php @@ -28,6 +28,13 @@ public function fromArray(array $config): CognitiveConfig ); } + $performanceConfig = null; + if (isset($config['cognitive']['performance'])) { + $performanceConfig = new PerformanceConfig( + batchSize: $config['cognitive']['performance']['batchSize'] ?? 100 + ); + } + return new CognitiveConfig( excludeFilePatterns: $config['cognitive']['excludeFilePatterns'], excludePatterns: $config['cognitive']['excludePatterns'], @@ -39,6 +46,7 @@ public function fromArray(array $config): CognitiveConfig groupByClass: $config['cognitive']['groupByClass'] ?? true, showDetailedCognitiveMetrics: $config['cognitive']['showDetailedCognitiveMetrics'] ?? true, cache: $cacheConfig, + performance: $performanceConfig, customReporters: $config['cognitive']['customReporters'] ?? [] ); } diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index 1eca064..29cbbf0 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -142,6 +142,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('performance') + ->children() + ->integerNode('batchSize') + ->defaultValue(100) + ->min(1) + ->end() + ->end() + ->end() ->arrayNode('customReporters') ->children() ->arrayNode('cognitive') diff --git a/src/Config/PerformanceConfig.php b/src/Config/PerformanceConfig.php new file mode 100644 index 0000000..95a9aad --- /dev/null +++ b/src/Config/PerformanceConfig.php @@ -0,0 +1,28 @@ + + */ + public function toArray(): array + { + return [ + 'batchSize' => $this->batchSize, + ]; + } +} From 3711557a3b19adf1c90f4fd64275782a19310554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Fri, 17 Oct 2025 23:49:47 +0200 Subject: [PATCH 2/8] Refactoring --- src/Application.php | 95 ++++++++-- src/Command/ChurnCommand.php | 175 ++++-------------- src/Command/Handler/ChurnReportHandler.php | 101 ---------- .../Pipeline/ChurnExecutionContext.php | 136 ++++++++++++++ src/Command/Pipeline/ChurnPipeline.php | 41 ++++ src/Command/Pipeline/ChurnPipelineFactory.php | 43 +++++ src/Command/Pipeline/ChurnPipelineStage.php | 28 +++ .../ChurnStages/ChurnCalculationStage.php | 56 ++++++ .../ChurnStages/ConfigurationStage.php | 55 ++++++ .../Pipeline/ChurnStages/CoverageStage.php | 90 +++++++++ .../Pipeline/ChurnStages/OutputStage.php | 50 +++++ .../ChurnStages/ReportGenerationStage.php | 111 +++++++++++ .../Pipeline/ChurnStages/ValidationStage.php | 44 +++++ .../BaselineStage.php | 2 +- .../ConfigurationStage.php | 8 +- .../CoverageStage.php | 8 +- .../MetricsCollectionStage.php | 7 +- .../OutputStage.php | 2 +- .../ReportGenerationStage.php | 2 +- .../SortingStage.php | 7 +- .../ValidationStage.php | 8 +- .../Pipeline/CommandPipelineFactory.php | 16 +- src/Command/Pipeline/PipelineStage.php | 5 +- 23 files changed, 806 insertions(+), 284 deletions(-) delete mode 100644 src/Command/Handler/ChurnReportHandler.php create mode 100644 src/Command/Pipeline/ChurnExecutionContext.php create mode 100644 src/Command/Pipeline/ChurnPipeline.php create mode 100644 src/Command/Pipeline/ChurnPipelineFactory.php create mode 100644 src/Command/Pipeline/ChurnPipelineStage.php create mode 100644 src/Command/Pipeline/ChurnStages/ChurnCalculationStage.php create mode 100644 src/Command/Pipeline/ChurnStages/ConfigurationStage.php create mode 100644 src/Command/Pipeline/ChurnStages/CoverageStage.php create mode 100644 src/Command/Pipeline/ChurnStages/OutputStage.php create mode 100644 src/Command/Pipeline/ChurnStages/ReportGenerationStage.php create mode 100644 src/Command/Pipeline/ChurnStages/ValidationStage.php rename src/Command/Pipeline/{Stages => CognitiveStages}/BaselineStage.php (95%) rename src/Command/Pipeline/{Stages => CognitiveStages}/ConfigurationStage.php (83%) rename src/Command/Pipeline/{Stages => CognitiveStages}/CoverageStage.php (90%) rename src/Command/Pipeline/{Stages => CognitiveStages}/MetricsCollectionStage.php (86%) rename src/Command/Pipeline/{Stages => CognitiveStages}/OutputStage.php (95%) rename src/Command/Pipeline/{Stages => CognitiveStages}/ReportGenerationStage.php (98%) rename src/Command/Pipeline/{Stages => CognitiveStages}/SortingStage.php (89%) rename src/Command/Pipeline/{Stages => CognitiveStages}/ValidationStage.php (84%) diff --git a/src/Application.php b/src/Application.php index a78cbdc..011e1a1 100644 --- a/src/Application.php +++ b/src/Application.php @@ -24,22 +24,29 @@ use Phauthentic\CognitiveCodeAnalysis\Cache\FileCache; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnCommand; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnValidationSpecificationFactory; +use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CompositeChurnSpecification; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsCommand; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsValidationSpecificationFactory; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CompositeCognitiveMetricsValidationSpecification; use Phauthentic\CognitiveCodeAnalysis\Command\EventHandler\ParserErrorHandler; use Phauthentic\CognitiveCodeAnalysis\Command\EventHandler\ProgressBarHandler; use Phauthentic\CognitiveCodeAnalysis\Command\EventHandler\VerboseHandler; -use Phauthentic\CognitiveCodeAnalysis\Command\Handler\ChurnReportHandler; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CommandPipelineFactory; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\BaselineStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ConfigurationStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\CoverageStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\MetricsCollectionStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\OutputStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ReportGenerationStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\SortingStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ValidationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnPipelineFactory; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\ChurnCalculationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\ConfigurationStage as ChurnConfigurationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\CoverageStage as ChurnCoverageStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\OutputStage as ChurnOutputStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\ReportGenerationStage as ChurnReportGenerationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\ValidationStage as ChurnValidationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\BaselineStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ConfigurationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\CoverageStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\MetricsCollectionStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\OutputStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ReportGenerationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\SortingStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ValidationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\ChurnTextRenderer; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRenderer; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRendererInterface; @@ -199,12 +206,8 @@ private function registerUtilityServices(): void private function registerCommandHandlers(): void { - $this->containerBuilder->register(ChurnReportHandler::class, ChurnReportHandler::class) - ->setArguments([ - new Reference(MetricsFacade::class), - new Reference(OutputInterface::class), - new Reference(ChurnReportFactoryInterface::class), - ]) + $this->containerBuilder->register(CompositeChurnSpecification::class, CompositeChurnSpecification::class) + ->setFactory([new Reference(ChurnValidationSpecificationFactory::class), 'create']) ->setPublic(true); } @@ -280,7 +283,13 @@ private function registerMetricsFacade(): void private function registerPipelineStages(): void { - // Register pipeline stages + $this->registerCognitivePipelineStages(); + $this->registerChurnPipelineStages(); + } + + private function registerCognitivePipelineStages(): void + { + // Register cognitive pipeline stages $this->containerBuilder->register(ValidationStage::class, ValidationStage::class) ->setArguments([ new Reference(CompositeCognitiveMetricsValidationSpecification::class), @@ -344,6 +353,55 @@ private function registerPipelineStages(): void ->setPublic(true); } + private function registerChurnPipelineStages(): void + { + // Register churn pipeline stages + $this->containerBuilder->register(ChurnValidationStage::class, ChurnValidationStage::class) + ->setArguments([ + new Reference(CompositeChurnSpecification::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(ChurnConfigurationStage::class, ChurnConfigurationStage::class) + ->setArguments([ + new Reference(MetricsFacade::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(ChurnCoverageStage::class, ChurnCoverageStage::class) + ->setPublic(true); + + $this->containerBuilder->register(ChurnCalculationStage::class, ChurnCalculationStage::class) + ->setArguments([ + new Reference(MetricsFacade::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(ChurnReportGenerationStage::class, ChurnReportGenerationStage::class) + ->setArguments([ + new Reference(MetricsFacade::class), + new Reference(ChurnReportFactoryInterface::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(ChurnOutputStage::class, ChurnOutputStage::class) + ->setArguments([ + new Reference(ChurnTextRenderer::class), + ]) + ->setPublic(true); + + $this->containerBuilder->register(ChurnPipelineFactory::class, ChurnPipelineFactory::class) + ->setArguments([ + new Reference(ChurnValidationStage::class), + new Reference(ChurnConfigurationStage::class), + new Reference(ChurnCoverageStage::class), + new Reference(ChurnCalculationStage::class), + new Reference(ChurnReportGenerationStage::class), + new Reference(ChurnOutputStage::class), + ]) + ->setPublic(true); + } + private function registerCommands(): void { $this->containerBuilder->register(CognitiveMetricsCommand::class, CognitiveMetricsCommand::class) @@ -354,10 +412,7 @@ private function registerCommands(): void $this->containerBuilder->register(ChurnCommand::class, ChurnCommand::class) ->setArguments([ - new Reference(MetricsFacade::class), - new Reference(ChurnTextRenderer::class), - new Reference(ChurnReportHandler::class), - new Reference(ChurnValidationSpecificationFactory::class), + new Reference(ChurnPipelineFactory::class), ]) ->setPublic(true); } diff --git a/src/Command/ChurnCommand.php b/src/Command/ChurnCommand.php index 4ba6361..c1826ef 100644 --- a/src/Command/ChurnCommand.php +++ b/src/Command/ChurnCommand.php @@ -4,18 +4,9 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command; -use Exception; -use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CloverReader; -use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoberturaReader; -use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoverageReportReaderInterface; -use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; -use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; -use Phauthentic\CognitiveCodeAnalysis\Command\Handler\ChurnReportHandler; -use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\ChurnTextRenderer; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnExecutionContext; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnPipelineFactory; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnCommandContext; -use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CompositeChurnSpecification; -use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnValidationSpecificationFactory; -use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CustomExporter; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -38,19 +29,10 @@ class ChurnCommand extends Command public const OPTION_COVERAGE_COBERTURA = 'coverage-cobertura'; public const OPTION_COVERAGE_CLOVER = 'coverage-clover'; - private CompositeChurnSpecification $validationSpecification; - - /** - * Constructor to initialize dependencies. - */ public function __construct( - readonly private MetricsFacade $metricsFacade, - readonly private ChurnTextRenderer $renderer, - readonly private ChurnReportHandler $report, - readonly private ChurnValidationSpecificationFactory $validationSpecificationFactory + readonly private ChurnPipelineFactory $pipelineFactory ) { parent::__construct(); - $this->validationSpecification = $this->validationSpecificationFactory->create(); } @@ -118,151 +100,60 @@ protected function configure(): void /** * Executes the command. * - * @SuppressWarnings("UnusedFormalParameter") * @param InputInterface $input * @param OutputInterface $output * @return int Command status code. */ protected function execute(InputInterface $input, OutputInterface $output): int { - $context = new ChurnCommandContext($input); - - // Validate all specifications (except custom exporters which need config) - if (!$this->validationSpecification->isSatisfiedBy($context)) { - $errorMessage = $this->validationSpecification->getDetailedErrorMessage($context); - $output->writeln('' . $errorMessage . ''); - return self::FAILURE; - } + $commandContext = new ChurnCommandContext($input); + $executionContext = new ChurnExecutionContext($commandContext, $output); - // Load configuration if provided - if ($context->hasConfigFile()) { - $configFile = $context->getConfigFile(); - if ($configFile !== null && !$this->loadConfiguration($configFile, $output)) { - return self::FAILURE; - } - } + // Build pipeline with stages + $pipeline = $this->pipelineFactory->createPipeline(); - // Validate custom exporters after config is loaded - if ($context->hasReportOptions()) { - $customExporterValidation = new CustomExporter( - $this->report->getReportFactory(), - $this->report->getConfigService() - ); - if (!$customExporterValidation->isSatisfiedBy($context)) { - $errorMessage = $customExporterValidation->getErrorMessageWithContext($context); - $output->writeln('' . $errorMessage . ''); - return self::FAILURE; - } - } + // Execute pipeline + $result = $pipeline->execute($executionContext); - // Load coverage reader - $coverageReader = $this->loadCoverageReader($context, $output); - if ($coverageReader === false) { - return self::FAILURE; + // Output execution summary if debug mode + if ($input->getOption(self::OPTION_DEBUG)) { + $this->outputExecutionSummary($executionContext, $output); } - // Calculate churn metrics - $metrics = $this->metricsFacade->calculateChurn( - path: $context->getPath(), - vcsType: $context->getVcsType(), - since: $context->getSince(), - coverageReader: $coverageReader - ); - - // Handle report generation or display - if ($context->hasReportOptions()) { - return $this->report->exportToFile( - $metrics, - $context->getReportType(), - $context->getReportFile() - ); + // Display error message if pipeline failed + if ($result->isFailure()) { + $output->writeln('' . $result->getErrorMessage() . ''); + return Command::FAILURE; } - $this->renderer->renderChurnTable(metrics: $metrics); - return self::SUCCESS; + return Command::SUCCESS; } /** - * Load coverage reader from file - * - * @param ChurnCommandContext $context Command context containing coverage file information - * @param OutputInterface $output Output interface for error messages - * @return CoverageReportReaderInterface|null|false Returns reader instance, null if no file provided, or false on error + * Output execution summary with timing information. */ - private function loadCoverageReader( - ChurnCommandContext $context, - OutputInterface $output - ): CoverageReportReaderInterface|null|false { - $coverageFile = $context->getCoverageFile(); - $format = $context->getCoverageFormat(); + private function outputExecutionSummary(ChurnExecutionContext $context, OutputInterface $output): void + { + $timings = $context->getTimings(); + $statistics = $context->getStatistics(); - if ($coverageFile === null) { - return null; - } + $output->writeln('Execution Summary:'); + $output->writeln(sprintf(' Total execution time: %.3fs', $context->getTotalTime())); - // Auto-detect format if not specified - if ($format === null) { - $format = $this->detectCoverageFormat($coverageFile); - if ($format === null) { - $output->writeln('Unable to detect coverage file format. Please specify format explicitly.'); - return false; + if (!empty($timings)) { + $output->writeln('Stage timings:'); + foreach ($timings as $stage => $duration) { + $output->writeln(sprintf(' %s: %.3fs', $stage, $duration)); } } - try { - return match ($format) { - 'cobertura' => new CoberturaReader($coverageFile), - 'clover' => new CloverReader($coverageFile), - default => throw new CognitiveAnalysisException("Unsupported coverage format: {$format}"), - }; - } catch (CognitiveAnalysisException $e) { - $output->writeln(sprintf( - 'Failed to load coverage file: %s', - $e->getMessage() - )); - return false; + if (empty($statistics)) { + return; } - } - /** - * Detect coverage file format by examining the XML structure - */ - private function detectCoverageFormat(string $coverageFile): ?string - { - $content = file_get_contents($coverageFile); - if ($content === false) { - return null; - } - - // Cobertura format has root element with line-rate attribute - if (preg_match('/]*line-rate=/', $content)) { - return 'cobertura'; - } - - // Clover format has with generated attribute and child - if (preg_match('/]*generated=.*metricsFacade->loadConfig($configFile); - return true; - } catch (Exception $e) { - $output->writeln('Failed to load configuration: ' . $e->getMessage() . ''); - return false; + $output->writeln('Statistics:'); + foreach ($statistics as $key => $value) { + $output->writeln(sprintf(' %s: %s', $key, $value)); } } } diff --git a/src/Command/Handler/ChurnReportHandler.php b/src/Command/Handler/ChurnReportHandler.php deleted file mode 100644 index 78647f8..0000000 --- a/src/Command/Handler/ChurnReportHandler.php +++ /dev/null @@ -1,101 +0,0 @@ -hasIncompleteReportOptions($reportType, $reportFile)) { - $this->output->writeln('Both report type and file must be provided.'); - - return Command::FAILURE; - } - - if (!$this->isValidReportType($reportType)) { - return $this->handleInvalidReportType($reportType); - } - - try { - $this->metricsFacade->exportChurnReport( - metrics: $metrics, - reportType: (string)$reportType, - filename: (string)$reportFile - ); - - return Command::SUCCESS; - } catch (Exception $exception) { - return $this->handleExceptions($exception); - } - } - - private function hasIncompleteReportOptions(?string $reportType, ?string $reportFile): bool - { - return ($reportType === null && $reportFile !== null) || ($reportType !== null && $reportFile === null); - } - - private function isValidReportType(?string $reportType): bool - { - if ($reportType === null) { - return false; - } - return $this->exporterFactory->isSupported($reportType); - } - - private function handleExceptions(Exception $exception): int - { - $this->output->writeln(sprintf( - 'Error generating report: %s', - $exception->getMessage() - )); - - return Command::FAILURE; - } - - private function handleInvalidReportType(?string $reportType): int - { - $supportedTypes = implode('`, `', $this->exporterFactory->getSupportedTypes()); - $this->output->writeln(sprintf( - 'Invalid report type `%s` provided. Supported types: `%s`', - $reportType, - $supportedTypes - )); - - return Command::FAILURE; - } - - public function getReportFactory(): ChurnReportFactoryInterface - { - return $this->exporterFactory; - } - - public function getConfigService(): ConfigService - { - return $this->metricsFacade->getConfigService(); - } -} diff --git a/src/Command/Pipeline/ChurnExecutionContext.php b/src/Command/Pipeline/ChurnExecutionContext.php new file mode 100644 index 0000000..b1a0dfa --- /dev/null +++ b/src/Command/Pipeline/ChurnExecutionContext.php @@ -0,0 +1,136 @@ + */ + private array $timings = []; + /** @var array */ + private array $statistics = []; + /** @var array */ + private array $data = []; + + public function __construct( + private readonly ChurnCommandContext $commandContext, + private readonly OutputInterface $output + ) { + } + + /** + * Get the command context. + */ + public function getCommandContext(): ChurnCommandContext + { + return $this->commandContext; + } + + /** + * Get the output interface. + */ + public function getOutput(): OutputInterface + { + return $this->output; + } + + /** + * Record timing for a stage. + */ + public function recordTiming(string $stageName, float $duration): void + { + $this->timings[$stageName] = $duration; + } + + /** + * Get timing for a specific stage. + */ + public function getTiming(string $stageName): ?float + { + return $this->timings[$stageName] ?? null; + } + + /** + * Get all timings. + * + * @return array + */ + public function getTimings(): array + { + return $this->timings; + } + + /** + * Get total execution time. + */ + public function getTotalTime(): float + { + return array_sum($this->timings); + } + + /** + * Set a data value in the context. + */ + public function setData(string $key, mixed $value): void + { + $this->data[$key] = $value; + } + + /** + * Get a data value from the context. + */ + public function getData(string $key): mixed + { + return $this->data[$key] ?? null; + } + + /** + * Check if a data key exists in the context. + */ + public function hasData(string $key): bool + { + return array_key_exists($key, $this->data); + } + + /** + * Increment a statistic counter. + */ + public function incrementStatistic(string $key, int $amount = 1): void + { + $this->statistics[$key] = ($this->statistics[$key] ?? 0) + $amount; + } + + /** + * Set a statistic value. + */ + public function setStatistic(string $key, mixed $value): void + { + $this->statistics[$key] = $value; + } + + /** + * Get a statistic value. + */ + public function getStatistic(string $key): mixed + { + return $this->statistics[$key] ?? null; + } + + /** + * Get all statistics. + * + * @return array + */ + public function getStatistics(): array + { + return $this->statistics; + } +} diff --git a/src/Command/Pipeline/ChurnPipeline.php b/src/Command/Pipeline/ChurnPipeline.php new file mode 100644 index 0000000..2ff5a72 --- /dev/null +++ b/src/Command/Pipeline/ChurnPipeline.php @@ -0,0 +1,41 @@ +stages as $stage) { + if ($stage->shouldSkip($context)) { + continue; + } + + $result = $stage->execute($context); + + if ($result->isFailure()) { + return $result; + } + } + + return OperationResult::success(); + } +} diff --git a/src/Command/Pipeline/ChurnPipelineFactory.php b/src/Command/Pipeline/ChurnPipelineFactory.php new file mode 100644 index 0000000..0c221b7 --- /dev/null +++ b/src/Command/Pipeline/ChurnPipelineFactory.php @@ -0,0 +1,43 @@ +validationStage, + $this->configurationStage, + $this->coverageStage, + $this->churnCalculationStage, + $this->reportGenerationStage, + $this->outputStage, + ]); + } +} diff --git a/src/Command/Pipeline/ChurnPipelineStage.php b/src/Command/Pipeline/ChurnPipelineStage.php new file mode 100644 index 0000000..4919adf --- /dev/null +++ b/src/Command/Pipeline/ChurnPipelineStage.php @@ -0,0 +1,28 @@ +getCommandContext(); + + // Get coverage data from previous stage + $coverageReader = $context->getData('coverageReader'); + + // Calculate churn metrics + $metrics = $this->metricsFacade->calculateChurn( + path: $commandContext->getPath(), + vcsType: $commandContext->getVcsType(), + since: $commandContext->getSince(), + coverageReader: $coverageReader + ); + + // Store metrics in context + $context->setData('churnMetrics', $metrics); + + // Record statistics + $context->setStatistic('churnCalculated', true); + $context->setStatistic('path', $commandContext->getPath()); + + return OperationResult::success(); + } + + public function getStageName(): string + { + return 'ChurnCalculation'; + } + + public function shouldSkip(ChurnExecutionContext $context): bool + { + return false; // Churn calculation should never be skipped + } +} diff --git a/src/Command/Pipeline/ChurnStages/ConfigurationStage.php b/src/Command/Pipeline/ChurnStages/ConfigurationStage.php new file mode 100644 index 0000000..507655c --- /dev/null +++ b/src/Command/Pipeline/ChurnStages/ConfigurationStage.php @@ -0,0 +1,55 @@ +getCommandContext(); + + if (!$commandContext->hasConfigFile()) { + return OperationResult::success(); + } + + $configFile = $commandContext->getConfigFile(); + if ($configFile === null) { + return OperationResult::success(); + } + + try { + $this->metricsFacade->loadConfig($configFile); + return OperationResult::success(); + } catch (Exception $e) { + $context->getOutput()->writeln('Failed to load configuration: ' . $e->getMessage() . ''); + return OperationResult::failure('Failed to load configuration: ' . $e->getMessage()); + } + } + + public function shouldSkip(ChurnExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + return !$commandContext->hasConfigFile(); + } + + public function getStageName(): string + { + return 'Configuration'; + } +} diff --git a/src/Command/Pipeline/ChurnStages/CoverageStage.php b/src/Command/Pipeline/ChurnStages/CoverageStage.php new file mode 100644 index 0000000..e7420a7 --- /dev/null +++ b/src/Command/Pipeline/ChurnStages/CoverageStage.php @@ -0,0 +1,90 @@ +getCommandContext(); + $coverageFile = $commandContext->getCoverageFile(); + $format = $commandContext->getCoverageFormat(); + + if ($coverageFile === null) { + // Store null in context to indicate no coverage + $context->setData('coverageReader', null); + return OperationResult::success(); + } + + // Auto-detect format if not specified + if ($format === null) { + $format = $this->detectCoverageFormat($coverageFile); + if ($format === null) { + $context->getOutput()->writeln('Unable to detect coverage file format. Please specify format explicitly.'); + return OperationResult::failure('Unable to detect coverage file format.'); + } + } + + try { + $reader = match ($format) { + 'cobertura' => new CoberturaReader($coverageFile), + 'clover' => new CloverReader($coverageFile), + default => throw new CognitiveAnalysisException("Unsupported coverage format: {$format}"), + }; + + $context->setData('coverageReader', $reader); + return OperationResult::success(); + } catch (CognitiveAnalysisException $e) { + $context->getOutput()->writeln(sprintf( + 'Failed to load coverage file: %s', + $e->getMessage() + )); + return OperationResult::failure('Failed to load coverage file: ' . $e->getMessage()); + } + } + + public function getStageName(): string + { + return 'Coverage'; + } + + public function shouldSkip(ChurnExecutionContext $context): bool + { + return false; // Coverage stage should never be skipped (it handles null coverage gracefully) + } + + /** + * Detect coverage file format by examining the XML structure. + */ + private function detectCoverageFormat(string $coverageFile): ?string + { + $content = file_get_contents($coverageFile); + if ($content === false) { + return null; + } + + // Cobertura format has root element with line-rate attribute + if (preg_match('/]*line-rate=/', $content)) { + return 'cobertura'; + } + + // Clover format has with generated attribute and child + if (preg_match('/]*generated=.*getData('churnMetrics'); + + if ($churnMetrics === null) { + return OperationResult::failure('Churn metrics not available for console output.'); + } + + // Render to console + $this->renderer->renderChurnTable(metrics: $churnMetrics); + + // Record statistics + $context->setStatistic('consoleOutputRendered', true); + + return OperationResult::success(); + } + + public function shouldSkip(ChurnExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + // Skip console output if report generation was requested + return $commandContext->hasReportOptions(); + } + + public function getStageName(): string + { + return 'ConsoleOutput'; + } +} diff --git a/src/Command/Pipeline/ChurnStages/ReportGenerationStage.php b/src/Command/Pipeline/ChurnStages/ReportGenerationStage.php new file mode 100644 index 0000000..a25afb2 --- /dev/null +++ b/src/Command/Pipeline/ChurnStages/ReportGenerationStage.php @@ -0,0 +1,111 @@ +getCommandContext(); + $churnMetrics = $context->getData('churnMetrics'); + + if ($churnMetrics === null) { + return OperationResult::failure('Churn metrics not available for report generation.'); + } + + $reportType = $commandContext->getReportType(); + $reportFile = $commandContext->getReportFile(); + + // Validate report options + if ($this->hasIncompleteReportOptions($reportType, $reportFile)) { + $context->getOutput()->writeln('Both report type and file must be provided.'); + return OperationResult::failure('Incomplete report options provided.'); + } + + if (!$this->isValidReportType($reportType)) { + return $this->handleInvalidReportType($context, $reportType); + } + + try { + $this->metricsFacade->exportChurnReport( + metrics: $churnMetrics, + reportType: (string)$reportType, + filename: (string)$reportFile + ); + + // Record success statistics + $context->setStatistic('reportGenerated', true); + $context->setStatistic('reportType', $reportType); + $context->setStatistic('reportFile', $reportFile); + + return OperationResult::success(); + } catch (Exception $exception) { + return $this->handleExceptions($context, $exception); + } + } + + public function shouldSkip(ChurnExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + return !$commandContext->hasReportOptions(); + } + + public function getStageName(): string + { + return 'ReportGeneration'; + } + + private function hasIncompleteReportOptions(?string $reportType, ?string $reportFile): bool + { + return ($reportType === null && $reportFile !== null) || ($reportType !== null && $reportFile === null); + } + + private function isValidReportType(?string $reportType): bool + { + if ($reportType === null) { + return false; + } + return $this->reportFactory->isSupported($reportType); + } + + private function handleExceptions(ChurnExecutionContext $context, Exception $exception): OperationResult + { + $context->getOutput()->writeln(sprintf( + 'Error generating report: %s', + $exception->getMessage() + )); + + return OperationResult::failure('Report generation failed: ' . $exception->getMessage()); + } + + private function handleInvalidReportType(ChurnExecutionContext $context, ?string $reportType): OperationResult + { + $supportedTypes = $this->reportFactory->getSupportedTypes(); + + $context->getOutput()->writeln(sprintf( + 'Invalid report type `%s` provided. Supported types: %s', + $reportType, + implode(', ', $supportedTypes) + )); + + return OperationResult::failure('Invalid report type provided.'); + } +} diff --git a/src/Command/Pipeline/ChurnStages/ValidationStage.php b/src/Command/Pipeline/ChurnStages/ValidationStage.php new file mode 100644 index 0000000..9bfcbac --- /dev/null +++ b/src/Command/Pipeline/ChurnStages/ValidationStage.php @@ -0,0 +1,44 @@ +getCommandContext(); + + if (!$this->specification->isSatisfiedBy($commandContext)) { + $errorMessage = $this->specification->getDetailedErrorMessage($commandContext); + $context->getOutput()->writeln('' . $errorMessage . ''); + return OperationResult::failure($errorMessage); + } + + return OperationResult::success(); + } + + public function getStageName(): string + { + return 'Validation'; + } + + public function shouldSkip(ChurnExecutionContext $context): bool + { + return false; // Validation should never be skipped + } +} diff --git a/src/Command/Pipeline/Stages/BaselineStage.php b/src/Command/Pipeline/CognitiveStages/BaselineStage.php similarity index 95% rename from src/Command/Pipeline/Stages/BaselineStage.php rename to src/Command/Pipeline/CognitiveStages/BaselineStage.php index 0bc2820..07a88d2 100644 --- a/src/Command/Pipeline/Stages/BaselineStage.php +++ b/src/Command/Pipeline/CognitiveStages/BaselineStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Exception; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; diff --git a/src/Command/Pipeline/Stages/ConfigurationStage.php b/src/Command/Pipeline/CognitiveStages/ConfigurationStage.php similarity index 83% rename from src/Command/Pipeline/Stages/ConfigurationStage.php rename to src/Command/Pipeline/CognitiveStages/ConfigurationStage.php index c20c7a0..cbf6616 100644 --- a/src/Command/Pipeline/Stages/ConfigurationStage.php +++ b/src/Command/Pipeline/CognitiveStages/ConfigurationStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Exception; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; @@ -42,6 +42,12 @@ public function execute(ExecutionContext $context): OperationResult } } + public function shouldSkip(ExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + return !$commandContext->hasConfigFile(); + } + public function getStageName(): string { return 'Configuration'; diff --git a/src/Command/Pipeline/Stages/CoverageStage.php b/src/Command/Pipeline/CognitiveStages/CoverageStage.php similarity index 90% rename from src/Command/Pipeline/Stages/CoverageStage.php rename to src/Command/Pipeline/CognitiveStages/CoverageStage.php index db3d7bf..3a6f6fc 100644 --- a/src/Command/Pipeline/Stages/CoverageStage.php +++ b/src/Command/Pipeline/CognitiveStages/CoverageStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CodeCoverageFactory; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; @@ -75,6 +75,12 @@ private function detectCoverageFormat(string $coverageFile): ?string return null; } + public function shouldSkip(ExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + return $commandContext->getCoverageFile() === null; + } + public function getStageName(): string { return 'Coverage'; diff --git a/src/Command/Pipeline/Stages/MetricsCollectionStage.php b/src/Command/Pipeline/CognitiveStages/MetricsCollectionStage.php similarity index 86% rename from src/Command/Pipeline/Stages/MetricsCollectionStage.php rename to src/Command/Pipeline/CognitiveStages/MetricsCollectionStage.php index 67f1600..810b6cb 100644 --- a/src/Command/Pipeline/Stages/MetricsCollectionStage.php +++ b/src/Command/Pipeline/CognitiveStages/MetricsCollectionStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; @@ -42,6 +42,11 @@ public function execute(ExecutionContext $context): OperationResult return OperationResult::success(); } + public function shouldSkip(ExecutionContext $context): bool + { + return false; // Metrics collection should never be skipped + } + public function getStageName(): string { return 'MetricsCollection'; diff --git a/src/Command/Pipeline/Stages/OutputStage.php b/src/Command/Pipeline/CognitiveStages/OutputStage.php similarity index 95% rename from src/Command/Pipeline/Stages/OutputStage.php rename to src/Command/Pipeline/CognitiveStages/OutputStage.php index bfc68e3..d048386 100644 --- a/src/Command/Pipeline/Stages/OutputStage.php +++ b/src/Command/Pipeline/CognitiveStages/OutputStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\PipelineStage; diff --git a/src/Command/Pipeline/Stages/ReportGenerationStage.php b/src/Command/Pipeline/CognitiveStages/ReportGenerationStage.php similarity index 98% rename from src/Command/Pipeline/Stages/ReportGenerationStage.php rename to src/Command/Pipeline/CognitiveStages/ReportGenerationStage.php index b64030d..3ec44ae 100644 --- a/src/Command/Pipeline/Stages/ReportGenerationStage.php +++ b/src/Command/Pipeline/CognitiveStages/ReportGenerationStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Exception; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\CognitiveReportFactoryInterface; diff --git a/src/Command/Pipeline/Stages/SortingStage.php b/src/Command/Pipeline/CognitiveStages/SortingStage.php similarity index 89% rename from src/Command/Pipeline/Stages/SortingStage.php rename to src/Command/Pipeline/CognitiveStages/SortingStage.php index b0b3506..ecb952d 100644 --- a/src/Command/Pipeline/Stages/SortingStage.php +++ b/src/Command/Pipeline/CognitiveStages/SortingStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsSorter; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; @@ -47,6 +47,11 @@ public function execute(ExecutionContext $context): OperationResult } } + public function shouldSkip(ExecutionContext $context): bool + { + return false; // Sorting should never be skipped + } + public function getStageName(): string { return 'Sorting'; diff --git a/src/Command/Pipeline/Stages/ValidationStage.php b/src/Command/Pipeline/CognitiveStages/ValidationStage.php similarity index 84% rename from src/Command/Pipeline/Stages/ValidationStage.php rename to src/Command/Pipeline/CognitiveStages/ValidationStage.php index e3f6f0a..cb95c50 100644 --- a/src/Command/Pipeline/Stages/ValidationStage.php +++ b/src/Command/Pipeline/CognitiveStages/ValidationStage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages; +namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CompositeCognitiveMetricsValidationSpecification; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; @@ -23,7 +23,6 @@ public function execute(ExecutionContext $context): OperationResult { $commandContext = $context->getCommandContext(); - // Validate all specifications if (!$this->specification->isSatisfiedBy($commandContext)) { $errorMessage = $this->specification->getDetailedErrorMessage($commandContext); return OperationResult::failure($errorMessage); @@ -32,6 +31,11 @@ public function execute(ExecutionContext $context): OperationResult return OperationResult::success(); } + public function shouldSkip(ExecutionContext $context): bool + { + return false; // Validation should never be skipped + } + public function getStageName(): string { return 'Validation'; diff --git a/src/Command/Pipeline/CommandPipelineFactory.php b/src/Command/Pipeline/CommandPipelineFactory.php index 4fa167f..12a7aab 100644 --- a/src/Command/Pipeline/CommandPipelineFactory.php +++ b/src/Command/Pipeline/CommandPipelineFactory.php @@ -4,14 +4,14 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\BaselineStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ConfigurationStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\CoverageStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\MetricsCollectionStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\OutputStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ReportGenerationStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\SortingStage; -use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\Stages\ValidationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\BaselineStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ConfigurationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\CoverageStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\MetricsCollectionStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\OutputStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ReportGenerationStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\SortingStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ValidationStage; /** * Factory for creating command pipelines with the correct stage order. diff --git a/src/Command/Pipeline/PipelineStage.php b/src/Command/Pipeline/PipelineStage.php index 20e9c32..6d15f1b 100644 --- a/src/Command/Pipeline/PipelineStage.php +++ b/src/Command/Pipeline/PipelineStage.php @@ -27,10 +27,7 @@ abstract public function execute(ExecutionContext $context): OperationResult; * @param ExecutionContext $context The execution context * @return bool True if the stage should be skipped */ - public function shouldSkip(ExecutionContext $context): bool - { - return false; - } + abstract public function shouldSkip(ExecutionContext $context): bool; /** * Get the name of this stage for logging and timing purposes. From c41c5797d200bf57b939a5ed5957aeae94de3fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Wed, 15 Oct 2025 00:16:33 +0200 Subject: [PATCH 3/8] Updating readme.md --- readme.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 556ad7e..5adc8e4 100644 --- a/readme.md +++ b/readme.md @@ -6,13 +6,29 @@ Cognitive Code Analysis is an approach to understanding and improving code by fo [Source: Human Cognitive Limitations. Broad, Consistent, Clinical Application of Physiological Principles Will Require Decision Support](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5822395/) +## Features 💎 + +* Cognitive Complexity Analysis: + * Calculates a cognitive complexity score for each class and method + * Provides detailed cognitive complexity metrics + * Generate reports in various formats (JSON, CSV, HTML) + * Baseline comparison to track complexity changes over time + * Configurable thresholds and weights for complexity analysis + * Optional result cache for faster subsequent runs (must be enabled in config) + * Custom report generators + * Also provides Halstead Complexity Metrics (must be enabled in config) + * Also provides Cyclomatic Complexity Metrics (must be enabled in config) +* Cognitive Complexity Churn Analysis to identify hotspots in the codebase + * Generate reports in various formats (JSON, CSV, HTML) + * Custom report generators + ## Installation ⚙️ ```bash composer require --dev phauthentic/cognitive-code-analysis ``` -## Running it +## Running it 🧑‍💻 Cognitive Complexity Analysis From 5f11ebad6d2718da011cfd60358b8d639e8d5247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Sat, 18 Oct 2025 22:35:27 +0200 Subject: [PATCH 4/8] Refactoring the baseline generation --- .gitignore | 1 + docs/Baseline-Analysis.md | 510 ++++++++++++++++++ schemas/baseline.json | 205 +++++++ src/Application.php | 11 +- src/Business/Cognitive/Baseline.php | 50 -- src/Business/Cognitive/Baseline/Baseline.php | 199 +++++++ .../Cognitive/Baseline/BaselineFile.php | 170 ++++++ .../Baseline/BaselineSchemaValidator.php | 311 +++++++++++ src/Command/CognitiveMetricsCommand.php | 8 + .../CognitiveMetricsCommandContext.php | 33 ++ .../BaselineGenerationStage.php | 79 +++ .../CognitiveStages/BaselineStage.php | 38 +- .../Pipeline/CognitiveStages/OutputStage.php | 16 + .../Pipeline/CommandPipelineFactory.php | 3 + src/Command/Pipeline/ExecutionContext.php | 38 ++ .../Cognitive/BaselineServiceTest.php | 34 +- 16 files changed, 1642 insertions(+), 64 deletions(-) create mode 100644 docs/Baseline-Analysis.md create mode 100644 schemas/baseline.json delete mode 100644 src/Business/Cognitive/Baseline.php create mode 100644 src/Business/Cognitive/Baseline/Baseline.php create mode 100644 src/Business/Cognitive/Baseline/BaselineFile.php create mode 100644 src/Business/Cognitive/Baseline/BaselineSchemaValidator.php create mode 100644 src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php diff --git a/.gitignore b/.gitignore index 15a5510..a5de66b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.phive/ /.phpunit.cache/ /.phpcca.cache/ +/.phpcca/ /tmp/ /tools/ /benchmarks/storage/ diff --git a/docs/Baseline-Analysis.md b/docs/Baseline-Analysis.md new file mode 100644 index 0000000..072918b --- /dev/null +++ b/docs/Baseline-Analysis.md @@ -0,0 +1,510 @@ +# Baseline Analysis + +The baseline analysis feature allows you to compare current cognitive complexity metrics against previously recorded metrics to track changes over time. This is particularly useful for monitoring code quality improvements or regressions during development. + +## Overview + +The baseline system works by: + +1. **Loading baseline data** from a JSON file containing previous metrics +2. **Calculating deltas** between current and baseline metrics for each method +3. **Displaying changes** in the output with visual indicators (Δ symbols) +4. **Tracking improvements and regressions** across all cognitive complexity metrics +5. **Validating configuration** to ensure accurate comparisons +6. **Auto-generating baseline files** with metadata and timestamps + +## Usage + +### Command Line Options + +#### Compare Against Baseline +Use the `--baseline` (or `-b`) option to specify a baseline file: + +```bash +bin/phpcca analyse --baseline= +``` + +#### Automatic Baseline Detection +If no baseline file is specified, the system automatically searches for the latest baseline file: + +```bash +# Automatically uses the latest baseline file from ./.phpcca/baseline/ +bin/phpcca analyse +``` + +The system will: +1. Look for baseline files in `./.phpcca/baseline/` directory +2. Find files matching pattern `baseline-*.json` +3. Select the most recently modified file +4. Display a message indicating which baseline was auto-detected + +#### Generate New Baseline +Use the `--generate-baseline` (or `-g`) option to create a new baseline file: + +```bash +# Generate with auto-generated timestamped filename +bin/phpcca analyse --generate-baseline + +# Generate with custom filename +bin/phpcca analyse --generate-baseline=my-baseline.json +``` + +### Examples + +```bash +# Automatic baseline detection (uses latest from ./.phpcca/baseline/) +bin/phpcca analyse src/ + +# Generate initial baseline +bin/phpcca analyse src/ --generate-baseline + +# Compare against specific baseline +bin/phpcca analyse src/ --baseline=./.phpcca/baseline/baseline-2025-01-18_14-30-45.json + +# Generate baseline and compare in one command +bin/phpcca analyse src/ --generate-baseline --baseline=previous-baseline.json +``` + +## Baseline File Formats + +### New Format (Version 2.0) + +The new baseline format includes metadata for better validation and tracking: + +```json +{ + "version": "2.0", + "createdAt": "2025-01-18 14:30:45", + "configHash": "abc123def456...", + "metrics": { + "ClassName": { + "methods": { + "methodName": { + "class": "ClassName", + "method": "methodName", + "file": "/path/to/file.php", + "line": 42, + "lineCount": 15, + "argCount": 3, + "returnCount": 1, + "variableCount": 5, + "propertyCallCount": 2, + "ifCount": 2, + "ifNestingLevel": 1, + "elseCount": 1, + "lineCountWeight": 0.0, + "argCountWeight": 0.0, + "returnCountWeight": 0.0, + "variableCountWeight": 0.0, + "propertyCallCountWeight": 0.0, + "ifCountWeight": 0.0, + "ifNestingLevelWeight": 0.0, + "elseCountWeight": 0.0, + "score": 2.5 + } + } + } + } +} +``` + +### Legacy Format (Backward Compatible) + +The old format is still supported for backward compatibility: + +```json +{ + "ClassName": { + "methods": { + "methodName": { + "class": "ClassName", + "method": "methodName", + "file": "/path/to/file.php", + "line": 42, + "lineCount": 15, + "argCount": 3, + "returnCount": 1, + "variableCount": 5, + "propertyCallCount": 2, + "ifCount": 2, + "ifNestingLevel": 1, + "elseCount": 1, + "lineCountWeight": 0.0, + "argCountWeight": 0.0, + "returnCountWeight": 0.0, + "variableCountWeight": 0.0, + "propertyCallCountWeight": 0.0, + "ifCountWeight": 0.0, + "ifNestingLevelWeight": 0.0, + "elseCountWeight": 0.0, + "score": 2.5 + } + } + } +} +``` + +### Metadata Fields + +#### Version +- **Purpose**: Identifies the baseline file format version +- **Values**: `"2.0"` for new format, absent for legacy format + +#### Created At +- **Purpose**: Records when the baseline was generated +- **Format**: `YYYY-MM-DD HH:MM:SS` (ISO-like format) +- **Example**: `"2025-01-18 14:30:45"` + +#### Config Hash +- **Purpose**: Ensures baseline was generated with same configuration +- **Scope**: Only metrics configuration (thresholds, scales) +- **Format**: MD5 hash of serialized metrics config +- **Example**: `"abc123def456789..."` + +## JSON Schema Validation + +All baseline files are automatically validated against a JSON Schema to ensure data integrity and format compliance. + +### Schema Location +- **Schema File**: `schemas/baseline.json` +- **Schema ID**: `https://github.com/phauthentic/cognitive-code-checker/schemas/baseline.json` +- **Draft Version**: JSON Schema Draft 7 + +### Validation Features +- **Format Detection**: Automatically detects new (v2.0) vs legacy format +- **Field Validation**: Validates all required and optional fields +- **Type Checking**: Ensures correct data types for all fields +- **Range Validation**: Validates numeric ranges (e.g., non-negative integers) +- **Pattern Matching**: Validates date format and string patterns +- **Comprehensive Errors**: Provides detailed error messages for validation failures + +### Validation Errors +When a baseline file fails validation, you'll see detailed error messages: + +``` +Invalid baseline file format: Invalid createdAt format. Expected: YYYY-MM-DD HH:MM:SS, +Invalid configHash. Must be a non-empty string, +Field 'lineCount' in method 'TestClass::testMethod' must be a non-negative integer +``` + +### Supported Formats +The schema validates both: +- **New Format (v2.0)**: With metadata fields (`version`, `createdAt`, `configHash`, `metrics`) +- **Legacy Format**: Direct class structure (backward compatible) + +## Configuration Validation + +The system automatically validates that baseline files were generated with compatible configuration: + +### Config Hash Validation +- **Automatic**: Compares baseline's config hash with current config +- **Scope**: Only metrics configuration (excludes display settings) +- **Behavior**: Shows warning if hashes don't match, continues with comparison + +### Warning Messages +When config hashes don't match, you'll see: +``` +Warning: Baseline config hash (abc123...) does not match current config hash (def456...). +Metrics comparison may not be accurate. +``` + +## Auto-Generated Baseline Files + +When using `--generate-baseline` without specifying a filename, the system automatically creates timestamped files: + +### Default Location +``` +./.phpcca/baseline/baseline-YYYY-MM-DD_HH-MM-SS.json +``` + +### Examples +- `./.phpcca/baseline/baseline-2025-01-18_14-30-45.json` +- `./.phpcca/baseline/baseline-2025-01-18_09-15-22.json` + +### Directory Creation +The system automatically creates the `./.phpcca/baseline/` directory if it doesn't exist. + +## Automatic Baseline Detection + +When no baseline file is explicitly provided, the system automatically searches for and uses the latest baseline file: + +### Detection Process +1. **Directory Scan**: Searches `./.phpcca/baseline/` directory +2. **Pattern Matching**: Finds files matching `baseline-*.json` pattern +3. **Validation**: Verifies files are valid baseline format (old or new) +4. **Selection**: Chooses the most recently modified file +5. **Notification**: Displays which baseline was auto-detected + +### Example Output +When automatic detection is used, you'll see: +``` +Auto-detected latest baseline file: baseline-2025-01-18_14-30-45.json +``` + +### Behavior +- **No baseline found**: Analysis runs without delta comparison +- **Multiple baselines**: Uses the most recently modified file +- **Invalid files**: Skips corrupted or invalid baseline files +- **Explicit baseline**: Always uses the specified file (overrides auto-detection) + +### Use Cases +- **Daily development**: Run analysis without specifying baseline each time +- **CI/CD pipelines**: Automatic baseline comparison without configuration +- **Team workflows**: Consistent baseline usage across team members + +## Creating Baseline Files + +### Method 1: Auto-Generation (Recommended) +Generate a baseline file with metadata: + +```bash +# Auto-generated timestamped filename +bin/phpcca analyse src/ --generate-baseline + +# Custom filename +bin/phpcca analyse src/ --generate-baseline=my-baseline.json +``` + +### Method 2: Export Current Analysis (Legacy) +Generate a baseline file by exporting your current analysis: + +```bash +# Run analysis and export to JSON +bin/phpcca analyse src/ --report-type=json --report-file=baseline.json + +# Use the exported file as baseline for future comparisons +bin/phpcca analyse src/ --baseline=baseline.json +``` + +### Method 3: Manual Creation +Create a baseline file manually by copying the structure from a previous analysis export and modifying the values as needed. + +## Migration from Legacy Format + +### Automatic Detection +The system automatically detects baseline file format: +- **New format**: Contains `version` field +- **Legacy format**: Missing `version` field + +### Upgrading Legacy Baselines +To upgrade legacy baseline files to the new format: + +```bash +# Generate new baseline with current analysis +bin/phpcca analyse src/ --generate-baseline=upgraded-baseline.json + +# Use the new baseline for future comparisons +bin/phpcca analyse src/ --baseline=upgraded-baseline.json +``` + +### Backward Compatibility +- **Legacy baselines**: Continue to work without modification +- **New baselines**: Include metadata for better validation +- **Mixed usage**: Can use both formats in the same project + +## Delta Calculation + +The system calculates deltas for each weighted metric by comparing: + +- **Baseline value** (from the baseline file) +- **Current value** (from the current analysis) + +### Delta Display + +Deltas are displayed in the output with visual indicators: + +- **`Δ +X.XXX`** (red): Metric has increased (worse) +- **`Δ -X.XXX`** (green): Metric has decreased (better) +- **No delta shown**: Metric has not changed + +### Example Output + +``` ++------------------+--------+----------+----------+----------+ +| Class | Method | Line Cnt | Arg Cnt | Score | ++------------------+--------+----------+----------+----------+ +| App\Service\User | create | 15 (0.0) | 3 (0.0) | 2.5 | +| | | Δ +1.2 | | | +| App\Service\User | update | 12 (0.0) | 2 (0.0) | 1.8 | +| | | Δ -0.5 | | | ++------------------+--------+----------+----------+----------+ +``` + +## Pipeline Integration + +The baseline functionality is integrated into the command pipeline as a dedicated stage: + +### Pipeline Order + +1. **Validation Stage** - Validates command arguments +2. **Configuration Stage** - Loads configuration +3. **Coverage Stage** - Processes coverage data (if provided) +4. **Metrics Collection Stage** - Collects current metrics +5. **Baseline Stage** - **Applies baseline comparison** ← +6. **Sorting Stage** - Sorts results +7. **Report Generation Stage** - Generates reports +8. **Output Stage** - Displays results + +### Baseline Stage Behavior + +- **Skipped**: If no baseline file is provided +- **Executed**: If baseline file is provided and exists +- **Error**: If baseline file doesn't exist or is invalid JSON + +## Error Handling + +The baseline system handles several error conditions: + +### File Not Found +``` +Error: Baseline file does not exist. +``` + +### Invalid JSON +``` +Error: Failed to process baseline: Syntax error +``` + +### Missing Metrics +If a method exists in the baseline but not in the current analysis, it's silently skipped. + +If a method exists in the current analysis but not in the baseline, no delta is calculated. + +## Use Cases + +### 1. Code Quality Monitoring + +Track cognitive complexity changes over time: + +```bash +# Initial baseline +bin/phpcca analyse src/ --report-type=json --report-file=baseline-v1.0.json + +# After refactoring +bin/phpcca analyse src/ --baseline=baseline-v1.0.json +``` + +### 2. CI/CD Integration + +Include baseline comparison in your continuous integration: + +```bash +# In your CI pipeline +bin/phpcca analyse src/ --baseline=baseline.json --report-type=json --report-file=analysis.json +``` + +### 3. Regression Detection + +Identify when code changes increase complexity: + +```bash +# Before feature development +bin/phpcca analyse src/ --report-type=json --report-file=pre-feature.json + +# After feature development +bin/phpcca analyse src/ --baseline=pre-feature.json +``` + +### 4. Refactoring Validation + +Verify that refactoring efforts reduce complexity: + +```bash +# Before refactoring +bin/phpcca analyse src/ --report-type=json --report-file=pre-refactor.json + +# After refactoring +bin/phpcca analyse src/ --baseline=pre-refactor.json +``` + +## Best Practices + +### 1. Regular Baseline Updates + +Update your baseline files regularly to maintain relevance: + +```bash +# Weekly baseline update +bin/phpcca analyse src/ --report-type=json --report-file=baseline-$(date +%Y%m%d).json +``` + +### 2. Version Control + +Store baseline files in version control to track changes over time: + +```bash +git add baseline.json +git commit -m "Update cognitive complexity baseline" +``` + +### 3. Automated Baseline Generation + +Create automated scripts to generate baselines: + +```bash +#!/bin/bash +# generate-baseline.sh +bin/phpcca analyse src/ --report-type=json --report-file=baseline-$(date +%Y%m%d).json +echo "Baseline generated: baseline-$(date +%Y%m%d).json" +``` + +### 4. Multiple Baselines + +Maintain different baselines for different purposes: + +- `baseline-main.json` - Main branch baseline +- `baseline-feature.json` - Feature branch baseline +- `baseline-release.json` - Release baseline + +## Configuration Integration + +Baseline functionality works with all configuration options: + +```bash +bin/phpcca analyse src/ \ + --baseline=baseline.json \ + --config=config.yml \ + --sort-by=score \ + --sort-order=desc \ + --report-type=html \ + --report-file=analysis-with-baseline.html +``` + +## Limitations + +1. **Method Matching**: Deltas are only calculated for methods that exist in both baseline and current analysis +2. **Class Matching**: Methods must have the same class and method name to be matched +3. **File Changes**: If file paths change, methods won't be matched +4. **New Methods**: New methods won't have delta information +5. **Removed Methods**: Removed methods are silently ignored + +## Troubleshooting + +### Common Issues + +**Baseline file not found:** +- Check file path is correct +- Ensure file exists and is readable + +**Invalid JSON in baseline:** +- Validate JSON syntax +- Check for trailing commas or missing quotes + +**No deltas shown:** +- Verify method names match exactly +- Check that baseline contains the expected methods +- Ensure detailed metrics are enabled in configuration + +**Unexpected delta values:** +- Verify baseline file contains correct metric values +- Check that baseline was generated with same configuration + +### Debug Mode + +Use debug mode to see more information about baseline processing: + +```bash +bin/phpcca analyse src/ --baseline=baseline.json --debug +``` + +This will show timing information and help identify issues with baseline processing. diff --git a/schemas/baseline.json b/schemas/baseline.json new file mode 100644 index 0000000..47b3d03 --- /dev/null +++ b/schemas/baseline.json @@ -0,0 +1,205 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/phauthentic/cognitive-code-checker/schemas/baseline.json", + "title": "Cognitive Code Analysis Baseline File", + "description": "Schema for validating baseline files used in cognitive complexity analysis", + "type": "object", + "oneOf": [ + { + "$comment": "New format (version 2.0) with metadata", + "type": "object", + "required": ["version", "createdAt", "configHash", "metrics"], + "properties": { + "version": { + "type": "string", + "const": "2.0", + "description": "Baseline file format version" + }, + "createdAt": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$", + "description": "Creation timestamp in YYYY-MM-DD HH:MM:SS format" + }, + "configHash": { + "type": "string", + "minLength": 1, + "description": "MD5 hash of the metrics configuration used to generate this baseline" + }, + "metrics": { + "$ref": "#/$defs/metrics" + } + }, + "additionalProperties": false + }, + { + "$comment": "Legacy format (direct metrics structure)", + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_\\\\]*$": { + "$ref": "#/$defs/classMetrics" + } + }, + "minProperties": 1, + "additionalProperties": false + } + ], + "$defs": { + "metrics": { + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_\\\\]*$": { + "$ref": "#/$defs/classMetrics" + } + }, + "minProperties": 1, + "additionalProperties": false + }, + "classMetrics": { + "type": "object", + "required": ["methods"], + "properties": { + "methods": { + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "$ref": "#/$defs/methodMetrics" + } + }, + "minProperties": 1, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "methodMetrics": { + "type": "object", + "required": [ + "class", + "method", + "lineCount", + "argCount", + "returnCount", + "variableCount", + "propertyCallCount", + "ifCount", + "ifNestingLevel", + "elseCount", + "lineCountWeight", + "argCountWeight", + "returnCountWeight", + "variableCountWeight", + "propertyCallCountWeight", + "ifCountWeight", + "ifNestingLevelWeight", + "elseCountWeight" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1, + "description": "Fully qualified class name" + }, + "method": { + "type": "string", + "minLength": 1, + "description": "Method name" + }, + "file": { + "type": ["string", "null"], + "description": "File path where the method is located" + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "Line number where the method starts" + }, + "lineCount": { + "type": "integer", + "minimum": 0, + "description": "Number of lines in the method" + }, + "argCount": { + "type": "integer", + "minimum": 0, + "description": "Number of method arguments" + }, + "returnCount": { + "type": "integer", + "minimum": 0, + "description": "Number of return statements" + }, + "variableCount": { + "type": "integer", + "minimum": 0, + "description": "Number of variables used in the method" + }, + "propertyCallCount": { + "type": "integer", + "minimum": 0, + "description": "Number of property accesses" + }, + "ifCount": { + "type": "integer", + "minimum": 0, + "description": "Number of if statements" + }, + "ifNestingLevel": { + "type": "integer", + "minimum": 0, + "description": "Maximum nesting level of if statements" + }, + "elseCount": { + "type": "integer", + "minimum": 0, + "description": "Number of else statements" + }, + "lineCountWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for line count metric" + }, + "argCountWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for argument count metric" + }, + "returnCountWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for return count metric" + }, + "variableCountWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for variable count metric" + }, + "propertyCallCountWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for property call count metric" + }, + "ifCountWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for if count metric" + }, + "ifNestingLevelWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for if nesting level metric" + }, + "elseCountWeight": { + "type": "number", + "minimum": 0, + "description": "Weighted score for else count metric" + }, + "score": { + "type": "number", + "minimum": 0, + "description": "Total cognitive complexity score for the method" + } + }, + "additionalProperties": false + } + } +} diff --git a/src/Application.php b/src/Application.php index 011e1a1..3e0ca29 100644 --- a/src/Application.php +++ b/src/Application.php @@ -9,7 +9,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\ChurnReportFactory; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\ChurnReportFactoryInterface; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CodeCoverageFactory; -use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline\Baseline; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollector; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsSorter; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\FileProcessed; @@ -40,6 +40,7 @@ use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\ReportGenerationStage as ChurnReportGenerationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ChurnStages\ValidationStage as ChurnValidationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\BaselineStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\BaselineGenerationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ConfigurationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\CoverageStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\MetricsCollectionStage; @@ -317,6 +318,7 @@ private function registerCognitivePipelineStages(): void $this->containerBuilder->register(BaselineStage::class, BaselineStage::class) ->setArguments([ new Reference(Baseline::class), + new Reference(ConfigService::class), ]) ->setPublic(true); @@ -326,6 +328,12 @@ private function registerCognitivePipelineStages(): void ]) ->setPublic(true); + $this->containerBuilder->register(BaselineGenerationStage::class, BaselineGenerationStage::class) + ->setArguments([ + new Reference(ConfigService::class), + ]) + ->setPublic(true); + $this->containerBuilder->register(ReportGenerationStage::class, ReportGenerationStage::class) ->setArguments([ new Reference(MetricsFacade::class), @@ -347,6 +355,7 @@ private function registerCognitivePipelineStages(): void new Reference(MetricsCollectionStage::class), new Reference(BaselineStage::class), new Reference(SortingStage::class), + new Reference(BaselineGenerationStage::class), new Reference(ReportGenerationStage::class), new Reference(OutputStage::class), ]) diff --git a/src/Business/Cognitive/Baseline.php b/src/Business/Cognitive/Baseline.php deleted file mode 100644 index c1f2065..0000000 --- a/src/Business/Cognitive/Baseline.php +++ /dev/null @@ -1,50 +0,0 @@ -> $baseline - */ - public function calculateDeltas(CognitiveMetricsCollection $metricsCollection, array $baseline): void - { - foreach ($baseline as $class => $data) { - foreach ($data['methods'] as $methodName => $methodData) { - $metrics = $metricsCollection->getClassWithMethod($class, $methodName); - if (!$metrics) { - continue; - } - - $previousMetrics = new CognitiveMetrics($methodData); - $metrics->calculateDeltas($previousMetrics); - } - } - } - - /** - * Loads the baseline file and returns the data as an array. - * - * @param string $baselineFile - * @return array> $baseline - * @throws \JsonException|\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException - */ - public function loadBaseline(string $baselineFile): array - { - if (!file_exists($baselineFile)) { - throw new CognitiveAnalysisException('Baseline file does not exist.'); - } - - $baseline = file_get_contents($baselineFile); - if ($baseline === false) { - throw new CognitiveAnalysisException('Failed to read baseline file.'); - } - - return json_decode($baseline, true, 512, JSON_THROW_ON_ERROR); - } -} diff --git a/src/Business/Cognitive/Baseline/Baseline.php b/src/Business/Cognitive/Baseline/Baseline.php new file mode 100644 index 0000000..8f02eeb --- /dev/null +++ b/src/Business/Cognitive/Baseline/Baseline.php @@ -0,0 +1,199 @@ +> $baseline + * @param bool $validateConfigHash Whether to validate config hash and emit warnings + * @param CognitiveConfig|null $currentConfig Current configuration for hash validation + * @return array Array of warning messages + */ + public function calculateDeltas( + CognitiveMetricsCollection $metricsCollection, + array $baseline, + bool $validateConfigHash = false, + ?CognitiveConfig $currentConfig = null + ): array { + $warnings = []; + + foreach ($baseline as $class => $data) { + foreach ($data['methods'] as $methodName => $methodData) { + $metrics = $metricsCollection->getClassWithMethod($class, $methodName); + if (!$metrics) { + continue; + } + + $previousMetrics = new CognitiveMetrics($methodData); + $metrics->calculateDeltas($previousMetrics); + } + } + + return $warnings; + } + + /** + * Loads the baseline file and returns the data as an array. + * Supports both old and new baseline file formats. + * + * @param string $baselineFile + * @return array{metrics: array>, baselineFile: BaselineFile|null, warnings: array} + * @throws \JsonException|\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException + */ + public function loadBaseline(string $baselineFile): array + { + if (!file_exists($baselineFile)) { + throw new CognitiveAnalysisException('Baseline file does not exist.'); + } + + $baseline = file_get_contents($baselineFile); + if ($baseline === false) { + throw new CognitiveAnalysisException('Failed to read baseline file.'); + } + + $data = json_decode($baseline, true, 512, JSON_THROW_ON_ERROR); + + // Validate against JSON schema + $validator = new BaselineSchemaValidator(); + $validationErrors = $validator->validate($data); + + if (!empty($validationErrors)) { + $errorMessage = 'Invalid baseline file format: ' . implode(', ', $validationErrors); + throw new CognitiveAnalysisException($errorMessage); + } + + $result = BaselineFile::fromJson($data); + + return [ + 'metrics' => $result['metrics'], + 'baselineFile' => $result['baselineFile'], + 'warnings' => [] + ]; + } + + /** + * Loads baseline and validates config hash if provided. + * + * @param string $baselineFile + * @param CognitiveConfig|null $currentConfig + * @return array{metrics: array>, baselineFile: BaselineFile|null, warnings: array} + * @throws \JsonException|\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException + */ + public function loadBaselineWithValidation(string $baselineFile, ?CognitiveConfig $currentConfig = null): array + { + $result = $this->loadBaseline($baselineFile); + $warnings = $result['warnings']; + + // Validate config hash if we have both baseline file and current config + if ($result['baselineFile'] !== null && $currentConfig !== null) { + if (!$result['baselineFile']->validateConfigHash($currentConfig)) { + $warnings[] = sprintf( + 'Warning: Baseline config hash (%s) does not match current config hash (%s). ' . + 'Metrics comparison may not be accurate.', + $result['baselineFile']->getConfigHash(), + BaselineFile::generateConfigHash($currentConfig) + ); + } + } + + $result['warnings'] = $warnings; + return $result; + } + + /** + * Find the latest baseline file in the default directory. + * + * @param string $baselineDirectory + * @return string|null Path to the latest baseline file, or null if none found + */ + public function findLatestBaselineFile(string $baselineDirectory = './.phpcca/baseline'): ?string + { + if (!is_dir($baselineDirectory)) { + return null; + } + + $baselineFiles = $this->getBaselineFiles($baselineDirectory); + + if (empty($baselineFiles)) { + return null; + } + + // Sort by modification time (newest first) + usort($baselineFiles, function ($a, $b) { + return filemtime($b) - filemtime($a); + }); + + return $baselineFiles[0]; + } + + /** + * Get all baseline files from the specified directory. + * + * @param string $baselineDirectory + * @return array Array of baseline file paths + */ + public function getBaselineFiles(string $baselineDirectory): array + { + if (!is_dir($baselineDirectory)) { + return []; + } + + $files = glob($baselineDirectory . '/baseline-*.json'); + + if ($files === false) { + return []; + } + + // Filter out non-baseline files and validate they are readable + $baselineFiles = []; + foreach ($files as $file) { + if (!is_file($file) || !is_readable($file)) { + continue; + } + + $baselineFiles[] = $file; + } + + return $baselineFiles; + } + + /** + * Check if a file is a valid baseline file (either old or new format). + * + * @param string $filePath + * @return bool + */ + public function isValidBaselineFile(string $filePath): bool + { + if (!is_file($filePath) || !is_readable($filePath)) { + return false; + } + + try { + $content = file_get_contents($filePath); + if ($content === false) { + return false; + } + + $data = json_decode($content, true); + if ($data === null) { + return false; + } + + // Use schema validator for comprehensive validation + $validator = new BaselineSchemaValidator(); + return $validator->isValidBaseline($data); + } catch (\Exception) { + return false; + } + } +} diff --git a/src/Business/Cognitive/Baseline/BaselineFile.php b/src/Business/Cognitive/Baseline/BaselineFile.php new file mode 100644 index 0000000..e53a13f --- /dev/null +++ b/src/Business/Cognitive/Baseline/BaselineFile.php @@ -0,0 +1,170 @@ +> $metrics + */ + public function __construct( + private readonly string $createdAt, + private readonly string $configHash, + /** @var array> */ + private readonly array $metrics + ) { + } + + /** + * Create a BaselineFile from a metrics collection and config. + */ + public static function fromMetricsCollection( + CognitiveMetricsCollection $metricsCollection, + CognitiveConfig $config + ): self { + $createdAt = date(self::DATE_FORMAT); + $configHash = self::generateConfigHash($config); + $metrics = self::extractMetricsFromCollection($metricsCollection); + + return new self($createdAt, $configHash, $metrics); + } + + /** + * Create a BaselineFile from JSON data (supports both old and new formats). + * + * @param array $data + * @return array{baselineFile: self|null, metrics: array} + */ + 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) { + $baselineFile = new self( + $data['createdAt'], + $data['configHash'], + $data['metrics'] + ); + + return [ + 'baselineFile' => $baselineFile, + 'metrics' => $data['metrics'] + ]; + } + + // Old format - return null for baselineFile, data as metrics + return [ + 'baselineFile' => null, + 'metrics' => $data + ]; + } + + /** + * Generate a config hash for the given configuration. + */ + public static function generateConfigHash(CognitiveConfig $config): string + { + $configArray = $config->toArray(); + $metricsConfig = $configArray['metrics'] ?? []; + + return md5(serialize($metricsConfig)); + } + + /** + * Extract metrics data from a metrics collection in the expected format. + * + * @return array>}> + */ + private static function extractMetricsFromCollection(CognitiveMetricsCollection $metricsCollection): array + { + /** @var array>}> $metrics */ + $metrics = []; + $groupedByClass = $metricsCollection->groupBy('class'); + + foreach ($groupedByClass as $class => $methods) { + foreach ($methods as $methodMetrics) { + $metrics[(string)$class]['methods'][$methodMetrics->getMethod()] = [ + 'class' => $methodMetrics->getClass(), + 'method' => $methodMetrics->getMethod(), + 'file' => $methodMetrics->getFileName(), + 'line' => $methodMetrics->getLine(), + 'lineCount' => $methodMetrics->getLineCount(), + 'argCount' => $methodMetrics->getArgCount(), + 'returnCount' => $methodMetrics->getReturnCount(), + 'variableCount' => $methodMetrics->getVariableCount(), + 'propertyCallCount' => $methodMetrics->getPropertyCallCount(), + 'ifCount' => $methodMetrics->getIfCount(), + 'ifNestingLevel' => $methodMetrics->getIfNestingLevel(), + 'elseCount' => $methodMetrics->getElseCount(), + 'lineCountWeight' => $methodMetrics->getLineCountWeight(), + 'argCountWeight' => $methodMetrics->getArgCountWeight(), + 'returnCountWeight' => $methodMetrics->getReturnCountWeight(), + 'variableCountWeight' => $methodMetrics->getVariableCountWeight(), + 'propertyCallCountWeight' => $methodMetrics->getPropertyCallCountWeight(), + 'ifCountWeight' => $methodMetrics->getIfCountWeight(), + 'ifNestingLevelWeight' => $methodMetrics->getIfNestingLevelWeight(), + 'elseCountWeight' => $methodMetrics->getElseCountWeight(), + 'score' => $methodMetrics->getScore(), + ]; + } + } + + return $metrics; + } + + public function getVersion(): string + { + return self::VERSION; + } + + public function getCreatedAt(): string + { + return $this->createdAt; + } + + public function getConfigHash(): string + { + return $this->configHash; + } + + /** + * @return array> + */ + public function getMetrics(): array + { + return $this->metrics; + } + + /** + * Validate if the config hash matches the current configuration. + */ + public function validateConfigHash(CognitiveConfig $currentConfig): bool + { + $currentHash = self::generateConfigHash($currentConfig); + return $this->configHash === $currentHash; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'version' => self::VERSION, + 'createdAt' => $this->createdAt, + 'configHash' => $this->configHash, + 'metrics' => $this->metrics, + ]; + } +} diff --git a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php new file mode 100644 index 0000000..4809dd1 --- /dev/null +++ b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php @@ -0,0 +1,311 @@ + $data + * @return array Array of validation errors (empty if valid) + */ + public function validate(array $data): array + { + $errors = []; + + // Check if it's new format (version 2.0) + if (isset($data['version'])) { + return array_merge($errors, $this->validateNewFormat($data)); + } + + // Legacy format + return $this->validateLegacyFormat($data); + } + + /** + * Validate new format (version 2.0) baseline data. + * + * @param array $data + * @return array + */ + private function validateNewFormat(array $data): array + { + $errors = []; + + // Required fields + $requiredFields = ['version', 'createdAt', 'configHash', 'metrics']; + foreach ($requiredFields as $field) { + if (isset($data[$field])) { + continue; + } + + $errors[] = "Missing required field: {$field}"; + } + + if (!empty($errors)) { + return $errors; + } + + // Validate version + if ($data['version'] !== '2.0') { + $errors[] = "Invalid version: {$data['version']}. Expected: 2.0"; + } + + // Validate createdAt format + if (!is_string($data['createdAt']) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $data['createdAt'])) { + $errors[] = "Invalid createdAt format. Expected: YYYY-MM-DD HH:MM:SS"; + } + + // Validate configHash + if (!is_string($data['configHash']) || empty($data['configHash'])) { + $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"; + } + + // Check for additional properties + $allowedFields = ['version', 'createdAt', 'configHash', 'metrics']; + foreach (array_keys($data) as $key) { + if (in_array($key, $allowedFields, true)) { + continue; + } + + $errors[] = "Unexpected field: {$key}"; + } + + return $errors; + } + + /** + * Validate legacy format baseline data. + * + * @param array $data + * @return array + */ + private function validateLegacyFormat(array $data): array + { + $errors = []; + + if (empty($data)) { + $errors[] = "Empty baseline data"; + return $errors; + } + + // Validate class structure + foreach ($data as $className => $classData) { + if (empty($className)) { + $errors[] = "Invalid class name: {$className}"; + continue; + } + + if (!is_array($classData)) { + $errors[] = "Class data for '{$className}' must be an object"; + continue; + } + + if (!isset($classData['methods'])) { + $errors[] = "Missing 'methods' field for class '{$className}'"; + continue; + } + + if (!is_array($classData['methods'])) { + $errors[] = "Methods for class '{$className}' must be an object"; + continue; + } + + // Validate methods + foreach ($classData['methods'] as $methodName => $methodData) { + if (!is_string($methodName) || empty($methodName)) { + $errors[] = "Invalid method name: {$methodName} in class '{$className}'"; + continue; + } + + $errors = array_merge($errors, $this->validateMethodData($methodData, $className, $methodName)); + } + + // Check for additional properties in class data + $allowedClassFields = ['methods']; + foreach (array_keys($classData) as $key) { + if (in_array($key, $allowedClassFields, true)) { + continue; + } + + $errors[] = "Unexpected field '{$key}' in class '{$className}'"; + } + } + + return $errors; + } + + /** + * Validate metrics structure (used in new format). + * + * @param array $metrics + * @return array + */ + private function validateMetrics(array $metrics): array + { + $errors = []; + + if (empty($metrics)) { + $errors[] = "Metrics object cannot be empty"; + return $errors; + } + + foreach ($metrics as $className => $classData) { + if (empty($className)) { + $errors[] = "Invalid class name in metrics: {$className}"; + continue; + } + + if (!is_array($classData)) { + $errors[] = "Class data for '{$className}' must be an object"; + continue; + } + + if (!isset($classData['methods'])) { + $errors[] = "Missing 'methods' field for class '{$className}'"; + continue; + } + + if (!is_array($classData['methods'])) { + $errors[] = "Methods for class '{$className}' must be an object"; + continue; + } + + // Validate methods + foreach ($classData['methods'] as $methodName => $methodData) { + if (!is_string($methodName) || empty($methodName)) { + $errors[] = "Invalid method name: {$methodName} in class '{$className}'"; + continue; + } + + $errors = array_merge($errors, $this->validateMethodData($methodData, $className, $methodName)); + } + + // Check for additional properties in class data + $allowedClassFields = ['methods']; + foreach (array_keys($classData) as $key) { + if (in_array($key, $allowedClassFields, true)) { + continue; + } + + $errors[] = "Unexpected field '{$key}' in class '{$className}'"; + } + } + + return $errors; + } + + /** + * Validate method data structure. + * + * @param mixed $methodData + * @param string $className + * @param string $methodName + * @return array + */ + private function validateMethodData($methodData, string $className, string $methodName): array + { + $errors = []; + + if (!is_array($methodData)) { + $errors[] = "Method data for '{$className}::{$methodName}' must be an object"; + return $errors; + } + + // Required fields for method data + $requiredFields = [ + 'class', 'method', 'lineCount', 'argCount', 'returnCount', + 'variableCount', 'propertyCallCount', 'ifCount', 'ifNestingLevel', 'elseCount', + 'lineCountWeight', 'argCountWeight', 'returnCountWeight', 'variableCountWeight', + 'propertyCallCountWeight', 'ifCountWeight', 'ifNestingLevelWeight', 'elseCountWeight' + ]; + + foreach ($requiredFields as $field) { + if (isset($methodData[$field])) { + continue; + } + + $errors[] = "Missing required field '{$field}' in method '{$className}::{$methodName}'"; + } + + if (!empty($errors)) { + return $errors; + } + + // Validate string fields + $stringFields = ['class', 'method']; + foreach ($stringFields as $field) { + if (is_string($methodData[$field]) && !empty($methodData[$field])) { + continue; + } + + $errors[] = "Field '{$field}' in method '{$className}::{$methodName}' must be a non-empty string"; + } + + // Validate integer fields + $integerFields = ['line', 'lineCount', 'argCount', 'returnCount', 'variableCount', 'propertyCallCount', 'ifCount', 'ifNestingLevel', 'elseCount']; + foreach ($integerFields as $field) { + if (!isset($methodData[$field]) || (is_int($methodData[$field]) && $methodData[$field] >= 0)) { + continue; + } + + $errors[] = "Field '{$field}' in method '{$className}::{$methodName}' must be a non-negative integer"; + } + + // Validate line field specifically (must be >= 1 if present) + if (isset($methodData['line']) && (!is_int($methodData['line']) || $methodData['line'] < 1)) { + $errors[] = "Field 'line' in method '{$className}::{$methodName}' must be a positive integer"; + } + + // Validate weight fields + $weightFields = ['lineCountWeight', 'argCountWeight', 'returnCountWeight', 'variableCountWeight', 'propertyCallCountWeight', 'ifCountWeight', 'ifNestingLevelWeight', 'elseCountWeight']; + foreach ($weightFields as $field) { + if (is_numeric($methodData[$field]) && $methodData[$field] >= 0) { + continue; + } + + $errors[] = "Field '{$field}' in method '{$className}::{$methodName}' must be a non-negative number"; + } + + // Validate optional score field + if (isset($methodData['score']) && (!is_numeric($methodData['score']) || $methodData['score'] < 0)) { + $errors[] = "Field 'score' in method '{$className}::{$methodName}' must be a non-negative number"; + } + + // Validate file field (optional, can be string or null) + if (isset($methodData['file']) && !is_string($methodData['file'])) { + $errors[] = "Field 'file' in method '{$className}::{$methodName}' must be a string or null"; + } + + return $errors; + } + + /** + * Check if the data represents a valid baseline format. + * + * @param array $data + * @return bool + */ + public function isValidBaseline(array $data): bool + { + try { + $errors = $this->validate($data); + return empty($errors); + } catch (\Exception) { + return false; + } + } +} diff --git a/src/Command/CognitiveMetricsCommand.php b/src/Command/CognitiveMetricsCommand.php index 216fa22..783b564 100644 --- a/src/Command/CognitiveMetricsCommand.php +++ b/src/Command/CognitiveMetricsCommand.php @@ -10,6 +10,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -26,6 +27,7 @@ class CognitiveMetricsCommand extends Command { public const OPTION_CONFIG_FILE = 'config'; public const OPTION_BASELINE = 'baseline'; + public const OPTION_GENERATE_BASELINE = 'generate-baseline'; public const OPTION_REPORT_TYPE = 'report-type'; public const OPTION_REPORT_FILE = 'report-file'; public const OPTION_DEBUG = 'debug'; @@ -65,6 +67,12 @@ protected function configure(): void mode: InputArgument::OPTIONAL, description: 'Baseline file to get the delta.', ) + ->addOption( + name: self::OPTION_GENERATE_BASELINE, + shortcut: 'g', + mode: InputOption::VALUE_NONE, + description: 'Generate a baseline file with the current analysis.', + ) ->addOption( name: self::OPTION_REPORT_TYPE, shortcut: 'r', diff --git a/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php index d59962c..b3d9adb 100644 --- a/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php +++ b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php @@ -99,6 +99,39 @@ public function hasBaselineFile(): bool return $this->getBaselineFile() !== null; } + public function getGenerateBaseline(): ?string + { + $value = $this->input->getOption('generate-baseline'); + + // For VALUE_NONE, true means option is present, false means not present + if ($value === true) { + return ''; // Auto-generate filename + } + + return null; // Option not present + } + + public function hasGenerateBaseline(): bool + { + $value = $this->input->getOption('generate-baseline'); + + // For VALUE_NONE, true means option is present, false means not present + return $value === true; + } + + public function getBaselineOutputPath(): string + { + $filename = $this->getGenerateBaseline(); + + if (empty($filename)) { + // Generate timestamped filename + $timestamp = date('Y-m-d_H-i-s'); + $filename = "./.phpcca/baseline/baseline-{$timestamp}.json"; + } + + return $filename; + } + /** * @return array */ diff --git a/src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php b/src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php new file mode 100644 index 0000000..3bc070b --- /dev/null +++ b/src/Command/Pipeline/CognitiveStages/BaselineGenerationStage.php @@ -0,0 +1,79 @@ +getCommandContext(); + $metricsCollection = $context->getData('metricsCollection'); + + if (!$commandContext->hasGenerateBaseline()) { + return OperationResult::success(); + } + + if ($metricsCollection === null) { + return OperationResult::failure('Metrics collection not available for baseline generation.'); + } + + try { + $outputPath = $commandContext->getBaselineOutputPath(); + $config = $this->configService->getConfig(); + + // Create BaselineFile object + $baselineFile = BaselineFile::fromMetricsCollection($metricsCollection, $config); + + // Ensure directory exists + $directory = dirname($outputPath); + if (!is_dir($directory)) { + if (!mkdir($directory, 0755, true)) { + throw new CognitiveAnalysisException("Failed to create directory: {$directory}"); + } + } + + // Write baseline file + $jsonData = json_encode($baselineFile, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + if (file_put_contents($outputPath, $jsonData) === false) { + throw new CognitiveAnalysisException("Failed to write baseline file: {$outputPath}"); + } + + // Add success message to context + $context->setData('baselineGenerated', $outputPath); + + return OperationResult::success("Baseline file generated: {$outputPath}"); + } catch (Exception $e) { + return OperationResult::failure('Failed to generate baseline: ' . $e->getMessage()); + } + } + + public function shouldSkip(ExecutionContext $context): bool + { + $commandContext = $context->getCommandContext(); + return !$commandContext->hasGenerateBaseline(); + } + + public function getStageName(): string + { + return 'BaselineGeneration'; + } +} diff --git a/src/Command/Pipeline/CognitiveStages/BaselineStage.php b/src/Command/Pipeline/CognitiveStages/BaselineStage.php index 07a88d2..4ec8f89 100644 --- a/src/Command/Pipeline/CognitiveStages/BaselineStage.php +++ b/src/Command/Pipeline/CognitiveStages/BaselineStage.php @@ -5,10 +5,11 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages; use Exception; -use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline\Baseline; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\ExecutionContext; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\PipelineStage; use Phauthentic\CognitiveCodeAnalysis\Command\Result\OperationResult; +use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; /** * Pipeline stage for applying baseline to metrics. @@ -17,7 +18,8 @@ class BaselineStage extends PipelineStage { public function __construct( - private readonly Baseline $baselineService + private readonly Baseline $baselineService, + private readonly ConfigService $configService ) { } @@ -31,13 +33,35 @@ public function execute(ExecutionContext $context): OperationResult } $baselineFile = $commandContext->getBaselineFile(); + + // If no baseline file provided, try to find the latest one automatically if ($baselineFile === null) { - return OperationResult::success(); + $baselineFile = $this->baselineService->findLatestBaselineFile(); + + if ($baselineFile === null) { + // No baseline file found, skip this stage + return OperationResult::success(); + } + + // Add info message about auto-detected baseline + $context->addWarning("Auto-detected latest baseline file: " . basename($baselineFile)); } try { - $baseline = $this->baselineService->loadBaseline($baselineFile); - $this->baselineService->calculateDeltas($metricsCollection, $baseline); + $config = $this->configService->getConfig(); + $result = $this->baselineService->loadBaselineWithValidation($baselineFile, $config); + + // Calculate deltas with the extracted metrics + $warnings = $this->baselineService->calculateDeltas( + $metricsCollection, + $result['metrics'] + ); + + // Add any validation warnings to the context + if (!empty($result['warnings'])) { + $context->addWarnings($result['warnings']); + } + return OperationResult::success(); } catch (Exception $e) { return OperationResult::failure('Failed to process baseline: ' . $e->getMessage()); @@ -46,8 +70,8 @@ public function execute(ExecutionContext $context): OperationResult public function shouldSkip(ExecutionContext $context): bool { - $commandContext = $context->getCommandContext(); - return !$commandContext->hasBaselineFile(); + // Never skip this stage - it will handle auto-detection internally + return false; } public function getStageName(): string diff --git a/src/Command/Pipeline/CognitiveStages/OutputStage.php b/src/Command/Pipeline/CognitiveStages/OutputStage.php index d048386..87bbdf3 100644 --- a/src/Command/Pipeline/CognitiveStages/OutputStage.php +++ b/src/Command/Pipeline/CognitiveStages/OutputStage.php @@ -28,6 +28,22 @@ public function execute(ExecutionContext $context): OperationResult return OperationResult::failure('Metrics collection not available for console output.'); } + // Display warnings if any + if ($context->hasWarnings()) { + $output = $context->getOutput(); + $output->writeln(''); + foreach ($context->getWarnings() as $warning) { + $output->writeln('' . $warning . ''); + } + $output->writeln(''); + } + + // Display baseline generation message if generated + $baselineGenerated = $context->getData('baselineGenerated'); + if ($baselineGenerated !== null) { + $context->getOutput()->writeln('Baseline file generated: ' . $baselineGenerated . ''); + } + // Render to console $this->renderer->render($sortedMetricsCollection, $context->getOutput()); diff --git a/src/Command/Pipeline/CommandPipelineFactory.php b/src/Command/Pipeline/CommandPipelineFactory.php index 12a7aab..4c8c99b 100644 --- a/src/Command/Pipeline/CommandPipelineFactory.php +++ b/src/Command/Pipeline/CommandPipelineFactory.php @@ -5,6 +5,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Pipeline; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\BaselineStage; +use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\BaselineGenerationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\ConfigurationStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\CoverageStage; use Phauthentic\CognitiveCodeAnalysis\Command\Pipeline\CognitiveStages\MetricsCollectionStage; @@ -25,6 +26,7 @@ public function __construct( private readonly MetricsCollectionStage $metricsCollectionStage, private readonly BaselineStage $baselineStage, private readonly SortingStage $sortingStage, + private readonly BaselineGenerationStage $baselineGenerationStage, private readonly ReportGenerationStage $reportGenerationStage, private readonly OutputStage $outputStage ) { @@ -42,6 +44,7 @@ public function createPipeline(): CommandPipeline $this->metricsCollectionStage, $this->baselineStage, $this->sortingStage, + $this->baselineGenerationStage, $this->reportGenerationStage, $this->outputStage, ]); diff --git a/src/Command/Pipeline/ExecutionContext.php b/src/Command/Pipeline/ExecutionContext.php index b6e7065..73b3a32 100644 --- a/src/Command/Pipeline/ExecutionContext.php +++ b/src/Command/Pipeline/ExecutionContext.php @@ -19,6 +19,8 @@ class ExecutionContext private array $statistics = []; /** @var array */ private array $data = []; + /** @var array */ + private array $warnings = []; public function __construct( private readonly CognitiveMetricsCommandContext $commandContext, @@ -133,4 +135,40 @@ public function getStatistics(): array { return $this->statistics; } + + /** + * Add a warning message. + */ + public function addWarning(string $warning): void + { + $this->warnings[] = $warning; + } + + /** + * Add multiple warning messages. + * + * @param array $warnings + */ + public function addWarnings(array $warnings): void + { + $this->warnings = array_merge($this->warnings, $warnings); + } + + /** + * Get all warnings. + * + * @return array + */ + public function getWarnings(): array + { + return $this->warnings; + } + + /** + * Check if there are any warnings. + */ + public function hasWarnings(): bool + { + return !empty($this->warnings); + } } diff --git a/tests/Unit/Business/Cognitive/BaselineServiceTest.php b/tests/Unit/Business/Cognitive/BaselineServiceTest.php index 67c76ff..c13c983 100644 --- a/tests/Unit/Business/Cognitive/BaselineServiceTest.php +++ b/tests/Unit/Business/Cognitive/BaselineServiceTest.php @@ -4,7 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive; -use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline\Baseline; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -55,8 +55,27 @@ public function testLoadBaselineSuccess(): void 'TestClass' => [ 'methods' => [ 'testMethod' => [ - 'complexity' => 8, - 'size' => 18 + 'class' => 'TestClass', + 'method' => 'testMethod', + 'file' => 'TestClass.php', + 'line' => 10, + 'lineCount' => 5, + 'argCount' => 2, + 'returnCount' => 1, + 'variableCount' => 3, + 'propertyCallCount' => 1, + 'ifCount' => 2, + 'ifNestingLevel' => 1, + 'elseCount' => 1, + 'lineCountWeight' => 0.5, + 'argCountWeight' => 0.3, + 'returnCountWeight' => 0.2, + 'variableCountWeight' => 0.4, + 'propertyCallCountWeight' => 0.1, + 'ifCountWeight' => 0.6, + 'ifNestingLevelWeight' => 0.7, + 'elseCountWeight' => 0.2, + 'score' => 8.5 ] ] ] @@ -67,9 +86,12 @@ public function testLoadBaselineSuccess(): void $result = $this->baselineService->loadBaseline($filePath); $this->assertIsArray($result); - $this->assertArrayHasKey('TestClass', $result); - $this->assertArrayHasKey('methods', $result['TestClass']); - $this->assertArrayHasKey('testMethod', $result['TestClass']['methods']); + $this->assertArrayHasKey('metrics', $result); + $this->assertArrayHasKey('baselineFile', $result); + $this->assertArrayHasKey('warnings', $result); + $this->assertArrayHasKey('TestClass', $result['metrics']); + $this->assertArrayHasKey('methods', $result['metrics']['TestClass']); + $this->assertArrayHasKey('testMethod', $result['metrics']['TestClass']['methods']); unlink($filePath); // Clean up } From 7b8226eebd1c81465027fa70e7f4c17af1c8b659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Sun, 19 Oct 2025 00:38:23 +0200 Subject: [PATCH 5/8] Fixes --- src/Business/Cognitive/Baseline/Baseline.php | 7 ++----- .../Cognitive/Baseline/BaselineSchemaValidator.php | 2 ++ src/Command/Pipeline/CognitiveStages/BaselineStage.php | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Business/Cognitive/Baseline/Baseline.php b/src/Business/Cognitive/Baseline/Baseline.php index 8f02eeb..c81e7f8 100644 --- a/src/Business/Cognitive/Baseline/Baseline.php +++ b/src/Business/Cognitive/Baseline/Baseline.php @@ -14,15 +14,11 @@ class Baseline /** * @param CognitiveMetricsCollection $metricsCollection * @param array> $baseline - * @param bool $validateConfigHash Whether to validate config hash and emit warnings - * @param CognitiveConfig|null $currentConfig Current configuration for hash validation * @return array Array of warning messages */ public function calculateDeltas( CognitiveMetricsCollection $metricsCollection, - array $baseline, - bool $validateConfigHash = false, - ?CognitiveConfig $currentConfig = null + array $baseline ): array { $warnings = []; @@ -112,6 +108,7 @@ public function loadBaselineWithValidation(string $baselineFile, ?CognitiveConfi /** * Find the latest baseline file in the default directory. * + * @SuppressWarnings("PHPMD.ShortVariable") * @param string $baselineDirectory * @return string|null Path to the latest baseline file, or null if none found */ diff --git a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php index 4809dd1..b1d1dbd 100644 --- a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php +++ b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php @@ -7,6 +7,8 @@ /** * JSON Schema validator for baseline files. * Validates baseline files against the defined schema structure. + * + * @SuppressWarnings("PHPMD") */ class BaselineSchemaValidator { diff --git a/src/Command/Pipeline/CognitiveStages/BaselineStage.php b/src/Command/Pipeline/CognitiveStages/BaselineStage.php index 4ec8f89..baad6bf 100644 --- a/src/Command/Pipeline/CognitiveStages/BaselineStage.php +++ b/src/Command/Pipeline/CognitiveStages/BaselineStage.php @@ -52,7 +52,7 @@ public function execute(ExecutionContext $context): OperationResult $result = $this->baselineService->loadBaselineWithValidation($baselineFile, $config); // Calculate deltas with the extracted metrics - $warnings = $this->baselineService->calculateDeltas( + $this->baselineService->calculateDeltas( $metricsCollection, $result['metrics'] ); From 5a7d72ff69f9c8680a517a62bdec3e78a7de25ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 20 Oct 2025 19:13:15 +0200 Subject: [PATCH 6/8] Moving tests --- .../Command}/ChurnSpecificationPatternTest.php | 8 ++++---- .../CognitiveMetricsSpecificationPatternTest.php | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) rename tests/{Command/ChurnSpecifications => Unit/Command}/ChurnSpecificationPatternTest.php (98%) rename tests/{ => Unit}/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php (98%) diff --git a/tests/Command/ChurnSpecifications/ChurnSpecificationPatternTest.php b/tests/Unit/Command/ChurnSpecificationPatternTest.php similarity index 98% rename from tests/Command/ChurnSpecifications/ChurnSpecificationPatternTest.php rename to tests/Unit/Command/ChurnSpecificationPatternTest.php index 4eda900..13517b4 100644 --- a/tests/Command/ChurnSpecifications/ChurnSpecificationPatternTest.php +++ b/tests/Unit/Command/ChurnSpecificationPatternTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Command\ChurnSpecifications; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Command; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnCommandContext; -use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CoverageFormatExclusivity; -use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CoverageFileExists; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CompositeChurnSpecification; +use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CoverageFileExists; +use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\CoverageFormatExclusivity; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; class ChurnSpecificationPatternTest extends TestCase diff --git a/tests/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php b/tests/Unit/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php similarity index 98% rename from tests/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php rename to tests/Unit/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php index 225f63f..7539b6f 100644 --- a/tests/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php +++ b/tests/Unit/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Command\CognitiveMetricsSpecifications; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Command\CognitiveMetricsSpecifications; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsCommandContext; -use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CoverageFormatExclusivity; -use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CoverageFileExists; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CompositeCognitiveMetricsValidationSpecification; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CoverageFileExists; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CoverageFormatExclusivity; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\SortFieldValid; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\SortOrderValid; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; class CognitiveMetricsSpecificationPatternTest extends TestCase From 174c02d6918cedc949e7ebeec25da942c25dafba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 20 Oct 2025 19:50:59 +0200 Subject: [PATCH 7/8] Adding more metrics to the baseline --- .../Cognitive/Baseline/BaselineFile.php | 5 ++ .../Baseline/BaselineSchemaValidator.php | 17 ++++++ src/Business/Cognitive/CognitiveMetrics.php | 56 +++++++++++++++++ src/Command/Presentation/TableRowBuilder.php | 61 +++++++++++++++++++ .../Command/CognitiveMetricsCommandTest.php | 1 + tests/Unit/Command/minimal-config.yml | 45 ++++++++++++++ 6 files changed, 185 insertions(+) create mode 100644 tests/Unit/Command/minimal-config.yml diff --git a/src/Business/Cognitive/Baseline/BaselineFile.php b/src/Business/Cognitive/Baseline/BaselineFile.php index e53a13f..b4e9156 100644 --- a/src/Business/Cognitive/Baseline/BaselineFile.php +++ b/src/Business/Cognitive/Baseline/BaselineFile.php @@ -116,6 +116,11 @@ private static function extractMetricsFromCollection(CognitiveMetricsCollection 'ifNestingLevelWeight' => $methodMetrics->getIfNestingLevelWeight(), 'elseCountWeight' => $methodMetrics->getElseCountWeight(), 'score' => $methodMetrics->getScore(), + 'halsteadVolume' => $methodMetrics->getHalstead()?->volume, + 'halsteadDifficulty' => $methodMetrics->getHalstead()?->difficulty, + 'halsteadEffort' => $methodMetrics->getHalstead()?->effort, + 'cyclomaticComplexity' => $methodMetrics->getCyclomatic()?->complexity, + 'cyclomaticRiskLevel' => $methodMetrics->getCyclomatic()?->riskLevel, ]; } } diff --git a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php index b1d1dbd..eec0b5b 100644 --- a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php +++ b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php @@ -292,6 +292,23 @@ private function validateMethodData($methodData, string $className, string $meth $errors[] = "Field 'file' in method '{$className}::{$methodName}' must be a string or null"; } + // Validate optional Halstead fields + $halsteadFields = ['halsteadVolume', 'halsteadDifficulty', 'halsteadEffort']; + foreach ($halsteadFields as $field) { + if (isset($methodData[$field]) && (!is_numeric($methodData[$field]) || $methodData[$field] < 0)) { + $errors[] = "Field '{$field}' in method '{$className}::{$methodName}' must be a non-negative number"; + } + } + + // Validate optional cyclomatic fields + if (isset($methodData['cyclomaticComplexity']) && (!is_int($methodData['cyclomaticComplexity']) || $methodData['cyclomaticComplexity'] < 0)) { + $errors[] = "Field 'cyclomaticComplexity' in method '{$className}::{$methodName}' must be a non-negative integer"; + } + + if (isset($methodData['cyclomaticRiskLevel']) && !is_string($methodData['cyclomaticRiskLevel'])) { + $errors[] = "Field 'cyclomaticRiskLevel' in method '{$className}::{$methodName}' must be a string"; + } + return $errors; } diff --git a/src/Business/Cognitive/CognitiveMetrics.php b/src/Business/Cognitive/CognitiveMetrics.php index cbef0d8..04f147c 100644 --- a/src/Business/Cognitive/CognitiveMetrics.php +++ b/src/Business/Cognitive/CognitiveMetrics.php @@ -62,6 +62,11 @@ class CognitiveMetrics implements JsonSerializable private ?Delta $ifNestingLevelWeightDelta = null; private ?Delta $elseCountWeightDelta = null; + private ?Delta $halsteadVolumeDelta = null; + private ?Delta $halsteadDifficultyDelta = null; + private ?Delta $halsteadEffortDelta = null; + private ?Delta $cyclomaticComplexityDelta = null; + private ?HalsteadMetrics $halstead = null; private ?CyclomaticMetrics $cyclomatic = null; private ?float $coverage = null; @@ -86,7 +91,26 @@ public function __construct(array $metrics) $this->halstead = new HalsteadMetrics($metrics['halstead']); } + // Handle baseline format with individual halstead fields + if (isset($metrics['halsteadVolume']) && !isset($metrics['halstead'])) { + $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'] + ]); + } + if (!isset($metrics['cyclomatic_complexity'])) { + // Handle baseline format with individual cyclomatic fields + if (isset($metrics['cyclomaticComplexity']) && !isset($metrics['cyclomatic_complexity'])) { + $this->cyclomatic = new CyclomaticMetrics([ + 'complexity' => $metrics['cyclomaticComplexity'], + 'riskLevel' => $metrics['cyclomaticRiskLevel'] ?? 'unknown' + ]); + } return; } @@ -180,6 +204,18 @@ public function calculateDeltas(self $other): void $this->ifCountWeightDelta = new Delta($other->getIfCountWeight(), $this->ifCountWeight); $this->ifNestingLevelWeightDelta = new Delta($other->getIfNestingLevelWeight(), $this->ifNestingLevelWeight); $this->elseCountWeightDelta = new Delta($other->getElseCountWeight(), $this->elseCountWeight); + + // Calculate Halstead deltas if both metrics have Halstead data + if ($this->halstead !== null && $other->getHalstead() !== null) { + $this->halsteadVolumeDelta = new Delta($other->getHalstead()->volume, $this->halstead->volume); + $this->halsteadDifficultyDelta = new Delta($other->getHalstead()->difficulty, $this->halstead->difficulty); + $this->halsteadEffortDelta = new Delta($other->getHalstead()->effort, $this->halstead->effort); + } + + // Calculate Cyclomatic delta if both metrics have Cyclomatic data + if ($this->cyclomatic !== null && $other->getCyclomatic() !== null) { + $this->cyclomaticComplexityDelta = new Delta($other->getCyclomatic()->complexity, $this->cyclomatic->complexity); + } } /** @@ -393,6 +429,26 @@ public function getElseCountWeightDelta(): ?Delta return $this->elseCountWeightDelta; } + public function getHalsteadVolumeDelta(): ?Delta + { + return $this->halsteadVolumeDelta; + } + + public function getHalsteadDifficultyDelta(): ?Delta + { + return $this->halsteadDifficultyDelta; + } + + public function getHalsteadEffortDelta(): ?Delta + { + return $this->halsteadEffortDelta; + } + + public function getCyclomaticComplexityDelta(): ?Delta + { + return $this->cyclomaticComplexityDelta; + } + public function getTimesChanged(): int { return $this->timesChanged ?? 0; diff --git a/src/Command/Presentation/TableRowBuilder.php b/src/Command/Presentation/TableRowBuilder.php index b0ef3dd..795342a 100644 --- a/src/Command/Presentation/TableRowBuilder.php +++ b/src/Command/Presentation/TableRowBuilder.php @@ -5,6 +5,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Presentation; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Delta; use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticMetrics; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; @@ -45,6 +46,12 @@ public function buildRow(CognitiveMetrics $metrics): array $row = $this->addCoverageValue($metrics, $row); } + // Add deltas for Halstead metrics + $row = $this->addHalsteadDeltas($metrics, $row); + + // Add deltas for Cyclomatic metrics + $row = $this->addCyclomaticDeltas($metrics, $row); + return $row; } @@ -252,4 +259,58 @@ private function extracted(array $fields, CognitiveMetrics $metrics): array return $fields; } + + /** + * @param array $row + * @return array + */ + private function addHalsteadDeltas(CognitiveMetrics $metrics, array $row): array + { + if (!$this->config->showHalsteadComplexity || !isset($row['halsteadVolume'])) { + return $row; + } + + $volumeDelta = $metrics->getHalsteadVolumeDelta(); + if ($volumeDelta !== null && !$volumeDelta->hasNotChanged()) { + $row['halsteadVolume'] .= $this->formatDelta($volumeDelta); + } + + $difficultyDelta = $metrics->getHalsteadDifficultyDelta(); + if ($difficultyDelta !== null && !$difficultyDelta->hasNotChanged()) { + $row['halsteadDifficulty'] .= $this->formatDelta($difficultyDelta); + } + + $effortDelta = $metrics->getHalsteadEffortDelta(); + if ($effortDelta !== null && !$effortDelta->hasNotChanged()) { + $row['halsteadEffort'] .= $this->formatDelta($effortDelta); + } + + return $row; + } + + /** + * @param array $row + * @return array + */ + private function addCyclomaticDeltas(CognitiveMetrics $metrics, array $row): array + { + if (!$this->config->showCyclomaticComplexity || !isset($row['cyclomaticComplexity'])) { + return $row; + } + + $complexityDelta = $metrics->getCyclomaticComplexityDelta(); + if ($complexityDelta !== null && !$complexityDelta->hasNotChanged()) { + $row['cyclomaticComplexity'] .= $this->formatDelta($complexityDelta); + } + + return $row; + } + + private function formatDelta(Delta $delta): string + { + if ($delta->hasIncreased()) { + return PHP_EOL . 'Δ +' . round($delta->getValue(), 3) . ''; + } + return PHP_EOL . 'Δ ' . round($delta->getValue(), 3) . ''; + } } diff --git a/tests/Unit/Command/CognitiveMetricsCommandTest.php b/tests/Unit/Command/CognitiveMetricsCommandTest.php index 8f44dcc..91650f6 100644 --- a/tests/Unit/Command/CognitiveMetricsCommandTest.php +++ b/tests/Unit/Command/CognitiveMetricsCommandTest.php @@ -182,6 +182,7 @@ public function testOutputWithoutOptions(): void $tester->execute([ 'path' => __DIR__ . '/../../../tests/TestCode', + '--config' => __DIR__ . '/minimal-config.yml', ]); $this->assertStringEqualsFile(__DIR__ . '/OutputWithoutOptions.txt', $tester->getDisplay(true)); diff --git a/tests/Unit/Command/minimal-config.yml b/tests/Unit/Command/minimal-config.yml new file mode 100644 index 0000000..cd4f02f --- /dev/null +++ b/tests/Unit/Command/minimal-config.yml @@ -0,0 +1,45 @@ +cognitive: + excludeFilePatterns: [] + excludePatterns: [] + scoreThreshold: 0.5 + showOnlyMethodsExceedingThreshold: false + showHalsteadComplexity: false + showCyclomaticComplexity: false + showDetailedCognitiveMetrics: true + groupByClass: true + metrics: + lineCount: + threshold: 60 + scale: 25.0 + enabled: true + argCount: + threshold: 4 + scale: 1.0 + enabled: true + returnCount: + threshold: 2 + scale: 5.0 + enabled: true + variableCount: + threshold: 4 + scale: 5.0 + enabled: true + propertyCallCount: + threshold: 4 + scale: 15.0 + enabled: true + ifCount: + threshold: 3 + scale: 1.0 + enabled: true + ifNestingLevel: + threshold: 1 + scale: 1.0 + enabled: true + elseCount: + threshold: 1 + scale: 1.0 + enabled: true + cache: + enabled: false + directory: './.phpcca.cache' From 33fba6a65000f93818a789f11915460ce9b71a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 20 Oct 2025 19:53:39 +0200 Subject: [PATCH 8/8] phpcs fixes --- src/Business/Cognitive/Baseline/BaselineSchemaValidator.php | 6 ++++-- src/Business/Cognitive/CognitiveMetrics.php | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php index eec0b5b..b247e6a 100644 --- a/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php +++ b/src/Business/Cognitive/Baseline/BaselineSchemaValidator.php @@ -295,9 +295,11 @@ private function validateMethodData($methodData, string $className, string $meth // Validate optional Halstead fields $halsteadFields = ['halsteadVolume', 'halsteadDifficulty', 'halsteadEffort']; foreach ($halsteadFields as $field) { - if (isset($methodData[$field]) && (!is_numeric($methodData[$field]) || $methodData[$field] < 0)) { - $errors[] = "Field '{$field}' in method '{$className}::{$methodName}' must be a non-negative number"; + if (!isset($methodData[$field]) || (is_numeric($methodData[$field]) && $methodData[$field] >= 0)) { + continue; } + + $errors[] = "Field '{$field}' in method '{$className}::{$methodName}' must be a non-negative number"; } // Validate optional cyclomatic fields diff --git a/src/Business/Cognitive/CognitiveMetrics.php b/src/Business/Cognitive/CognitiveMetrics.php index 04f147c..ce58123 100644 --- a/src/Business/Cognitive/CognitiveMetrics.php +++ b/src/Business/Cognitive/CognitiveMetrics.php @@ -213,9 +213,11 @@ public function calculateDeltas(self $other): void } // Calculate Cyclomatic delta if both metrics have Cyclomatic data - if ($this->cyclomatic !== null && $other->getCyclomatic() !== null) { - $this->cyclomaticComplexityDelta = new Delta($other->getCyclomatic()->complexity, $this->cyclomatic->complexity); + if ($this->cyclomatic === null || $other->getCyclomatic() === null) { + return; } + + $this->cyclomaticComplexityDelta = new Delta($other->getCyclomatic()->complexity, $this->cyclomatic->complexity); } /**