diff --git a/composer.json b/composer.json index 694266e..4e50031 100644 --- a/composer.json +++ b/composer.json @@ -82,6 +82,7 @@ "bin/phpbench run benchmarks --report=aggregate" ], "all": [ + "@csfix", "@cscheck", "@analyze", "@phpmd", diff --git a/src/Application.php b/src/Application.php index 980bb4e..ee1d0d4 100644 --- a/src/Application.php +++ b/src/Application.php @@ -6,6 +6,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChangeCounter\ChangeCounterFactory; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnCalculator; +use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CodeCoverageFactory; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollector; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsSorter; @@ -100,6 +101,9 @@ private function registerServices(): void $this->containerBuilder->register(CognitiveMetricsSorter::class, CognitiveMetricsSorter::class) ->setPublic(true); + $this->containerBuilder->register(CodeCoverageFactory::class, CodeCoverageFactory::class) + ->setPublic(true); + $this->containerBuilder->register(Processor::class, Processor::class) ->setPublic(true); @@ -222,6 +226,7 @@ private function registerCommands(): void new Reference(Baseline::class), new Reference(CognitiveMetricsReportHandler::class), new Reference(CognitiveMetricsSorter::class), + new Reference(CodeCoverageFactory::class), ]) ->setPublic(true); diff --git a/src/Business/CodeCoverage/CodeCoverageFactory.php b/src/Business/CodeCoverage/CodeCoverageFactory.php new file mode 100644 index 0000000..d7da569 --- /dev/null +++ b/src/Business/CodeCoverage/CodeCoverageFactory.php @@ -0,0 +1,22 @@ + new CloverReader($filePath), + 'cobertura' => new CoberturaReader($filePath), + default => throw new CognitiveAnalysisException("Unknown code coverage implementation: {$name}"), + }; + } +} diff --git a/src/Business/Cognitive/CognitiveMetrics.php b/src/Business/Cognitive/CognitiveMetrics.php index 0c35ad7..d3c904f 100644 --- a/src/Business/Cognitive/CognitiveMetrics.php +++ b/src/Business/Cognitive/CognitiveMetrics.php @@ -64,6 +64,7 @@ class CognitiveMetrics implements JsonSerializable private ?HalsteadMetrics $halstead = null; private ?CyclomaticMetrics $cyclomatic = null; + private ?float $coverage = null; /** * @param array $metrics @@ -340,6 +341,16 @@ public function getScore(): float return $this->score; } + public function setCoverage(?float $coverage): void + { + $this->coverage = $coverage; + } + + public function getCoverage(): ?float + { + return $this->coverage; + } + public function getLineCountWeightDelta(): ?Delta { return $this->lineCountWeightDelta; @@ -435,6 +446,7 @@ public function toArray(): array 'ifCountWeightDelta' => $this->ifCountWeightDelta, 'ifNestingLevelWeightDelta' => $this->ifNestingLevelWeightDelta, 'elseCountWeightDelta' => $this->elseCountWeightDelta, + 'coverage' => $this->coverage, ]; } diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index ffbf61c..eda366c 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -12,7 +12,6 @@ use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; use SplFileInfo; -use Symfony\Component\Messenger\Exception\ExceptionInterface; use Symfony\Component\Messenger\MessageBusInterface; use Throwable; @@ -21,11 +20,6 @@ */ class CognitiveMetricsCollector { - /** - * @var array>|null Cached ignored items from the last parsing operation - */ - private ?array $ignoredItems = null; - public function __construct( protected readonly Parser $parser, protected readonly DirectoryScanner $directoryScanner, @@ -40,7 +34,7 @@ public function __construct( * @param string $path * @param CognitiveConfig $config * @return CognitiveMetricsCollection - * @throws CognitiveAnalysisException|ExceptionInterface + * @throws CognitiveAnalysisException */ public function collect(string $path, CognitiveConfig $config): CognitiveMetricsCollection { @@ -53,7 +47,7 @@ public function collect(string $path, CognitiveConfig $config): CognitiveMetrics * @param array $paths Array of paths to process * @param CognitiveConfig $config * @return CognitiveMetricsCollection Merged collection of metrics from all paths - * @throws CognitiveAnalysisException|ExceptionInterface + * @throws CognitiveAnalysisException */ public function collectFromPaths(array $paths, CognitiveConfig $config): CognitiveMetricsCollection { @@ -88,7 +82,6 @@ private function getCodeFromFile(SplFileInfo $file): string * * @param iterable $files * @return CognitiveMetricsCollection - * @throws ExceptionInterface */ private function findMetrics(iterable $files): CognitiveMetricsCollection { @@ -101,9 +94,6 @@ private function findMetrics(iterable $files): CognitiveMetricsCollection $this->getCodeFromFile($file) ); - // Store ignored items from the parser - $this->ignoredItems = $this->parser->getIgnored(); - $fileCount++; // Clear memory periodically to prevent memory leaks @@ -197,6 +187,7 @@ private function isExcluded(string $classAndMethod): bool * @param string $path Path to the directory or file to scan * @param array $exclude List of regx to exclude * @return iterable An iterable of SplFileInfo objects + * @throws CognitiveAnalysisException */ private function findSourceFiles(string $path, array $exclude = []): iterable { @@ -206,36 +197,6 @@ private function findSourceFiles(string $path, array $exclude = []): iterable ); } - /** - * Get all ignored classes and methods from the last parsing operation. - * - * @return array> Array with 'classes' and 'methods' keys - */ - public function getIgnored(): array - { - return $this->ignoredItems ?? ['classes' => [], 'methods' => []]; - } - - /** - * Get ignored classes from the last parsing operation. - * - * @return array Array of ignored class FQCNs - */ - public function getIgnoredClasses(): array - { - return $this->ignoredItems['classes'] ?? []; - } - - /** - * Get ignored methods from the last parsing operation. - * - * @return array Array of ignored method keys (ClassName::methodName) - */ - public function getIgnoredMethods(): array - { - return $this->ignoredItems['methods'] ?? []; - } - /** * Get the project root directory path. * diff --git a/src/Business/MetricsFacade.php b/src/Business/MetricsFacade.php index 933b03a..ac1ca14 100644 --- a/src/Business/MetricsFacade.php +++ b/src/Business/MetricsFacade.php @@ -4,16 +4,15 @@ namespace Phauthentic\CognitiveCodeAnalysis\Business; -use JsonException; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChangeCounter\ChangeCounterFactory; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnCalculator; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\ChurnExporterFactory; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoverageReportReaderInterface; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollector; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter\CognitiveExporterFactory; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\ScoreCalculator; -use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; @@ -81,14 +80,20 @@ public function getCognitiveMetrics(string $path): CognitiveMetricsCollection * Collects and returns cognitive metrics for multiple paths. * * @param array $paths Array of file or directory paths to collect metrics from. + * @param CoverageReportReaderInterface|null $coverageReader Optional coverage reader for coverage data. * @return CognitiveMetricsCollection The collected cognitive metrics from all paths. */ - public function getCognitiveMetricsFromPaths(array $paths): CognitiveMetricsCollection + public function getCognitiveMetricsFromPaths(array $paths, ?CoverageReportReaderInterface $coverageReader = null): CognitiveMetricsCollection { $metricsCollection = $this->cognitiveMetricsCollector->collectFromPaths($paths, $this->configService->getConfig()); foreach ($metricsCollection as $metric) { $this->scoreCalculator->calculate($metric, $this->configService->getConfig()); + + // Add coverage data if reader is provided + if ($coverageReader !== null) { + $this->addCoverageToMetric($metric, $coverageReader); + } } return $metricsCollection; @@ -136,36 +141,6 @@ public function getConfig(): CognitiveConfig return $this->configService->getConfig(); } - /** - * Get all ignored classes and methods from the last metrics collection. - * - * @return array> Array with 'classes' and 'methods' keys - */ - public function getIgnored(): array - { - return $this->cognitiveMetricsCollector->getIgnored(); - } - - /** - * Get ignored classes from the last metrics collection. - * - * @return array Array of ignored class FQCNs - */ - public function getIgnoredClasses(): array - { - return $this->cognitiveMetricsCollector->getIgnoredClasses(); - } - - /** - * Get ignored methods from the last metrics collection. - * - * @return array Array of ignored method keys (ClassName::methodName) - */ - public function getIgnoredMethods(): array - { - return $this->cognitiveMetricsCollector->getIgnoredMethods(); - } - /** * @param array> $classes */ @@ -188,4 +163,48 @@ public function exportMetricsReport( $exporter = $this->getCognitiveExporterFactory()->create($reportType); $exporter->export($metricsCollection, $filename); } + + /** + * Add coverage data to a metric + */ + private function addCoverageToMetric( + CognitiveMetrics $metric, + CoverageReportReaderInterface $coverageReader + ): void { + // Strip leading backslash from class name for coverage lookup + $className = ltrim($metric->getClass(), '\\'); + + // Try to get method-level coverage first + $coverageDetails = $coverageReader->getCoverageDetails($className); + if ($coverageDetails !== null) { + $this->addMethodLevelCoverage($metric, $coverageDetails); + return; + } + + // Fall back to class-level coverage if details not available + $coverage = $coverageReader->getLineCoverage($className); + if ($coverage !== null) { + $metric->setCoverage($coverage); + } + } + + /** + * Add method-level coverage from coverage details + */ + private function addMethodLevelCoverage( + CognitiveMetrics $metric, + CodeCoverage\CoverageDetails $coverageDetails + ): void { + $methods = $coverageDetails->getMethods(); + $methodName = $metric->getMethod(); + + if (isset($methods[$methodName])) { + $methodCoverage = $methods[$methodName]; + $metric->setCoverage($methodCoverage->getLineRate()); + return; + } + + // Fall back to class-level coverage if method not found + $metric->setCoverage($coverageDetails->getLineRate()); + } } diff --git a/src/Command/CognitiveMetricsCommand.php b/src/Command/CognitiveMetricsCommand.php index d1e9671..8ac124b 100644 --- a/src/Command/CognitiveMetricsCommand.php +++ b/src/Command/CognitiveMetricsCommand.php @@ -5,10 +5,13 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command; use Exception; +use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CodeCoverageFactory; +use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoverageReportReaderInterface; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsSorter; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; +use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveMetricsReportHandler; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRendererInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -33,6 +36,8 @@ class CognitiveMetricsCommand extends Command public const OPTION_DEBUG = 'debug'; public const OPTION_SORT_BY = 'sort-by'; public const OPTION_SORT_ORDER = 'sort-order'; + public const OPTION_COVERAGE_COBERTURA = 'coverage-cobertura'; + public const OPTION_COVERAGE_CLOVER = 'coverage-clover'; private const ARGUMENT_PATH = 'path'; public function __construct( @@ -40,7 +45,8 @@ public function __construct( readonly private CognitiveMetricTextRendererInterface $renderer, readonly private Baseline $baselineService, readonly private CognitiveMetricsReportHandler $reportHandler, - readonly private CognitiveMetricsSorter $sorter + readonly private CognitiveMetricsSorter $sorter, + readonly private CodeCoverageFactory $coverageFactory ) { parent::__construct(); } @@ -97,6 +103,16 @@ protected function configure(): void mode: InputArgument::OPTIONAL, description: 'Enables debug output', default: false + ) + ->addOption( + name: self::OPTION_COVERAGE_COBERTURA, + mode: InputArgument::OPTIONAL, + description: 'Path to Cobertura XML coverage file to display coverage data.' + ) + ->addOption( + name: self::OPTION_COVERAGE_CLOVER, + mode: InputArgument::OPTIONAL, + description: 'Path to Clover XML coverage file to display coverage data.' ); } @@ -118,23 +134,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $metricsCollection = $this->metricsFacade->getCognitiveMetricsFromPaths($paths); + $coverageReader = $this->handleCoverageOptions($input, $output); + if ($coverageReader === false) { + return Command::FAILURE; + } - $this->handleBaseLine($input, $metricsCollection); + $metricsCollection = $this->metricsFacade->getCognitiveMetricsFromPaths($paths, $coverageReader); - // Apply sorting if specified - $sortBy = $input->getOption(self::OPTION_SORT_BY); - $sortOrder = $input->getOption(self::OPTION_SORT_ORDER); + $this->handleBaseLine($input, $metricsCollection); - if ($sortBy !== null) { - try { - $metricsCollection = $this->sorter->sort($metricsCollection, $sortBy, $sortOrder); - } catch (\InvalidArgumentException $e) { - $output->writeln('Sorting error: ' . $e->getMessage() . ''); - $output->writeln('Available sort fields: ' . implode(', ', $this->sorter->getSortableFields()) . ''); - return Command::FAILURE; - } + $sortResult = $this->applySorting($input, $output, $metricsCollection); + if ($sortResult['status'] === Command::FAILURE) { + return Command::FAILURE; } + $metricsCollection = $sortResult['collection']; $reportType = $input->getOption(self::OPTION_REPORT_TYPE); $reportFile = $input->getOption(self::OPTION_REPORT_FILE); @@ -178,6 +191,59 @@ private function handleBaseLine(InputInterface $input, CognitiveMetricsCollectio } } + /** + * Handle coverage options and return coverage reader + * + * @return CoverageReportReaderInterface|null|false Returns reader, null if no coverage, or false on error + */ + private function handleCoverageOptions(InputInterface $input, OutputInterface $output): CoverageReportReaderInterface|null|false + { + $coberturaFile = $input->getOption(self::OPTION_COVERAGE_COBERTURA); + $cloverFile = $input->getOption(self::OPTION_COVERAGE_CLOVER); + + // Validate that only one coverage option is specified + if ($coberturaFile !== null && $cloverFile !== null) { + $output->writeln('Only one coverage format can be specified at a time.'); + return false; + } + + $coverageFile = $coberturaFile ?? $cloverFile; + $coverageFormat = $coberturaFile !== null ? 'cobertura' : ($cloverFile !== null ? 'clover' : null); + + if (!$this->coverageFileExists($coverageFile, $output)) { + return false; + } + + return $this->loadCoverageReader($coverageFile, $coverageFormat, $output); + } + + /** + * Apply sorting to metrics collection + * + * @return array{status: int, collection: CognitiveMetricsCollection} + */ + private function applySorting( + InputInterface $input, + OutputInterface $output, + CognitiveMetricsCollection $metricsCollection + ): array { + $sortBy = $input->getOption(self::OPTION_SORT_BY); + $sortOrder = $input->getOption(self::OPTION_SORT_ORDER); + + if ($sortBy === null) { + return ['status' => Command::SUCCESS, 'collection' => $metricsCollection]; + } + + try { + $sorted = $this->sorter->sort($metricsCollection, $sortBy, $sortOrder); + return ['status' => Command::SUCCESS, 'collection' => $sorted]; + } catch (\InvalidArgumentException $e) { + $output->writeln('Sorting error: ' . $e->getMessage() . ''); + $output->writeln('Available sort fields: ' . implode(', ', $this->sorter->getSortableFields()) . ''); + return ['status' => Command::FAILURE, 'collection' => $metricsCollection]; + } + } + /** * Loads configuration and handles errors. * @@ -195,4 +261,85 @@ private function loadConfiguration(string $configFile, OutputInterface $output): return false; } } + + /** + * Load coverage reader from file + * + * @param string|null $coverageFile Path to coverage file or null + * @param string|null $format Coverage format ('cobertura', 'clover') or null for auto-detect + * @param OutputInterface $output Output interface for error messages + * @return CoverageReportReaderInterface|null|false Returns reader instance, null if no file provided, or false on error + */ + private function loadCoverageReader( + ?string $coverageFile, + ?string $format, + OutputInterface $output + ): CoverageReportReaderInterface|null|false { + if ($coverageFile === null) { + return null; + } + + // 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; + } + } + + try { + return $this->coverageFactory->createFromName($format, $coverageFile); + } catch (CognitiveAnalysisException $e) { + $output->writeln(sprintf( + 'Failed to load coverage file: %s', + $e->getMessage() + )); + return false; + } + } + + /** + * 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=.*writeln(sprintf( + 'Coverage file not found: %s', + $coverageFile + )); + + return false; + } } diff --git a/src/Command/Presentation/CognitiveMetricTextRenderer.php b/src/Command/Presentation/CognitiveMetricTextRenderer.php index ab750d5..8aee736 100644 --- a/src/Command/Presentation/CognitiveMetricTextRenderer.php +++ b/src/Command/Presentation/CognitiveMetricTextRenderer.php @@ -6,23 +6,23 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; -use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; +use Phauthentic\CognitiveCodeAnalysis\Business\Traits\CoverageDataDetector; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Output\OutputInterface; -use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\MetricFormatter; -use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\TableRowBuilder; -use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\TableHeaderBuilder; /** * */ class CognitiveMetricTextRenderer implements CognitiveMetricTextRendererInterface { + use CoverageDataDetector; + private MetricFormatter $formatter; private TableRowBuilder $rowBuilder; private TableHeaderBuilder $headerBuilder; + private bool $hasCoverage = false; public function __construct( private readonly ConfigService $configService, @@ -37,19 +37,33 @@ private function metricExceedsThreshold(CognitiveMetrics $metric, CognitiveConfi $metric->getScore() <= $config->scoreThreshold; } + /** + * Check if any metric in the collection has coverage data + */ + private function hasCoverageInCollection(CognitiveMetricsCollection $metricsCollection): bool + { + foreach ($metricsCollection as $metric) { + if ($metric->getCoverage() !== null) { + return true; + } + } + + return false; + } + /** * @param CognitiveMetricsCollection $metricsCollection * @param OutputInterface $output - * @throws CognitiveAnalysisException */ public function render(CognitiveMetricsCollection $metricsCollection, OutputInterface $output): void { $config = $this->configService->getConfig(); + $this->hasCoverage = $this->hasCoverageInCollection($metricsCollection); // Recreate components with current configuration $this->formatter = new MetricFormatter($config); - $this->rowBuilder = new TableRowBuilder($this->formatter, $config); - $this->headerBuilder = new TableHeaderBuilder($config); + $this->rowBuilder = new TableRowBuilder($this->formatter, $config, $this->hasCoverage); + $this->headerBuilder = new TableHeaderBuilder($config, $this->hasCoverage); if ($config->groupByClass) { $this->renderGroupedByClass($metricsCollection, $config, $output); @@ -63,10 +77,12 @@ public function render(CognitiveMetricsCollection $metricsCollection, OutputInte * @param CognitiveMetricsCollection $metricsCollection * @param CognitiveConfig $config * @param OutputInterface $output - * @throws CognitiveAnalysisException */ - private function renderGroupedByClass(CognitiveMetricsCollection $metricsCollection, CognitiveConfig $config, OutputInterface $output): void - { + private function renderGroupedByClass( + CognitiveMetricsCollection $metricsCollection, + CognitiveConfig $config, + OutputInterface $output + ): void { $groupedByClass = $metricsCollection->groupBy('class'); foreach ($groupedByClass as $className => $metrics) { @@ -87,8 +103,10 @@ private function renderGroupedByClass(CognitiveMetricsCollection $metricsCollect * * @return array> */ - private function buildRowsForClass(CognitiveMetricsCollection $metrics, CognitiveConfig $config): array - { + private function buildRowsForClass( + CognitiveMetricsCollection $metrics, + CognitiveConfig $config + ): array { $rows = []; foreach ($metrics as $metric) { if ($this->metricExceedsThreshold($metric, $config)) { @@ -114,10 +132,12 @@ private function getFilenameFromMetrics(CognitiveMetricsCollection $metrics): st * @param CognitiveMetricsCollection $metricsCollection * @param CognitiveConfig $config * @param OutputInterface $output - * @throws CognitiveAnalysisException */ - private function renderAllMethodsInSingleTable(CognitiveMetricsCollection $metricsCollection, CognitiveConfig $config, OutputInterface $output): void - { + private function renderAllMethodsInSingleTable( + CognitiveMetricsCollection $metricsCollection, + CognitiveConfig $config, + OutputInterface $output + ): void { $rows = $this->buildRowsForSingleTable($metricsCollection, $config); $totalMethods = count($rows); @@ -131,8 +151,10 @@ private function renderAllMethodsInSingleTable(CognitiveMetricsCollection $metri * * @return array> */ - private function buildRowsForSingleTable(CognitiveMetricsCollection $metricsCollection, CognitiveConfig $config): array - { + private function buildRowsForSingleTable( + CognitiveMetricsCollection $metricsCollection, + CognitiveConfig $config + ): array { $rows = []; foreach ($metricsCollection as $metric) { if ($this->metricExceedsThreshold($metric, $config)) { @@ -144,38 +166,23 @@ private function buildRowsForSingleTable(CognitiveMetricsCollection $metricsColl } /** - * @param string $className - * @param array $rows - * @param string $filename - * @param OutputInterface $output + * @param array> $rows + * @param array $headers + * @param array $infoLines */ - private function renderTable(string $className, array $rows, string $filename, OutputInterface $output): void - { + private function renderTableCommon( + array $rows, + array $headers, + array $infoLines, + OutputInterface $output + ): void { $table = new Table($output); $table->setStyle('box'); - $table->setHeaders($this->getTableHeaders()); - - $output->writeln("Class: $className"); - $output->writeln("File: $filename"); + $table->setHeaders($headers); - $table->setRows($rows); - $table->render(); - - $output->writeln(""); - } - - /** - * @param array $rows - * @param int $totalMethods - * @param OutputInterface $output - */ - private function renderSingleTable(array $rows, int $totalMethods, OutputInterface $output): void - { - $table = new Table($output); - $table->setStyle('box'); - $table->setHeaders($this->getSingleTableHeaders()); - - $output->writeln("All Methods ($totalMethods total)"); + foreach ($infoLines as $line) { + $output->writeln($line); + } $table->setRows($rows); $table->render(); @@ -184,18 +191,34 @@ private function renderSingleTable(array $rows, int $totalMethods, OutputInterfa } /** - * @return string[] + * @param array> $rows */ - private function getTableHeaders(): array - { - return $this->headerBuilder->getGroupedTableHeaders(); + private function renderTable( + string $className, + array $rows, + string $filename, + OutputInterface $output + ): void { + $headers = $this->headerBuilder->getGroupedTableHeaders(); + $infoLines = [ + "Class: $className", + "File: $filename" + ]; + $this->renderTableCommon($rows, $headers, $infoLines, $output); } /** - * @return string[] + * @param array> $rows */ - private function getSingleTableHeaders(): array - { - return $this->headerBuilder->getSingleTableHeaders(); + private function renderSingleTable( + array $rows, + int $totalMethods, + OutputInterface $output + ): void { + $headers = $this->headerBuilder->getSingleTableHeaders(); + $infoLines = [ + "All Methods ($totalMethods total)" + ]; + $this->renderTableCommon($rows, $headers, $infoLines, $output); } } diff --git a/src/Command/Presentation/TableHeaderBuilder.php b/src/Command/Presentation/TableHeaderBuilder.php index 00f01a1..1fd79ae 100644 --- a/src/Command/Presentation/TableHeaderBuilder.php +++ b/src/Command/Presentation/TableHeaderBuilder.php @@ -11,8 +11,12 @@ */ class TableHeaderBuilder { + /** + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") + */ public function __construct( - private readonly CognitiveConfig $config + private readonly CognitiveConfig $config, + private readonly bool $hasCoverage = false, ) { } @@ -27,23 +31,28 @@ public function getGroupedTableHeaders(): array "Method Name", ]; - if ($this->config->showDetailedCognitiveMetrics) { - $fields = array_merge($fields, [ - "Lines", - "Arguments", - "Returns", - "Variables", - "Property\nAccesses", - "If", - "If Nesting\nLevel", - "Else", - ]); - } + $fields = $this->addCognitiveMetricDetails($fields); $fields[] = "Cognitive\nComplexity"; $fields = $this->addHalsteadHeaders($fields); $fields = $this->addCyclomaticHeaders($fields); + $fields = $this->addCoverageHeader($fields); + + return $fields; + } + + /** + * @param array $fields + * @return array + */ + private function addCoverageHeader(array $fields): array + { + if (!$this->hasCoverage) { + return $fields; + } + + $fields[] = "Line\nCoverage"; return $fields; } @@ -60,24 +69,17 @@ public function getSingleTableHeaders(): array "Method Name", ]; - if ($this->config->showDetailedCognitiveMetrics) { - $fields = array_merge($fields, [ - "Lines", - "Arguments", - "Returns", - "Variables", - "Property\nAccesses", - "If", - "If Nesting\nLevel", - "Else", - ]); - } + $fields = $this->addCognitiveMetricDetails($fields); $fields[] = "Cognitive\nComplexity"; $fields = $this->addHalsteadHeaders($fields); $fields = $this->addCyclomaticHeaders($fields); + if ($this->hasCoverage) { + $fields[] = "Coverage"; + } + return $fields; } @@ -112,4 +114,25 @@ private function addCyclomaticHeaders(array $fields): array return $fields; } + + /** + * @param array $fields + * @return array + */ + private function addCognitiveMetricDetails(array $fields): array + { + if ($this->config->showDetailedCognitiveMetrics) { + $fields = array_merge($fields, [ + "Lines", + "Arguments", + "Returns", + "Variables", + "Property\nAccesses", + "If", + "If Nesting\nLevel", + "Else", + ]); + } + return $fields; + } } diff --git a/src/Command/Presentation/TableRowBuilder.php b/src/Command/Presentation/TableRowBuilder.php index 80faf88..b0ef3dd 100644 --- a/src/Command/Presentation/TableRowBuilder.php +++ b/src/Command/Presentation/TableRowBuilder.php @@ -15,15 +15,20 @@ */ class TableRowBuilder { + /** + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") + */ public function __construct( private readonly MetricFormatter $formatter, - private readonly CognitiveConfig $config + private readonly CognitiveConfig $config, + private readonly bool $hasCoverage = false, ) { } /** * Build a table row from metrics without class information * + * @param CognitiveMetrics $metrics * @return array */ public function buildRow(CognitiveMetrics $metrics): array @@ -36,12 +41,17 @@ public function buildRow(CognitiveMetrics $metrics): array $row = $this->addDelta($key, $metrics, $row); } + if ($this->hasCoverage) { + $row = $this->addCoverageValue($metrics, $row); + } + return $row; } /** * Build a table row from metrics with class information * + * @param CognitiveMetrics $metrics * @return array */ public function buildRowWithClassInfo(CognitiveMetrics $metrics): array @@ -54,6 +64,10 @@ public function buildRowWithClassInfo(CognitiveMetrics $metrics): array $row = $this->addDelta($key, $metrics, $row); } + if ($this->hasCoverage) { + $row = $this->addCoverageValue($metrics, $row); + } + return $row; } @@ -68,25 +82,7 @@ private function metricsToArray(CognitiveMetrics $metrics): array 'methodName' => $metrics->getMethod(), ]; - if ($this->config->showDetailedCognitiveMetrics) { - $fields = array_merge($fields, [ - 'lineCount' => $metrics->getLineCount(), - 'argCount' => $metrics->getArgCount(), - 'returnCount' => $metrics->getReturnCount(), - 'variableCount' => $metrics->getVariableCount(), - 'propertyCallCount' => $metrics->getPropertyCallCount(), - 'ifCount' => $metrics->getIfCount(), - 'ifNestingLevel' => $metrics->getIfNestingLevel(), - 'elseCount' => $metrics->getElseCount(), - ]); - } - - $fields['score'] = $this->formatter->formatScore($metrics->getScore()); - - $fields = $this->addHalsteadFields($fields, $metrics->getHalstead()); - $fields = $this->addCyclomaticFields($fields, $metrics->getCyclomatic()); - - return $fields; + return $this->extracted($fields, $metrics); } /** @@ -101,25 +97,7 @@ private function metricsToArrayWithClassInfo(CognitiveMetrics $metrics): array 'methodName' => $metrics->getMethod(), ]; - if ($this->config->showDetailedCognitiveMetrics) { - $fields = array_merge($fields, [ - 'lineCount' => $metrics->getLineCount(), - 'argCount' => $metrics->getArgCount(), - 'returnCount' => $metrics->getReturnCount(), - 'variableCount' => $metrics->getVariableCount(), - 'propertyCallCount' => $metrics->getPropertyCallCount(), - 'ifCount' => $metrics->getIfCount(), - 'ifNestingLevel' => $metrics->getIfNestingLevel(), - 'elseCount' => $metrics->getElseCount(), - ]); - } - - $fields['score'] = $this->formatter->formatScore($metrics->getScore()); - - $fields = $this->addHalsteadFields($fields, $metrics->getHalstead()); - $fields = $this->addCyclomaticFields($fields, $metrics->getCyclomatic()); - - return $fields; + return $this->extracted($fields, $metrics); } /** @@ -165,7 +143,7 @@ private function addWeightedValue(string $key, CognitiveMetrics $metrics, array $getMethod = 'get' . $key; $getMethodWeight = 'get' . $key . 'Weight'; - $weight = $metrics->{$getMethodWeight}(); + $weight = (float)$metrics->{$getMethodWeight}(); $row[$key] = $metrics->{$getMethod}() . ' (' . round($weight, 3) . ')'; return $row; @@ -222,7 +200,7 @@ private function getKeys(): array } /** - * Assert that a delta method exists + * @throws CognitiveAnalysisException */ private function assertDeltaMethodExists(CognitiveMetrics $metrics, string $getDeltaMethod): void { @@ -230,4 +208,48 @@ private function assertDeltaMethodExists(CognitiveMetrics $metrics, string $getD throw new CognitiveAnalysisException('Method not found: ' . $getDeltaMethod); } } + + /** + * @param array $row + * @return array + */ + private function addCoverageValue(CognitiveMetrics $metrics, array $row): array + { + $coverage = $metrics->getCoverage(); + if ($coverage === null) { + $row['coverage'] = 'N/A'; + return $row; + } + + $row['coverage'] = sprintf('%.2f%%', $coverage * 100); + return $row; + } + + /** + * @param array $fields + * @param CognitiveMetrics $metrics + * @return array + */ + private function extracted(array $fields, CognitiveMetrics $metrics): array + { + if ($this->config->showDetailedCognitiveMetrics) { + $fields = array_merge($fields, [ + 'lineCount' => $metrics->getLineCount(), + 'argCount' => $metrics->getArgCount(), + 'returnCount' => $metrics->getReturnCount(), + 'variableCount' => $metrics->getVariableCount(), + 'propertyCallCount' => $metrics->getPropertyCallCount(), + 'ifCount' => $metrics->getIfCount(), + 'ifNestingLevel' => $metrics->getIfNestingLevel(), + 'elseCount' => $metrics->getElseCount(), + ]); + } + + $fields['score'] = $this->formatter->formatScore($metrics->getScore()); + + $fields = $this->addHalsteadFields($fields, $metrics->getHalstead()); + $fields = $this->addCyclomaticFields($fields, $metrics->getCyclomatic()); + + return $fields; + } } diff --git a/tests/Fixtures/Coverage/coverage.xml b/tests/Fixtures/Coverage/coverage.xml index 3524df0..fda4c47 100644 --- a/tests/Fixtures/Coverage/coverage.xml +++ b/tests/Fixtures/Coverage/coverage.xml @@ -1,4 +1,4 @@ -Moand update + diff --git a/tests/Fixtures/Coverage/testcode-clover.xml b/tests/Fixtures/Coverage/testcode-clover.xml new file mode 100644 index 0000000..de5067e --- /dev/null +++ b/tests/Fixtures/Coverage/testcode-clover.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Fixtures/Coverage/testcode-cobertura.xml b/tests/Fixtures/Coverage/testcode-cobertura.xml new file mode 100644 index 0000000..64c9d14 --- /dev/null +++ b/tests/Fixtures/Coverage/testcode-cobertura.xml @@ -0,0 +1,112 @@ + + + + + /home/florian/projects/cognitive-code-analysis/tests/TestCode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Unit/Business/Cognitive/CognitiveMetricsTest.php b/tests/Unit/Business/Cognitive/CognitiveMetricsTest.php index 870bf60..819cfa7 100644 --- a/tests/Unit/Business/Cognitive/CognitiveMetricsTest.php +++ b/tests/Unit/Business/Cognitive/CognitiveMetricsTest.php @@ -122,6 +122,7 @@ public function testToArray(): void 'ifCountWeightDelta' => null, 'ifNestingLevelWeightDelta' => null, 'elseCountWeightDelta' => null, + 'coverage' => null, ]; $this->assertSame($expectedArray, $metrics->toArray()); @@ -161,6 +162,7 @@ public function testJsonSerialize(): void 'ifCountWeightDelta' => null, 'ifNestingLevelWeightDelta' => null, 'elseCountWeightDelta' => null, + 'coverage' => null, ]; $this->assertSame($expectedArray, $metrics->jsonSerialize()); diff --git a/tests/Unit/Command/CognitiveMetricsCommandCoverageTest.php b/tests/Unit/Command/CognitiveMetricsCommandCoverageTest.php new file mode 100644 index 0000000..a7f9448 --- /dev/null +++ b/tests/Unit/Command/CognitiveMetricsCommandCoverageTest.php @@ -0,0 +1,202 @@ +getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../TestCode/Paginator.php', + $option => __DIR__ . '/../../Fixtures/Coverage/' . $file, + ]); + + $this->assertEquals( + Command::SUCCESS, + $tester->getStatusCode(), + "Command should succeed with {$format} coverage" + ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Coverage', $output, 'Output should contain Coverage column'); + $this->assertStringContainsString('%', $output, 'Output should contain percentage values'); + } + + #[Test] + public function testAnalyseWithBothCoverageFormatsReturnsError(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../TestCode/Paginator.php', + '--coverage-clover' => __DIR__ . '/../../Fixtures/Coverage/testcode-clover.xml', + '--coverage-cobertura' => __DIR__ . '/../../Fixtures/Coverage/testcode-cobertura.xml', + ]); + + $this->assertEquals( + Command::FAILURE, + $tester->getStatusCode(), + 'Command should fail when both coverage formats are specified' + ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Only one coverage format can be specified at a time', $output); + } + + #[Test] + public function testAnalyseWithNonExistentCoverageFile(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../TestCode/Paginator.php', + '--coverage-clover' => __DIR__ . '/../../Fixtures/Coverage/does-not-exist.xml', + ]); + + $this->assertEquals( + Command::FAILURE, + $tester->getStatusCode(), + 'Command should fail with non-existent coverage file' + ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Coverage file not found', $output); + } + + #[Test] + public function testAnalyseWithoutCoverageDoesNotShowCoverageColumn(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../TestCode/Paginator.php', + ]); + + $this->assertEquals(Command::SUCCESS, $tester->getStatusCode()); + $output = $tester->getDisplay(); + // Check that the Coverage column header is not in the output + $this->assertStringNotContainsString( + 'Line\nCoverage', + $output, + 'Output should not contain Coverage column when no coverage file is provided' + ); + } + + #[Test] + #[DataProvider('methodLevelCoverageProvider')] + public function testAnalyseShowsMethodLevelCoverage(string $format, string $file, string $zeroMethod, string $fullMethod): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../TestCode/Paginator.php', + "--coverage-{$format}" => __DIR__ . '/../../Fixtures/Coverage/' . $file, + ]); + + $this->assertEquals(Command::SUCCESS, $tester->getStatusCode()); + $output = $tester->getDisplay(); + + // The Paginator class has methods with different coverage levels + $this->assertMatchesRegularExpression( + "/{$zeroMethod}.*0\.00%/s", + $output, + "Should show 0.00% coverage for {$zeroMethod} method" + ); + + $this->assertMatchesRegularExpression( + "/{$fullMethod}.*100\.00%/s", + $output, + "Should show 100.00% coverage for {$fullMethod} method" + ); + } + + #[Test] + public function testAnalyseWithCoverageAndMultiplePaths(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../TestCode/Paginator.php,' . __DIR__ . '/../../TestCode/FileWithTwoClasses.php', + '--coverage-clover' => __DIR__ . '/../../Fixtures/Coverage/testcode-clover.xml', + ]); + + $this->assertEquals( + Command::SUCCESS, + $tester->getStatusCode(), + 'Command should succeed with coverage and multiple paths' + ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Coverage', $output, 'Output should contain Coverage column'); + $this->assertStringContainsString('Paginator', $output, 'Output should contain Paginator class'); + } + + /** + * @return array + */ + public static function coverageFormatProvider(): array + { + return [ + 'Clover format' => [ + '--coverage-clover', + 'testcode-clover.xml', + 'Clover', + ], + 'Cobertura format' => [ + '--coverage-cobertura', + 'testcode-cobertura.xml', + 'Cobertura', + ], + ]; + } + + /** + * @return array + */ + public static function methodLevelCoverageProvider(): array + { + return [ + 'Clover format shows method coverage' => [ + 'clover', + 'testcode-clover.xml', + 'count', + 'getQuery', + ], + 'Cobertura format shows method coverage' => [ + 'cobertura', + 'testcode-cobertura.xml', + 'count', + 'getQuery', + ], + ]; + } +}