diff --git a/config.yml b/config.yml index 5043680..09c2097 100644 --- a/config.yml +++ b/config.yml @@ -40,3 +40,12 @@ cognitive: threshold: 1 scale: 1.0 enabled: true + # Example of custom reporters: + # customReporters: + # cognitive: + # pdf: + # class: 'My\Custom\PdfReporter' + # file: '/path/to/PdfReporter.php' + # churn: + # class: 'My\Custom\ChurnReporter' + # file: '/path/to/ChurnReporter.php' diff --git a/docs/Creating-Custom-Reporters.md b/docs/Creating-Custom-Reporters.md new file mode 100644 index 0000000..6383d7b --- /dev/null +++ b/docs/Creating-Custom-Reporters.md @@ -0,0 +1,286 @@ +# Creating Custom Reporters + +This guide explains how to create custom reporters for the Cognitive Code Checker to output metrics in your preferred format. + +## Overview + +The Cognitive Code Checker supports two types of reports: + +- **Cognitive reporter**: Export cognitive complexity metrics +- **Churn reporter**: Export code churn metrics + +Both types follow similar patterns but have different interfaces and data structures. + +## Reporter Types + +### Cognitive reporter + +Cognitive reporter handle cognitive complexity metrics data and implement the `ReportGeneratorInterface` from the `Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report` namespace. + +**Interface:** + +```php +interface ReportGeneratorInterface +{ + public function export(CognitiveMetricsCollection $metrics, string $filename): void; +} +``` + +**Data Structure:** `CognitiveMetricsCollection` contains individual `CognitiveMetrics` objects with methods like: + +- `getClass()` - Class name +- `getMethod()` - Method name +- `getLineCount()` - Number of lines +- `getScore()` - Combined cognitive complexity score +- `getLineCountWeight()`, `getArgCountWeight()`, etc. - Individual metric weights +- `getLineCountWeightDelta()`, etc. - Delta values for comparison + +### Churn reporter + +Churn reporter handle code churn metrics data and implement the `ReportGeneratorInterface` from the `Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report` namespace. + +**Interface:** + +```php +interface ReportGeneratorInterface +{ + /** + * @param array> $classes + */ + public function export(array $classes, string $filename): void; +} +``` + +**Data Structure:** Array with class names as keys and arrays containing: +- `file` - File path +- `score` - Churn score +- `churn` - Churn value +- `timesChanged` - Number of times changed +- `coverage` - Test coverage (optional) +- `riskLevel` - Risk level (optional) + +## Configuration + +Add your custom reporter to the `config.yml` file under the `customReporters` section: + +```yaml +cognitive: + # ... other cognitive settings ... + customReporters: + cognitive: + pdf: # Custom reporter name + class: 'My\Custom\PdfReporter' + file: '/path/to/PdfReporter.php' + churn: + churn: # Custom reporter name + class: 'My\Custom\ChurnReporter' + file: '/path/to/ChurnReporter.php' +``` + +### Configuration Parameters + +- **`class`** (required): Fully qualified class name of your reporter +- **`file`** (optional): Path to the PHP file containing your reporter class. Set to `null` if using autoloading + +## Constructor Patterns + +The system automatically detects whether your reporter needs the `CognitiveConfig` object: + +### Reporter with Config Access + +```php +class PdfReporter implements ReportGeneratorInterface +{ + private CognitiveConfig $config; + + public function __construct(CognitiveConfig $config) + { + $this->config = $config; + } + // ... rest of implementation +} +``` + +### Reporter without Config + +```php +class SimpleReporter implements ReportGeneratorInterface +{ + public function __construct() + { + // No config needed + } + // ... rest of implementation +} +``` + +The system will automatically try to pass the config to your constructor. If your constructor doesn't accept it, the system will fall back to calling the constructor without arguments. + +Here's a complete example of a custom PDF reporter for cognitive metrics: + +```php +config = $config; + } + + public function export(CognitiveMetricsCollection $metrics, string $filename): void + { + // Ensure directory exists + $directory = dirname($filename); + if (!is_dir($directory)) { + throw new CognitiveAnalysisException("Directory {$directory} does not exist"); + } + + // Create PDF content + $pdfContent = $this->generatePdfContent($metrics); + + // Write to file + if (file_put_contents($filename, $pdfContent) === false) { + throw new CognitiveAnalysisException("Could not write to file: {$filename}"); + } + } + + private function generatePdfContent(CognitiveMetricsCollection $metrics): string + { + $content = "%PDF-1.4\n"; + $content .= "1 0 obj\n"; + $content .= "<< /Type /Catalog /Pages 2 0 R >>\n"; + $content .= "endobj\n"; + + // Add your PDF generation logic here + $groupedByClass = $metrics->groupBy('class'); + + foreach ($groupedByClass as $class => $methods) { + $content .= "% Class: {$class}\n"; + foreach ($methods as $metric) { + $content .= "Method: {$metric->getMethod()}, Score: {$metric->getScore()}\n"; + } + } + + return $content; + } +} +``` + +## Creating a Custom Churn Reporter + +Here's an example of a custom churn reporter for churn metrics: + +```php +generateChurnContent($classes); + + // Write to file + if (file_put_contents($filename, $churnContent) === false) { + throw new CognitiveAnalysisException("Could not write to file: {$filename}"); + } + } + + private function generateChurnContent(array $classes): string + { + $content = "Churn Report\n"; + $content .= "============\n\n"; + + foreach ($classes as $className => $data) { + $content .= "Class: {$className}\n"; + $content .= "File: {$data['file']}\n"; + $content .= "Score: {$data['score']}\n"; + $content .= "Churn: {$data['churn']}\n"; + $content .= "Times Changed: {$data['timesChanged']}\n"; + $content .= "---\n"; + } + + return $content; + } +} +``` + +## Using Your Custom Reporter + +Once configured, you can use your custom reporter by specifying its name when generating reports: + +```bash +# For cognitive metrics +php bin/cognitive-report --format=pdf --output=report.pdf + +# For churn metrics +php bin/churn-report --format=churn --output=churn.txt +``` + +## Best Practices + +1. **Error Handling**: Always throw `CognitiveAnalysisException` for errors +2. **File Validation**: Check that directories exist and files are writable +3. **Data Access**: Use the provided methods to access metric data +4. **Configuration**: Use `CognitiveConfig` if you need access to settings +5. **Testing**: Test your reporter with real data to ensure proper formatting + +## Built-in reporter Reference + +For inspiration, examine the built-in reporter: + +**Cognitive reporter:** +- +- `JsonReport` - JSON format +- `CsvReport` - CSV format +- `HtmlReport` - HTML with Bootstrap styling +- `MarkdownReport` - Markdown tables + +**Churn reporter:** + +- `JsonReport` - JSON format +- `CsvReport` - CSV format +- `HtmlReport` - HTML with Bootstrap styling +- `MarkdownReport` - Markdown tables +- `SvgTreemapReport` - SVG treemap visualization + +## Troubleshooting + +**Common Issues:** + +1. **Class not found**: Ensure the `class` parameter uses the full namespace +2. **File not found**: Check the `file` path is correct and accessible +3. **Interface not implemented**: Ensure your class implements the correct `ReportGeneratorInterface` +4. **Constructor issues**: Your reporter can optionally accept `CognitiveConfig` in its constructor - the system will automatically detect this + +**Debug Tips:** + +- Check the configuration syntax in `config.yml` +- Verify file paths are absolute or relative to the project root +- Test with simple reporter first before complex implementations +- Use the built-in reporter as templates for your custom ones diff --git a/phpmd.xml b/phpmd.xml index 5497422..e99933c 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -11,7 +11,9 @@ - + + + diff --git a/src/Application.php b/src/Application.php index 1979830..63a5e97 100644 --- a/src/Application.php +++ b/src/Application.php @@ -6,6 +6,8 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChangeCounter\ChangeCounterFactory; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnCalculator; +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\CognitiveMetricsCollector; @@ -14,15 +16,23 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\ParserFailed; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\SourceFilesFound; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Parser; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\CognitiveReportFactory; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\CognitiveReportFactoryInterface; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\ScoreCalculator; -use Phauthentic\CognitiveCodeAnalysis\Business\DirectoryScanner; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; +use Phauthentic\CognitiveCodeAnalysis\Business\Utility\DirectoryScanner; use Phauthentic\CognitiveCodeAnalysis\Command\ChurnCommand; +use Phauthentic\CognitiveCodeAnalysis\Command\ChurnSpecifications\ChurnValidationSpecificationFactory; use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsCommand; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsValidationSpecificationFactory; 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\Presentation\ChurnTextRenderer; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRenderer; @@ -59,6 +69,15 @@ public function __construct() } private function registerServices(): void + { + $this->registerCoreServices(); + $this->registerReportFactories(); + $this->registerPresentationServices(); + $this->registerUtilityServices(); + $this->registerCommandHandlers(); + } + + private function registerCoreServices(): void { $outputClass = getenv('APP_ENV') === 'test' ? NullOutput::class : ConsoleOutput::class; @@ -80,27 +99,54 @@ private function registerServices(): void $this->containerBuilder->register(ConfigService::class, ConfigService::class) ->setPublic(true); - $this->containerBuilder->register(ChurnTextRenderer::class, ChurnTextRenderer::class) - ->setArguments([ - new Reference(OutputInterface::class) - ]) + $this->containerBuilder->register(Baseline::class, Baseline::class) ->setPublic(true); - $this->containerBuilder->register(CognitiveMetricTextRendererInterface::class, CognitiveMetricTextRenderer::class) + $this->containerBuilder->register(CognitiveMetricsSorter::class, CognitiveMetricsSorter::class) + ->setPublic(true); + + $this->containerBuilder->register(CodeCoverageFactory::class, CodeCoverageFactory::class) + ->setPublic(true); + + $this->containerBuilder->register(CognitiveMetricsValidationSpecificationFactory::class, CognitiveMetricsValidationSpecificationFactory::class) + ->setPublic(true); + + $this->containerBuilder->register(ChurnValidationSpecificationFactory::class, ChurnValidationSpecificationFactory::class) + ->setPublic(true); + } + + private function registerReportFactories(): void + { + $this->containerBuilder->register(ChurnReportFactoryInterface::class, ChurnReportFactory::class) ->setArguments([ - new Reference(ConfigService::class) + new Reference(ConfigService::class), ]) ->setPublic(true); - $this->containerBuilder->register(Baseline::class, Baseline::class) + $this->containerBuilder->register(CognitiveReportFactoryInterface::class, CognitiveReportFactory::class) + ->setArguments([ + new Reference(ConfigService::class), + ]) ->setPublic(true); + } - $this->containerBuilder->register(CognitiveMetricsSorter::class, CognitiveMetricsSorter::class) + private function registerPresentationServices(): void + { + $this->containerBuilder->register(ChurnTextRenderer::class, ChurnTextRenderer::class) + ->setArguments([ + new Reference(OutputInterface::class) + ]) ->setPublic(true); - $this->containerBuilder->register(CodeCoverageFactory::class, CodeCoverageFactory::class) + $this->containerBuilder->register(CognitiveMetricTextRendererInterface::class, CognitiveMetricTextRenderer::class) + ->setArguments([ + new Reference(ConfigService::class) + ]) ->setPublic(true); + } + private function registerUtilityServices(): void + { $this->containerBuilder->register(Processor::class, Processor::class) ->setPublic(true); @@ -132,11 +178,15 @@ private function registerServices(): void new Reference(NodeTraverserInterface::class), ]) ->setPublic(true); + } + private function registerCommandHandlers(): void + { $this->containerBuilder->register(ChurnReportHandler::class, ChurnReportHandler::class) ->setArguments([ new Reference(MetricsFacade::class), new Reference(OutputInterface::class), + new Reference(ChurnReportFactoryInterface::class), ]) ->setPublic(true); @@ -144,6 +194,32 @@ private function registerServices(): void ->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); } @@ -210,6 +286,8 @@ private function registerMetricsFacade(): void new Reference(ConfigService::class), new Reference(ChurnCalculator::class), new Reference(ChangeCounterFactory::class), + new Reference(ChurnReportFactoryInterface::class), + new Reference(CognitiveReportFactoryInterface::class), ]) ->setPublic(true); } @@ -220,10 +298,12 @@ private function registerCommands(): void ->setArguments([ new Reference(MetricsFacade::class), new Reference(CognitiveMetricTextRendererInterface::class), - new Reference(Baseline::class), new Reference(CognitiveMetricsReportHandler::class), - new Reference(CognitiveMetricsSorter::class), - new Reference(CodeCoverageFactory::class), + new Reference(ConfigurationLoadHandler::class), + new Reference(CoverageLoadHandler::class), + new Reference(BaselineHandler::class), + new Reference(SortingHandler::class), + new Reference(CognitiveMetricsValidationSpecificationFactory::class), ]) ->setPublic(true); @@ -232,6 +312,7 @@ private function registerCommands(): void new Reference(MetricsFacade::class), new Reference(ChurnTextRenderer::class), new Reference(ChurnReportHandler::class), + new Reference(ChurnValidationSpecificationFactory::class), ]) ->setPublic(true); } diff --git a/src/Business/Churn/ChurnCalculator.php b/src/Business/Churn/ChurnCalculator.php index 52aa38b..f022c3e 100644 --- a/src/Business/Churn/ChurnCalculator.php +++ b/src/Business/Churn/ChurnCalculator.php @@ -14,42 +14,33 @@ class ChurnCalculator * * @param CognitiveMetricsCollection $metricsCollection * @param CoverageReportReaderInterface|null $coverageReader - * @return array> + * @return ChurnMetricsCollection */ public function calculate( CognitiveMetricsCollection $metricsCollection, ?CoverageReportReaderInterface $coverageReader = null - ): array { - $classes = []; - $classes = $this->groupByClasses($metricsCollection, $classes); - $classes = $this->calculateChurn($classes, $coverageReader); + ): ChurnMetricsCollection { + $collection = $this->groupByClasses($metricsCollection); + $collection = $this->calculateChurn($collection, $coverageReader); - return $this->sortClassesByChurnDescending($classes); + return $collection->sortByChurnDescending(); } - /** - * @param array> $classes - * @return array> - */ - public function sortClassesByChurnDescending(array $classes): array + public function sortClassesByChurnDescending(ChurnMetricsCollection $collection): ChurnMetricsCollection { - uasort($classes, function ($classA, $classB) { - return $classB['churn'] <=> $classA['churn']; - }); - - return $classes; + return $collection->sortByChurnDescending(); } - /** - * @param array> $classes - * @param CoverageReportReaderInterface|null $coverageReader - * @return array> - */ - public function calculateChurn(array $classes, ?CoverageReportReaderInterface $coverageReader = null): array - { - foreach ($classes as $className => $data) { + public function calculateChurn( + ChurnMetricsCollection $collection, + ?CoverageReportReaderInterface $coverageReader = null + ): ChurnMetricsCollection { + $newCollection = new ChurnMetricsCollection(); + + foreach ($collection as $metric) { // Calculate standard churn - $classes[$className]['churn'] = $data['timesChanged'] * $data['score']; + $churn = $metric->getTimesChanged() * $metric->getScore(); + $metric->setChurn($churn); // Add coverage information if available $coverage = null; @@ -57,43 +48,56 @@ public function calculateChurn(array $classes, ?CoverageReportReaderInterface $c $riskLevel = null; if ($coverageReader !== null) { - $coverage = $this->getCoverageForClass($className, $coverageReader); - $riskChurn = $data['timesChanged'] * $data['score'] * (1 - $coverage); - $riskLevel = $this->calculateRiskLevel($classes[$className]['churn'], $coverage); + $coverage = $this->getCoverageForClass($metric->getClassName(), $coverageReader); + $riskChurn = $metric->getTimesChanged() * $metric->getScore() * (1 - $coverage); + $riskLevel = $this->calculateRiskLevel($metric->getChurn(), $coverage); } - $classes[$className]['coverage'] = $coverage; - $classes[$className]['riskChurn'] = $riskChurn; - $classes[$className]['riskLevel'] = $riskLevel; + $metric->setCoverage($coverage); + $metric->setRiskChurn($riskChurn); + $metric->setRiskLevel($riskLevel); + + $newCollection->add($metric); } - return $classes; + return $newCollection; } - /** - * @param CognitiveMetricsCollection $metricsCollection - * @param array> $classes - * @return array> - */ - public function groupByClasses(CognitiveMetricsCollection $metricsCollection, array $classes): array + public function groupByClasses(CognitiveMetricsCollection $metricsCollection): ChurnMetricsCollection { + $collection = new ChurnMetricsCollection(); + $classData = []; + foreach ($metricsCollection as $metric) { if (empty($metric->getClass())) { continue; } - if (!isset($classes[$metric->getClass()])) { - $classes[$metric->getClass()] = [ + $className = $metric->getClass(); + if (!isset($classData[$className])) { + $classData[$className] = [ 'timesChanged' => 0, 'score' => 0, - 'file' => $metric->getFilename(), + 'file' => $metric->getFileName(), ]; } - $classes[$metric->getClass()]['timesChanged'] = $metric->getTimesChanged(); - $classes[$metric->getClass()]['score'] += $metric->getScore(); + $classData[$className]['timesChanged'] = $metric->getTimesChanged(); + $classData[$className]['score'] += $metric->getScore(); + } + + foreach ($classData as $className => $data) { + $churnMetric = new ChurnMetrics( + className: $className, + file: $data['file'], + score: $data['score'], + timesChanged: $data['timesChanged'], + churn: 0.0 // Will be calculated later + ); + $collection->add($churnMetric); } - return $classes; + + return $collection; } /** diff --git a/src/Business/Churn/ChurnMetrics.php b/src/Business/Churn/ChurnMetrics.php new file mode 100644 index 0000000..032de84 --- /dev/null +++ b/src/Business/Churn/ChurnMetrics.php @@ -0,0 +1,184 @@ +className = $className; + $this->file = $file; + $this->score = $score; + $this->timesChanged = $timesChanged; + $this->churn = $churn; + $this->coverage = $coverage; + $this->riskChurn = $riskChurn; + $this->riskLevel = $riskLevel; + } + + /** + * Create ChurnMetrics from array data (for backward compatibility). + * + * @param string $className + * @param array $data + * @return self + */ + public static function fromArray(string $className, array $data): self + { + return new self( + className: $className, + file: $data['file'] ?? '', + score: (float)($data['score'] ?? 0), + timesChanged: (int)($data['timesChanged'] ?? 0), + churn: (float)($data['churn'] ?? 0), + coverage: isset($data['coverage']) ? (float)$data['coverage'] : null, + riskChurn: isset($data['riskChurn']) ? (float)$data['riskChurn'] : null, + riskLevel: $data['riskLevel'] ?? null + ); + } + + /** + * Convert to array format (for backward compatibility). + * + * @return array + */ + public function toArray(): array + { + return [ + 'file' => $this->file, + 'score' => $this->score, + 'timesChanged' => $this->timesChanged, + 'churn' => $this->churn, + 'coverage' => $this->coverage, + 'riskChurn' => $this->riskChurn, + 'riskLevel' => $this->riskLevel, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'className' => $this->className, + 'file' => $this->file, + 'score' => $this->score, + 'timesChanged' => $this->timesChanged, + 'churn' => $this->churn, + 'coverage' => $this->coverage, + 'riskChurn' => $this->riskChurn, + 'riskLevel' => $this->riskLevel, + ]; + } + + public function getClassName(): string + { + return $this->className; + } + + public function getFile(): string + { + return $this->file; + } + + public function getScore(): float + { + return $this->score; + } + + public function setScore(float $score): void + { + $this->score = $score; + } + + public function getTimesChanged(): int + { + return $this->timesChanged; + } + + public function setTimesChanged(int $timesChanged): void + { + $this->timesChanged = $timesChanged; + } + + public function getChurn(): float + { + return $this->churn; + } + + public function setChurn(float $churn): void + { + $this->churn = $churn; + } + + public function getCoverage(): ?float + { + return $this->coverage; + } + + public function setCoverage(?float $coverage): void + { + $this->coverage = $coverage; + } + + public function getRiskChurn(): ?float + { + return $this->riskChurn; + } + + public function setRiskChurn(?float $riskChurn): void + { + $this->riskChurn = $riskChurn; + } + + public function getRiskLevel(): ?string + { + return $this->riskLevel; + } + + public function setRiskLevel(?string $riskLevel): void + { + $this->riskLevel = $riskLevel; + } + + /** + * Check if this metric has coverage data. + */ + public function hasCoverageData(): bool + { + return $this->coverage !== null; + } + + /** + * Check if this metric has risk data. + */ + public function hasRiskData(): bool + { + return $this->riskChurn !== null && $this->riskLevel !== null; + } +} diff --git a/src/Business/Churn/ChurnMetricsCollection.php b/src/Business/Churn/ChurnMetricsCollection.php new file mode 100644 index 0000000..67292b3 --- /dev/null +++ b/src/Business/Churn/ChurnMetricsCollection.php @@ -0,0 +1,230 @@ + + * @SuppressWarnings("PHPMD.TooManyPublicMethods") + */ +class ChurnMetricsCollection implements IteratorAggregate, Countable, JsonSerializable +{ + /** + * @var ChurnMetrics[] + */ + private array $metrics = []; + + /** + * Add a ChurnMetrics object to the collection. + */ + public function add(ChurnMetrics $metric): void + { + $this->metrics[$metric->getClassName()] = $metric; + } + + /** + * Filter the collection using a callback function. + * + * @return self A new collection with filtered results + */ + public function filter(Closure $callback): self + { + $filtered = array_filter($this->metrics, $callback); + + $newCollection = new self(); + foreach ($filtered as $metric) { + $newCollection->add($metric); + } + + return $newCollection; + } + + /** + * Get an iterator for the collection. + * + * @return Traversable + */ + #[\ReturnTypeWillChange] + public function getIterator(): Traversable + { + return new ArrayIterator($this->metrics); + } + + /** + * Get the count of metrics in the collection. + */ + public function count(): int + { + return count($this->metrics); + } + + /** + * Check if the collection contains a metric for the given class name. + */ + public function contains(string $className): bool + { + return isset($this->metrics[$className]); + } + + /** + * Get a metric by class name. + */ + public function getByClassName(string $className): ?ChurnMetrics + { + return $this->metrics[$className] ?? null; + } + + /** + * Filter metrics with churn greater than the specified value. + */ + public function filterWithChurnGreaterThan(float $churn): self + { + return $this->filter(function (ChurnMetrics $metric) use ($churn) { + return $metric->getChurn() > $churn; + }); + } + + /** + * Filter metrics with score greater than the specified value. + */ + public function filterWithScoreGreaterThan(float $score): self + { + return $this->filter(function (ChurnMetrics $metric) use ($score) { + return $metric->getScore() > $score; + }); + } + + /** + * Filter metrics that have coverage data. + */ + public function filterWithCoverage(): self + { + return $this->filter(function (ChurnMetrics $metric) { + return $metric->hasCoverageData(); + }); + } + + /** + * Filter metrics that have risk data. + */ + public function filterWithRiskData(): self + { + return $this->filter(function (ChurnMetrics $metric) { + return $metric->hasRiskData(); + }); + } + + /** + * Sort the collection by churn in descending order. + * + * @SuppressWarnings("PHPMD.ShortVariable") + */ + public function sortByChurnDescending(): self + { + $sorted = $this->metrics; + uasort($sorted, function (ChurnMetrics $a, ChurnMetrics $b) { + return $b->getChurn() <=> $a->getChurn(); + }); + + $newCollection = new self(); + foreach ($sorted as $metric) { + $newCollection->add($metric); + } + + return $newCollection; + } + + /** + * Sort the collection by score in descending order. + * + * @SuppressWarnings("PHPMD.ShortVariable") + */ + public function sortByScoreDescending(): self + { + $sorted = $this->metrics; + uasort($sorted, function (ChurnMetrics $a, ChurnMetrics $b) { + return $b->getScore() <=> $a->getScore(); + }); + + $newCollection = new self(); + foreach ($sorted as $metric) { + $newCollection->add($metric); + } + + return $newCollection; + } + + /** + * Convert to array format (for backward compatibility). + * + * @return array> + */ + public function toArray(): array + { + $result = []; + foreach ($this->metrics as $className => $metric) { + $result[$className] = $metric->toArray(); + } + return $result; + } + + /** + * Create collection from array format (for backward compatibility). + * + * @SuppressWarnings("PHPMD.StaticAccess") + * @param array> $data + * @return self + */ + public static function fromArray(array $data): self + { + $collection = new self(); + foreach ($data as $className => $metricData) { + $collection->add(ChurnMetrics::fromArray($className, $metricData)); + } + return $collection; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_values($this->metrics); + } + + /** + * Get all class names in the collection. + * + * @return array + */ + public function getClassNames(): array + { + return array_keys($this->metrics); + } + + /** + * Check if the collection is empty. + */ + public function isEmpty(): bool + { + return empty($this->metrics); + } + + /** + * Clear all metrics from the collection. + */ + public function clear(): void + { + $this->metrics = []; + } +} diff --git a/src/Business/Churn/Exporter/ChurnExporterFactory.php b/src/Business/Churn/Exporter/ChurnExporterFactory.php deleted file mode 100644 index 183aed0..0000000 --- a/src/Business/Churn/Exporter/ChurnExporterFactory.php +++ /dev/null @@ -1,53 +0,0 @@ - new JsonExporter(), - 'csv' => new CsvExporter(), - 'html' => new HtmlExporter(), - 'markdown' => new MarkdownExporter(), - 'svg-treemap', 'svg' => new SvgTreemapExporter(), - default => throw new InvalidArgumentException("Unsupported exporter type: {$type}"), - }; - } - - /** - * Get list of supported exporter types. - * - * @return array - */ - public function getSupportedTypes(): array - { - return ['json', 'csv', 'html', 'markdown', 'svg-treemap', 'svg']; - } - - /** - * Check if a type is supported. - * - * @param string $type - * @return bool - */ - public function isSupported(string $type): bool - { - return in_array($type, $this->getSupportedTypes(), true); - } -} diff --git a/src/Business/Churn/Exporter/DataExporterInterface.php b/src/Business/Churn/Exporter/DataExporterInterface.php deleted file mode 100644 index 162ff0c..0000000 --- a/src/Business/Churn/Exporter/DataExporterInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -> $classes - */ - public function export(array $classes, string $filename): void; -} diff --git a/src/Business/Churn/Exporter/MarkdownExporter.php b/src/Business/Churn/Exporter/MarkdownExporter.php deleted file mode 100644 index f982784..0000000 --- a/src/Business/Churn/Exporter/MarkdownExporter.php +++ /dev/null @@ -1,114 +0,0 @@ - - */ - private array $header = [ - 'Class', - 'Score', - 'Churn', - 'Times Changed', - ]; - - /** - * @var array - */ - private array $headerWithCoverage = [ - 'Class', - 'Score', - 'Churn', - 'Risk Churn', - 'Times Changed', - 'Coverage', - 'Risk Level', - ]; - - /** - * @param array> $classes - * @param string $filename - * @throws \Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException - */ - public function export(array $classes, string $filename): void - { - $this->assertFileIsWritable($filename); - - $markdown = $this->generateMarkdown($classes); - - $this->writeFile($filename, $markdown); - } - - /** - * @param array> $classes - * @return string - */ - private function generateMarkdown(array $classes): string - { - $hasCoverageData = $this->hasCoverageData($classes); - $header = $hasCoverageData ? $this->headerWithCoverage : $this->header; - - $markdown = "# Churn Metrics Report\n\n"; - $markdown .= "Generated: " . (new Datetime())->format('Y-m-d H:i:s') . "\n\n"; - $markdown .= "Total Classes: " . count($classes) . "\n\n"; - - // Create table header - $markdown .= $this->buildMarkdownTableHeader($header) . "\n"; - $markdown .= $this->buildMarkdownTableSeparator(count($header)) . "\n"; - - // Add rows - foreach ($classes as $className => $data) { - if ($data['score'] == 0 || $data['churn'] == 0) { - continue; - } - - $markdown .= $this->addRow($className, $data, $hasCoverageData); - } - - return $markdown; - } - - /** - * Add a single row to the markdown table - * - * @param string $className - * @param array $data - * @param bool $hasCoverageData - * @return string - */ - private function addRow(string $className, array $data, bool $hasCoverageData): string - { - $row = [ - $this->escapeMarkdown($className), - (string)$data['score'], - (string)round((float)$data['churn'], 3), - ]; - - if ($hasCoverageData) { - $row[] = $data['riskChurn'] !== null ? (string)round((float)$data['riskChurn'], 3) : 'N/A'; - } - - $row[] = (string)$data['timesChanged']; - - if ($hasCoverageData) { - $row[] = $data['coverage'] !== null ? sprintf('%.2f%%', $data['coverage'] * 100) : 'N/A'; - $row[] = $data['riskLevel'] ?? 'N/A'; - } - - return "| " . implode(" | ", $row) . " |\n"; - } -} diff --git a/src/Business/Churn/Exporter/AbstractExporter.php b/src/Business/Churn/Report/AbstractReport.php similarity index 87% rename from src/Business/Churn/Exporter/AbstractExporter.php rename to src/Business/Churn/Report/AbstractReport.php index b080e3f..6f8219b 100644 --- a/src/Business/Churn/Exporter/AbstractExporter.php +++ b/src/Business/Churn/Report/AbstractReport.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; -abstract class AbstractExporter implements DataExporterInterface +abstract class AbstractReport implements ReportGeneratorInterface { /** * @throws CognitiveAnalysisException diff --git a/src/Business/Churn/Report/ChurnReportFactory.php b/src/Business/Churn/Report/ChurnReportFactory.php new file mode 100644 index 0000000..2ab455d --- /dev/null +++ b/src/Business/Churn/Report/ChurnReportFactory.php @@ -0,0 +1,106 @@ +registry = new ReporterRegistry(); + } + + /** + * Create an exporter instance based on the report type. + * + * @param string $type The type of exporter to create (json, csv, html, markdown, svg-treemap) + * @return ReportGeneratorInterface + * @throws InvalidArgumentException If the type is not supported + */ + public function create(string $type): ReportGeneratorInterface + { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['churn'] ?? []; + + // Check built-in exporters first + $builtIn = match ($type) { + 'json' => new JsonReport(), + 'csv' => new CsvReport(), + 'html' => new HtmlReport(), + 'markdown' => new MarkdownReport(), + 'svg-treemap', 'svg' => new SvgTreemapReport(), + default => null, + }; + + if ($builtIn !== null) { + return $builtIn; + } + + // Check custom exporters + if (isset($customReporters[$type])) { + return $this->createCustomExporter($customReporters[$type]); + } + + throw new InvalidArgumentException("Unsupported exporter type: {$type}"); + } + + /** + * Create a custom exporter instance. + * + * @param array $config + * @return ReportGeneratorInterface + */ + private function createCustomExporter(array $config): ReportGeneratorInterface + { + $cognitiveConfig = $this->configService->getConfig(); + + $this->registry->loadExporter($config['class'], $config['file'] ?? null); + $exporter = $this->registry->instantiate( + $config['class'], + $cognitiveConfig + ); + $this->registry->validateInterface($exporter, ReportGeneratorInterface::class); + + // PHPStan needs explicit type assertion since instantiate returns object + assert($exporter instanceof ReportGeneratorInterface); + return $exporter; + } + + /** + * Get list of supported exporter types. + * + * @return array + */ + public function getSupportedTypes(): array + { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['churn'] ?? []; + + return array_merge( + ['json', 'csv', 'html', 'markdown', 'svg-treemap', 'svg'], + array_keys($customReporters) + ); + } + + /** + * Check if a type is supported. + * + * @param string $type + * @return bool + */ + public function isSupported(string $type): bool + { + return in_array($type, $this->getSupportedTypes(), true); + } +} diff --git a/src/Business/Churn/Report/ChurnReportFactoryInterface.php b/src/Business/Churn/Report/ChurnReportFactoryInterface.php new file mode 100644 index 0000000..19a06aa --- /dev/null +++ b/src/Business/Churn/Report/ChurnReportFactoryInterface.php @@ -0,0 +1,35 @@ + + */ + public function getSupportedTypes(): array; + + /** + * Check if a type is supported. + * + * @param string $type + * @return bool + */ + public function isSupported(string $type): bool; +} diff --git a/src/Business/Churn/Exporter/CsvExporter.php b/src/Business/Churn/Report/CsvReport.php similarity index 57% rename from src/Business/Churn/Exporter/CsvExporter.php rename to src/Business/Churn/Report/CsvReport.php index d1b3b1c..60cfed3 100644 --- a/src/Business/Churn/Exporter/CsvExporter.php +++ b/src/Business/Churn/Report/CsvReport.php @@ -2,12 +2,14 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report; + +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; /** - * CsvExporter for Churn metrics. + * CsvReport for Churn metrics. */ -class CsvExporter extends AbstractExporter +class CsvReport extends AbstractReport { /** * @var array @@ -21,11 +23,10 @@ class CsvExporter extends AbstractExporter ]; /** - * @param array> $classes * @param string $filename * @throws \Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException */ - public function export(array $classes, string $filename): void + public function export(ChurnMetricsCollection $metrics, string $filename): void { $this->assertFileIsWritable($filename); @@ -34,14 +35,14 @@ public function export(array $classes, string $filename): void /* @phpstan-ignore argument.type */ fputcsv($file, $this->header, ',', '"', '\\'); - foreach ($classes as $class => $data) { + foreach ($metrics as $metric) { /* @phpstan-ignore argument.type */ fputcsv($file, [ - $class, - $data['file'] ?? '', - $data['score'] ?? 0, - $data['churn'] ?? 0, - $data['timesChanged'] ?? 0, + $metric->getClassName(), + $metric->getFile(), + $metric->getScore(), + $metric->getChurn(), + $metric->getTimesChanged(), ], ',', '"', '\\'); } diff --git a/src/Business/Churn/Exporter/HtmlExporter.php b/src/Business/Churn/Report/HtmlReport.php similarity index 72% rename from src/Business/Churn/Exporter/HtmlExporter.php rename to src/Business/Churn/Report/HtmlReport.php index 395ca20..a0fbb17 100644 --- a/src/Business/Churn/Exporter/HtmlExporter.php +++ b/src/Business/Churn/Report/HtmlReport.php @@ -2,14 +2,15 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; /** - * HtmlExporter for Churn metrics. + * HtmlReport for Churn metrics. */ -class HtmlExporter extends AbstractExporter +class HtmlReport extends AbstractReport { /** * @var array @@ -21,15 +22,14 @@ class HtmlExporter extends AbstractExporter ]; /** - * @param array> $classes * @param string $filename * @throws CognitiveAnalysisException */ - public function export(array $classes, string $filename): void + public function export(ChurnMetricsCollection $metrics, string $filename): void { $this->assertFileIsWritable($filename); - $html = $this->generateHtml($classes); + $html = $this->generateHtml($metrics); $this->writeFile($filename, $html); } @@ -45,11 +45,10 @@ private function formatNumber(float $number): string } /** - * @param array> $classes * @return string * @throws CognitiveAnalysisException */ - private function generateHtml(array $classes): string + private function generateHtml(ChurnMetricsCollection $metrics): string { ob_start(); ?> @@ -65,7 +64,7 @@ private function generateHtml(array $classes): string

