diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b34ff78..dc3cd9c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -69,6 +69,3 @@ jobs: - name: Run phpstan run: bin/phpstan -V && bin/phpstan --error-format=github - - - name: Run Infection - run: bin/infection diff --git a/src/Application.php b/src/Application.php index 5c45059..c49a49c 100644 --- a/src/Application.php +++ b/src/Application.php @@ -9,6 +9,7 @@ use Phauthentic\CodeQualityMetrics\Business\Cognitive\ScoreCalculator; use Phauthentic\CodeQualityMetrics\Business\DirectoryScanner; use Phauthentic\CodeQualityMetrics\Business\Halstead\HalsteadMetricsCollector; +use Phauthentic\CodeQualityMetrics\Command\Cognitive\CognitiveCollectorShellOutputPlugin; use Phauthentic\CodeQualityMetrics\Command\CognitiveMetricsCommand; use Phauthentic\CodeQualityMetrics\Command\HalsteadMetricsCommand; use Phauthentic\CodeQualityMetrics\Business\MetricsFacade; @@ -21,6 +22,10 @@ use PhpParser\ParserFactory; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\Console\Application as SymfonyApplication; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -74,6 +79,19 @@ private function registerServices(): void $this->containerBuilder->register(NodeTraverserInterface::class, NodeTraverser::class) ->setPublic(true); + + $this->containerBuilder->register(OutputInterface::class, ConsoleOutput::class) + ->setPublic(true); + + $this->containerBuilder->register(InputInterface::class, ArgvInput::class) + ->setPublic(true); + + $this->containerBuilder->register(CognitiveCollectorShellOutputPlugin::class, CognitiveCollectorShellOutputPlugin::class) + ->setArguments([ + new Reference(InputInterface::class), + new Reference(OutputInterface::class) + ]) + ->setPublic(true); } private function bootstrap(): void @@ -93,6 +111,9 @@ private function bootstrapMetricsCollectors(): void new Reference(ParserFactory::class), new Reference(NodeTraverserInterface::class), new Reference(DirectoryScanner::class), + [ + $this->containerBuilder->get(CognitiveCollectorShellOutputPlugin::class) + ] ]) ->setPublic(true); @@ -157,7 +178,10 @@ public function run(): void { $application = $this->containerBuilder->get(SymfonyApplication::class); // @phpstan-ignore-next-line - $application->run(); + $application->run( + $this->containerBuilder->get(InputInterface::class), + $this->containerBuilder->get(OutputInterface::class) + ); } public function get(string $id): mixed diff --git a/src/Business/AbstractMetricCollector.php b/src/Business/AbstractMetricCollector.php index c5ed599..c974e9f 100644 --- a/src/Business/AbstractMetricCollector.php +++ b/src/Business/AbstractMetricCollector.php @@ -5,6 +5,7 @@ namespace Phauthentic\CodeQualityMetrics\Business; use Generator; +use Phauthentic\CodeQualityMetrics\Business\Cognitive\FindMetricsPluginInterface; use PhpParser\Error; use PhpParser\NodeTraverserInterface; use PhpParser\Parser; @@ -32,10 +33,14 @@ protected function getExcludePatternsFromConfig(array $config): array return []; } + /** + * @param array $findMetricsPlugins + */ public function __construct( protected readonly ParserFactory $parserFactory, protected readonly NodeTraverserInterface $traverser, - protected readonly DirectoryScanner $directoryScanner + protected readonly DirectoryScanner $directoryScanner, + protected readonly array $findMetricsPlugins = [] ) { $this->parser = $this->parserFactory->createForHostVersion(); } diff --git a/src/Business/Cognitive/CognitiveMetrics.php b/src/Business/Cognitive/CognitiveMetrics.php index ccdc545..b4762a2 100644 --- a/src/Business/Cognitive/CognitiveMetrics.php +++ b/src/Business/Cognitive/CognitiveMetrics.php @@ -13,17 +13,17 @@ class CognitiveMetrics implements JsonSerializable { /** - * @var array + * @var array */ private array $metrics = [ - 'lineCount', - 'argCount', - 'returnCount', - 'variableCount', - 'propertyCallCount', - 'ifCount', - 'ifNestingLevel', - 'elseCount' + 'lineCount' => 'lineCount', + 'argCount' => 'argCount', + 'returnCount' => 'returnCount', + 'variableCount' => 'variableCount', + 'propertyCallCount' => 'propertyCallCount', + 'ifCount' => 'ifCount', + 'ifNestingLevel' => 'ifNestingLevel', + 'elseCount' => 'elseCount' ]; private string $class; @@ -64,6 +64,7 @@ public function __construct(array $metrics) { $this->assertArrayKeyIsPresent($metrics, 'class'); $this->assertArrayKeyIsPresent($metrics, 'method'); + $this->method = $metrics['method']; $this->class = $metrics['class']; @@ -77,10 +78,20 @@ public function __construct(array $metrics) */ private function setRequiredMetricProperties(array $metrics): void { - foreach ($this->metrics as $metricName) { - $this->assertArrayKeyIsPresent($metrics, $metricName); - $this->$metricName = $metrics[$metricName]; + $missingKeys = array_diff_key($this->metrics, $metrics); + if (!empty($missingKeys)) { + throw new InvalidArgumentException('Missing required keys'); } + + // Not pretty to set each but more efficient than using a loop and $this->metrics + $this->lineCount = $metrics['lineCount']; + $this->argCount = $metrics['argCount']; + $this->returnCount = $metrics['returnCount']; + $this->variableCount = $metrics['variableCount']; + $this->propertyCallCount = $metrics['propertyCallCount']; + $this->ifCount = $metrics['ifCount']; + $this->ifNestingLevel = $metrics['ifNestingLevel']; + $this->elseCount = $metrics['elseCount']; } /** @@ -89,12 +100,15 @@ private function setRequiredMetricProperties(array $metrics): void */ private function setOptionalMetricProperties(array $metrics): void { - foreach ($this->metrics as $metricName) { - $property = $metricName . 'Weight'; - if (array_key_exists($property, $metrics)) { - $this->$property = $metrics[$property]; - } - } + // Not pretty to set each but more efficient than using a loop and $this->metrics + $this->lineCountWeight = $metrics['lineCountWeight'] ?? 0.0; + $this->argCountWeight = $metrics['argCountWeight'] ?? 0.0; + $this->returnCountWeight = $metrics['returnCountWeight'] ?? 0.0; + $this->variableCountWeight = $metrics['variableCountWeight'] ?? 0.0; + $this->propertyCallCountWeight = $metrics['propertyCallCountWeight'] ?? 0.0; + $this->ifCountWeight = $metrics['ifCountWeight'] ?? 0.0; + $this->ifNestingLevelWeight = $metrics['ifNestingLevelWeight'] ?? 0.0; + $this->elseCountWeight = $metrics['elseCountWeight'] ?? 0.0; } private function assertSame(self $other): void @@ -108,8 +122,7 @@ private function assertSame(self $other): void $this->getClass(), $this->getMethod(), $other->getClass(), - $other->getMethod( - ) + $other->getMethod() )); } @@ -146,12 +159,11 @@ public static function fromArray(array $metrics): self */ private function assertArrayKeyIsPresent(array $array, string $key): void { - if (!array_key_exists($key, $array)) { + if (!isset($array[$key])) { throw new InvalidArgumentException("Missing required key: $key"); } } - // Getters for read-only attributes public function getClass(): string { return $this->class; diff --git a/src/Business/Cognitive/CognitiveMetricsCollection.php b/src/Business/Cognitive/CognitiveMetricsCollection.php index 4f26ce5..89d4562 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollection.php +++ b/src/Business/Cognitive/CognitiveMetricsCollection.php @@ -28,7 +28,7 @@ class CognitiveMetricsCollection implements IteratorAggregate, Countable, JsonSe */ public function add(CognitiveMetrics $metric): void { - $this->metrics[] = $metric; + $this->metrics[$metric->getClass() . '::' . $metric->getMethod()] = $metric; } /** @@ -75,21 +75,13 @@ public function filterWithScoreGreaterThan(float $score): CognitiveMetricsCollec public function contains(CognitiveMetrics $otherMetric): bool { - foreach ($this->metrics as $metric) { - if ($otherMetric->equals($metric)) { - return true; - } - } - - return false; + return isset($this->metrics[$otherMetric->getClass() . '::' . $otherMetric->getMethod()]); } public function getClassWithMethod(string $class, string $method): ?CognitiveMetrics { - foreach ($this->metrics as $metric) { - if ($metric->getClass() === $class && $metric->getMethod() === $method) { - return $metric; - } + if (isset($this->metrics[$class . '::' . $method])) { + return $this->metrics[$class . '::' . $method]; } return null; diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index 41c266c..a4f2559 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -39,7 +39,15 @@ protected function findMetrics(iterable $files): CognitiveMetricsCollection $metricsCollection = new CognitiveMetricsCollection(); $visitor = new CognitiveMetricsVisitor(); + foreach ($this->findMetricsPlugins as $plugin) { + $plugin->beforeIteration($files); + } + foreach ($files as $file) { + foreach ($this->findMetricsPlugins as $plugin) { + $plugin->beforeFindMetrics($file); + } + $code = file_get_contents($file->getRealPath()); if ($code === false) { @@ -53,6 +61,14 @@ protected function findMetrics(iterable $files): CognitiveMetricsCollection $this->traverser->removeVisitor($visitor); $this->processMethodMetrics($methodMetrics, $metricsCollection); + + foreach ($this->findMetricsPlugins as $plugin) { + $plugin->afterFindMetrics($file); + } + } + + foreach ($this->findMetricsPlugins as $plugin) { + $plugin->afterIteration($metricsCollection); } return $metricsCollection; @@ -76,7 +92,7 @@ private function processMethodMetrics( 'method' => $method ]); - $metric = CognitiveMetrics::fromArray($metricsArray); + $metric = new CognitiveMetrics($metricsArray); if (!$metricsCollection->contains($metric)) { $metricsCollection->add($metric); diff --git a/src/Business/Cognitive/FindMetricsPluginInterface.php b/src/Business/Cognitive/FindMetricsPluginInterface.php new file mode 100644 index 0000000..e562061 --- /dev/null +++ b/src/Business/Cognitive/FindMetricsPluginInterface.php @@ -0,0 +1,24 @@ + $files + */ + public function beforeIteration(iterable $files): void; + + public function afterIteration(CognitiveMetricsCollection $metricsCollection): void; +} diff --git a/src/Command/Cognitive/CognitiveCollectorShellOutputPlugin.php b/src/Command/Cognitive/CognitiveCollectorShellOutputPlugin.php new file mode 100644 index 0000000..a9c0e59 --- /dev/null +++ b/src/Command/Cognitive/CognitiveCollectorShellOutputPlugin.php @@ -0,0 +1,76 @@ +startTime = microtime(true); + } + + public function afterFindMetrics(SplFileInfo $fileInfo): void + { + if ( + $this->input->hasOption(CognitiveMetricsCommand::OPTION_DEBUG) + && $this->input->getOption(CognitiveMetricsCommand::OPTION_DEBUG) === false + ) { + return; + } + + $runtime = microtime(true) - $this->startTime; + + $this->output->writeln('Processed ' . $fileInfo->getRealPath()); + $this->output->writeln('Number: ' . $this->count . ' Memory: ' . $this->formatBytes(memory_get_usage(true)) . ' -- Runtime: ' . round($runtime, 4) . 's'); + + $this->count++; + } + + public function beforeIteration(iterable $files): void + { + } + + public function afterIteration(CognitiveMetricsCollection $metricsCollection): void + { + } + + /** + * Converts memory size to a human-readable format (bytes, KB, MB, GB, TB). + * + * @param int $size Memory size in bytes. + * @return string Human-readable memory size. + */ + private function formatBytes(int $size): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + + while ($size >= 1024 && $i < count($units) - 1) { + $size /= 1024; + $i++; + } + + return round($size, 2) . ' ' . $units[$i]; + } +} diff --git a/src/Command/CognitiveMetricsCommand.php b/src/Command/CognitiveMetricsCommand.php index a6a0cae..cda875a 100644 --- a/src/Command/CognitiveMetricsCommand.php +++ b/src/Command/CognitiveMetricsCommand.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** @@ -25,10 +26,11 @@ class CognitiveMetricsCommand extends Command { // Option names for exporting metrics in different formats and loading a configuration file. - private const OPTION_CONFIG_FILE = 'config'; - private const OPTION_BASELINE = 'baseline'; - private const OPTION_REPORT_TYPE = 'report-type'; - private const OPTION_REPORT_FILE = 'report-file'; + public const OPTION_CONFIG_FILE = 'config'; + public const OPTION_BASELINE = 'baseline'; + public const OPTION_REPORT_TYPE = 'report-type'; + public const OPTION_REPORT_FILE = 'report-file'; + public const OPTION_DEBUG = 'debug'; // Argument name for the path to the PHP files or directories. private const ARGUMENT_PATH = 'path'; @@ -54,7 +56,8 @@ protected function configure(): void ->addOption(self::OPTION_CONFIG_FILE, 'c', InputArgument::OPTIONAL, 'Path to a configuration file', null) ->addOption(self::OPTION_BASELINE, 'b', InputArgument::OPTIONAL, 'Baseline file to get the delta.', null) ->addOption(self::OPTION_REPORT_TYPE, 'r', InputArgument::OPTIONAL, 'Type of report to generate (json, csv, html).', null, ['json', 'csv', 'html']) - ->addOption(self::OPTION_REPORT_FILE, 'f', InputArgument::OPTIONAL, 'File to write the report to.'); + ->addOption(self::OPTION_REPORT_FILE, 'f', InputArgument::OPTIONAL, 'File to write the report to.') + ->addOption(self::OPTION_DEBUG, null, InputArgument::OPTIONAL, 'Enables debug output', false); } /** diff --git a/tests/Unit/Business/Cognitive/CognitiveMetricsCollectionTest.php b/tests/Unit/Business/Cognitive/CognitiveMetricsCollectionTest.php index 3859dab..d521dce 100644 --- a/tests/Unit/Business/Cognitive/CognitiveMetricsCollectionTest.php +++ b/tests/Unit/Business/Cognitive/CognitiveMetricsCollectionTest.php @@ -11,16 +11,44 @@ use PHPUnit\Framework\TestCase; /** - * + * Unit tests for CognitiveMetricsCollection */ class CognitiveMetricsCollectionTest extends TestCase { + private function createCognitiveMetrics(array $data): CognitiveMetrics + { + return CognitiveMetrics::fromArray($data); + } + public function testAddAndCount(): void { $metricsCollection = new CognitiveMetricsCollection(); - $metrics1 = $this->createMock(CognitiveMetrics::class); - $metrics2 = $this->createMock(CognitiveMetrics::class); + $metrics1 = $this->createCognitiveMetrics([ + 'class' => 'ClassA', + 'method' => 'methodA', + 'lineCount' => 10, + 'argCount' => 2, + 'returnCount' => 1, + 'variableCount' => 3, + 'propertyCallCount' => 4, + 'ifCount' => 2, + 'ifNestingLevel' => 1, + 'elseCount' => 0 + ]); + + $metrics2 = $this->createCognitiveMetrics([ + 'class' => 'ClassB', + 'method' => 'methodB', + 'lineCount' => 20, + 'argCount' => 4, + 'returnCount' => 2, + 'variableCount' => 5, + 'propertyCallCount' => 3, + 'ifCount' => 3, + 'ifNestingLevel' => 2, + 'elseCount' => 1 + ]); $this->assertSame(0, $metricsCollection->count()); @@ -34,8 +62,31 @@ public function testGetIterator(): void { $metricsCollection = new CognitiveMetricsCollection(); - $metrics1 = $this->createMock(CognitiveMetrics::class); - $metrics2 = $this->createMock(CognitiveMetrics::class); + $metrics1 = $this->createCognitiveMetrics([ + 'class' => 'ClassA', + 'method' => 'methodA', + 'lineCount' => 10, + 'argCount' => 2, + 'returnCount' => 1, + 'variableCount' => 3, + 'propertyCallCount' => 4, + 'ifCount' => 2, + 'ifNestingLevel' => 1, + 'elseCount' => 0 + ]); + + $metrics2 = $this->createCognitiveMetrics([ + 'class' => 'ClassB', + 'method' => 'methodB', + 'lineCount' => 20, + 'argCount' => 4, + 'returnCount' => 2, + 'variableCount' => 5, + 'propertyCallCount' => 3, + 'ifCount' => 3, + 'ifNestingLevel' => 2, + 'elseCount' => 1 + ]); $metricsCollection->add($metrics1); $metricsCollection->add($metrics2); @@ -50,11 +101,35 @@ public function testFilter(): void { $metricsCollection = new CognitiveMetricsCollection(); - $metrics1 = $this->createMock(CognitiveMetrics::class); - $metrics2 = $this->createMock(CognitiveMetrics::class); - - $metrics1->method('getScore')->willReturn(5.0); - $metrics2->method('getScore')->willReturn(10.0); + $metrics1 = $this->createCognitiveMetrics([ + 'class' => 'ClassA', + 'method' => 'methodA', + 'lineCount' => 10, + 'argCount' => 2, + 'returnCount' => 1, + 'variableCount' => 3, + 'propertyCallCount' => 4, + 'ifCount' => 2, + 'ifNestingLevel' => 1, + 'elseCount' => 0, + 'score' => 5.0 + ]); + $metrics1->setScore(5.0); + + $metrics2 = $this->createCognitiveMetrics([ + 'class' => 'ClassB', + 'method' => 'methodB', + 'lineCount' => 20, + 'argCount' => 4, + 'returnCount' => 2, + 'variableCount' => 5, + 'propertyCallCount' => 3, + 'ifCount' => 3, + 'ifNestingLevel' => 2, + 'elseCount' => 1, + 'score' => 10.0 + ]); + $metrics2->setScore(10.0); $metricsCollection->add($metrics1); $metricsCollection->add($metrics2); @@ -64,38 +139,51 @@ public function testFilter(): void }); $this->assertCount(1, $filtered); - $this->assertSame(10.0, $filtered->getIterator()[0]->getScore()); - } - - public function testFilterWithScoreGreaterThan(): void - { - $metricsCollection = new CognitiveMetricsCollection(); - - $metrics1 = $this->createMock(CognitiveMetrics::class); - $metrics2 = $this->createMock(CognitiveMetrics::class); - - $metrics1->method('getScore')->willReturn(5.0); - $metrics2->method('getScore')->willReturn(10.0); - - $metricsCollection->add($metrics1); - $metricsCollection->add($metrics2); - - $filtered = $metricsCollection->filterWithScoreGreaterThan(7.0); - - $this->assertCount(1, $filtered); - $this->assertSame(10.0, $filtered->getIterator()[0]->getScore()); + $this->assertSame(10.0, $filtered->getIterator()['ClassB::methodB']->getScore()); } public function testContains(): void { $metricsCollection = new CognitiveMetricsCollection(); - $metrics1 = $this->createMock(CognitiveMetrics::class); - $metrics2 = $this->createMock(\Phauthentic\CodeQualityMetrics\Business\Cognitive\CognitiveMetrics::class); - $metrics3 = $this->createMock(CognitiveMetrics::class); - - $metrics1->method('equals')->willReturn(false); - $metrics2->method('equals')->willReturn(true); + $metrics1 = $this->createCognitiveMetrics([ + 'class' => 'ClassA', + 'method' => 'methodA', + 'lineCount' => 10, + 'argCount' => 2, + 'returnCount' => 1, + 'variableCount' => 3, + 'propertyCallCount' => 4, + 'ifCount' => 2, + 'ifNestingLevel' => 1, + 'elseCount' => 0 + ]); + + $metrics2 = $this->createCognitiveMetrics([ + 'class' => 'ClassB', + 'method' => 'methodB', + 'lineCount' => 20, + 'argCount' => 4, + 'returnCount' => 2, + 'variableCount' => 5, + 'propertyCallCount' => 3, + 'ifCount' => 3, + 'ifNestingLevel' => 2, + 'elseCount' => 1 + ]); + + $metrics3 = $this->createCognitiveMetrics([ + 'class' => 'ClassC', + 'method' => 'methodC', + 'lineCount' => 30, + 'argCount' => 6, + 'returnCount' => 3, + 'variableCount' => 8, + 'propertyCallCount' => 7, + 'ifCount' => 4, + 'ifNestingLevel' => 3, + 'elseCount' => 2 + ]); $metricsCollection->add($metrics1); $metricsCollection->add($metrics2); @@ -103,59 +191,4 @@ public function testContains(): void $this->assertTrue($metricsCollection->contains($metrics2)); $this->assertFalse($metricsCollection->contains($metrics3)); } - - public function testFilterByClassName(): void - { - $metricsCollection = new CognitiveMetricsCollection(); - - $metrics1 = $this->createMock(CognitiveMetrics::class); - $metrics2 = $this->createMock(CognitiveMetrics::class); - - $metrics1->method('getClass')->willReturn('ClassA'); - $metrics2->method('getClass')->willReturn('ClassB'); - - $metricsCollection->add($metrics1); - $metricsCollection->add($metrics2); - - $filtered = $metricsCollection->filterByClassName('ClassA'); - - $this->assertCount(1, $filtered); - $this->assertSame('ClassA', $filtered->getIterator()[0]->getClass()); - } - - public function testGroupBy(): void - { - $metricsCollection = new CognitiveMetricsCollection(); - - $metrics1 = $this->createMock(CognitiveMetrics::class); - $metrics2 = $this->createMock(CognitiveMetrics::class); - $metrics3 = $this->createMock(\Phauthentic\CodeQualityMetrics\Business\Cognitive\CognitiveMetrics::class); - - $metrics1->method('getClass')->willReturn('ClassA'); - $metrics2->method('getClass')->willReturn('ClassB'); - $metrics3->method('getClass')->willReturn('ClassA'); - - $metricsCollection->add($metrics1); - $metricsCollection->add($metrics2); - $metricsCollection->add($metrics3); - - $grouped = $metricsCollection->groupBy('class'); - - $this->assertCount(2, $grouped); - $this->assertCount(2, $grouped['ClassA']); - $this->assertCount(1, $grouped['ClassB']); - } - - public function testGroupByThrowsExceptionOnInvalidProperty(): void - { - $metricsCollection = new CognitiveMetricsCollection(); - - $metrics = $this->createMock(CognitiveMetrics::class); - $metricsCollection->add($metrics); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Property 'invalidProperty' does not exist in CognitiveMetrics class"); - - $metricsCollection->groupBy('invalidProperty'); - } }