Churn Metrics Report -

- This report contains the churn metrics for files. + This report contains the churn metrics for files.

@@ -77,12 +76,12 @@ private function generateHtml(array $classes): string - $data) : ?> + - - - - + + + + diff --git a/src/Business/Churn/Exporter/JsonExporter.php b/src/Business/Churn/Report/JsonReport.php similarity index 61% rename from src/Business/Churn/Exporter/JsonExporter.php rename to src/Business/Churn/Report/JsonReport.php index 0615b25..a324a3d 100644 --- a/src/Business/Churn/Exporter/JsonExporter.php +++ b/src/Business/Churn/Report/JsonReport.php @@ -2,23 +2,23 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\Business\Utility\Datetime; -class JsonExporter extends AbstractExporter +class JsonReport extends AbstractReport { /** - * @param array> $classes * @throws \JsonException|\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException */ - public function export(array $classes, string $filename): void + public function export(ChurnMetricsCollection $metrics, string $filename): void { $this->assertFileIsWritable($filename); $data = [ 'createdAt' => (new DateTime())->format('Y-m-d H:i:s'), - 'classes' => $classes, + 'classes' => $metrics->toArray(), ]; $jsonData = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); diff --git a/src/Business/Churn/Report/MarkdownReport.php b/src/Business/Churn/Report/MarkdownReport.php new file mode 100644 index 0000000..ecd6f4c --- /dev/null +++ b/src/Business/Churn/Report/MarkdownReport.php @@ -0,0 +1,123 @@ + + */ + private array $header = [ + 'Class', + 'Score', + 'Churn', + 'Times Changed', + ]; + + /** + * @var array + */ + private array $headerWithCoverage = [ + 'Class', + 'Score', + 'Churn', + 'Risk Churn', + 'Times Changed', + 'Coverage', + 'Risk Level', + ]; + + /** + * @param string $filename + * @throws \Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException + */ + public function export(ChurnMetricsCollection $metrics, string $filename): void + { + $this->assertFileIsWritable($filename); + + $markdown = $this->generateMarkdown($metrics); + + $this->writeFile($filename, $markdown); + } + + private function generateMarkdown(ChurnMetricsCollection $metrics): string + { + $hasCoverageData = $this->hasCoverageData($metrics); + $header = $hasCoverageData ? $this->headerWithCoverage : $this->header; + + $markdown = "# Churn Metrics Report\n\n"; + $markdown .= "Generated: " . (new Datetime())->format('Y-m-d H:i:s') . "\n\n"; + $markdown .= "Total Classes: " . count($metrics) . "\n\n"; + + // Create table header + $markdown .= $this->buildMarkdownTableHeader($header) . "\n"; + $markdown .= $this->buildMarkdownTableSeparator(count($header)) . "\n"; + + // Add rows + foreach ($metrics as $metric) { + if ($metric->getScore() == 0 || $metric->getChurn() == 0) { + continue; + } + + $markdown .= $this->addRow($metric, $hasCoverageData); + } + + return $markdown; + } + + /** + * Add a single row to the markdown table + * + * @param ChurnMetrics $metric + * @param bool $hasCoverageData + * @return string + */ + private function addRow(ChurnMetrics $metric, bool $hasCoverageData): string + { + $row = [ + $this->escapeMarkdown($metric->getClassName()), + (string)$metric->getScore(), + (string)round($metric->getChurn(), 3), + ]; + + if ($hasCoverageData) { + $row[] = $metric->getRiskChurn() !== null ? (string)round($metric->getRiskChurn(), 3) : 'N/A'; + } + + $row[] = (string)$metric->getTimesChanged(); + + if ($hasCoverageData) { + $row[] = $metric->getCoverage() !== null ? sprintf('%.2f%%', $metric->getCoverage() * 100) : 'N/A'; + $row[] = $metric->getRiskLevel() ?? 'N/A'; + } + + return "| " . implode(" | ", $row) . " |\n"; + } + + /** + * Check if the metrics collection has coverage data + */ + private function hasCoverageData(ChurnMetricsCollection $metrics): bool + { + foreach ($metrics as $metric) { + if ($metric->hasCoverageData()) { + return true; + } + } + return false; + } +} diff --git a/src/Business/Churn/Report/ReportGeneratorInterface.php b/src/Business/Churn/Report/ReportGeneratorInterface.php new file mode 100644 index 0000000..205321b --- /dev/null +++ b/src/Business/Churn/Report/ReportGeneratorInterface.php @@ -0,0 +1,12 @@ +> $classes * @param string $filename * @throws CognitiveAnalysisException */ - public function export(array $classes, string $filename): void + public function export(ChurnMetricsCollection $metrics, string $filename): void { $this->assertFileIsWritable($filename); - $svg = $this->generateSvgTreemap(classes: $classes); + $svg = $this->generateSvgTreemap(metrics: $metrics); if (file_put_contents($filename, $svg) === false) { throw new CognitiveAnalysisException("Unable to write to file: $filename"); @@ -47,12 +47,11 @@ public function export(array $classes, string $filename): void /** * Generates a treemap SVG for the churn data. * - * @param array> $classes * @return string */ - private function generateSvgTreemap(array $classes): string + private function generateSvgTreemap(ChurnMetricsCollection $metrics): string { - $items = $this->treemapMath->prepareItems($classes); + $items = $this->treemapMath->prepareItems($metrics->toArray()); [$minScore, $maxScore] = $this->treemapMath->findScoreRange($items); diff --git a/src/Business/Churn/Exporter/TreemapMath.php b/src/Business/Churn/Report/TreemapMath.php similarity index 99% rename from src/Business/Churn/Exporter/TreemapMath.php rename to src/Business/Churn/Report/TreemapMath.php index 7bc5a53..e050461 100644 --- a/src/Business/Churn/Exporter/TreemapMath.php +++ b/src/Business/Churn/Report/TreemapMath.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report; /** * Handles mathematical operations for treemap generation. diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index acbd42b..4cc9a7d 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -7,7 +7,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\FileProcessed; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\ParserFailed; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\SourceFilesFound; -use Phauthentic\CognitiveCodeAnalysis\Business\DirectoryScanner; +use Phauthentic\CognitiveCodeAnalysis\Business\Utility\DirectoryScanner; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; diff --git a/src/Business/Cognitive/Exporter/CognitiveExporterFactory.php b/src/Business/Cognitive/Exporter/CognitiveExporterFactory.php deleted file mode 100644 index dfe6242..0000000 --- a/src/Business/Cognitive/Exporter/CognitiveExporterFactory.php +++ /dev/null @@ -1,58 +0,0 @@ - new JsonExporter(), - 'csv' => new CsvExporter(), - 'html' => new HtmlExporter(), - 'markdown' => new MarkdownExporter($this->config), - default => throw new InvalidArgumentException("Unsupported exporter type: {$type}"), - }; - } - - /** - * Get list of supported exporter types. - * - * @return array - */ - public function getSupportedTypes(): array - { - return ['json', 'csv', 'html', 'markdown']; - } - - /** - * Check if a type is supported. - * - * @param string $type - * @return bool - */ - public function isSupported(string $type): bool - { - return in_array($type, $this->getSupportedTypes(), true); - } -} diff --git a/src/Business/Cognitive/Parser.php b/src/Business/Cognitive/Parser.php index 356fc57..c256c8a 100644 --- a/src/Business/Cognitive/Parser.php +++ b/src/Business/Cognitive/Parser.php @@ -4,18 +4,18 @@ namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive; +use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticComplexityCalculator; use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetricsCalculator; -use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculator; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\PhpParser\AnnotationVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\CognitiveMetricsVisitor; +use Phauthentic\CognitiveCodeAnalysis\PhpParser\CombinedMetricsVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\CyclomaticComplexityVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\HalsteadMetricsVisitor; -use Phauthentic\CognitiveCodeAnalysis\PhpParser\CombinedMetricsVisitor; +use PhpParser\Error; +use PhpParser\NodeTraverser; use PhpParser\NodeTraverserInterface; use PhpParser\Parser as PhpParser; -use PhpParser\NodeTraverser; -use PhpParser\Error; use PhpParser\ParserFactory; use ReflectionClass; @@ -194,7 +194,7 @@ public function clearStaticCaches(): void $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\PhpParser\AnnotationVisitor', 'fqcnCache'); // Clear regex pattern caches - $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\Business\DirectoryScanner', 'compiledPatterns'); + $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\Business\Utility\DirectoryScanner', 'compiledPatterns'); $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollector', 'compiledPatterns'); // Clear accumulated data in visitors diff --git a/src/Business/Cognitive/Report/CognitiveReportFactory.php b/src/Business/Cognitive/Report/CognitiveReportFactory.php new file mode 100644 index 0000000..aebba33 --- /dev/null +++ b/src/Business/Cognitive/Report/CognitiveReportFactory.php @@ -0,0 +1,104 @@ +registry = new ReporterRegistry(); + } + + /** + * Create an exporter instance based on the report type. + * + * @param string $type The type of exporter to create (json, csv, html, markdown) + * @return ReportGeneratorInterface + * @throws InvalidArgumentException If the type is not supported + */ + public function create(string $type): ReportGeneratorInterface + { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['cognitive'] ?? []; + + // Check built-in exporters first + $builtIn = match ($type) { + 'json' => new JsonReport(), + 'csv' => new CsvReport(), + 'html' => new HtmlReport(), + 'markdown' => new MarkdownReport($config), + default => null, + }; + + if ($builtIn !== null) { + return $builtIn; + } + + if (isset($customReporters[$type])) { + return $this->createCustomExporter($customReporters[$type]); + } + + throw new InvalidArgumentException("Unsupported exporter type: {$type}"); + } + + /** + * Create a custom exporter instance. + * + * @param array $config + * @return ReportGeneratorInterface + */ + private function createCustomExporter(array $config): ReportGeneratorInterface + { + $cognitiveConfig = $this->configService->getConfig(); + + $this->registry->loadExporter($config['class'], $config['file'] ?? null); + $exporter = $this->registry->instantiate( + $config['class'], + $cognitiveConfig + ); + $this->registry->validateInterface($exporter, ReportGeneratorInterface::class); + + // PHPStan needs explicit type assertion since instantiate returns object + assert($exporter instanceof ReportGeneratorInterface); + return $exporter; + } + + /** + * Get list of supported exporter types. + * + * @return array + */ + public function getSupportedTypes(): array + { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['cognitive'] ?? []; + + return array_merge( + ['json', 'csv', 'html', 'markdown'], + array_keys($customReporters) + ); + } + + /** + * Check if a type is supported. + * + * @param string $type + * @return bool + */ + public function isSupported(string $type): bool + { + return in_array($type, $this->getSupportedTypes(), true); + } +} diff --git a/src/Business/Cognitive/Report/CognitiveReportFactoryInterface.php b/src/Business/Cognitive/Report/CognitiveReportFactoryInterface.php new file mode 100644 index 0000000..ac73a6a --- /dev/null +++ b/src/Business/Cognitive/Report/CognitiveReportFactoryInterface.php @@ -0,0 +1,35 @@ + + */ + public function getSupportedTypes(): array; + + /** + * Check if a type is supported. + * + * @param string $type + * @return bool + */ + public function isSupported(string $type): bool; +} diff --git a/src/Business/Cognitive/Exporter/CsvExporter.php b/src/Business/Cognitive/Report/CsvReport.php similarity index 96% rename from src/Business/Cognitive/Exporter/CsvExporter.php rename to src/Business/Cognitive/Report/CsvReport.php index 2e84963..64dc0ac 100644 --- a/src/Business/Cognitive/Exporter/CsvExporter.php +++ b/src/Business/Cognitive/Report/CsvReport.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; -class CsvExporter implements DataExporterInterface +class CsvReport implements ReportGeneratorInterface { /** * @var array diff --git a/src/Business/Cognitive/Exporter/HtmlExporter.php b/src/Business/Cognitive/Report/HtmlReport.php similarity index 96% rename from src/Business/Cognitive/Exporter/HtmlExporter.php rename to src/Business/Cognitive/Report/HtmlReport.php index 89d48c3..199a64b 100644 --- a/src/Business/Cognitive/Exporter/HtmlExporter.php +++ b/src/Business/Cognitive/Report/HtmlReport.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Delta; @@ -10,9 +10,9 @@ use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; /** - * HtmlExporter class for exporting metrics as an HTML file. + * HtmlReport class for exporting metrics as an HTML file. */ -class HtmlExporter implements DataExporterInterface +class HtmlReport implements ReportGeneratorInterface { /** * @var array diff --git a/src/Business/Cognitive/Exporter/JsonExporter.php b/src/Business/Cognitive/Report/JsonReport.php similarity index 94% rename from src/Business/Cognitive/Exporter/JsonExporter.php rename to src/Business/Cognitive/Report/JsonReport.php index 997e5cc..231a369 100644 --- a/src/Business/Cognitive/Exporter/JsonExporter.php +++ b/src/Business/Cognitive/Report/JsonReport.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; -class JsonExporter implements DataExporterInterface +class JsonReport implements ReportGeneratorInterface { /** * @throws \JsonException|\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException diff --git a/src/Business/Cognitive/Exporter/MarkdownExporter.php b/src/Business/Cognitive/Report/MarkdownReport.php similarity index 98% rename from src/Business/Cognitive/Exporter/MarkdownExporter.php rename to src/Business/Cognitive/Report/MarkdownReport.php index f4e61a2..528e1a5 100644 --- a/src/Business/Cognitive/Exporter/MarkdownExporter.php +++ b/src/Business/Cognitive/Report/MarkdownReport.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Delta; -use Phauthentic\CognitiveCodeAnalysis\Business\Exporter\MarkdownFormatterTrait; +use Phauthentic\CognitiveCodeAnalysis\Business\Reporter\MarkdownFormatterTrait; use Phauthentic\CognitiveCodeAnalysis\Business\Utility\Datetime; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; @@ -15,7 +15,7 @@ /** * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ -class MarkdownExporter implements DataExporterInterface +class MarkdownReport implements ReportGeneratorInterface { use MarkdownFormatterTrait; diff --git a/src/Business/Cognitive/Exporter/DataExporterInterface.php b/src/Business/Cognitive/Report/ReportGeneratorInterface.php similarity index 66% rename from src/Business/Cognitive/Exporter/DataExporterInterface.php rename to src/Business/Cognitive/Report/ReportGeneratorInterface.php index 42d24a4..22e7391 100644 --- a/src/Business/Cognitive/Exporter/DataExporterInterface.php +++ b/src/Business/Cognitive/Report/ReportGeneratorInterface.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; -interface DataExporterInterface +interface ReportGeneratorInterface { public function export(CognitiveMetricsCollection $metrics, string $filename): void; } diff --git a/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculator.php b/src/Business/Cyclomatic/CyclomaticComplexityCalculator.php similarity index 73% rename from src/Business/CyclomaticComplexity/CyclomaticComplexityCalculator.php rename to src/Business/Cyclomatic/CyclomaticComplexityCalculator.php index afe1411..1176e73 100644 --- a/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculator.php +++ b/src/Business/Cyclomatic/CyclomaticComplexityCalculator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic; class CyclomaticComplexityCalculator implements CyclomaticComplexityCalculatorInterface { @@ -30,7 +30,7 @@ public function calculateComplexity(array $decisionPointCounts): int } /** - * Create detailed breakdown of complexity factors. + * Create a detailed breakdown of complexity factors. * * @param array $decisionPointCounts Array of decision point counts * @param int $totalComplexity Total complexity value @@ -77,15 +77,22 @@ public function createSummary(array $classComplexities, array $methodComplexitie 'very_high_risk_methods' => [], ]; - // Class summary - foreach ($classComplexities as $className => $complexity) { - $summary['classes'][$className] = [ - 'complexity' => $complexity, - 'risk_level' => $this->getRiskLevel($complexity), - ]; - } + $summary = $this->classSummary($classComplexities, $summary); + $summary = $this->methodSummary($methodComplexities, $methodBreakdowns, $summary); + + return $summary; + } - // Method summary + /** + * Create method summary with risk assessment. + * + * @param array $methodComplexities Method complexities indexed by "ClassName::methodName" + * @param array> $methodBreakdowns Method breakdowns indexed by "ClassName::methodName" + * @param array $summary Summary array to populate + * @return array Updated summary with method data + */ + public function methodSummary(array $methodComplexities, array $methodBreakdowns, array $summary): array + { foreach ($methodComplexities as $methodKey => $complexity) { $riskLevel = $this->getRiskLevel($complexity); $summary['methods'][$methodKey] = [ @@ -97,6 +104,7 @@ public function createSummary(array $classComplexities, array $methodComplexitie if ($complexity >= 10) { $summary['high_risk_methods'][$methodKey] = $complexity; } + if ($complexity < 15) { continue; } @@ -106,4 +114,23 @@ public function createSummary(array $classComplexities, array $methodComplexitie return $summary; } + + /** + * Create class summary with risk assessment. + * + * @param array $classComplexities Class complexities indexed by class name + * @param array $summary Summary array to populate + * @return array Updated summary with class data + */ + public function classSummary(array $classComplexities, array $summary): array + { + foreach ($classComplexities as $className => $complexity) { + $summary['classes'][$className] = [ + 'complexity' => $complexity, + 'risk_level' => $this->getRiskLevel($complexity), + ]; + } + + return $summary; + } } diff --git a/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorInterface.php b/src/Business/Cyclomatic/CyclomaticComplexityCalculatorInterface.php similarity index 95% rename from src/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorInterface.php rename to src/Business/Cyclomatic/CyclomaticComplexityCalculatorInterface.php index 50bbd30..c37363b 100644 --- a/src/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorInterface.php +++ b/src/Business/Cyclomatic/CyclomaticComplexityCalculatorInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic; interface CyclomaticComplexityCalculatorInterface { diff --git a/src/Business/MetricsFacade.php b/src/Business/MetricsFacade.php index 1bbb764..651adef 100644 --- a/src/Business/MetricsFacade.php +++ b/src/Business/MetricsFacade.php @@ -6,12 +6,13 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChangeCounter\ChangeCounterFactory; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnCalculator; -use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\ChurnExporterFactory; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\ChurnReportFactoryInterface; 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\Report\CognitiveReportFactoryInterface; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\ScoreCalculator; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; @@ -21,9 +22,6 @@ */ class MetricsFacade { - private ?ChurnExporterFactory $churnExporterFactory = null; - private ?CognitiveExporterFactory $cognitiveExporterFactory = null; - /** * Constructor initializes the metrics collectors, score calculator, and config service. */ @@ -32,31 +30,11 @@ public function __construct( private readonly ScoreCalculator $scoreCalculator, private readonly ConfigService $configService, private readonly ChurnCalculator $churnCalculator, - private readonly ChangeCounterFactory $changeCounterFactory + private readonly ChangeCounterFactory $changeCounterFactory, + private readonly ChurnReportFactoryInterface $churnReportFactory, + private readonly CognitiveReportFactoryInterface $cognitiveReportFactory ) { - $this->loadConfig(__DIR__ . '/../../config.yml'); - } - - /** - * Get or create the churn exporter factory. - */ - private function getChurnExporterFactory(): ChurnExporterFactory - { - if ($this->churnExporterFactory === null) { - $this->churnExporterFactory = new ChurnExporterFactory(); - } - return $this->churnExporterFactory; - } - - /** - * Get or create the cognitive exporter factory. - */ - private function getCognitiveExporterFactory(): CognitiveExporterFactory - { - if ($this->cognitiveExporterFactory === null) { - $this->cognitiveExporterFactory = new CognitiveExporterFactory($this->configService->getConfig()); - } - return $this->cognitiveExporterFactory; + // Configuration will be loaded when needed } /** @@ -103,19 +81,12 @@ public function getCognitiveMetricsFromPaths( return $metricsCollection; } - /** - * @param string $path - * @param string $vcsType - * @param string $since - * @param CoverageReportReaderInterface|null $coverageReader - * @return array> - */ public function calculateChurn( string $path, string $vcsType = 'git', string $since = '1900-01-01', ?CoverageReportReaderInterface $coverageReader = null - ): array { + ): ChurnMetricsCollection { $metricsCollection = $this->getCognitiveMetrics($path); $counter = $this->changeCounterFactory->create($vcsType); @@ -145,16 +116,18 @@ public function getConfig(): CognitiveConfig return $this->configService->getConfig(); } - /** - * @param array> $classes - */ + public function getConfigService(): ConfigService + { + return $this->configService; + } + public function exportChurnReport( - array $classes, + ChurnMetricsCollection $metrics, string $reportType, string $filename ): void { - $exporter = $this->getChurnExporterFactory()->create($reportType); - $exporter->export($classes, $filename); + $exporter = $this->churnReportFactory->create($reportType); + $exporter->export($metrics, $filename); } public function exportMetricsReport( @@ -162,7 +135,7 @@ public function exportMetricsReport( string $reportType, string $filename ): void { - $exporter = $this->getCognitiveExporterFactory()->create($reportType); + $exporter = $this->cognitiveReportFactory->create($reportType); $exporter->export($metricsCollection, $filename); } diff --git a/src/Business/Exporter/AbstractMarkdownExporter.php b/src/Business/Reporter/AbstractMarkdownReporter.php similarity index 95% rename from src/Business/Exporter/AbstractMarkdownExporter.php rename to src/Business/Reporter/AbstractMarkdownReporter.php index f3fab4e..4ab5b93 100644 --- a/src/Business/Exporter/AbstractMarkdownExporter.php +++ b/src/Business/Reporter/AbstractMarkdownReporter.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Reporter; /** * Abstract base class for Markdown exporters providing common formatting utilities. */ -abstract class AbstractMarkdownExporter +abstract class AbstractMarkdownReporter { /** * Escape special markdown characters in strings. diff --git a/src/Business/Exporter/MarkdownFormatterTrait.php b/src/Business/Reporter/MarkdownFormatterTrait.php similarity index 96% rename from src/Business/Exporter/MarkdownFormatterTrait.php rename to src/Business/Reporter/MarkdownFormatterTrait.php index e055e13..8a7dab9 100644 --- a/src/Business/Exporter/MarkdownFormatterTrait.php +++ b/src/Business/Reporter/MarkdownFormatterTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Reporter; /** * Trait providing common markdown formatting utilities. diff --git a/src/Business/Reporter/ReporterRegistry.php b/src/Business/Reporter/ReporterRegistry.php new file mode 100644 index 0000000..4ad0fa5 --- /dev/null +++ b/src/Business/Reporter/ReporterRegistry.php @@ -0,0 +1,76 @@ + */ + private array $loadedFiles = []; + + /** + * Load an exporter class, optionally including a file first. + * + * @param string $class The fully qualified class name + * @param string|null $file Optional file path to include + * @throws CognitiveAnalysisException If file doesn't exist or class is not found + */ + public function loadExporter(string $class, ?string $file): void + { + if ($file !== null && !isset($this->loadedFiles[$file])) { + if (!file_exists($file)) { + throw new CognitiveAnalysisException("Exporter file not found: {$file}"); + } + require_once $file; + $this->loadedFiles[$file] = true; + } + + if (!class_exists($class)) { + throw new CognitiveAnalysisException("Exporter class not found: {$class}"); + } + } + + /** + * Instantiate an exporter class with optional CognitiveConfig dependency. + * + * @param string $class The fully qualified class name + * @param CognitiveConfig|null $config The config to pass if available + * @return object The instantiated exporter + */ + public function instantiate(string $class, ?CognitiveConfig $config): object + { + // Always try to pass config first, fallback to no-arg constructor if it fails + try { + if ($config !== null) { + return new $class($config); + } + return new $class(); + } catch (\ArgumentCountError $e) { + // Constructor doesn't accept config parameter, try without it + return new $class(); + } + } + + /** + * Validate that an exporter implements the expected interface. + * + * @param object $exporter The exporter instance to validate + * @param string $expectedInterface The interface it should implement + * @throws CognitiveAnalysisException If the exporter doesn't implement the interface + */ + public function validateInterface(object $exporter, string $expectedInterface): void + { + if (!$exporter instanceof $expectedInterface) { + throw new CognitiveAnalysisException( + "Exporter must implement {$expectedInterface}" + ); + } + } +} diff --git a/src/Business/Traits/CoverageDataDetector.php b/src/Business/Utility/CoverageDataDetector.php similarity index 89% rename from src/Business/Traits/CoverageDataDetector.php rename to src/Business/Utility/CoverageDataDetector.php index 85e0fad..d4ca520 100644 --- a/src/Business/Traits/CoverageDataDetector.php +++ b/src/Business/Utility/CoverageDataDetector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business\Traits; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Utility; /** * Trait for detecting if coverage data is present in class data arrays. diff --git a/src/Business/DirectoryScanner.php b/src/Business/Utility/DirectoryScanner.php similarity index 98% rename from src/Business/DirectoryScanner.php rename to src/Business/Utility/DirectoryScanner.php index f55058b..162ea0c 100644 --- a/src/Business/DirectoryScanner.php +++ b/src/Business/Utility/DirectoryScanner.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Business; +namespace Phauthentic\CognitiveCodeAnalysis\Business\Utility; use FilesystemIterator; use Generator; diff --git a/src/Command/ChurnCommand.php b/src/Command/ChurnCommand.php index 115df40..4ba6361 100644 --- a/src/Command/ChurnCommand.php +++ b/src/Command/ChurnCommand.php @@ -4,6 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command; +use Exception; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CloverReader; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoberturaReader; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoverageReportReaderInterface; @@ -11,6 +12,10 @@ use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use Phauthentic\CognitiveCodeAnalysis\Command\Handler\ChurnReportHandler; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\ChurnTextRenderer; +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; @@ -33,17 +38,22 @@ 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 ChurnReportHandler $report, + readonly private ChurnValidationSpecificationFactory $validationSpecificationFactory ) { parent::__construct(); + $this->validationSpecification = $this->validationSpecificationFactory->create(); } + /** * Configures the command options and arguments. */ @@ -115,61 +125,77 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $coberturaFile = $input->getOption(self::OPTION_COVERAGE_COBERTURA); - $cloverFile = $input->getOption(self::OPTION_COVERAGE_CLOVER); + $context = new ChurnCommandContext($input); - // 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.'); + // 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; } - $coverageFile = $coberturaFile ?? $cloverFile; - $coverageFormat = $coberturaFile !== null ? 'cobertura' : ($cloverFile !== null ? 'clover' : null); + // Load configuration if provided + if ($context->hasConfigFile()) { + $configFile = $context->getConfigFile(); + if ($configFile !== null && !$this->loadConfiguration($configFile, $output)) { + return self::FAILURE; + } + } - if (!$this->coverageFileExists($coverageFile, $output)) { - return self::FAILURE; + // 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; + } } - $coverageReader = $this->loadCoverageReader($coverageFile, $coverageFormat, $output); + // Load coverage reader + $coverageReader = $this->loadCoverageReader($context, $output); if ($coverageReader === false) { return self::FAILURE; } - $classes = $this->metricsFacade->calculateChurn( - path: $input->getArgument(self::ARGUMENT_PATH), - vcsType: $input->getOption(self::OPTION_VCS), - since: $input->getOption(self::OPTION_SINCE), + // Calculate churn metrics + $metrics = $this->metricsFacade->calculateChurn( + path: $context->getPath(), + vcsType: $context->getVcsType(), + since: $context->getSince(), coverageReader: $coverageReader ); - $reportType = $input->getOption(self::OPTION_REPORT_TYPE); - $reportFile = $input->getOption(self::OPTION_REPORT_FILE); - - if ($reportType !== null || $reportFile !== null) { - return $this->report->exportToFile($classes, $reportType, $reportFile); + // Handle report generation or display + if ($context->hasReportOptions()) { + return $this->report->exportToFile( + $metrics, + $context->getReportType(), + $context->getReportFile() + ); } - $this->renderer->renderChurnTable( - classes: $classes - ); - + $this->renderer->renderChurnTable(metrics: $metrics); return self::SUCCESS; } /** * 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 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 */ private function loadCoverageReader( - ?string $coverageFile, - ?string $format, + ChurnCommandContext $context, OutputInterface $output ): CoverageReportReaderInterface|null|false { + $coverageFile = $context->getCoverageFile(); + $format = $context->getCoverageFormat(); + if ($coverageFile === null) { return null; } @@ -221,24 +247,22 @@ private function detectCoverageFormat(string $coverageFile): ?string return null; } - private function coverageFileExists(?string $coverageFile, OutputInterface $output): bool - { - // If no coverage file is provided, validation passes (backward compatibility) - if ($coverageFile === null) { - return true; - } - // If coverage file is provided, check if it exists - if (file_exists($coverageFile)) { + /** + * Loads configuration and handles errors. + * + * @param string $configFile + * @param OutputInterface $output + * @return bool Success or failure. + */ + private function loadConfiguration(string $configFile, OutputInterface $output): bool + { + try { + $this->metricsFacade->loadConfig($configFile); return true; + } catch (Exception $e) { + $output->writeln('Failed to load configuration: ' . $e->getMessage() . ''); + return false; } - - // Coverage file was provided but doesn't exist - show error - $output->writeln(sprintf( - 'Coverage file not found: %s', - $coverageFile - )); - - return false; } } diff --git a/src/Command/ChurnSpecifications/ChurnCommandContext.php b/src/Command/ChurnSpecifications/ChurnCommandContext.php new file mode 100644 index 0000000..431d413 --- /dev/null +++ b/src/Command/ChurnSpecifications/ChurnCommandContext.php @@ -0,0 +1,91 @@ +input->getOption('config'); + } + + public function hasConfigFile(): bool + { + return $this->getConfigFile() !== null; + } + + public function getCoberturaFile(): ?string + { + return $this->input->getOption('coverage-cobertura'); + } + + public function getCloverFile(): ?string + { + return $this->input->getOption('coverage-clover'); + } + + public function hasCoberturaFile(): bool + { + return $this->getCoberturaFile() !== null; + } + + public function hasCloverFile(): bool + { + return $this->getCloverFile() !== null; + } + + public function getCoverageFile(): ?string + { + return $this->getCoberturaFile() ?? $this->getCloverFile(); + } + + public function getCoverageFormat(): ?string + { + if ($this->hasCoberturaFile()) { + return 'cobertura'; + } + if ($this->hasCloverFile()) { + return 'clover'; + } + return null; + } + + public function getReportType(): ?string + { + return $this->input->getOption('report-type'); + } + + public function getReportFile(): ?string + { + return $this->input->getOption('report-file'); + } + + public function hasReportOptions(): bool + { + return $this->getReportType() !== null || $this->getReportFile() !== null; + } + + public function getPath(): string + { + return $this->input->getArgument('path'); + } + + public function getVcsType(): string + { + return $this->input->getOption('vcs') ?? 'git'; + } + + public function getSince(): string + { + return $this->input->getOption('since') ?? '2000-01-01'; + } +} diff --git a/src/Command/ChurnSpecifications/ChurnCommandSpecification.php b/src/Command/ChurnSpecifications/ChurnCommandSpecification.php new file mode 100644 index 0000000..2ffa87a --- /dev/null +++ b/src/Command/ChurnSpecifications/ChurnCommandSpecification.php @@ -0,0 +1,14 @@ +specifications as $specification) { + if (!$specification->isSatisfiedBy($context)) { + return false; + } + } + return true; + } + + public function getErrorMessage(): string + { + return 'Validation failed'; + } + + public function getDetailedErrorMessage(ChurnCommandContext $context): string + { + foreach ($this->specifications as $specification) { + if (!$specification->isSatisfiedBy($context)) { + // Use context-specific error message if available + if (method_exists($specification, 'getErrorMessageWithContext')) { + return $specification->getErrorMessageWithContext($context); + } + return $specification->getErrorMessage(); + } + } + return ''; + } + + public function getFirstFailedSpecification(ChurnCommandContext $context): ?ChurnCommandSpecification + { + foreach ($this->specifications as $specification) { + if (!$specification->isSatisfiedBy($context)) { + return $specification; + } + } + return null; + } +} diff --git a/src/Command/ChurnSpecifications/CoverageFileExists.php b/src/Command/ChurnSpecifications/CoverageFileExists.php new file mode 100644 index 0000000..af586d2 --- /dev/null +++ b/src/Command/ChurnSpecifications/CoverageFileExists.php @@ -0,0 +1,24 @@ +getCoverageFile(); + return $coverageFile === null || file_exists($coverageFile); + } + + public function getErrorMessage(): string + { + return 'Coverage file not found'; + } + + public function getErrorMessageWithContext(ChurnCommandContext $context): string + { + return sprintf('Coverage file not found: %s', $context->getCoverageFile()); + } +} diff --git a/src/Command/ChurnSpecifications/CoverageFormatExclusivity.php b/src/Command/ChurnSpecifications/CoverageFormatExclusivity.php new file mode 100644 index 0000000..89751a2 --- /dev/null +++ b/src/Command/ChurnSpecifications/CoverageFormatExclusivity.php @@ -0,0 +1,18 @@ +hasCoberturaFile() && $context->hasCloverFile()); + } + + public function getErrorMessage(): string + { + return 'Only one coverage format can be specified at a time.'; + } +} diff --git a/src/Command/ChurnSpecifications/CoverageFormatSupported.php b/src/Command/ChurnSpecifications/CoverageFormatSupported.php new file mode 100644 index 0000000..240d3a8 --- /dev/null +++ b/src/Command/ChurnSpecifications/CoverageFormatSupported.php @@ -0,0 +1,24 @@ +getCoverageFormat(); + return $format === null || in_array($format, ['cobertura', 'clover'], true); + } + + public function getErrorMessage(): string + { + return 'Unsupported coverage format'; + } + + public function getErrorMessageWithContext(ChurnCommandContext $context): string + { + return sprintf('Unsupported coverage format: %s', $context->getCoverageFormat()); + } +} diff --git a/src/Command/ChurnSpecifications/CustomExporter.php b/src/Command/ChurnSpecifications/CustomExporter.php new file mode 100644 index 0000000..1395b97 --- /dev/null +++ b/src/Command/ChurnSpecifications/CustomExporter.php @@ -0,0 +1,123 @@ +hasReportOptions()) { + return true; + } + + $reportType = $context->getReportType(); + if ($reportType === null) { + return true; + } + + // Check if it's a built-in type (always valid) + $builtInTypes = ['json', 'csv', 'html', 'markdown', 'svg-treemap', 'svg']; + if (in_array($reportType, $builtInTypes, true)) { + return true; + } + + // For custom exporters, validate they can be loaded + return $this->validateCustomExporter($reportType); + } + + public function getErrorMessage(): string + { + return 'Custom exporter validation failed'; + } + + public function getErrorMessageWithContext(ChurnCommandContext $context): string + { + $reportType = $context->getReportType(); + if ($reportType === null) { + return 'Report type is required for validation'; + } + + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['churn'] ?? []; + + if (!isset($customReporters[$reportType])) { + $supportedTypes = implode('`, `', $this->reportFactory->getSupportedTypes()); + return "Custom exporter `{$reportType}` not found in configuration. Supported types: `{$supportedTypes}`"; + } + + $exporterConfig = $customReporters[$reportType]; + $class = $exporterConfig['class'] ?? ''; + $file = $exporterConfig['file'] ?? null; + + if ($file !== null && !file_exists($file)) { + return "Exporter file not found: {$file}"; + } + + if ($file === null && !class_exists($class)) { + return "Exporter class not found: {$class}"; + } + + return "Custom exporter `{$reportType}` validation failed"; + } + + private function validateCustomExporter(string $reportType): bool + { + try { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['churn'] ?? []; + + if (!isset($customReporters[$reportType])) { + return false; + } + + $exporterConfig = $customReporters[$reportType]; + $class = $exporterConfig['class'] ?? ''; + $file = $exporterConfig['file'] ?? null; + + // Validate file exists if specified + if ($file !== null && !file_exists($file)) { + return false; + } + + // For file-based exporters, we'll do basic validation + // The actual class loading will happen later with proper autoloading + if ($file !== null) { + // Check if the file is readable + try { + $content = file_get_contents($file); + if ($content === false) { + return false; + } + + // Basic check: does the file contain a class with the expected name? + // We'll look for the class name without the namespace prefix + $className = basename(str_replace('\\', '/', $class)); + return strpos($content, $className) !== false; + } catch (\Throwable) { + return false; + } + } + + // If no file specified, class should be autoloadable + return class_exists($class); + } catch (\Exception) { + return false; + } + } +} diff --git a/src/Command/ChurnSpecifications/ReportOptionsComplete.php b/src/Command/ChurnSpecifications/ReportOptionsComplete.php new file mode 100644 index 0000000..9e0afd0 --- /dev/null +++ b/src/Command/ChurnSpecifications/ReportOptionsComplete.php @@ -0,0 +1,23 @@ +getReportType(); + $reportFile = $context->getReportFile(); + + // Either both are provided or neither + return ($reportType !== null && $reportFile !== null) || + ($reportType === null && $reportFile === null); + } + + public function getErrorMessage(): string + { + return 'Both report type and file must be provided.'; + } +} diff --git a/src/Command/CognitiveMetricsCommand.php b/src/Command/CognitiveMetricsCommand.php index 87e69f0..2d9ebab 100644 --- a/src/Command/CognitiveMetricsCommand.php +++ b/src/Command/CognitiveMetricsCommand.php @@ -4,16 +4,17 @@ 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\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\Presentation\CognitiveMetricTextRendererInterface; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsCommandContext; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CompositeCognitiveMetricsValidationSpecification; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CognitiveMetricsValidationSpecificationFactory; +use Phauthentic\CognitiveCodeAnalysis\Command\CognitiveMetricsSpecifications\CustomExporterValidation; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -22,6 +23,8 @@ /** * Command to parse PHP files or directories and output method metrics. + * + * @SuppressWarnings("CyclomaticComplexity") */ #[AsCommand( name: 'analyse', @@ -40,17 +43,23 @@ class CognitiveMetricsCommand extends Command public const OPTION_COVERAGE_CLOVER = 'coverage-clover'; private const ARGUMENT_PATH = 'path'; + private CompositeCognitiveMetricsValidationSpecification $specification; + public function __construct( readonly private MetricsFacade $metricsFacade, readonly private CognitiveMetricTextRendererInterface $renderer, - readonly private Baseline $baselineService, readonly private CognitiveMetricsReportHandler $reportHandler, - readonly private CognitiveMetricsSorter $sorter, - readonly private CodeCoverageFactory $coverageFactory + readonly private ConfigurationLoadHandler $configHandler, + readonly private CoverageLoadHandler $coverageHandler, + readonly private BaselineHandler $baselineHandler, + readonly private SortingHandler $sortingHandler, + readonly private CognitiveMetricsValidationSpecificationFactory $specificationFactory ) { parent::__construct(); + $this->specification = $this->specificationFactory->create(); } + /** * Configures the command options and arguments. */ @@ -122,228 +131,88 @@ protected function configure(): void * @param InputInterface $input * @param OutputInterface $output * @return int Command status code. - * @throws Exception + * @throws \Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException */ protected function execute(InputInterface $input, OutputInterface $output): int { - $pathInput = $input->getArgument(self::ARGUMENT_PATH); - $paths = $this->parsePaths($pathInput); + $context = new CognitiveMetricsCommandContext($input); - $configFile = $input->getOption(self::OPTION_CONFIG_FILE); - if ($configFile && !$this->loadConfiguration($configFile, $output)) { - return Command::FAILURE; + // Validate all specifications + if (!$this->specification->isSatisfiedBy($context)) { + return $this->handleValidationError($context, $output); } - $coverageReader = $this->handleCoverageOptions($input, $output); - if ($coverageReader === false) { - return Command::FAILURE; + // Load configuration + $configResult = $this->configHandler->load($context); + if ($configResult->isFailure()) { + return $configResult->toCommandStatus($output); } - $metricsCollection = $this->metricsFacade->getCognitiveMetricsFromPaths($paths, $coverageReader); - - $this->handleBaseLine($input, $metricsCollection); + // Validate custom exporters after config is loaded + if ($context->hasReportOptions()) { + $customExporterValidation = new CustomExporterValidation( + $this->reportHandler->getReportFactory(), + $this->reportHandler->getConfigService() + ); - $sortResult = $this->applySorting($input, $output, $metricsCollection); - if ($sortResult['status'] === Command::FAILURE) { - return Command::FAILURE; + if (!$customExporterValidation->isSatisfiedBy($context)) { + return $this->handleValidationError($context, $output, $customExporterValidation); + } } - $metricsCollection = $sortResult['collection']; - $reportType = $input->getOption(self::OPTION_REPORT_TYPE); - $reportFile = $input->getOption(self::OPTION_REPORT_FILE); - - if ($reportType !== null || $reportFile !== null) { - return $this->reportHandler->handle($metricsCollection, $reportType, $reportFile); + // Load coverage reader + $coverageResult = $this->coverageHandler->load($context); + if ($coverageResult->isFailure()) { + return $coverageResult->toCommandStatus($output); } - $this->renderer->render($metricsCollection, $output); - - return Command::SUCCESS; - } - - /** - * Parses the path input to handle both single paths and comma-separated multiple paths. - * - * @param string $pathInput The input path(s) from the command argument - * @return array Array of paths to process - */ - private function parsePaths(string $pathInput): array - { - $paths = array_map('trim', explode(',', $pathInput)); - return array_filter($paths, function ($path) { - return !empty($path); - }); - } + // Get metrics + $metricsCollection = $this->metricsFacade->getCognitiveMetricsFromPaths( + $context->getPaths(), + $coverageResult->getData() + ); - /** - * Handles the baseline option and loads the baseline file if provided. - * - * @param InputInterface $input - * @param CognitiveMetricsCollection $metricsCollection - * @throws Exception - */ - private function handleBaseLine(InputInterface $input, CognitiveMetricsCollection $metricsCollection): void - { - $baselineFile = $input->getOption(self::OPTION_BASELINE); - if (!$baselineFile) { - return; + // Apply baseline + $baselineResult = $this->baselineHandler->apply($context, $metricsCollection); + if ($baselineResult->isFailure()) { + return $baselineResult->toCommandStatus($output); } - $baseline = $this->baselineService->loadBaseline($baselineFile); - $this->baselineService->calculateDeltas($metricsCollection, $baseline); - } - - /** - * 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; + // Apply sorting + $sortResult = $this->sortingHandler->sort($context, $metricsCollection); + if ($sortResult->isFailure()) { + return $sortResult->toCommandStatus($output); } - $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]; + // Generate report or display results + if ($context->hasReportOptions()) { + return $this->reportHandler->handle( + $sortResult->getData(), + $context->getReportType(), + $context->getReportFile() + ); } - 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]; - } - } + $this->renderer->render($sortResult->getData(), $output); - /** - * Loads configuration and handles errors. - * - * @param string $configFile - * @param OutputInterface $output - * @return bool Success or failure. - */ - private function loadConfiguration(string $configFile, OutputInterface $output): bool - { - try { - $this->metricsFacade->loadConfig($configFile); - return true; - } catch (Exception $e) { - $output->writeln('Failed to load configuration: ' . $e->getMessage() . ''); - return false; - } + return Command::SUCCESS; } - /** - * 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 + * Handle validation errors with consistent error output. */ - 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=.*getErrorMessageWithContext($context) + : $this->specification->getDetailedErrorMessage($context); - // Coverage file was provided but doesn't exist - show error - $output->writeln(sprintf( - 'Coverage file not found: %s', - $coverageFile - )); + $output->writeln('' . $errorMessage . ''); - return false; + return Command::FAILURE; } } diff --git a/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php new file mode 100644 index 0000000..d59962c --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsCommandContext.php @@ -0,0 +1,115 @@ +input->getOption('config'); + } + + public function hasConfigFile(): bool + { + return $this->getConfigFile() !== null; + } + + public function getCoberturaFile(): ?string + { + return $this->input->getOption('coverage-cobertura'); + } + + public function getCloverFile(): ?string + { + return $this->input->getOption('coverage-clover'); + } + + public function hasCoberturaFile(): bool + { + return $this->getCoberturaFile() !== null; + } + + public function hasCloverFile(): bool + { + return $this->getCloverFile() !== null; + } + + public function getCoverageFile(): ?string + { + return $this->getCoberturaFile() ?? $this->getCloverFile(); + } + + public function getCoverageFormat(): ?string + { + if ($this->hasCoberturaFile()) { + return 'cobertura'; + } + if ($this->hasCloverFile()) { + return 'clover'; + } + return null; + } + + public function getReportType(): ?string + { + return $this->input->getOption('report-type'); + } + + public function getReportFile(): ?string + { + return $this->input->getOption('report-file'); + } + + public function hasReportOptions(): bool + { + return $this->getReportType() !== null || $this->getReportFile() !== null; + } + + public function getSortBy(): ?string + { + return $this->input->getOption('sort-by'); + } + + public function getSortOrder(): string + { + return $this->input->getOption('sort-order') ?? 'asc'; + } + + public function hasSortingOptions(): bool + { + return $this->getSortBy() !== null; + } + + public function getBaselineFile(): ?string + { + return $this->input->getOption('baseline'); + } + + public function hasBaselineFile(): bool + { + return $this->getBaselineFile() !== null; + } + + /** + * @return array + */ + public function getPaths(): array + { + $pathInput = $this->input->getArgument('path'); + return array_map('trim', explode(',', $pathInput)); + } + + public function getDebug(): bool + { + return (bool) $this->input->getOption('debug'); + } +} diff --git a/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecification.php b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecification.php new file mode 100644 index 0000000..e0a3401 --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecification.php @@ -0,0 +1,14 @@ +specifications as $specification) { + if (!$specification->isSatisfiedBy($context)) { + return false; + } + } + return true; + } + + public function getErrorMessage(): string + { + return 'Validation failed'; + } + + public function getFirstFailedSpecification( + CognitiveMetricsCommandContext $context + ): ?CognitiveMetricsSpecification { + foreach ($this->specifications as $specification) { + if (!$specification->isSatisfiedBy($context)) { + return $specification; + } + } + return null; + } + + public function getDetailedErrorMessage(CognitiveMetricsCommandContext $context): string + { + foreach ($this->specifications as $specification) { + if (!$specification->isSatisfiedBy($context)) { + // Use context-specific error message if available + if (method_exists($specification, 'getErrorMessageWithContext')) { + return $specification->getErrorMessageWithContext($context); + } + return $specification->getErrorMessage(); + } + } + return ''; + } +} diff --git a/src/Command/CognitiveMetricsSpecifications/CoverageFileExists.php b/src/Command/CognitiveMetricsSpecifications/CoverageFileExists.php new file mode 100644 index 0000000..1bd1a9a --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/CoverageFileExists.php @@ -0,0 +1,24 @@ +getCoverageFile(); + return $coverageFile === null || file_exists($coverageFile); + } + + public function getErrorMessage(): string + { + return 'Coverage file not found'; + } + + public function getErrorMessageWithContext(CognitiveMetricsCommandContext $context): string + { + return sprintf('Coverage file not found: %s', $context->getCoverageFile()); + } +} diff --git a/src/Command/CognitiveMetricsSpecifications/CoverageFormatExclusivity.php b/src/Command/CognitiveMetricsSpecifications/CoverageFormatExclusivity.php new file mode 100644 index 0000000..11f42bb --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/CoverageFormatExclusivity.php @@ -0,0 +1,18 @@ +hasCoberturaFile() && $context->hasCloverFile()); + } + + public function getErrorMessage(): string + { + return 'Only one coverage format can be specified at a time.'; + } +} diff --git a/src/Command/CognitiveMetricsSpecifications/CoverageFormatSupported.php b/src/Command/CognitiveMetricsSpecifications/CoverageFormatSupported.php new file mode 100644 index 0000000..4dd7edf --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/CoverageFormatSupported.php @@ -0,0 +1,24 @@ +getCoverageFormat(); + return $format === null || in_array($format, ['cobertura', 'clover'], true); + } + + public function getErrorMessage(): string + { + return 'Unsupported coverage format'; + } + + public function getErrorMessageWithContext(CognitiveMetricsCommandContext $context): string + { + return sprintf('Unsupported coverage format: %s', $context->getCoverageFormat()); + } +} diff --git a/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php b/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php new file mode 100644 index 0000000..9dcf04d --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/CustomExporterValidation.php @@ -0,0 +1,123 @@ +hasReportOptions()) { + return true; + } + + $reportType = $context->getReportType(); + if ($reportType === null) { + return true; + } + + // Check if it's a built-in type (always valid) + $builtInTypes = ['json', 'csv', 'html', 'markdown']; + if (in_array($reportType, $builtInTypes, true)) { + return true; + } + + // For custom exporters, validate they can be loaded + return $this->validateCustomExporter($reportType); + } + + public function getErrorMessage(): string + { + return 'Custom exporter validation failed'; + } + + public function getErrorMessageWithContext(CognitiveMetricsCommandContext $context): string + { + $reportType = $context->getReportType(); + if ($reportType === null) { + return 'Report type is required for validation'; + } + + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['cognitive'] ?? []; + + if (!isset($customReporters[$reportType])) { + $supportedTypes = implode('`, `', $this->reportFactory->getSupportedTypes()); + return "Custom exporter `{$reportType}` not found in configuration. Supported types: `{$supportedTypes}`"; + } + + $exporterConfig = $customReporters[$reportType]; + $class = $exporterConfig['class'] ?? ''; + $file = $exporterConfig['file'] ?? null; + + if ($file !== null && !file_exists($file)) { + return "Exporter file not found: {$file}"; + } + + if ($file === null && !class_exists($class)) { + return "Exporter class not found: {$class}"; + } + + return "Custom exporter `{$reportType}` validation failed"; + } + + private function validateCustomExporter(string $reportType): bool + { + try { + $config = $this->configService->getConfig(); + $customReporters = $config->customReporters['cognitive'] ?? []; + + if (!isset($customReporters[$reportType])) { + return false; + } + + $exporterConfig = $customReporters[$reportType]; + $class = $exporterConfig['class'] ?? ''; + $file = $exporterConfig['file'] ?? null; + + // Validate file exists if specified + if ($file !== null && !file_exists($file)) { + return false; + } + + // For file-based exporters, we'll do basic validation + // The actual class loading will happen later with proper autoloading + if ($file !== null) { + // Check if the file is readable + try { + $content = file_get_contents($file); + if ($content === false) { + return false; + } + + // Basic check: does the file contain a class with the expected name? + // We'll look for the class name without the namespace prefix + $className = basename(str_replace('\\', '/', $class)); + return strpos($content, $className) !== false; + } catch (\Throwable) { + return false; + } + } + + // If no file specified, class should be autoloadable + return class_exists($class); + } catch (\Exception) { + return false; + } + } +} diff --git a/src/Command/CognitiveMetricsSpecifications/SortFieldValid.php b/src/Command/CognitiveMetricsSpecifications/SortFieldValid.php new file mode 100644 index 0000000..d8b8ef8 --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/SortFieldValid.php @@ -0,0 +1,59 @@ +getSortBy(); + + // If no sort field is specified, validation passes + if ($sortBy === null) { + return true; + } + + return in_array($sortBy, self::SORTABLE_FIELDS, true); + } + + public function getErrorMessage(): string + { + return 'Invalid sort field provided.'; + } + + public function getErrorMessageWithContext(CognitiveMetricsCommandContext $context): string + { + return sprintf( + 'Invalid sort field "%s". Available fields: %s', + $context->getSortBy(), + implode(', ', self::SORTABLE_FIELDS) + ); + } +} diff --git a/src/Command/CognitiveMetricsSpecifications/SortOrderValid.php b/src/Command/CognitiveMetricsSpecifications/SortOrderValid.php new file mode 100644 index 0000000..3df3b98 --- /dev/null +++ b/src/Command/CognitiveMetricsSpecifications/SortOrderValid.php @@ -0,0 +1,29 @@ +getSortOrder(); + return in_array(strtolower($sortOrder), self::VALID_SORT_ORDERS, true); + } + + public function getErrorMessage(): string + { + return 'Sort order must be "asc" or "desc"'; + } + + public function getErrorMessageWithContext(CognitiveMetricsCommandContext $context): string + { + return sprintf( + 'Sort order must be "asc" or "desc", got "%s"', + $context->getSortOrder() + ); + } +} diff --git a/src/Command/Handler/ChurnReportHandler.php b/src/Command/Handler/ChurnReportHandler.php index 78a639b..78647f8 100644 --- a/src/Command/Handler/ChurnReportHandler.php +++ b/src/Command/Handler/ChurnReportHandler.php @@ -5,29 +5,29 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Handler; use Exception; -use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\ChurnExporterFactory; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\ChurnReportFactoryInterface; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; +use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Output\OutputInterface; class ChurnReportHandler { - private ChurnExporterFactory $exporterFactory; - public function __construct( private MetricsFacade $metricsFacade, - private OutputInterface $output + private OutputInterface $output, + private ChurnReportFactoryInterface $exporterFactory ) { - $this->exporterFactory = new ChurnExporterFactory(); } /** * Handles report option validation and report generation. * - * @param array> $classes + * @param ChurnMetricsCollection $metrics */ public function exportToFile( - array $classes, + ChurnMetricsCollection $metrics, ?string $reportType, ?string $reportFile, ): int { @@ -43,7 +43,7 @@ public function exportToFile( try { $this->metricsFacade->exportChurnReport( - classes: $classes, + metrics: $metrics, reportType: (string)$reportType, filename: (string)$reportFile ); @@ -88,4 +88,14 @@ private function handleInvalidReportType(?string $reportType): int return Command::FAILURE; } + + public function getReportFactory(): ChurnReportFactoryInterface + { + return $this->exporterFactory; + } + + public function getConfigService(): ConfigService + { + return $this->metricsFacade->getConfigService(); + } } diff --git a/src/Command/Handler/CognitiveAnalysis/BaselineHandler.php b/src/Command/Handler/CognitiveAnalysis/BaselineHandler.php new file mode 100644 index 0000000..9ed0bd4 --- /dev/null +++ b/src/Command/Handler/CognitiveAnalysis/BaselineHandler.php @@ -0,0 +1,50 @@ +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/ConfigurationLoadHandler.php b/src/Command/Handler/CognitiveAnalysis/ConfigurationLoadHandler.php new file mode 100644 index 0000000..657de13 --- /dev/null +++ b/src/Command/Handler/CognitiveAnalysis/ConfigurationLoadHandler.php @@ -0,0 +1,46 @@ +hasConfigFile()) { + return OperationResult::success(); + } + + $configFile = $context->getConfigFile(); + if ($configFile === null) { + return OperationResult::success(); + } + + try { + $this->metricsFacade->loadConfig($configFile); + return OperationResult::success(); + } catch (Exception $e) { + return OperationResult::failure('Failed to load configuration: ' . $e->getMessage()); + } + } +} diff --git a/src/Command/Handler/CognitiveAnalysis/CoverageLoadHandler.php b/src/Command/Handler/CognitiveAnalysis/CoverageLoadHandler.php new file mode 100644 index 0000000..86ce40c --- /dev/null +++ b/src/Command/Handler/CognitiveAnalysis/CoverageLoadHandler.php @@ -0,0 +1,76 @@ +getCoverageFile(); + $format = $context->getCoverageFormat(); + + if ($coverageFile === null) { + return OperationResult::success(null); + } + + // Auto-detect format if not specified + if ($format === null) { + $format = $this->detectCoverageFormat($coverageFile); + if ($format === null) { + return OperationResult::failure('Unable to detect coverage file format. Please specify format explicitly.'); + } + } + + try { + $reader = $this->coverageFactory->createFromName($format, $coverageFile); + return OperationResult::success($reader); + } catch (CognitiveAnalysisException $e) { + return OperationResult::failure('Failed to load coverage file: ' . $e->getMessage()); + } + } + + /** + * 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=.*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 index a1720f9..0051bb3 100644 --- a/src/Command/Handler/CognitiveMetricsReportHandler.php +++ b/src/Command/Handler/CognitiveMetricsReportHandler.php @@ -6,7 +6,9 @@ use Exception; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\CognitiveReportFactoryInterface; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; +use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Output\OutputInterface; @@ -14,7 +16,8 @@ class CognitiveMetricsReportHandler { public function __construct( private MetricsFacade $metricsFacade, - private OutputInterface $output + private OutputInterface $output, + private CognitiveReportFactoryInterface $reportFactory ) { } @@ -56,7 +59,10 @@ private function hasIncompleteReportOptions(?string $reportType, ?string $report private function isValidReportType(?string $reportType): bool { - return in_array($reportType, ['json', 'csv', 'html', 'markdown']); + if ($reportType === null) { + return false; + } + return $this->reportFactory->isSupported($reportType); } private function handleExceptions(Exception $exception): int @@ -71,11 +77,24 @@ private function handleExceptions(Exception $exception): int public function handleInvalidReporType(?string $reportType): int { + $supportedTypes = $this->reportFactory->getSupportedTypes(); + $this->output->writeln(sprintf( - 'Invalid report type `%s` provided. Only `json`, `csv`, `html`, and `markdown` are accepted.', - $reportType + '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/Presentation/ChurnTextRenderer.php b/src/Command/Presentation/ChurnTextRenderer.php index bfdc0d8..f6e31ec 100644 --- a/src/Command/Presentation/ChurnTextRenderer.php +++ b/src/Command/Presentation/ChurnTextRenderer.php @@ -4,7 +4,8 @@ namespace Phauthentic\CognitiveCodeAnalysis\Command\Presentation; -use Phauthentic\CognitiveCodeAnalysis\Business\Traits\CoverageDataDetector; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; +use Phauthentic\CognitiveCodeAnalysis\Business\Utility\CoverageDataDetector; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Output\OutputInterface; @@ -48,38 +49,34 @@ public function reportWritten(string $reportFile): void )); } - /** - * @param array $classes An associative array where keys are class names and values are arrays - * containing 'score', 'churn', 'timesChanged', and optionally 'coverage', 'riskChurn', 'riskLevel'. - */ - public function renderChurnTable(array $classes): void + public function renderChurnTable(ChurnMetricsCollection $metrics): void { // Determine if coverage data is available - $hasCoverageData = $this->hasCoverageData($classes); + $hasCoverageData = $this->hasCoverageData($metrics); $table = new Table($this->output); $table->setHeaders($hasCoverageData ? $this->churnTableHeaderWithCoverage : $this->churnTableHeader); - foreach ($classes as $className => $data) { - if ($data['score'] == 0 || $data['churn'] == 0) { + foreach ($metrics as $metric) { + if ($metric->getScore() == 0 || $metric->getChurn() == 0) { continue; } $row = [ - $className, - $data['score'], - round($data['churn'], 3), + $metric->getClassName(), + $metric->getScore(), + round($metric->getChurn(), 3), ]; if ($hasCoverageData) { - $row[] = $data['riskChurn'] !== null ? round($data['riskChurn'], 3) : 'N/A'; + $row[] = $metric->getRiskChurn() !== null ? round($metric->getRiskChurn(), 3) : 'N/A'; } - $row[] = $data['timesChanged']; + $row[] = $metric->getTimesChanged(); if ($hasCoverageData) { - $row[] = $data['coverage'] !== null ? sprintf('%.2f%%', $data['coverage'] * 100) : 'N/A'; - $row[] = $data['riskLevel'] ?? 'N/A'; + $row[] = $metric->getCoverage() !== null ? sprintf('%.2f%%', $metric->getCoverage() * 100) : 'N/A'; + $row[] = $metric->getRiskLevel() ?? 'N/A'; } $table->addRow($row); @@ -87,4 +84,17 @@ public function renderChurnTable(array $classes): void $table->render(); } + + /** + * Check if the metrics collection has coverage data + */ + private function hasCoverageData(ChurnMetricsCollection $metrics): bool + { + foreach ($metrics as $metric) { + if ($metric->hasCoverageData()) { + return true; + } + } + return false; + } } diff --git a/src/Command/Presentation/CognitiveMetricTextRenderer.php b/src/Command/Presentation/CognitiveMetricTextRenderer.php index cb81d27..44ea657 100644 --- a/src/Command/Presentation/CognitiveMetricTextRenderer.php +++ b/src/Command/Presentation/CognitiveMetricTextRenderer.php @@ -6,7 +6,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; -use Phauthentic\CognitiveCodeAnalysis\Business\Traits\CoverageDataDetector; +use Phauthentic\CognitiveCodeAnalysis\Business\Utility\CoverageDataDetector; use Phauthentic\CognitiveCodeAnalysis\Config\CognitiveConfig; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; use Symfony\Component\Console\Helper\Table; diff --git a/src/Command/Result/OperationResult.php b/src/Command/Result/OperationResult.php new file mode 100644 index 0000000..bd97372 --- /dev/null +++ b/src/Command/Result/OperationResult.php @@ -0,0 +1,83 @@ +success; + } + + /** + * Check if the operation failed. + */ + public function isFailure(): bool + { + return !$this->success; + } + + /** + * Get the data from the operation (only available on success). + */ + public function getData(): mixed + { + return $this->data; + } + + /** + * Get the error message (only available on failure). + */ + public function getErrorMessage(): string + { + return $this->errorMessage; + } + + /** + * Convert the result to a command status code. + * Outputs error message if failed, returns appropriate status code. + */ + public function toCommandStatus(OutputInterface $output): int + { + if ($this->isFailure()) { + $output->writeln('' . $this->errorMessage . ''); + return 1; // Command::FAILURE + } + + return 0; // Command::SUCCESS + } +} diff --git a/src/Config/CognitiveConfig.php b/src/Config/CognitiveConfig.php index 0279f49..6ec5d33 100644 --- a/src/Config/CognitiveConfig.php +++ b/src/Config/CognitiveConfig.php @@ -13,6 +13,8 @@ class CognitiveConfig * @param array $excludeFilePatterns * @param array $excludePatterns * @param array $metrics + * @param array> $customReporters + * @SuppressWarnings("PHPMD.ExcessiveParameterList") */ public function __construct( public readonly array $excludeFilePatterns, @@ -24,6 +26,7 @@ public function __construct( public readonly bool $showCyclomaticComplexity = false, public readonly bool $groupByClass = false, public readonly bool $showDetailedCognitiveMetrics = true, + public readonly array $customReporters = [], ) { } } diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php index 17641aa..55876b4 100644 --- a/src/Config/ConfigFactory.php +++ b/src/Config/ConfigFactory.php @@ -29,7 +29,8 @@ public function fromArray(array $config): CognitiveConfig showHalsteadComplexity: $config['cognitive']['showHalsteadComplexity'] ?? false, showCyclomaticComplexity: $config['cognitive']['showCyclomaticComplexity'] ?? false, groupByClass: $config['cognitive']['groupByClass'] ?? true, - showDetailedCognitiveMetrics: $config['cognitive']['showDetailedCognitiveMetrics'] ?? true + showDetailedCognitiveMetrics: $config['cognitive']['showDetailedCognitiveMetrics'] ?? true, + customReporters: $config['cognitive']['customReporters'] ?? [] ); } } diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index c5ea35b..7834c5f 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -70,6 +70,9 @@ public function getCognitiveMetricDefaults(): array ]; } + /** + * @SuppressWarnings("ExcessiveMethodLength") + */ public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('config'); @@ -129,6 +132,36 @@ public function getConfigTreeBuilder(): TreeBuilder }) ->end() ->end() + ->arrayNode('customReporters') + ->children() + ->arrayNode('cognitive') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('class') + ->isRequired() + ->end() + ->scalarNode('file') + ->defaultValue(null) + ->end() + ->end() + ->end() + ->end() + ->arrayNode('churn') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('class') + ->isRequired() + ->end() + ->scalarNode('file') + ->defaultValue(null) + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() ->end() ->end() ->end(); diff --git a/src/Config/ConfigService.php b/src/Config/ConfigService.php index 725d601..187a773 100644 --- a/src/Config/ConfigService.php +++ b/src/Config/ConfigService.php @@ -38,9 +38,12 @@ private function loadDefaultConfig(): void */ public function loadConfig(string $configFilePath): void { + $defaultConfig = Yaml::parseFile(__DIR__ . '/../../config.yml'); + $providedConfig = Yaml::parseFile($configFilePath); + $config = $this->processor->processConfiguration($this->configuration, [ - Yaml::parseFile(__DIR__ . '/../../config.yml'), - Yaml::parseFile($configFilePath), + $defaultConfig, + $providedConfig, ]); $this->config = (new ConfigFactory())->fromArray($config); diff --git a/src/PhpParser/CombinedMetricsVisitor.php b/src/PhpParser/CombinedMetricsVisitor.php index 6c4c46a..3e920a6 100644 --- a/src/PhpParser/CombinedMetricsVisitor.php +++ b/src/PhpParser/CombinedMetricsVisitor.php @@ -4,8 +4,8 @@ namespace Phauthentic\CognitiveCodeAnalysis\PhpParser; +use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticComplexityCalculator; use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetricsCalculator; -use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculator; use PhpParser\Node; use PhpParser\NodeVisitor; diff --git a/src/PhpParser/CyclomaticComplexityVisitor.php b/src/PhpParser/CyclomaticComplexityVisitor.php index a3ee371..b7499db 100644 --- a/src/PhpParser/CyclomaticComplexityVisitor.php +++ b/src/PhpParser/CyclomaticComplexityVisitor.php @@ -4,7 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\PhpParser; -use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculatorInterface; +use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticComplexityCalculatorInterface; use PhpParser\Node; use PhpParser\NodeVisitorAbstract; diff --git a/tests/Command/ChurnSpecifications/ChurnSpecificationPatternTest.php b/tests/Command/ChurnSpecifications/ChurnSpecificationPatternTest.php new file mode 100644 index 0000000..4eda900 --- /dev/null +++ b/tests/Command/ChurnSpecifications/ChurnSpecificationPatternTest.php @@ -0,0 +1,109 @@ +createInput([ + 'path' => '/test', + '--coverage-cobertura' => 'coverage.xml' + ]); + $context1 = new ChurnCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test valid case - only clover + $input2 = $this->createInput([ + 'path' => '/test', + '--coverage-clover' => 'coverage.xml' + ]); + $context2 = new ChurnCommandContext($input2); + $this->assertTrue($spec->isSatisfiedBy($context2)); + + // Test invalid case - both formats + $input3 = $this->createInput([ + 'path' => '/test', + '--coverage-cobertura' => 'cobertura.xml', + '--coverage-clover' => 'clover.xml' + ]); + $context3 = new ChurnCommandContext($input3); + $this->assertFalse($spec->isSatisfiedBy($context3)); + $this->assertEquals('Only one coverage format can be specified at a time.', $spec->getErrorMessage()); + } + + public function testCoverageFileExistsSpecification(): void + { + $spec = new CoverageFileExists(); + + // Test valid case - no coverage file + $input1 = $this->createInput(['path' => '/test']); + $context1 = new ChurnCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test invalid case - non-existent file + $input2 = $this->createInput([ + 'path' => '/test', + '--coverage-cobertura' => '/non/existent/file.xml' + ]); + $context2 = new ChurnCommandContext($input2); + $this->assertFalse($spec->isSatisfiedBy($context2)); + $this->assertStringContainsString('Coverage file not found', $spec->getErrorMessage()); + } + + public function testCompositeValidationSpecification(): void + { + $spec = new CompositeChurnSpecification([ + new CoverageFormatExclusivity(), + new CoverageFileExists(), + ]); + + // Test valid case + $input1 = $this->createInput(['path' => '/test']); + $context1 = new ChurnCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test invalid case - both coverage formats + $input2 = $this->createInput([ + 'path' => '/test', + '--coverage-cobertura' => 'cobertura.xml', + '--coverage-clover' => 'clover.xml' + ]); + $context2 = new ChurnCommandContext($input2); + $this->assertFalse($spec->isSatisfiedBy($context2)); + + $failedSpec = $spec->getFirstFailedSpecification($context2); + $this->assertInstanceOf(CoverageFormatExclusivity::class, $failedSpec); + $this->assertEquals('Only one coverage format can be specified at a time.', $failedSpec->getErrorMessage()); + } +} diff --git a/tests/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php b/tests/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php new file mode 100644 index 0000000..225f63f --- /dev/null +++ b/tests/Command/CognitiveMetricsSpecifications/CognitiveMetricsSpecificationPatternTest.php @@ -0,0 +1,209 @@ +createInput([ + 'path' => '/test', + '--coverage-cobertura' => 'coverage.xml' + ]); + $context1 = new CognitiveMetricsCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test valid case - only clover + $input2 = $this->createInput([ + 'path' => '/test', + '--coverage-clover' => 'coverage.xml' + ]); + $context2 = new CognitiveMetricsCommandContext($input2); + $this->assertTrue($spec->isSatisfiedBy($context2)); + + // Test invalid case - both formats + $input3 = $this->createInput([ + 'path' => '/test', + '--coverage-cobertura' => 'cobertura.xml', + '--coverage-clover' => 'clover.xml' + ]); + $context3 = new CognitiveMetricsCommandContext($input3); + $this->assertFalse($spec->isSatisfiedBy($context3)); + $this->assertEquals('Only one coverage format can be specified at a time.', $spec->getErrorMessage()); + } + + public function testCoverageFileExistsSpecification(): void + { + $spec = new CoverageFileExists(); + + // Test valid case - no coverage file + $input1 = $this->createInput(['path' => '/test']); + $context1 = new CognitiveMetricsCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test invalid case - non-existent file + $input2 = $this->createInput([ + 'path' => '/test', + '--coverage-cobertura' => '/non/existent/file.xml' + ]); + $context2 = new CognitiveMetricsCommandContext($input2); + $this->assertFalse($spec->isSatisfiedBy($context2)); + $this->assertStringContainsString('Coverage file not found', $spec->getErrorMessage()); + } + + public function testSortFieldValidSpecification(): void + { + $spec = new SortFieldValid(); + + // Test valid case - no sort field + $input1 = $this->createInput(['path' => '/test']); + $context1 = new CognitiveMetricsCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test valid case - valid sort field + $input2 = $this->createInput([ + 'path' => '/test', + '--sort-by' => 'score' + ]); + $context2 = new CognitiveMetricsCommandContext($input2); + $this->assertTrue($spec->isSatisfiedBy($context2)); + + // Test invalid case - invalid sort field + $input3 = $this->createInput([ + 'path' => '/test', + '--sort-by' => 'invalid_field' + ]); + $context3 = new CognitiveMetricsCommandContext($input3); + $this->assertFalse($spec->isSatisfiedBy($context3)); + $this->assertEquals('Invalid sort field provided.', $spec->getErrorMessage()); + + // Test detailed error message + $this->assertStringContainsString('Invalid sort field "invalid_field"', $spec->getErrorMessageWithContext($context3)); + $this->assertStringContainsString('Available fields:', $spec->getErrorMessageWithContext($context3)); + } + + public function testSortOrderValidSpecification(): void + { + $spec = new SortOrderValid(); + + // Test valid case - asc + $input1 = $this->createInput([ + 'path' => '/test', + '--sort-order' => 'asc' + ]); + $context1 = new CognitiveMetricsCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test valid case - desc + $input2 = $this->createInput([ + 'path' => '/test', + '--sort-order' => 'desc' + ]); + $context2 = new CognitiveMetricsCommandContext($input2); + $this->assertTrue($spec->isSatisfiedBy($context2)); + + // Test invalid case - invalid sort order + $input3 = $this->createInput([ + 'path' => '/test', + '--sort-order' => 'invalid' + ]); + $context3 = new CognitiveMetricsCommandContext($input3); + $this->assertFalse($spec->isSatisfiedBy($context3)); + $this->assertEquals('Sort order must be "asc" or "desc"', $spec->getErrorMessage()); + + // Test detailed error message + $this->assertStringContainsString('Sort order must be "asc" or "desc", got "invalid"', $spec->getErrorMessageWithContext($context3)); + } + + public function testCompositeValidationSpecification(): void + { + $spec = new CompositeCognitiveMetricsValidationSpecification([ + new CoverageFormatExclusivity(), + new CoverageFileExists(), + new SortFieldValid(), + ]); + + // Test valid case + $input1 = $this->createInput(['path' => '/test']); + $context1 = new CognitiveMetricsCommandContext($input1); + $this->assertTrue($spec->isSatisfiedBy($context1)); + + // Test invalid case - both coverage formats + $input2 = $this->createInput([ + 'path' => '/test', + '--coverage-cobertura' => 'cobertura.xml', + '--coverage-clover' => 'clover.xml' + ]); + $context2 = new CognitiveMetricsCommandContext($input2); + $this->assertFalse($spec->isSatisfiedBy($context2)); + + $failedSpec = $spec->getFirstFailedSpecification($context2); + $this->assertInstanceOf(CoverageFormatExclusivity::class, $failedSpec); + $this->assertEquals('Only one coverage format can be specified at a time.', $failedSpec->getErrorMessage()); + + // Test detailed error message + $detailedError = $spec->getDetailedErrorMessage($context2); + $this->assertEquals('Only one coverage format can be specified at a time.', $detailedError); + } + + public function testCognitiveMetricsCommandContext(): void + { + $input = $this->createInput([ + 'path' => '/test/path', + '--config' => 'config.yml', + '--coverage-cobertura' => 'coverage.xml', + '--sort-by' => 'score', + '--sort-order' => 'desc', + '--baseline' => 'baseline.json', + '--debug' => true + ]); + + $context = new CognitiveMetricsCommandContext($input); + + $this->assertEquals('/test/path', $context->getPaths()[0]); + $this->assertTrue($context->hasConfigFile()); + $this->assertEquals('config.yml', $context->getConfigFile()); + $this->assertTrue($context->hasCoberturaFile()); + $this->assertEquals('coverage.xml', $context->getCoberturaFile()); + $this->assertEquals('cobertura', $context->getCoverageFormat()); + $this->assertEquals('score', $context->getSortBy()); + $this->assertEquals('desc', $context->getSortOrder()); + $this->assertTrue($context->hasBaselineFile()); + $this->assertEquals('baseline.json', $context->getBaselineFile()); + $this->assertTrue($context->getDebug()); + } +} diff --git a/tests/Fixtures/Coverage/coverage-clover.xml b/tests/Fixtures/Coverage/coverage-clover.xml index a6cc46f..62bbb77 100644 --- a/tests/Fixtures/Coverage/coverage-clover.xml +++ b/tests/Fixtures/Coverage/coverage-clover.xml @@ -1886,7 +1886,7 @@ - + @@ -2043,7 +2043,7 @@ - + diff --git a/tests/Fixtures/Coverage/coverage.xml b/tests/Fixtures/Coverage/coverage.xml index fda4c47..35fbfa7 100644 --- a/tests/Fixtures/Coverage/coverage.xml +++ b/tests/Fixtures/Coverage/coverage.xml @@ -1755,7 +1755,7 @@ - + @@ -2788,7 +2788,7 @@ - + diff --git a/tests/Fixtures/CustomReporters/ConfigAwareChurnTextReporter.php b/tests/Fixtures/CustomReporters/ConfigAwareChurnTextReporter.php new file mode 100644 index 0000000..d6cf98b --- /dev/null +++ b/tests/Fixtures/CustomReporters/ConfigAwareChurnTextReporter.php @@ -0,0 +1,80 @@ +generateReport($metrics); + file_put_contents($filename, $content); + } + + private function generateReport(ChurnMetricsCollection $metrics): string + { + $output = "=== Config-Aware Churn Analysis Report ===\n"; + $output .= "Generated by ConfigAwareChurnTextReporter\n\n"; + + $output .= "Configuration:\n"; + $output .= "Score Threshold: " . $this->config->scoreThreshold . "\n"; + $output .= "Group By Class: " . ($this->config->groupByClass ? 'Yes' : 'No') . "\n"; + $output .= "Show Only Methods Exceeding Threshold: " . ($this->config->showOnlyMethodsExceedingThreshold ? 'Yes' : 'No') . "\n\n"; + + $output .= "Analysis Summary:\n"; + $output .= "Total Classes: " . count($metrics) . "\n"; + + $totalMethods = 0; + foreach ($metrics as $metric) { + // Count methods based on score (simplified) + $totalMethods += (int)$metric->getScore(); + } + $output .= "Total Methods: " . $totalMethods . "\n\n"; + + $output .= "Churn Analysis Results:\n"; + $output .= str_repeat("-", 50) . "\n"; + + $aboveThresholdCount = 0; + + foreach ($metrics as $metric) { + $score = $metric->getScore(); + $isAboveThreshold = $score > $this->config->scoreThreshold; + + if ($isAboveThreshold) { + $aboveThresholdCount++; + $output .= "Class: " . $metric->getClassName() . " [ABOVE THRESHOLD]\n"; + } else { + $output .= "Class: " . $metric->getClassName() . "\n"; + } + + $output .= "File: " . $metric->getFile() . "\n"; + $output .= "Score: " . $metric->getScore() . "\n"; + $output .= "Churn: " . $metric->getChurn() . "\n"; + $output .= "Times Changed: " . $metric->getTimesChanged() . "\n"; + + if ($metric->hasCoverageData()) { + $output .= "Coverage: " . ($metric->getCoverage() * 100) . "%\n"; + $output .= "Risk Level: " . $metric->getRiskLevel() . "\n"; + } + + $output .= "\n"; + } + + $output .= "Classes Above Threshold (" . $this->config->scoreThreshold . "): " . $aboveThresholdCount . "\n"; + + return $output; + } +} diff --git a/tests/Fixtures/CustomReporters/ConfigAwareTextReporter.php b/tests/Fixtures/CustomReporters/ConfigAwareTextReporter.php new file mode 100644 index 0000000..555655f --- /dev/null +++ b/tests/Fixtures/CustomReporters/ConfigAwareTextReporter.php @@ -0,0 +1,96 @@ +config = $config; + } + + public function export(CognitiveMetricsCollection $metrics, string $filename): void + { + // Ensure directory exists + $directory = dirname($filename); + if (!is_dir($directory)) { + throw new CognitiveAnalysisException("Directory {$directory} does not exist"); + } + + // Generate text content with config information + $content = $this->generateTextContent($metrics); + + // Write to file + if (file_put_contents($filename, $content) === false) { + throw new CognitiveAnalysisException("Could not write to file: {$filename}"); + } + } + + private function generateTextContent(CognitiveMetricsCollection $metrics): string + { + $content = "=== Config-Aware Cognitive Complexity Report ===\n"; + $content .= "Generated by ConfigAwareTextReporter\n"; + $content .= "===============================================\n\n"; + + // Include config information + $content .= "Configuration:\n"; + $content .= sprintf("- Score Threshold: %.2f\n", $this->config->scoreThreshold); + $content .= sprintf("- Group By Class: %s\n", $this->config->groupByClass ? 'Yes' : 'No'); + $content .= sprintf( + "- Show Only Methods Exceeding Threshold: %s\n", + $this->config->showOnlyMethodsExceedingThreshold ? 'Yes' : 'No' + ); + $content .= "\n"; + + $totalMethods = 0; + $totalScore = 0.0; + $methodsAboveThreshold = 0; + + foreach ($metrics as $metric) { + $isAboveThreshold = $metric->getScore() > $this->config->scoreThreshold; + $thresholdIndicator = $isAboveThreshold ? ' [ABOVE THRESHOLD]' : ''; + + $content .= sprintf( + "Class: %s, Method: %s, Score: %.2f%s\n", + $metric->getClass(), + $metric->getMethod(), + $metric->getScore(), + $thresholdIndicator + ); + + $totalMethods++; + $totalScore += $metric->getScore(); + + if (!$isAboveThreshold) { + continue; + } + + $methodsAboveThreshold++; + } + + $content .= "\n===============================================\n"; + $content .= sprintf("Total Methods: %d\n", $totalMethods); + $content .= sprintf("Average Score: %.2f\n", $totalMethods > 0 ? $totalScore / $totalMethods : 0); + $content .= sprintf( + "Methods Above Threshold (%.2f): %d\n", + $this->config->scoreThreshold, + $methodsAboveThreshold + ); + $content .= "===============================================\n"; + + return $content; + } +} diff --git a/tests/Fixtures/CustomReporters/CustomChurnTextReporter.php b/tests/Fixtures/CustomReporters/CustomChurnTextReporter.php new file mode 100644 index 0000000..a243747 --- /dev/null +++ b/tests/Fixtures/CustomReporters/CustomChurnTextReporter.php @@ -0,0 +1,57 @@ +generateReport($metrics); + file_put_contents($filename, $content); + } + + private function generateReport(ChurnMetricsCollection $metrics): string + { + $output = "=== Custom Churn Analysis Report ===\n"; + $output .= "Generated by CustomChurnTextReporter\n\n"; + + $output .= "Analysis Summary:\n"; + $output .= "Total Classes: " . count($metrics) . "\n"; + + $totalMethods = 0; + foreach ($metrics as $metric) { + // Count methods based on score (simplified) + $totalMethods += (int)$metric->getScore(); + } + $output .= "Total Methods: " . $totalMethods . "\n\n"; + + $output .= "Churn Analysis Results:\n"; + $output .= str_repeat("-", 50) . "\n"; + + foreach ($metrics as $metric) { + $output .= "Class: " . $metric->getClassName() . "\n"; + $output .= " File: " . $metric->getFile() . "\n"; + $output .= " Score: " . $metric->getScore() . "\n"; + $output .= " Churn: " . $metric->getChurn() . "\n"; + $output .= " Times Changed: " . $metric->getTimesChanged() . "\n"; + + if ($metric->hasCoverageData()) { + $output .= " Coverage: " . ($metric->getCoverage() * 100) . "%\n"; + $output .= " Risk Churn: " . $metric->getRiskChurn() . "\n"; + $output .= " Risk Level: " . $metric->getRiskLevel() . "\n"; + } + + $output .= "\n"; + } + + return $output; + } +} diff --git a/tests/Fixtures/CustomReporters/CustomTextReporter.php b/tests/Fixtures/CustomReporters/CustomTextReporter.php new file mode 100644 index 0000000..9032408 --- /dev/null +++ b/tests/Fixtures/CustomReporters/CustomTextReporter.php @@ -0,0 +1,61 @@ +generateTextContent($metrics); + + // Write to file + if (file_put_contents($filename, $content) === false) { + throw new CognitiveAnalysisException("Could not write to file: {$filename}"); + } + } + + private function generateTextContent(CognitiveMetricsCollection $metrics): string + { + $content = "=== Cognitive Complexity Report ===\n"; + $content .= "Generated by CustomTextReporter\n"; + $content .= "=====================================\n\n"; + + $totalMethods = 0; + $totalScore = 0.0; + + foreach ($metrics as $metric) { + $content .= sprintf( + "Class: %s, Method: %s, Score: %.2f\n", + $metric->getClass(), + $metric->getMethod(), + $metric->getScore() + ); + $totalMethods++; + $totalScore += $metric->getScore(); + } + + $content .= "\n=====================================\n"; + $content .= sprintf("Total Methods: %d\n", $totalMethods); + $content .= sprintf("Average Score: %.2f\n", $totalMethods > 0 ? $totalScore / $totalMethods : 0); + $content .= "=====================================\n"; + + return $content; + } +} diff --git a/tests/Fixtures/config-aware-churn-reporter-config.yml b/tests/Fixtures/config-aware-churn-reporter-config.yml new file mode 100644 index 0000000..b0029c9 --- /dev/null +++ b/tests/Fixtures/config-aware-churn-reporter-config.yml @@ -0,0 +1,19 @@ +cognitive: + excludeFilePatterns: [] + excludePatterns: [] + scoreThreshold: 0.80 + showOnlyMethodsExceedingThreshold: true + showHalsteadComplexity: false + showCyclomaticComplexity: false + showDetailedCognitiveMetrics: true + groupByClass: true + metrics: + lineCount: + threshold: 60 + scale: 25.0 + enabled: true + customReporters: + churn: + configchurn: + class: 'Phauthentic\CognitiveCodeAnalysis\Tests\Fixtures\CustomReporters\ConfigAwareChurnTextReporter' + file: 'tests/Fixtures/CustomReporters/ConfigAwareChurnTextReporter.php' diff --git a/tests/Fixtures/config-aware-text-reporter-config.yml b/tests/Fixtures/config-aware-text-reporter-config.yml new file mode 100644 index 0000000..532cf17 --- /dev/null +++ b/tests/Fixtures/config-aware-text-reporter-config.yml @@ -0,0 +1,19 @@ +cognitive: + excludeFilePatterns: [] + excludePatterns: [] + scoreThreshold: 0.8 + showOnlyMethodsExceedingThreshold: true + showHalsteadComplexity: false + showCyclomaticComplexity: false + showDetailedCognitiveMetrics: true + groupByClass: false + metrics: + lineCount: + threshold: 60 + scale: 25.0 + enabled: true + customReporters: + cognitive: + configtext: + class: 'Tests\Fixtures\CustomReporters\ConfigAwareTextReporter' + file: 'tests/Fixtures/CustomReporters/ConfigAwareTextReporter.php' diff --git a/tests/Fixtures/config-exporter-config.yml b/tests/Fixtures/config-exporter-config.yml new file mode 100644 index 0000000..eb88f61 --- /dev/null +++ b/tests/Fixtures/config-exporter-config.yml @@ -0,0 +1,19 @@ +cognitive: + excludeFilePatterns: [] + excludePatterns: [] + scoreThreshold: 0.8 + showOnlyMethodsExceedingThreshold: false + showHalsteadComplexity: false + showCyclomaticComplexity: false + showDetailedCognitiveMetrics: true + groupByClass: false + metrics: + lineCount: + threshold: 60 + scale: 25.0 + enabled: true + customReporters: + cognitive: + config: + class: 'TestConfigExporter\ConfigExporter' + file: null diff --git a/tests/Fixtures/custom-churn-exporter-config.yml b/tests/Fixtures/custom-churn-exporter-config.yml new file mode 100644 index 0000000..196f73c --- /dev/null +++ b/tests/Fixtures/custom-churn-exporter-config.yml @@ -0,0 +1,19 @@ +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 + customReporters: + churn: + custom: + class: 'Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\JsonReport' + file: null diff --git a/tests/Fixtures/custom-churn-text-reporter-config.yml b/tests/Fixtures/custom-churn-text-reporter-config.yml new file mode 100644 index 0000000..081ae47 --- /dev/null +++ b/tests/Fixtures/custom-churn-text-reporter-config.yml @@ -0,0 +1,19 @@ +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 + customReporters: + churn: + customchurn: + class: 'Phauthentic\CognitiveCodeAnalysis\Tests\Fixtures\CustomReporters\CustomChurnTextReporter' + file: 'tests/Fixtures/CustomReporters/CustomChurnTextReporter.php' diff --git a/tests/Fixtures/custom-cognitive-exporter-config.yml b/tests/Fixtures/custom-cognitive-exporter-config.yml new file mode 100644 index 0000000..8304c55 --- /dev/null +++ b/tests/Fixtures/custom-cognitive-exporter-config.yml @@ -0,0 +1,19 @@ +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 + customReporters: + cognitive: + custom: + class: 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport' + file: null diff --git a/tests/Fixtures/custom-exporters-config.yml b/tests/Fixtures/custom-exporters-config.yml new file mode 100644 index 0000000..61bfa9e --- /dev/null +++ b/tests/Fixtures/custom-exporters-config.yml @@ -0,0 +1,23 @@ +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 + customReporters: + cognitive: + test: + class: 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport' + file: null + churn: + test: + class: 'Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\JsonReport' + file: null diff --git a/tests/Fixtures/custom-text-reporter-config.yml b/tests/Fixtures/custom-text-reporter-config.yml new file mode 100644 index 0000000..1aab439 --- /dev/null +++ b/tests/Fixtures/custom-text-reporter-config.yml @@ -0,0 +1,19 @@ +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 + customReporters: + cognitive: + customtext: + class: 'Tests\Fixtures\CustomReporters\CustomTextReporter' + file: 'tests/Fixtures/CustomReporters/CustomTextReporter.php' diff --git a/tests/Fixtures/invalid-custom-exporter-config.yml b/tests/Fixtures/invalid-custom-exporter-config.yml new file mode 100644 index 0000000..6c15920 --- /dev/null +++ b/tests/Fixtures/invalid-custom-exporter-config.yml @@ -0,0 +1,19 @@ +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 + customReporters: + cognitive: + invalid: + class: 'NonExistent\Exporter' + file: null diff --git a/tests/TestCode/FileWithTwoClasses.php b/tests/TestCode/FileWithTwoClasses.php index 0d1ee49..e231923 100644 --- a/tests/TestCode/FileWithTwoClasses.php +++ b/tests/TestCode/FileWithTwoClasses.php @@ -21,3 +21,4 @@ public function add(int $one, int $two): int return $one + $two; } } +// Another test change diff --git a/tests/TestCode/Paginator.php b/tests/TestCode/Paginator.php index db1b34d..34016b6 100644 --- a/tests/TestCode/Paginator.php +++ b/tests/TestCode/Paginator.php @@ -261,3 +261,4 @@ private function convertWhereInIdentifiersToDatabaseValues(array $identifiers): return array_map(static fn ($id): mixed => $connection->convertToDatabaseValue($id, $type), $identifiers); } } +// Test change diff --git a/tests/Unit/Business/Churn/ChurnCalculatorTest.php b/tests/Unit/Business/Churn/ChurnCalculatorTest.php index 7a69d74..efc9ac0 100644 --- a/tests/Unit/Business/Churn/ChurnCalculatorTest.php +++ b/tests/Unit/Business/Churn/ChurnCalculatorTest.php @@ -5,6 +5,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn; use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnCalculator; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; use Phauthentic\CognitiveCodeAnalysis\Business\CodeCoverage\CoverageReportReaderInterface; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; @@ -18,11 +19,13 @@ public function testCalculate(): void $metric1->method('getClass')->willReturn('ClassA'); $metric1->method('getTimesChanged')->willReturn(5); $metric1->method('getScore')->willReturn(2.0); + $metric1->method('getFileName')->willReturn('ClassA.php'); $metric2 = $this->createMock(CognitiveMetrics::class); $metric2->method('getClass')->willReturn('ClassB'); $metric2->method('getTimesChanged')->willReturn(3); $metric2->method('getScore')->willReturn(4.0); + $metric2->method('getFileName')->willReturn('ClassB.php'); $metricsCollection = $this->createMock(CognitiveMetricsCollection::class); $metricsCollection->method('getIterator')->willReturn(new \ArrayIterator([$metric1, $metric2])); @@ -30,28 +33,24 @@ public function testCalculate(): void $churnCalculator = new ChurnCalculator(); $result = $churnCalculator->calculate($metricsCollection); - $expected = [ - 'ClassB' => [ - 'timesChanged' => 3, - 'score' => 4.0, - 'churn' => 12.0, - 'file' => '', - 'coverage' => null, - 'riskChurn' => null, - 'riskLevel' => null, - ], - 'ClassA' => [ - 'timesChanged' => 5, - 'score' => 2.0, - 'churn' => 10.0, - 'file' => '', - 'coverage' => null, - 'riskChurn' => null, - 'riskLevel' => null, - ], - ]; - - $this->assertEquals($expected, $result); + $this->assertInstanceOf(ChurnMetricsCollection::class, $result); + $this->assertCount(2, $result); + + // Check ClassB (should be first due to higher churn) + $classBMetric = $result->getByClassName('ClassB'); + $this->assertNotNull($classBMetric); + $this->assertEquals(3, $classBMetric->getTimesChanged()); + $this->assertEquals(4.0, $classBMetric->getScore()); + $this->assertEquals(12.0, $classBMetric->getChurn()); + $this->assertEquals('ClassB.php', $classBMetric->getFile()); + + // Check ClassA (should be second) + $classAMetric = $result->getByClassName('ClassA'); + $this->assertNotNull($classAMetric); + $this->assertEquals(5, $classAMetric->getTimesChanged()); + $this->assertEquals(2.0, $classAMetric->getScore()); + $this->assertEquals(10.0, $classAMetric->getChurn()); + $this->assertEquals('ClassA.php', $classAMetric->getFile()); } public function testCalculateWithCoverage(): void @@ -60,13 +59,13 @@ public function testCalculateWithCoverage(): void $metric1->method('getClass')->willReturn('ClassA'); $metric1->method('getTimesChanged')->willReturn(10); $metric1->method('getScore')->willReturn(3.0); - $metric1->method('getFilename')->willReturn('ClassA.php'); + $metric1->method('getFileName')->willReturn('ClassA.php'); $metric2 = $this->createMock(CognitiveMetrics::class); $metric2->method('getClass')->willReturn('ClassB'); $metric2->method('getTimesChanged')->willReturn(5); $metric2->method('getScore')->willReturn(8.0); - $metric2->method('getFilename')->willReturn('ClassB.php'); + $metric2->method('getFileName')->willReturn('ClassB.php'); $metricsCollection = $this->createMock(CognitiveMetricsCollection::class); $metricsCollection->method('getIterator')->willReturn(new \ArrayIterator([$metric1, $metric2])); @@ -84,15 +83,19 @@ public function testCalculateWithCoverage(): void // ClassA: churn=30, coverage=0.9, riskChurn=30*(1-0.9)=3 // ClassB: churn=40, coverage=0.2, riskChurn=40*(1-0.2)=32 - $this->assertEquals(40.0, $result['ClassB']['churn']); - $this->assertEqualsWithDelta(32.0, $result['ClassB']['riskChurn'], 0.01); - $this->assertEquals(0.2, $result['ClassB']['coverage']); - $this->assertEquals('CRITICAL', $result['ClassB']['riskLevel']); // churn>30 && coverage<0.5 - - $this->assertEquals(30.0, $result['ClassA']['churn']); - $this->assertEqualsWithDelta(3.0, $result['ClassA']['riskChurn'], 0.01); - $this->assertEquals(0.9, $result['ClassA']['coverage']); - $this->assertEquals('LOW', $result['ClassA']['riskLevel']); + $classBMetric = $result->getByClassName('ClassB'); + $this->assertNotNull($classBMetric); + $this->assertEquals(40.0, $classBMetric->getChurn()); + $this->assertEqualsWithDelta(32.0, $classBMetric->getRiskChurn(), 0.01); + $this->assertEquals(0.2, $classBMetric->getCoverage()); + $this->assertEquals('CRITICAL', $classBMetric->getRiskLevel()); // churn>30 && coverage<0.5 + + $classAMetric = $result->getByClassName('ClassA'); + $this->assertNotNull($classAMetric); + $this->assertEquals(30.0, $classAMetric->getChurn()); + $this->assertEqualsWithDelta(3.0, $classAMetric->getRiskChurn(), 0.01); + $this->assertEquals(0.9, $classAMetric->getCoverage()); + $this->assertEquals('LOW', $classAMetric->getRiskLevel()); } public function testCalculateRiskLevels(): void @@ -102,28 +105,28 @@ public function testCalculateRiskLevels(): void $metricCritical->method('getClass')->willReturn('CriticalClass'); $metricCritical->method('getTimesChanged')->willReturn(10); $metricCritical->method('getScore')->willReturn(4.0); // churn = 40 - $metricCritical->method('getFilename')->willReturn('CriticalClass.php'); + $metricCritical->method('getFileName')->willReturn('CriticalClass.php'); // Test HIGH: churn > 20 AND coverage < 0.7 $metricHigh = $this->createMock(CognitiveMetrics::class); $metricHigh->method('getClass')->willReturn('HighClass'); $metricHigh->method('getTimesChanged')->willReturn(5); $metricHigh->method('getScore')->willReturn(5.0); // churn = 25 - $metricHigh->method('getFilename')->willReturn('HighClass.php'); + $metricHigh->method('getFileName')->willReturn('HighClass.php'); // Test MEDIUM: churn > 10 AND coverage < 0.8 $metricMedium = $this->createMock(CognitiveMetrics::class); $metricMedium->method('getClass')->willReturn('MediumClass'); $metricMedium->method('getTimesChanged')->willReturn(3); $metricMedium->method('getScore')->willReturn(4.0); // churn = 12 - $metricMedium->method('getFilename')->willReturn('MediumClass.php'); + $metricMedium->method('getFileName')->willReturn('MediumClass.php'); // Test LOW $metricLow = $this->createMock(CognitiveMetrics::class); $metricLow->method('getClass')->willReturn('LowClass'); $metricLow->method('getTimesChanged')->willReturn(2); $metricLow->method('getScore')->willReturn(3.0); // churn = 6 - $metricLow->method('getFilename')->willReturn('LowClass.php'); + $metricLow->method('getFileName')->willReturn('LowClass.php'); $metricsCollection = $this->createMock(CognitiveMetricsCollection::class); $metricsCollection->method('getIterator')->willReturn( @@ -142,10 +145,21 @@ public function testCalculateRiskLevels(): void $churnCalculator = new ChurnCalculator(); $result = $churnCalculator->calculate($metricsCollection, $coverageReader); - $this->assertEquals('CRITICAL', $result['CriticalClass']['riskLevel']); - $this->assertEquals('HIGH', $result['HighClass']['riskLevel']); - $this->assertEquals('MEDIUM', $result['MediumClass']['riskLevel']); - $this->assertEquals('LOW', $result['LowClass']['riskLevel']); + $criticalMetric = $result->getByClassName('CriticalClass'); + $this->assertNotNull($criticalMetric); + $this->assertEquals('CRITICAL', $criticalMetric->getRiskLevel()); + + $highMetric = $result->getByClassName('HighClass'); + $this->assertNotNull($highMetric); + $this->assertEquals('HIGH', $highMetric->getRiskLevel()); + + $mediumMetric = $result->getByClassName('MediumClass'); + $this->assertNotNull($mediumMetric); + $this->assertEquals('MEDIUM', $mediumMetric->getRiskLevel()); + + $lowMetric = $result->getByClassName('LowClass'); + $this->assertNotNull($lowMetric); + $this->assertEquals('LOW', $lowMetric->getRiskLevel()); } public function testCalculateWithNoCoverageForClass(): void @@ -154,7 +168,7 @@ public function testCalculateWithNoCoverageForClass(): void $metric->method('getClass')->willReturn('ClassA'); $metric->method('getTimesChanged')->willReturn(5); $metric->method('getScore')->willReturn(2.0); - $metric->method('getFilename')->willReturn('ClassA.php'); + $metric->method('getFileName')->willReturn('ClassA.php'); $metricsCollection = $this->createMock(CognitiveMetricsCollection::class); $metricsCollection->method('getIterator')->willReturn(new \ArrayIterator([$metric])); @@ -166,7 +180,9 @@ public function testCalculateWithNoCoverageForClass(): void $result = $churnCalculator->calculate($metricsCollection, $coverageReader); // When coverage is null, assume 0.0 coverage - $this->assertEquals(0.0, $result['ClassA']['coverage']); - $this->assertEquals(10.0, $result['ClassA']['riskChurn']); // 5 * 2.0 * (1 - 0.0) + $classAMetric = $result->getByClassName('ClassA'); + $this->assertNotNull($classAMetric); + $this->assertEquals(0.0, $classAMetric->getCoverage()); + $this->assertEquals(10.0, $classAMetric->getRiskChurn()); // 5 * 2.0 * (1 - 0.0) } } diff --git a/tests/Unit/Business/Churn/Exporter/AbstractExporterTestCase.php b/tests/Unit/Business/Churn/Exporter/AbstractExporterTestCase.php deleted file mode 100644 index 38b3f91..0000000 --- a/tests/Unit/Business/Churn/Exporter/AbstractExporterTestCase.php +++ /dev/null @@ -1,85 +0,0 @@ -filename)) { - unlink($this->filename); - } - Datetime::$fixedDate = null; - } - - protected function getTestData(): array - { - return [ - 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics' => [ - 'timesChanged' => 6, - 'score' => 2.042, - 'file' => '/home/florian/projects/cognitive-code-checker/src/Business/Cognitive/CognitiveMetrics.php', - 'churn' => 12.252, - ], - 'Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRenderer' => [ - 'timesChanged' => 10, - 'score' => 0.806, - 'file' => '/home/florian/projects/cognitive-code-checker/src/Command/Presentation/CognitiveMetricTextRenderer.php', - 'churn' => 8.06, - ], - 'Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade' => [ - 'timesChanged' => 8, - 'score' => 0.693, - 'file' => '/home/florian/projects/cognitive-code-checker/src/Business/MetricsFacade.php', - 'churn' => 5.544, - ], - ]; - } - - protected function getTestDataWithCoverage(): array - { - return [ - 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics' => [ - 'timesChanged' => 6, - 'score' => 2.042, - 'file' => '/home/florian/projects/cognitive-code-checker/src/Business/Cognitive/CognitiveMetrics.php', - 'churn' => 12.252, - 'coverage' => 0.85, - 'riskChurn' => 1.8378, - 'riskLevel' => 'low', - ], - 'Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRenderer' => [ - 'timesChanged' => 10, - 'score' => 0.806, - 'file' => '/home/florian/projects/cognitive-code-checker/src/Command/Presentation/CognitiveMetricTextRenderer.php', - 'churn' => 8.06, - 'coverage' => 0.65, - 'riskChurn' => 2.821, - 'riskLevel' => 'medium', - ], - 'Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade' => [ - 'timesChanged' => 8, - 'score' => 0.693, - 'file' => '/home/florian/projects/cognitive-code-checker/src/Business/MetricsFacade.php', - 'churn' => 5.544, - 'coverage' => 0.92, - 'riskChurn' => 0.443, - 'riskLevel' => 'low', - ], - ]; - } -} diff --git a/tests/Unit/Business/Churn/Report/AbstractReporterTestCase.php b/tests/Unit/Business/Churn/Report/AbstractReporterTestCase.php new file mode 100644 index 0000000..48dde52 --- /dev/null +++ b/tests/Unit/Business/Churn/Report/AbstractReporterTestCase.php @@ -0,0 +1,101 @@ +filename)) { + unlink($this->filename); + } + Datetime::$fixedDate = null; + } + + protected function getTestData(): ChurnMetricsCollection + { + $collection = new ChurnMetricsCollection(); + + $collection->add(new ChurnMetrics( + className: 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics', + file: '/home/florian/projects/cognitive-code-checker/src/Business/Cognitive/CognitiveMetrics.php', + score: 2.042, + timesChanged: 6, + churn: 12.252 + )); + + $collection->add(new ChurnMetrics( + className: 'Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRenderer', + file: '/home/florian/projects/cognitive-code-checker/src/Command/Presentation/CognitiveMetricTextRenderer.php', + score: 0.806, + timesChanged: 10, + churn: 8.06 + )); + + $collection->add(new ChurnMetrics( + className: 'Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade', + file: '/home/florian/projects/cognitive-code-checker/src/Business/MetricsFacade.php', + score: 0.693, + timesChanged: 8, + churn: 5.544 + )); + + return $collection; + } + + protected function getTestDataWithCoverage(): ChurnMetricsCollection + { + $collection = new ChurnMetricsCollection(); + + $collection->add(new ChurnMetrics( + className: 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics', + file: '/home/florian/projects/cognitive-code-checker/src/Business/Cognitive/CognitiveMetrics.php', + score: 2.042, + timesChanged: 6, + churn: 12.252, + coverage: 0.85, + riskChurn: 1.8378, + riskLevel: 'low' + )); + + $collection->add(new ChurnMetrics( + className: 'Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRenderer', + file: '/home/florian/projects/cognitive-code-checker/src/Command/Presentation/CognitiveMetricTextRenderer.php', + score: 0.806, + timesChanged: 10, + churn: 8.06, + coverage: 0.65, + riskChurn: 2.821, + riskLevel: 'medium' + )); + + $collection->add(new ChurnMetrics( + className: 'Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade', + file: '/home/florian/projects/cognitive-code-checker/src/Business/MetricsFacade.php', + score: 0.693, + timesChanged: 8, + churn: 5.544, + coverage: 0.92, + riskChurn: 0.443, + riskLevel: 'low' + )); + + return $collection; + } +} diff --git a/tests/Unit/Business/Churn/Report/ChurnReporterFactoryCustomTest.php b/tests/Unit/Business/Churn/Report/ChurnReporterFactoryCustomTest.php new file mode 100644 index 0000000..d50986a --- /dev/null +++ b/tests/Unit/Business/Churn/Report/ChurnReporterFactoryCustomTest.php @@ -0,0 +1,233 @@ + $customReporters]); + + $configService = $this->createMock(ConfigService::class); + $configService->method('getConfig')->willReturn($config); + + return $configService; + } + + #[Test] + public function testCreateBuiltInExporter(): void + { + $configService = $this->createMockConfigService(); + $factory = new ChurnReportFactory($configService); + + $exporter = $factory->create('json'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\JsonReport', $exporter); + } + + #[Test] + public function testCreateCustomExporterWithFile(): void + { + // Create a temporary PHP file with a custom exporter + $tempFile = tempnam(sys_get_temp_dir(), 'custom_churn_exporter_') . '.php'; + $classContent = <<<'PHP' + [ + 'class' => 'TestCustomChurn\CustomChurnExporter', + 'file' => $tempFile + ] + ]; + + $factory = new ChurnReportFactory($this->createMockConfigService($customReporters)); + $exporter = $factory->create('custom'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('TestCustomChurn\CustomChurnExporter', $exporter); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testCreateCustomExporterWithoutFile(): void + { + // Create a temporary PHP file and include it manually to simulate autoloading + $tempFile = tempnam(sys_get_temp_dir(), 'autoloaded_churn_exporter_') . '.php'; + $classContent = <<<'PHP' + [ + 'class' => 'TestAutoloadedChurn\AutoloadedChurnExporter', + 'file' => null + ] + ]; + + $factory = new ChurnReportFactory($this->createMockConfigService($customReporters)); + $exporter = $factory->create('autoloaded'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('TestAutoloadedChurn\AutoloadedChurnExporter', $exporter); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testCreateUnsupportedExporter(): void + { + $factory = new ChurnReportFactory($this->createMockConfigService()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported exporter type: unsupported'); + + $factory->create('unsupported'); + } + + #[Test] + public function testGetSupportedTypesIncludesCustomExporters(): void + { + $customReporters = [ + 'custom1' => [ + 'class' => 'TestCustom1\Exporter', + 'file' => null + ], + 'custom2' => [ + 'class' => 'TestCustom2\Exporter', + 'file' => null + ] + ]; + + $factory = new ChurnReportFactory($this->createMockConfigService($customReporters)); + $supportedTypes = $factory->getSupportedTypes(); + + $expectedBuiltInTypes = ['json', 'csv', 'html', 'markdown', 'svg-treemap', 'svg']; + $expectedCustomTypes = ['custom1', 'custom2']; + + foreach ($expectedBuiltInTypes as $type) { + $this->assertContains($type, $supportedTypes); + } + + foreach ($expectedCustomTypes as $type) { + $this->assertContains($type, $supportedTypes); + } + } + + #[Test] + public function testIsSupportedWithCustomExporters(): void + { + $customReporters = [ + 'custom' => [ + 'class' => 'TestCustom\Exporter', + 'file' => null + ] + ]; + + $factory = new ChurnReportFactory($this->createMockConfigService($customReporters)); + + $this->assertTrue($factory->isSupported('json')); + $this->assertTrue($factory->isSupported('custom')); + $this->assertFalse($factory->isSupported('unsupported')); + } + + #[Test] + public function testCustomExporterWithInvalidInterface(): void + { + // Create a temporary PHP file with a class that doesn't implement the interface + $tempFile = tempnam(sys_get_temp_dir(), 'invalid_churn_exporter_') . '.php'; + $classContent = <<<'PHP' + [ + 'class' => 'TestInvalidChurn\InvalidChurnExporter', + 'file' => $tempFile + ] + ]; + + $factory = new ChurnReportFactory($this->createMockConfigService($customReporters)); + + $this->expectException(CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter must implement Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\ReportGeneratorInterface'); + + $factory->create('invalid'); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testCustomExporterWithNonExistentFile(): void + { + $customReporters = [ + 'missing' => [ + 'class' => 'TestMissing\Exporter', + 'file' => '/non/existent/file.php' + ] + ]; + + $factory = new ChurnReportFactory($this->createMockConfigService($customReporters)); + + $this->expectException(CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter file not found: /non/existent/file.php'); + + $factory->create('missing'); + } +} diff --git a/tests/Unit/Business/Churn/Exporter/CsvExportTest.php b/tests/Unit/Business/Churn/Report/CsvExportTest.php similarity index 65% rename from tests/Unit/Business/Churn/Exporter/CsvExportTest.php rename to tests/Unit/Business/Churn/Report/CsvExportTest.php index 7021f3e..edaa127 100644 --- a/tests/Unit/Business/Churn/Exporter/CsvExportTest.php +++ b/tests/Unit/Business/Churn/Report/CsvExportTest.php @@ -2,18 +2,19 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Report; -use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\CsvExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnMetricsCollection; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\CsvReport; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use PHPUnit\Framework\Attributes\Test; -class CsvExportTest extends AbstractExporterTestCase +class CsvExportTest extends AbstractReporterTestCase { protected function setUp(): void { parent::setUp(); - $this->exporter = new CsvExporter(); + $this->exporter = new CsvReport(); $this->filename = sys_get_temp_dir() . '/test_metrics.csv'; } @@ -25,7 +26,7 @@ public function testExport(): void $this->exporter->export($classes, $this->filename); $this->assertFileEquals( - expected: __DIR__ . '/CsvExporterContent.csv', + expected: __DIR__ . '/CsvReporterContent.csv', actual: $this->filename ); } @@ -36,6 +37,7 @@ public function testNotWriteableFile(): void $this->expectException(CognitiveAnalysisException::class); $this->expectExceptionMessage('Directory /not/writable does not exist for file /not/writable/file.csv'); - $this->exporter->export([], '/not/writable/file.csv'); + $emptyCollection = new ChurnMetricsCollection(); + $this->exporter->export($emptyCollection, '/not/writable/file.csv'); } } diff --git a/tests/Unit/Business/Churn/Exporter/CsvExporterContent.csv b/tests/Unit/Business/Churn/Report/CsvReporterContent.csv similarity index 100% rename from tests/Unit/Business/Churn/Exporter/CsvExporterContent.csv rename to tests/Unit/Business/Churn/Report/CsvReporterContent.csv diff --git a/tests/Unit/Business/Churn/Exporter/HtmlExporterContent.html b/tests/Unit/Business/Churn/Report/HtmlReporterContent.html similarity index 100% rename from tests/Unit/Business/Churn/Exporter/HtmlExporterContent.html rename to tests/Unit/Business/Churn/Report/HtmlReporterContent.html diff --git a/tests/Unit/Business/Churn/Exporter/HtmlExporterTest.php b/tests/Unit/Business/Churn/Report/HtmlReporterTest.php similarity index 67% rename from tests/Unit/Business/Churn/Exporter/HtmlExporterTest.php rename to tests/Unit/Business/Churn/Report/HtmlReporterTest.php index f0eb245..2476cf8 100644 --- a/tests/Unit/Business/Churn/Exporter/HtmlExporterTest.php +++ b/tests/Unit/Business/Churn/Report/HtmlReporterTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Report; -use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\CsvExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\CsvReport; use PHPUnit\Framework\Attributes\Test; -class HtmlExporterTest extends AbstractExporterTestCase +class HtmlReporterTest extends AbstractReporterTestCase { protected function setUp(): void { parent::setUp(); - $this->exporter = new CsvExporter(); + $this->exporter = new CsvReport(); $this->filename = sys_get_temp_dir() . '/test_metrics.html'; } @@ -24,7 +24,7 @@ public function testExport(): void $this->exporter->export($classes, $this->filename); $this->assertFileEquals( - expected: __DIR__ . '/HtmlExporterContent.html', + expected: __DIR__ . '/HtmlReporterContent.html', actual: $this->filename ); } diff --git a/tests/Unit/Business/Churn/Exporter/JsonExporterContent.json b/tests/Unit/Business/Churn/Report/JsonReporterContent.json similarity index 71% rename from tests/Unit/Business/Churn/Exporter/JsonExporterContent.json rename to tests/Unit/Business/Churn/Report/JsonReporterContent.json index 0921001..c711916 100644 --- a/tests/Unit/Business/Churn/Exporter/JsonExporterContent.json +++ b/tests/Unit/Business/Churn/Report/JsonReporterContent.json @@ -2,22 +2,31 @@ "createdAt": "2023-10-01 12:00:00", "classes": { "Phauthentic\\CognitiveCodeAnalysis\\Business\\Cognitive\\CognitiveMetrics": { - "timesChanged": 6, - "score": 2.042, "file": "\/home\/florian\/projects\/cognitive-code-checker\/src\/Business\/Cognitive\/CognitiveMetrics.php", - "churn": 12.252 + "score": 2.042, + "timesChanged": 6, + "churn": 12.252, + "coverage": null, + "riskChurn": null, + "riskLevel": null }, "Phauthentic\\CognitiveCodeAnalysis\\Command\\Presentation\\CognitiveMetricTextRenderer": { - "timesChanged": 10, - "score": 0.806, "file": "\/home\/florian\/projects\/cognitive-code-checker\/src\/Command\/Presentation\/CognitiveMetricTextRenderer.php", - "churn": 8.06 + "score": 0.806, + "timesChanged": 10, + "churn": 8.06, + "coverage": null, + "riskChurn": null, + "riskLevel": null }, "Phauthentic\\CognitiveCodeAnalysis\\Business\\MetricsFacade": { - "timesChanged": 8, - "score": 0.693, "file": "\/home\/florian\/projects\/cognitive-code-checker\/src\/Business\/MetricsFacade.php", - "churn": 5.544 + "score": 0.693, + "timesChanged": 8, + "churn": 5.544, + "coverage": null, + "riskChurn": null, + "riskLevel": null } } } \ No newline at end of file diff --git a/tests/Unit/Business/Churn/Exporter/JsonExporterTest.php b/tests/Unit/Business/Churn/Report/JsonReporterTest.php similarity index 67% rename from tests/Unit/Business/Churn/Exporter/JsonExporterTest.php rename to tests/Unit/Business/Churn/Report/JsonReporterTest.php index 81dd872..bbd98e3 100644 --- a/tests/Unit/Business/Churn/Exporter/JsonExporterTest.php +++ b/tests/Unit/Business/Churn/Report/JsonReporterTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Report; -use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\JsonExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\JsonReport; use PHPUnit\Framework\Attributes\Test; -class JsonExporterTest extends AbstractExporterTestCase +class JsonReporterTest extends AbstractReporterTestCase { protected function setUp(): void { parent::setUp(); - $this->exporter = new JsonExporter(); + $this->exporter = new JsonReport(); $this->filename = sys_get_temp_dir() . '/test_metrics.json'; } @@ -25,7 +25,7 @@ public function testExport(): void $this->exporter->export($classes, $this->filename); $this->assertFileEquals( - expected: __DIR__ . '/JsonExporterContent.json', + expected: __DIR__ . '/JsonReporterContent.json', actual: $this->filename ); } diff --git a/tests/Unit/Business/Churn/Exporter/MarkdownExporterContent.md b/tests/Unit/Business/Churn/Report/MarkdownReporterContent.md similarity index 100% rename from tests/Unit/Business/Churn/Exporter/MarkdownExporterContent.md rename to tests/Unit/Business/Churn/Report/MarkdownReporterContent.md diff --git a/tests/Unit/Business/Churn/Exporter/MarkdownExporterContentWithCoverage.md b/tests/Unit/Business/Churn/Report/MarkdownReporterContentWithCoverage.md similarity index 100% rename from tests/Unit/Business/Churn/Exporter/MarkdownExporterContentWithCoverage.md rename to tests/Unit/Business/Churn/Report/MarkdownReporterContentWithCoverage.md diff --git a/tests/Unit/Business/Churn/Exporter/MarkdownExporterTest.php b/tests/Unit/Business/Churn/Report/MarkdownReporterTest.php similarity index 70% rename from tests/Unit/Business/Churn/Exporter/MarkdownExporterTest.php rename to tests/Unit/Business/Churn/Report/MarkdownReporterTest.php index 9726886..f97318b 100644 --- a/tests/Unit/Business/Churn/Exporter/MarkdownExporterTest.php +++ b/tests/Unit/Business/Churn/Report/MarkdownReporterTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Report; -use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\MarkdownExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\MarkdownReport; use PHPUnit\Framework\Attributes\Test; -class MarkdownExporterTest extends AbstractExporterTestCase +class MarkdownReporterTest extends AbstractReporterTestCase { protected function setUp(): void { parent::setUp(); - $this->exporter = new MarkdownExporter(); + $this->exporter = new MarkdownReport(); $this->filename = sys_get_temp_dir() . '/test_metrics.md'; } @@ -25,7 +25,7 @@ public function testExport(): void $this->exporter->export($classes, $this->filename); $this->assertFileEquals( - expected: __DIR__ . '/MarkdownExporterContent.md', + expected: __DIR__ . '/MarkdownReporterContent.md', actual: $this->filename ); } @@ -38,7 +38,7 @@ public function testExportWithCoverage(): void $this->exporter->export($classes, $this->filename); $this->assertFileEquals( - expected: __DIR__ . '/MarkdownExporterContentWithCoverage.md', + expected: __DIR__ . '/MarkdownReporterContentWithCoverage.md', actual: $this->filename ); } diff --git a/tests/Unit/Business/Churn/Exporter/SvgTreemapExporterTest.php b/tests/Unit/Business/Churn/Report/SvgTreemapReporterTest.php similarity index 65% rename from tests/Unit/Business/Churn/Exporter/SvgTreemapExporterTest.php rename to tests/Unit/Business/Churn/Report/SvgTreemapReporterTest.php index 8fba35e..1c50ab0 100644 --- a/tests/Unit/Business/Churn/Exporter/SvgTreemapExporterTest.php +++ b/tests/Unit/Business/Churn/Report/SvgTreemapReporterTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Report; -use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\SvgTreemapExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Report\SvgTreemapReport; use PHPUnit\Framework\Attributes\Test; -class SvgTreemapExporterTest extends AbstractExporterTestCase +class SvgTreemapReporterTest extends AbstractReporterTestCase { protected function setUp(): void { parent::setUp(); - $this->exporter = new SvgTreemapExporter(); + $this->exporter = new SvgTreemapReport(); $this->filename = sys_get_temp_dir() . '/test_metrics.json'; } @@ -25,7 +25,7 @@ public function testExport(): void $this->exporter->export($classes, $this->filename); $this->assertFileEquals( - expected: __DIR__ . '/SvgTreemapExporterTest.svg', + expected: __DIR__ . '/SvgTreemapReporterTest.svg', actual: $this->filename ); } diff --git a/tests/Unit/Business/Churn/Exporter/SvgTreemapExporterTest.svg b/tests/Unit/Business/Churn/Report/SvgTreemapReporterTest.svg similarity index 100% rename from tests/Unit/Business/Churn/Exporter/SvgTreemapExporterTest.svg rename to tests/Unit/Business/Churn/Report/SvgTreemapReporterTest.svg diff --git a/tests/Unit/Business/Churn/Report/TestCognitiveConfig.php b/tests/Unit/Business/Churn/Report/TestCognitiveConfig.php new file mode 100644 index 0000000..3531074 --- /dev/null +++ b/tests/Unit/Business/Churn/Report/TestCognitiveConfig.php @@ -0,0 +1,39 @@ +config = new CognitiveConfig( + excludeFilePatterns: [], + excludePatterns: [], + metrics: [], + showOnlyMethodsExceedingThreshold: false, + scoreThreshold: 0.5 + ); + } + + private function createMockConfigService(array $customReporters = []): ConfigService&MockObject + { + $config = new CognitiveConfig( + excludeFilePatterns: [], + excludePatterns: [], + metrics: [], + showOnlyMethodsExceedingThreshold: false, + scoreThreshold: 0.5, + customReporters: ['cognitive' => $customReporters] + ); + + $configService = $this->createMock(ConfigService::class); + $configService->method('getConfig')->willReturn($config); + + return $configService; + } + + #[Test] + public function testCreateBuiltInExporter(): void + { + $factory = new CognitiveReportFactory($this->createMockConfigService()); + + $exporter = $factory->create('json'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport', $exporter); + } + + #[Test] + public function testCreateBuiltInExporterWithConfig(): void + { + $factory = new CognitiveReportFactory($this->createMockConfigService()); + + $exporter = $factory->create('markdown'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\MarkdownReport', $exporter); + } + + #[Test] + public function testCreateCustomExporterWithFile(): void + { + // Create a temporary PHP file with a custom exporter + $tempFile = tempnam(sys_get_temp_dir(), 'custom_cognitive_exporter_') . '.php'; + $classContent = <<<'PHP' + [ + 'class' => 'TestCustomCognitive\CustomCognitiveExporter', + 'file' => $tempFile, + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + $exporter = $factory->create('custom'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('TestCustomCognitive\CustomCognitiveExporter', $exporter); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testCreateCustomExporterWithConfig(): void + { + // Create a temporary PHP file with a custom exporter that requires config + $tempFile = tempnam(sys_get_temp_dir(), 'config_cognitive_exporter_') . '.php'; + $classContent = <<<'PHP' +config = $config; + } + + public function export(CognitiveMetricsCollection $metrics, string $filename): void { + file_put_contents($filename, 'config cognitive data: ' . $this->config->scoreThreshold); + } +} +PHP; + file_put_contents($tempFile, $classContent); + + try { + $customReporters = [ + 'config' => [ + 'class' => 'TestConfigCognitive\ConfigCognitiveExporter', + 'file' => $tempFile, + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + $exporter = $factory->create('config'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('TestConfigCognitive\ConfigCognitiveExporter', $exporter); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testCreateCustomExporterWithoutFile(): void + { + // Create a temporary PHP file and include it manually to simulate autoloading + $tempFile = tempnam(sys_get_temp_dir(), 'autoloaded_cognitive_exporter_') . '.php'; + $classContent = <<<'PHP' + [ + 'class' => 'TestAutoloadedCognitive\AutoloadedCognitiveExporter', + 'file' => null, + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + $exporter = $factory->create('autoloaded'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('TestAutoloadedCognitive\AutoloadedCognitiveExporter', $exporter); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testCreateUnsupportedExporter(): void + { + $factory = new CognitiveReportFactory($this->createMockConfigService()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported exporter type: unsupported'); + + $factory->create('unsupported'); + } + + #[Test] + public function testGetSupportedTypesIncludesCustomExporters(): void + { + $customReporters = [ + 'custom1' => [ + 'class' => 'TestCustom1\Exporter', + 'file' => null, + 'requiresConfig' => false + ], + 'custom2' => [ + 'class' => 'TestCustom2\Exporter', + 'file' => null, + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + $supportedTypes = $factory->getSupportedTypes(); + + $expectedBuiltInTypes = ['json', 'csv', 'html', 'markdown']; + $expectedCustomTypes = ['custom1', 'custom2']; + + foreach ($expectedBuiltInTypes as $type) { + $this->assertContains($type, $supportedTypes); + } + + foreach ($expectedCustomTypes as $type) { + $this->assertContains($type, $supportedTypes); + } + } + + #[Test] + public function testIsSupportedWithCustomExporters(): void + { + $customReporters = [ + 'custom' => [ + 'class' => 'TestCustom\Exporter', + 'file' => null, + 'requiresConfig' => false + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + + $this->assertTrue($factory->isSupported('json')); + $this->assertTrue($factory->isSupported('custom')); + $this->assertFalse($factory->isSupported('unsupported')); + } + + #[Test] + public function testCustomExporterWithInvalidInterface(): void + { + // Create a temporary PHP file with a class that doesn't implement the interface + $tempFile = tempnam(sys_get_temp_dir(), 'invalid_cognitive_exporter_') . '.php'; + $classContent = <<<'PHP' + [ + 'class' => 'TestInvalidCognitive\InvalidCognitiveExporter', + 'file' => $tempFile, + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + + $this->expectException(\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter must implement Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\ReportGeneratorInterface'); + + $factory->create('invalid'); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testCustomExporterWithNonExistentFile(): void + { + $customReporters = [ + 'missing' => [ + 'class' => 'TestMissing\Exporter', + 'file' => '/non/existent/file.php', + 'requiresConfig' => false + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + + $this->expectException(\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter file not found: /non/existent/file.php'); + + $factory->create('missing'); + } + + #[Test] + public function testCustomExporterRequiresConfigButConfigIsNull(): void + { + // Create a temporary PHP file with a custom exporter that requires config + $tempFile = tempnam(sys_get_temp_dir(), 'null_config_exporter_') . '.php'; + $classContent = <<<'PHP' +config = $config; + } + + public function export(CognitiveMetricsCollection $metrics, string $filename): void { + file_put_contents($filename, 'null config cognitive data'); + } +} +PHP; + file_put_contents($tempFile, $classContent); + + try { + $customReporters = [ + 'nullconfig' => [ + 'class' => 'TestNullConfigCognitive\NullConfigCognitiveExporter', + 'file' => $tempFile, + // This should create without config + ] + ]; + + $factory = new CognitiveReportFactory($this->createMockConfigService($customReporters)); + $exporter = $factory->create('nullconfig'); + + $this->assertInstanceOf(ReportGeneratorInterface::class, $exporter); + $this->assertInstanceOf('TestNullConfigCognitive\NullConfigCognitiveExporter', $exporter); + } finally { + unlink($tempFile); + } + } +} diff --git a/tests/Unit/Business/Cognitive/Exporter/CsvExporterTest.php b/tests/Unit/Business/Cognitive/Report/CsvReporterTest.php similarity index 94% rename from tests/Unit/Business/Cognitive/Exporter/CsvExporterTest.php rename to tests/Unit/Business/Cognitive/Report/CsvReporterTest.php index bbcfeca..ac0bad2 100644 --- a/tests/Unit/Business/Cognitive/Exporter/CsvExporterTest.php +++ b/tests/Unit/Business/Cognitive/Report/CsvReporterTest.php @@ -2,24 +2,24 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; -use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter\CsvExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\CsvReport; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -class CsvExporterTest extends TestCase +class CsvReporterTest extends TestCase { - private CsvExporter $csvExporter; + private CsvReport $csvExporter; private string $filename; protected function setUp(): void { parent::setUp(); - $this->csvExporter = new CsvExporter(); + $this->csvExporter = new CsvReport(); $this->filename = sys_get_temp_dir() . '/test_metrics.csv'; } diff --git a/tests/Unit/Business/Cognitive/Exporter/HtmlExporterContent.html b/tests/Unit/Business/Cognitive/Report/HtmlReporterContent.html similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/HtmlExporterContent.html rename to tests/Unit/Business/Cognitive/Report/HtmlReporterContent.html diff --git a/tests/Unit/Business/Cognitive/Exporter/HtmlExporterTest.php b/tests/Unit/Business/Cognitive/Report/HtmlReporterTest.php similarity index 87% rename from tests/Unit/Business/Cognitive/Exporter/HtmlExporterTest.php rename to tests/Unit/Business/Cognitive/Report/HtmlReporterTest.php index 2093c7f..254ad35 100644 --- a/tests/Unit/Business/Cognitive/Exporter/HtmlExporterTest.php +++ b/tests/Unit/Business/Cognitive/Report/HtmlReporterTest.php @@ -2,24 +2,24 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; -use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter\HtmlExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\HtmlReport; use Phauthentic\CognitiveCodeAnalysis\Business\Utility\Datetime; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -class HtmlExporterTest extends TestCase +class HtmlReporterTest extends TestCase { - private HtmlExporter $csvExporter; + private HtmlReport $csvExporter; private string $filename; protected function setUp(): void { parent::setUp(); - $this->csvExporter = new HtmlExporter(); + $this->csvExporter = new HtmlReport(); $this->filename = sys_get_temp_dir() . '/test_metrics.csv'; Datetime::$fixedDate = '2023-10-01 12:00:00'; } @@ -64,7 +64,7 @@ public function testExportCreatesFile(): void $this->csvExporter->export($metricsCollection, $this->filename); $this->assertFileEquals( - __DIR__ . '/HtmlExporterContent.html', + __DIR__ . '/HtmlReporterContent.html', $this->filename ); } diff --git a/tests/Unit/Business/Cognitive/Exporter/JsonExporterTest.php b/tests/Unit/Business/Cognitive/Report/JsonReporterTest.php similarity index 93% rename from tests/Unit/Business/Cognitive/Exporter/JsonExporterTest.php rename to tests/Unit/Business/Cognitive/Report/JsonReporterTest.php index 2248bfe..490f601 100644 --- a/tests/Unit/Business/Cognitive/Exporter/JsonExporterTest.php +++ b/tests/Unit/Business/Cognitive/Report/JsonReporterTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; -use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter\JsonExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; /** - * Test case for JsonExporter class. + * Test case for JsonReport class. */ -class JsonExporterTest extends TestCase +class JsonReporterTest extends TestCase { #[Test] public function testExport(): void @@ -53,8 +53,8 @@ public function testExport(): void $metricsCollection->add($metrics1); $metricsCollection->add($metrics2); - // Create an instance of JsonExporter and export the metrics. - $jsonExporter = new JsonExporter(); + // Create an instance of JsonReport and export the metrics. + $jsonExporter = new JsonReport(); $jsonExporter->export($metricsCollection, $filename); // Read the contents of the file and decode the JSON. diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_AllMetrics.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_AllMetrics.md similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_AllMetrics.md rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_AllMetrics.md diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_CyclomaticOnly.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_CyclomaticOnly.md similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_CyclomaticOnly.md rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_CyclomaticOnly.md diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_HalsteadOnly.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_HalsteadOnly.md similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_HalsteadOnly.md rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_HalsteadOnly.md diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_Minimal.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Minimal.md similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_Minimal.md rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Minimal.md diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_NoDetailedMetrics.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_NoDetailedMetrics.md similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_NoDetailedMetrics.md rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_NoDetailedMetrics.md diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_SingleTable.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_SingleTable.md similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_SingleTable.md rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_SingleTable.md diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_Threshold.md b/tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Threshold.md similarity index 100% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterContent_Threshold.md rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterContent_Threshold.md diff --git a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterTest.php b/tests/Unit/Business/Cognitive/Report/MarkdownReporterTest.php similarity index 94% rename from tests/Unit/Business/Cognitive/Exporter/MarkdownExporterTest.php rename to tests/Unit/Business/Cognitive/Report/MarkdownReporterTest.php index a97f9f5..263d49c 100644 --- a/tests/Unit/Business/Cognitive/Exporter/MarkdownExporterTest.php +++ b/tests/Unit/Business/Cognitive/Report/MarkdownReporterTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Exporter; +namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Cognitive\Report; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetrics; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; -use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Exporter\MarkdownExporter; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\MarkdownReport; use Phauthentic\CognitiveCodeAnalysis\Business\Utility\Datetime; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigLoader; use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; @@ -15,7 +15,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Config\Definition\Processor; -class MarkdownExporterTest extends TestCase +class MarkdownReporterTest extends TestCase { private string $filename; @@ -41,13 +41,13 @@ protected function tearDown(): void public static function configurationProvider(): array { return [ - 'All metrics' => ['all-metrics-config.yml', 'MarkdownExporterContent_AllMetrics.md'], - 'Minimal' => ['minimal-config.yml', 'MarkdownExporterContent_Minimal.md'], - 'Single table' => ['single-table-config.yml', 'MarkdownExporterContent_SingleTable.md'], - 'Halstead only' => ['halstead-only-config.yml', 'MarkdownExporterContent_HalsteadOnly.md'], - 'Cyclomatic only' => ['cyclomatic-only-config.yml', 'MarkdownExporterContent_CyclomaticOnly.md'], - 'No detailed metrics' => ['no-detailed-metrics-config.yml', 'MarkdownExporterContent_NoDetailedMetrics.md'], - 'Threshold' => ['threshold-config.yml', 'MarkdownExporterContent_Threshold.md'], + 'All metrics' => ['all-metrics-config.yml', 'MarkdownReporterContent_AllMetrics.md'], + 'Minimal' => ['minimal-config.yml', 'MarkdownReporterContent_Minimal.md'], + 'Single table' => ['single-table-config.yml', 'MarkdownReporterContent_SingleTable.md'], + 'Halstead only' => ['halstead-only-config.yml', 'MarkdownReporterContent_HalsteadOnly.md'], + 'Cyclomatic only' => ['cyclomatic-only-config.yml', 'MarkdownReporterContent_CyclomaticOnly.md'], + 'No detailed metrics' => ['no-detailed-metrics-config.yml', 'MarkdownReporterContent_NoDetailedMetrics.md'], + 'Threshold' => ['threshold-config.yml', 'MarkdownReporterContent_Threshold.md'], ]; } @@ -63,7 +63,7 @@ public function testExportWithConfiguration(string $configFile, string $expected $config = $configService->getConfig(); $metricsCollection = $this->createTestMetricsCollection(); - $exporter = new MarkdownExporter($config); + $exporter = new MarkdownReport($config); $exporter->export($metricsCollection, $this->filename); $this->assertFileEquals( diff --git a/tests/Unit/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorTest.php b/tests/Unit/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorTest.php index f88c02b..ffc7635 100644 --- a/tests/Unit/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorTest.php +++ b/tests/Unit/Business/CyclomaticComplexity/CyclomaticComplexityCalculatorTest.php @@ -4,7 +4,7 @@ namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\CyclomaticComplexity; -use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculator; +use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticComplexityCalculator; use PHPUnit\Framework\TestCase; class CyclomaticComplexityCalculatorTest extends TestCase diff --git a/tests/Unit/Business/DirectoryScannerTest.php b/tests/Unit/Business/DirectoryScannerTest.php index e3e5ae1..9ae9025 100644 --- a/tests/Unit/Business/DirectoryScannerTest.php +++ b/tests/Unit/Business/DirectoryScannerTest.php @@ -5,7 +5,6 @@ namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business; use FilesystemIterator; -use Phauthentic\CognitiveCodeAnalysis\Business\DirectoryScanner; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use RecursiveDirectoryIterator; @@ -71,7 +70,7 @@ private function deleteDirectory(string $dir): void #[Test] public function testScan(): void { - $scanner = new DirectoryScanner(); + $scanner = new \Phauthentic\CognitiveCodeAnalysis\Business\Utility\DirectoryScanner(); $excludePatterns = ['exclude_me', 'exclude_me_too']; $files = []; diff --git a/tests/Unit/Business/Exporter/ExporterRegistryTest.php b/tests/Unit/Business/Exporter/ExporterRegistryTest.php new file mode 100644 index 0000000..2f13fff --- /dev/null +++ b/tests/Unit/Business/Exporter/ExporterRegistryTest.php @@ -0,0 +1,159 @@ +registry = new ReporterRegistry(); + } + + #[Test] + public function testLoadExporterWithExistingClass(): void + { + // Test loading a class that already exists (JsonReport) + $this->registry->loadExporter( + 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport', + null + ); + + // Should not throw an exception + $this->assertTrue(true); + } + + #[Test] + public function testLoadExporterWithFile(): void + { + // Create a temporary PHP file with a test class + $tempFile = tempnam(sys_get_temp_dir(), 'test_exporter_') . '.php'; + $classContent = <<<'PHP' +registry->loadExporter('TestNamespace\TestExporter', $tempFile); + $this->assertTrue(true); + } finally { + unlink($tempFile); + } + } + + #[Test] + public function testLoadExporterWithNonExistentFile(): void + { + $this->expectException(CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter file not found: /non/existent/file.php'); + + $this->registry->loadExporter('TestClass', '/non/existent/file.php'); + } + + #[Test] + public function testLoadExporterWithNonExistentClass(): void + { + $this->expectException(CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter class not found: NonExistentClass'); + + $this->registry->loadExporter('NonExistentClass', null); + } + + #[Test] + public function testInstantiateWithoutConfig(): void + { + $exporter = $this->registry->instantiate( + 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport', + null + ); + + $this->assertInstanceOf('Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport', $exporter); + } + + #[Test] + public function testInstantiateWithConfig(): void + { + $config = new CognitiveConfig( + excludeFilePatterns: [], + excludePatterns: [], + metrics: [], + showOnlyMethodsExceedingThreshold: false, + scoreThreshold: 0.5 + ); + + $exporter = $this->registry->instantiate( + 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\MarkdownReport', + $config + ); + + $this->assertInstanceOf('Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\MarkdownReport', $exporter); + } + + #[Test] + public function testValidateInterfaceWithValidExporter(): void + { + $exporter = new \Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport(); + + $this->registry->validateInterface( + $exporter, + 'Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\ReportGeneratorInterface' + ); + + // Should not throw an exception + $this->assertTrue(true); + } + + #[Test] + public function testValidateInterfaceWithInvalidExporter(): void + { + $this->expectException(CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter must implement InvalidInterface'); + + $exporter = new \Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Report\JsonReport(); + + $this->registry->validateInterface($exporter, 'InvalidInterface'); + } + + #[Test] + public function testFileIsLoadedOnlyOnce(): void + { + // Create a temporary PHP file + $tempFile = tempnam(sys_get_temp_dir(), 'test_exporter_once_') . '.php'; + $classContent = <<<'PHP' +registry->loadExporter('TestOnceNamespace\TestOnceExporter', $tempFile); + $this->registry->loadExporter('TestOnceNamespace\TestOnceExporter', $tempFile); + + // Should not throw an exception (file should be loaded only once) + $this->assertTrue(true); + } finally { + unlink($tempFile); + } + } +} diff --git a/tests/Unit/Business/MetricsFacadeCustomExportersTest.php b/tests/Unit/Business/MetricsFacadeCustomExportersTest.php new file mode 100644 index 0000000..b9076c1 --- /dev/null +++ b/tests/Unit/Business/MetricsFacadeCustomExportersTest.php @@ -0,0 +1,194 @@ +metricsFacade = (new Application())->get(MetricsFacade::class); + } + + #[Test] + public function testMetricsFacadeWithCustomExporters(): void + { + // Load the custom config from fixture + $configFile = __DIR__ . '/../../Fixtures/custom-exporters-config.yml'; + $this->metricsFacade->loadConfig($configFile); + $config = $this->metricsFacade->getConfig(); + + $this->assertInstanceOf(CognitiveConfig::class, $config); + $this->assertArrayHasKey('cognitive', $config->customReporters); + $this->assertArrayHasKey('churn', $config->customReporters); + $this->assertArrayHasKey('test', $config->customReporters['cognitive']); + $this->assertArrayHasKey('test', $config->customReporters['churn']); + } + + #[Test] + public function testExportMetricsReportWithCustomExporter(): void + { + // Load the custom config from fixture + $configFile = __DIR__ . '/../../Fixtures/custom-cognitive-exporter-config.yml'; + $this->metricsFacade->loadConfig($configFile); + + // Get metrics + $metricsCollection = $this->metricsFacade->getCognitiveMetrics($this->testCodePath); + + // Export using the custom exporter + $tempOutputFile = tempnam(sys_get_temp_dir(), 'custom_export_test_') . '.json'; + + $this->metricsFacade->exportMetricsReport( + $metricsCollection, + 'custom', + $tempOutputFile + ); + + $this->assertFileExists($tempOutputFile); + $content = file_get_contents($tempOutputFile); + $this->assertNotEmpty($content); + $this->assertJson($content); + + unlink($tempOutputFile); + } + + #[Test] + public function testExportChurnReportWithCustomExporter(): void + { + // Load the custom config from fixture + $configFile = __DIR__ . '/../../Fixtures/custom-churn-exporter-config.yml'; + $this->metricsFacade->loadConfig($configFile); + + // Calculate churn + $churnData = $this->metricsFacade->calculateChurn($this->testCodePath, 'git', '1900-01-01'); + + // Export using the custom exporter + $tempOutputFile = tempnam(sys_get_temp_dir(), 'custom_churn_export_test_') . '.json'; + + $this->metricsFacade->exportChurnReport( + $churnData, + 'custom', + $tempOutputFile + ); + + $this->assertFileExists($tempOutputFile); + $content = file_get_contents($tempOutputFile); + $this->assertNotEmpty($content); + $this->assertJson($content); + + unlink($tempOutputFile); + } + + #[Test] + public function testExportWithNonExistentCustomExporter(): void + { + // Load the custom config from fixture + $configFile = __DIR__ . '/../../Fixtures/invalid-custom-exporter-config.yml'; + $this->metricsFacade->loadConfig($configFile); + + // Get metrics + $metricsCollection = $this->metricsFacade->getCognitiveMetrics($this->testCodePath); + + // Try to export using the invalid custom exporter + $tempOutputFile = tempnam(sys_get_temp_dir(), 'invalid_export_test_') . '.json'; + + $this->expectException(\Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException::class); + $this->expectExceptionMessage('Exporter class not found: NonExistent\Exporter'); + + $this->metricsFacade->exportMetricsReport( + $metricsCollection, + 'invalid', + $tempOutputFile + ); + } + + #[Test] + public function testExportWithCustomExporterRequiringConfig(): void + { + // Create a temporary PHP file with a custom exporter that requires config + $tempExporterFile = tempnam(sys_get_temp_dir(), 'config_exporter_') . '.php'; + $exporterContent = <<<'PHP' +config = $config; + } + + public function export(CognitiveMetricsCollection $metrics, string $filename): void { + $data = [ + 'config' => [ + 'scoreThreshold' => $this->config->scoreThreshold, + 'groupByClass' => $this->config->groupByClass + ], + 'metrics' => 'exported' + ]; + file_put_contents($filename, json_encode($data)); + } +} +PHP; + file_put_contents($tempExporterFile, $exporterContent); + + try { + // Load the custom config from fixture and update the file path + $configFile = __DIR__ . '/../../Fixtures/config-exporter-config.yml'; + $configContent = file_get_contents($configFile); + $configContent = str_replace('file: null', "file: '{$tempExporterFile}'", $configContent); + + $tempConfigFile = tempnam(sys_get_temp_dir(), 'config_exporter_config_') . '.yml'; + file_put_contents($tempConfigFile, $configContent); + + try { + // Load the custom config + $this->metricsFacade->loadConfig($tempConfigFile); + + // Get metrics + $metricsCollection = $this->metricsFacade->getCognitiveMetrics($this->testCodePath); + + // Export using the custom exporter + $tempOutputFile = tempnam(sys_get_temp_dir(), 'config_export_test_') . '.json'; + + $this->metricsFacade->exportMetricsReport( + $metricsCollection, + 'config', + $tempOutputFile + ); + + $this->assertFileExists($tempOutputFile); + $content = file_get_contents($tempOutputFile); + $this->assertNotEmpty($content); + + $data = json_decode($content, true); + $this->assertArrayHasKey('config', $data); + $this->assertEquals(0.8, $data['config']['scoreThreshold']); + $this->assertFalse($data['config']['groupByClass']); + + unlink($tempOutputFile); + } finally { + unlink($tempConfigFile); + } + } finally { + unlink($tempExporterFile); + } + } +} diff --git a/tests/Unit/Command/ChurnCommandTest.php b/tests/Unit/Command/ChurnCommandTest.php index bffa308..ae7ab95 100644 --- a/tests/Unit/Command/ChurnCommandTest.php +++ b/tests/Unit/Command/ChurnCommandTest.php @@ -107,4 +107,104 @@ public static function reportDataProvider(): array ] ]; } + + #[Test] + public function testAnalyseWithCustomChurnTextReporter(): void + { + $application = new Application(); + $command = $application->getContainer()->get(ChurnCommand::class); + $tester = new CommandTester($command); + + $tempOutputFile = tempnam(sys_get_temp_dir(), 'custom_churn_report_') . '.txt'; + + $tester->execute([ + 'path' => __DIR__ . '/../../../tests/TestCode', + '--config' => __DIR__ . '/../../../tests/Fixtures/custom-churn-text-reporter-config.yml', + '--report-type' => 'customchurn', + '--report-file' => $tempOutputFile, + ]); + + // Debug: Show the actual output and status + if ($tester->getStatusCode() !== Command::SUCCESS) { + echo "Command failed with status: " . $tester->getStatusCode() . "\n"; + echo "Output: " . $tester->getDisplay() . "\n"; + } + + $this->assertEquals(Command::SUCCESS, $tester->getStatusCode(), 'Command should succeed with custom churn text reporter'); + $this->assertFileExists($tempOutputFile, 'Custom churn report file should be created'); + + $content = file_get_contents($tempOutputFile); + $this->assertNotEmpty($content, 'Report file should not be empty'); + $this->assertStringContainsString('=== Custom Churn Analysis Report ===', $content, 'Should contain report header'); + $this->assertStringContainsString('Generated by CustomChurnTextReporter', $content, 'Should contain reporter identification'); + $this->assertStringContainsString('Total Classes:', $content, 'Should contain summary'); + $this->assertStringContainsString('Total Methods:', $content, 'Should contain method count'); + + // Verify that actual churn data is present + $this->assertStringContainsString('Class:', $content, 'Should contain class information'); + $this->assertStringContainsString('File:', $content, 'Should contain file information'); + $this->assertStringContainsString('Times Changed:', $content, 'Should contain times changed information'); + $this->assertStringContainsString('Churn:', $content, 'Should contain churn information'); + + unlink($tempOutputFile); + } + + #[Test] + public function testAnalyseWithConfigAwareChurnReporter(): void + { + $application = new Application(); + $command = $application->getContainer()->get(ChurnCommand::class); + $tester = new CommandTester($command); + + $tempOutputFile = tempnam(sys_get_temp_dir(), 'config_aware_churn_report_') . '.txt'; + + $tester->execute([ + 'path' => __DIR__ . '/../../../tests/TestCode', + '--config' => __DIR__ . '/../../../tests/Fixtures/config-aware-churn-reporter-config.yml', + '--report-type' => 'configchurn', + '--report-file' => $tempOutputFile, + ]); + + $this->assertEquals(Command::SUCCESS, $tester->getStatusCode(), 'Command should succeed with config-aware churn reporter'); + $this->assertFileExists($tempOutputFile, 'Config-aware churn report file should be created'); + + $content = file_get_contents($tempOutputFile); + $this->assertNotEmpty($content, 'Config-aware churn report file should not be empty'); + $this->assertStringContainsString('=== Config-Aware Churn Analysis Report ===', $content, 'Should contain config-aware report header'); + $this->assertStringContainsString('Generated by ConfigAwareChurnTextReporter', $content, 'Should contain reporter identification'); + + // Verify config information is included + $this->assertStringContainsString('Configuration:', $content, 'Should contain configuration section'); + $this->assertStringContainsString('Score Threshold: 0.8', $content, 'Should contain score threshold from config'); + $this->assertStringContainsString('Group By Class: Yes', $content, 'Should contain group by class setting'); + $this->assertStringContainsString('Show Only Methods Exceeding Threshold: Yes', $content, 'Should contain threshold filtering setting'); + + // Verify threshold-based analysis + $this->assertStringContainsString('[ABOVE THRESHOLD]', $content, 'Should mark classes above threshold'); + $this->assertStringContainsString('Classes Above Threshold (0.8):', $content, 'Should count classes above threshold'); + + unlink($tempOutputFile); + } + + #[Test] + public function testAnalyseWithInvalidCustomChurnReporter(): void + { + $application = new Application(); + $command = $application->getContainer()->get(ChurnCommand::class); + $tester = new CommandTester($command); + + $tempOutputFile = tempnam(sys_get_temp_dir(), 'invalid_custom_churn_report_') . '.txt'; + + $tester->execute([ + 'path' => __DIR__ . '/../../../tests/TestCode', + '--config' => __DIR__ . '/../../../tests/Fixtures/custom-churn-text-reporter-config.yml', + '--report-type' => 'nonexistent', + '--report-file' => $tempOutputFile, + ]); + + $this->assertEquals(Command::FAILURE, $tester->getStatusCode(), 'Command should fail with invalid custom churn reporter'); + $this->assertFileDoesNotExist($tempOutputFile, 'Report file should not be created for invalid reporter'); + + // Note: Error messages are written to stderr, which CommandTester doesn't capture by default + } } diff --git a/tests/Unit/Command/CognitiveMetricsCommandTest.php b/tests/Unit/Command/CognitiveMetricsCommandTest.php index c8d6f17..8f44dcc 100644 --- a/tests/Unit/Command/CognitiveMetricsCommandTest.php +++ b/tests/Unit/Command/CognitiveMetricsCommandTest.php @@ -152,7 +152,7 @@ public function testAnalyseWithInvalidSortField(): void ]); $this->assertEquals(Command::FAILURE, $tester->getStatusCode(), 'Command should fail with invalid sort field'); - $this->assertStringContainsString('Sorting error', $tester->getDisplay()); + $this->assertStringContainsString('Invalid sort field "invalid-field"', $tester->getDisplay()); } #[Test] @@ -169,7 +169,7 @@ public function testAnalyseWithInvalidSortOrder(): void ]); $this->assertEquals(Command::FAILURE, $tester->getStatusCode(), 'Command should fail with invalid sort order'); - $this->assertStringContainsString('Sorting error', $tester->getDisplay()); + $this->assertStringContainsString('Sort order must be "asc" or "desc", got "invalid"', $tester->getDisplay()); } public function testOutputWithoutOptions(): void @@ -263,7 +263,7 @@ public static function multiplePathsDataProvider(): array 'Command should succeed with multiple files' ], 'multiple files with spaces' => [ - __DIR__ . '/../../../src/Command/CognitiveMetricsCommand.php, ' . __DIR__ . '/../../../src/Business/MetricsFacade.php, ' . __DIR__ . '/../../../src/Business/DirectoryScanner.php', + __DIR__ . '/../../../src/Command/CognitiveMetricsCommand.php, ' . __DIR__ . '/../../../src/Business/MetricsFacade.php, ' . __DIR__ . '/../../../src/Business/Utility/DirectoryScanner.php', 'Command should succeed with multiple files and spaces' ], 'multiple directories' => [ @@ -275,9 +275,109 @@ public static function multiplePathsDataProvider(): array 'Command should succeed with mixed directories and files' ], 'mixed paths with spaces' => [ - __DIR__ . '/../../../src/Command, ' . __DIR__ . '/../../../src/Business/MetricsFacade.php, ' . __DIR__ . '/../../../src/Business/DirectoryScanner.php', + __DIR__ . '/../../../src/Command, ' . __DIR__ . '/../../../src/Business/MetricsFacade.php, ' . __DIR__ . '/../../../src/Business/Utility/DirectoryScanner.php', 'Command should succeed with mixed paths and spaces' ], ]; } + + #[Test] + public function testAnalyseWithCustomTextReporter(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tempOutputFile = tempnam(sys_get_temp_dir(), 'custom_text_report_') . '.txt'; + + $tester->execute([ + 'path' => __DIR__ . '/../../../tests/TestCode', + '--config' => __DIR__ . '/../../../tests/Fixtures/custom-text-reporter-config.yml', + '--report-type' => 'customtext', + '--report-file' => $tempOutputFile, + ]); + + // Debug: Show the actual output and status + if ($tester->getStatusCode() !== Command::SUCCESS) { + echo "Command failed with status: " . $tester->getStatusCode() . "\n"; + echo "Output: " . $tester->getDisplay() . "\n"; + } + + $this->assertEquals(Command::SUCCESS, $tester->getStatusCode(), 'Command should succeed with custom text reporter'); + $this->assertFileExists($tempOutputFile, 'Custom report file should be created'); + + $content = file_get_contents($tempOutputFile); + $this->assertNotEmpty($content, 'Report file should not be empty'); + $this->assertStringContainsString('=== Cognitive Complexity Report ===', $content, 'Should contain report header'); + $this->assertStringContainsString('Generated by CustomTextReporter', $content, 'Should contain reporter identification'); + $this->assertStringContainsString('Total Methods:', $content, 'Should contain summary'); + $this->assertStringContainsString('Average Score:', $content, 'Should contain average score'); + + // Verify that actual metrics data is present + $this->assertStringContainsString('Class:', $content, 'Should contain class information'); + $this->assertStringContainsString('Method:', $content, 'Should contain method information'); + $this->assertStringContainsString('Score:', $content, 'Should contain score information'); + + unlink($tempOutputFile); + } + + #[Test] + public function testAnalyseWithConfigAwareCustomReporter(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tempOutputFile = tempnam(sys_get_temp_dir(), 'config_aware_report_') . '.txt'; + + $tester->execute([ + 'path' => __DIR__ . '/../../../tests/TestCode', + '--config' => __DIR__ . '/../../../tests/Fixtures/config-aware-text-reporter-config.yml', + '--report-type' => 'configtext', + '--report-file' => $tempOutputFile, + ]); + + $this->assertEquals(Command::SUCCESS, $tester->getStatusCode(), 'Command should succeed with config-aware custom reporter'); + $this->assertFileExists($tempOutputFile, 'Config-aware report file should be created'); + + $content = file_get_contents($tempOutputFile); + $this->assertNotEmpty($content, 'Config-aware report file should not be empty'); + $this->assertStringContainsString('=== Config-Aware Cognitive Complexity Report ===', $content, 'Should contain config-aware report header'); + $this->assertStringContainsString('Generated by ConfigAwareTextReporter', $content, 'Should contain reporter identification'); + + // Verify config information is included + $this->assertStringContainsString('Configuration:', $content, 'Should contain configuration section'); + $this->assertStringContainsString('Score Threshold: 0.80', $content, 'Should contain score threshold from config'); + $this->assertStringContainsString('Group By Class: No', $content, 'Should contain group by class setting'); + $this->assertStringContainsString('Show Only Methods Exceeding Threshold: Yes', $content, 'Should contain threshold filtering setting'); + + // Verify threshold-based analysis + $this->assertStringContainsString('[ABOVE THRESHOLD]', $content, 'Should mark methods above threshold'); + $this->assertStringContainsString('Methods Above Threshold (0.80):', $content, 'Should count methods above threshold'); + + unlink($tempOutputFile); + } + + #[Test] + public function testAnalyseWithInvalidCustomReporter(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tempOutputFile = tempnam(sys_get_temp_dir(), 'invalid_custom_report_') . '.txt'; + + $tester->execute([ + 'path' => __DIR__ . '/../../../tests/TestCode', + '--config' => __DIR__ . '/../../../tests/Fixtures/custom-text-reporter-config.yml', + '--report-type' => 'nonexistent', + '--report-file' => $tempOutputFile, + ]); + + $this->assertEquals(Command::FAILURE, $tester->getStatusCode(), 'Command should fail with invalid custom reporter'); + $this->assertFileDoesNotExist($tempOutputFile, 'Report file should not be created for invalid reporter'); + + // Note: Error messages are written to stderr, which CommandTester doesn't capture by default + // The important thing is that the command fails with the correct status code + } } diff --git a/tests/Unit/Config/CustomExportersConfigTest.php b/tests/Unit/Config/CustomExportersConfigTest.php new file mode 100644 index 0000000..b94235c --- /dev/null +++ b/tests/Unit/Config/CustomExportersConfigTest.php @@ -0,0 +1,304 @@ +getConfigTreeBuilder(); + $configTree = $treeBuilder->buildTree(); + + $config = [ + '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 + ] + ], + 'customReporters' => [ + 'cognitive' => [ + 'pdf' => [ + 'class' => 'My\Custom\PdfExporter', + 'file' => '/path/to/PdfExporter.php', + ], + 'xml' => [ + 'class' => 'My\Custom\XmlExporter', + 'file' => null, + ] + ], + 'churn' => [ + 'custom' => [ + 'class' => 'My\Custom\ChurnExporter', + 'file' => '/path/to/ChurnExporter.php' + ] + ] + ] + ] + ]; + + $processedConfig = $processor->process($configTree, [$config]); + + $this->assertArrayHasKey('cognitive', $processedConfig); + $this->assertArrayHasKey('customReporters', $processedConfig['cognitive']); + $this->assertArrayHasKey('cognitive', $processedConfig['cognitive']['customReporters']); + $this->assertArrayHasKey('churn', $processedConfig['cognitive']['customReporters']); + + // Test cognitive exporters + $cognitiveExporters = $processedConfig['cognitive']['customReporters']['cognitive']; + $this->assertArrayHasKey('pdf', $cognitiveExporters); + $this->assertArrayHasKey('xml', $cognitiveExporters); + + $this->assertEquals('My\Custom\PdfExporter', $cognitiveExporters['pdf']['class']); + $this->assertEquals('/path/to/PdfExporter.php', $cognitiveExporters['pdf']['file']); + + $this->assertEquals('My\Custom\XmlExporter', $cognitiveExporters['xml']['class']); + $this->assertNull($cognitiveExporters['xml']['file']); + + // Test churn exporters + $churnExporters = $processedConfig['cognitive']['customReporters']['churn']; + $this->assertArrayHasKey('custom', $churnExporters); + $this->assertEquals('My\Custom\ChurnExporter', $churnExporters['custom']['class']); + $this->assertEquals('/path/to/ChurnExporter.php', $churnExporters['custom']['file']); + } + + #[Test] + public function testCustomExportersWithDefaults(): void + { + $configLoader = new ConfigLoader(); + $processor = new Processor(); + $treeBuilder = $configLoader->getConfigTreeBuilder(); + $configTree = $treeBuilder->buildTree(); + + $config = [ + '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 + ] + ], + 'customReporters' => [ + 'cognitive' => [ + 'minimal' => [ + 'class' => 'My\Custom\MinimalExporter' + // file should default to null + ] + ] + ] + ] + ]; + + $processedConfig = $processor->process($configTree, [$config]); + + $cognitiveExporters = $processedConfig['cognitive']['customReporters']['cognitive']; + $this->assertArrayHasKey('minimal', $cognitiveExporters); + $this->assertEquals('My\Custom\MinimalExporter', $cognitiveExporters['minimal']['class']); + $this->assertNull($cognitiveExporters['minimal']['file']); + } + + #[Test] + public function testEmptyCustomExporters(): void + { + $configLoader = new ConfigLoader(); + $processor = new Processor(); + $treeBuilder = $configLoader->getConfigTreeBuilder(); + $configTree = $treeBuilder->buildTree(); + + $config = [ + '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 + ] + ] + // No customReporters section + ] + ]; + + $processedConfig = $processor->process($configTree, [$config]); + + $this->assertArrayHasKey('cognitive', $processedConfig); + + // customReporters might not be present if not provided + if (!isset($processedConfig['cognitive']['customReporters'])) { + return; + } + + $this->assertArrayHasKey('cognitive', $processedConfig['cognitive']['customReporters']); + $this->assertArrayHasKey('churn', $processedConfig['cognitive']['customReporters']); + $this->assertEmpty($processedConfig['cognitive']['customReporters']['cognitive']); + $this->assertEmpty($processedConfig['cognitive']['customReporters']['churn']); + } + + #[Test] + public function testConfigFactoryWithCustomExporters(): void + { + $config = [ + '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 + ] + ], + 'customReporters' => [ + 'cognitive' => [ + 'test' => [ + 'class' => 'Test\Exporter', + 'file' => '/test/file.php', + ] + ], + 'churn' => [ + 'test' => [ + 'class' => 'Test\ChurnExporter', + 'file' => null + ] + ] + ] + ] + ]; + + $configFactory = new ConfigFactory(); + $cognitiveConfig = $configFactory->fromArray($config); + + $this->assertInstanceOf(CognitiveConfig::class, $cognitiveConfig); + $this->assertArrayHasKey('cognitive', $cognitiveConfig->customReporters); + $this->assertArrayHasKey('churn', $cognitiveConfig->customReporters); + + $cognitiveExporters = $cognitiveConfig->customReporters['cognitive']; + $this->assertArrayHasKey('test', $cognitiveExporters); + $this->assertEquals('Test\Exporter', $cognitiveExporters['test']['class']); + $this->assertEquals('/test/file.php', $cognitiveExporters['test']['file']); + + $churnExporters = $cognitiveConfig->customReporters['churn']; + $this->assertArrayHasKey('test', $churnExporters); + $this->assertEquals('Test\ChurnExporter', $churnExporters['test']['class']); + $this->assertNull($churnExporters['test']['file']); + } + + #[Test] + public function testConfigFactoryWithoutCustomExporters(): void + { + $config = [ + '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 + ] + ] + // No customReporters section + ] + ]; + + $configFactory = new ConfigFactory(); + $cognitiveConfig = $configFactory->fromArray($config); + + $this->assertInstanceOf(CognitiveConfig::class, $cognitiveConfig); + $this->assertEmpty($cognitiveConfig->customReporters); + } + + #[Test] + public function testInvalidCustomExporterConfiguration(): void + { + $configLoader = new ConfigLoader(); + $processor = new Processor(); + $treeBuilder = $configLoader->getConfigTreeBuilder(); + $configTree = $treeBuilder->buildTree(); + + $config = [ + '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 + ] + ], + 'customReporters' => [ + 'cognitive' => [ + 'invalid' => [ + // Missing required 'class' field + 'file' => '/test/file.php', + ] + ] + ] + ] + ]; + + $this->expectException(\Symfony\Component\Config\Definition\Exception\InvalidConfigurationException::class); + + $processor->process($configTree, [$config]); + } +} diff --git a/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php b/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php index d112261..fa65c76 100644 --- a/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php +++ b/tests/Unit/PhpParser/CyclomaticComplexityVisitorTest.php @@ -4,10 +4,10 @@ namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\PhpParser; -use Phauthentic\CognitiveCodeAnalysis\Business\CyclomaticComplexity\CyclomaticComplexityCalculator; +use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticComplexityCalculator; use Phauthentic\CognitiveCodeAnalysis\PhpParser\CyclomaticComplexityVisitor; -use PhpParser\ParserFactory; use PhpParser\NodeTraverser; +use PhpParser\ParserFactory; use PHPUnit\Framework\TestCase; class CyclomaticComplexityVisitorTest extends TestCase
formatNumber((float)($data['churn'] ?? 0)); ?>escape($metric->getClassName()); ?>getScore(); ?>getTimesChanged(); ?>formatNumber($metric->getChurn()); ?>