diff --git a/Makefile b/Makefile index a67ba97..0ac79e9 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ endef GIT_VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev-main") PHP_VERSION := $(shell php -r 'echo PHP_VERSION;' 2>/dev/null || echo "n/a") PROJECT_NAME := TWIG METRICS -REPO_URL := https://github.com/smnandre/twig-metrics +REPO_URL := https://github.com/smnandre/twigmetrics about: @echo "┌───────────────────────────── 🌿 ────────────────────────────────┐"; \ diff --git a/README.md b/README.md index 29f09e3..1b958e0 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ $ bin/twigmetrics analyze templates/ ╭─ Template Files ─────╮ ╭─ Logical Comp... ────╮ ╭─ Twig Callables ─────╮ - │ ● ● ● ● ○ ○ C │ │ ● ● ● ● ● ● A+ │ │ ● ● ● ● ● ● A+ │ + │ ● ● ● ● ○ ○ C │ │ ● ● ● ● ● ○ B │ │ ● ● ● ● ● ○ B │ ╰──────────────────────╯ ╰──────────────────────╯ ╰──────────────────────╯ ╭─ Code Style ─────────╮ ╭─ Architecture ───────╮ ╭─ Maintainability ────╮ - │ ● ● ● ● ● ● A+ │ │ ● ● ● ● ○ ○ C │ │ ● ● ● ● ● ● A+ │ + │ ● ● ● ● ● ○ C │ │ ● ● ● ● ● ○ B │ │ ● ● ● ● ● ● A+ │ ╰──────────────────────╯ ╰──────────────────────╯ ╰──────────────────────╯ ``` @@ -92,10 +92,10 @@ vendor/bin/twigmetrics path/to/templates ``` ╭─ Template Files ───────────────────────────────────────────────────────────╮ │ │ - │ Templates ................. 188 Directories ................ 19 │ - │ Total Lines ............ 11,204 Avg Lines/Template ....... 59.6 │ - │ Characters ............. 503.8k Chars/Template .......... 2,680 │ - │ Dir Depth Avg ............. 1.6 File Size CV % .......... 76.3% │ + │ Total Templates ........... 188 Total Lines .............. 11,213 │ + │ Average Lines/File ....... 59.6 Median Lines ................. 48 │ + │ Size Coefficient (CV) .... 0.77 Gini Index ................ 0.380 │ + │ Directories ................ 19 Characters ............... 503.8k │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` @@ -105,10 +105,9 @@ vendor/bin/twigmetrics path/to/templates ``` ╭─ Logical Complexity ───────────────────────────────────────────────────────╮ │ │ - │ Avg Complexity ............ 8.3 Max Complexity ............. 79 │ - │ Avg Depth ................. 1.2 Max Depth ................... 6 │ - │ IFs/Template .............. 1.3 FORs/Template ............. 0.6 │ - │ Nested Control Depth ........ 6 │ + │ Avg Complexity ............. 8.3 Max Complexity .............. 79 │ + │ Avg Depth .................. 1.2 Max Depth .................... 6 │ + │ IFs/Template ............... 1.3 FORs/Template .............. 0.6 │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` @@ -118,9 +117,9 @@ vendor/bin/twigmetrics path/to/templates ``` ╭─ Twig Callables ───────────────────────────────────────────────────────────╮ │ │ - │ Funcs/Template ............ 2.9 Filters/Template ......... 18.9 │ - │ Vars/Template ............ 13.5 Unique Funcs ............... 23 │ - │ Unique Filters ............. 31 Macros Defined .............. 5 │ + │ Total Calls ............. 4,632 Unique Functions ............. 23 │ + │ Unique Filters ............. 32 Unique Tests .................. 7 │ + │ Funcs/Template ............ 2.9 Filters/Template ........... 18.9 │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` @@ -130,10 +129,10 @@ vendor/bin/twigmetrics path/to/templates ``` ╭─ Code Style ───────────────────────────────────────────────────────────────╮ │ │ - │ Avg Line Length .......... 41.0 Comments/Template ......... 0.6 │ - │ Comment Ratio ............ 1.0% Trail Spaces/Line ........ 0.00 │ - │ Empty Lines % ............ 8.2% Formatting Score ......... 92.7 │ - │ Indent Consistency % ... 100.0% Naming Conv. Score % .... 97.5% │ + │ Avg Line Length ........... 41.0 Max Line Length ............ 217 │ + │ Indent Consistency ...... 100.0% P95 Length ................. 217 │ + │ Consistency Score ........ 92.7% Style Violations ........... 128 │ + │ Comments/Template .......... 0.6 Mixed Indentation ............ 0 │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` @@ -143,9 +142,9 @@ vendor/bin/twigmetrics path/to/templates ``` ╭─ Architecture ─────────────────────────────────────────────────────────────╮ │ │ - │ Extends/Template ......... 0.22 Includes/Template ........ 0.57 │ - │ Embeds/Template .......... 0.04 Imports/Template ......... 0.00 │ - │ Avg Inherit Depth ......... 0.2 Standalone Files .......... 122 │ + │ Imports/Template ......... 0.00 Extends/Template ........... 0.22 │ + │ Avg Inherit Depth ......... 0.2 Includes/Template .......... 0.57 │ + │ Embeds/Template .......... 0.04 Blocks/Template ............ 1.13 │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` @@ -155,9 +154,9 @@ vendor/bin/twigmetrics path/to/templates ``` ╭─ Maintainability ──────────────────────────────────────────────────────────╮ │ │ - │ Large Templates (>200L) ..... 4 High Complexity (>20) ...... 17 │ - │ Deep Nesting (>5) ........... 1 Empty Templates ............. 0 │ - │ Standalone ................ 122 Risk Score .................. A │ + │ Empty Lines Ratio ....... 10.0% MI Average ................ 107.2 │ + │ MI Median ............... 106.7 Comment Density ............ 1.3% │ + │ High Risk ................... 3 Medium Risk .................. 40 │ │ │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` diff --git a/bin/twigmetrics b/bin/twigmetrics index 41e026e..83c338f 100755 --- a/bin/twigmetrics +++ b/bin/twigmetrics @@ -17,7 +17,7 @@ foreach ($possibleFiles as $possibleFile) { } if (null === $file) { - throw new \RuntimeException('Unable to locate autoload.php file.'); + throw new RuntimeException('Unable to locate autoload.php file.'); } require_once $file; diff --git a/src/Analyzer/BatchAnalyzer.php b/src/Analyzer/BatchAnalyzer.php index 8616950..257092a 100644 --- a/src/Analyzer/BatchAnalyzer.php +++ b/src/Analyzer/BatchAnalyzer.php @@ -60,11 +60,11 @@ public function analyze(array $files): BatchAnalysisResult $dependencyGraph = $this->buildDependencyGraph($results); - $enhancedResults = $this->enhanceWithCrossTemplateInsights($results, $dependencyGraph); + $augmentedResults = $this->addCrossTemplateInsights($results, $dependencyGraph); $totalTime = microtime(true) - $startTime; - return new BatchAnalysisResult($enhancedResults, $dependencyGraph, $totalTime, $errors); + return new BatchAnalysisResult($augmentedResults, $dependencyGraph, $totalTime, $errors); } /** @@ -95,13 +95,13 @@ private function buildDependencyGraph(array $results): array * * @return AnalysisResult[] */ - private function enhanceWithCrossTemplateInsights(array $results, array $dependencyGraph): array + private function addCrossTemplateInsights(array $results, array $dependencyGraph): array { $referenceCounter = $this->calculateReferenceFrequency($dependencyGraph); $this->analyzeBlockUsage($results); $architecturalRoles = $this->determineArchitecturalRoles($results, $referenceCounter); - $enhancedResults = []; + $augmented = []; foreach ($results as $result) { $templatePath = $result->getRelativePath(); $metrics = $result->getData(); @@ -114,10 +114,10 @@ private function enhanceWithCrossTemplateInsights(array $results, array $depende $metrics['potential_issues'] = $this->identifyPotentialIssues($metrics); - $enhancedResults[] = new AnalysisResult($result->file, $metrics, $result->analysisTime); + $augmented[] = new AnalysisResult($result->file, $metrics, $result->analysisTime); } - return $enhancedResults; + return $augmented; } /** diff --git a/src/Analyzer/BlockUsageAnalyzer.php b/src/Analyzer/BlockUsageAnalyzer.php new file mode 100644 index 0000000..06bc4b7 --- /dev/null +++ b/src/Analyzer/BlockUsageAnalyzer.php @@ -0,0 +1,58 @@ + + */ +final class BlockUsageAnalyzer +{ + /** + * @param AnalysisResult[] $results + */ + public function analyzeBlockUsage(array $results): BlockMetrics + { + $defined = []; + $used = []; + $orphaned = []; + + foreach ($results as $result) { + $blocks = $result->getMetric('provided_blocks') ?? $result->getMetric('blocksDefinitions') ?? []; + if (is_array($blocks)) { + foreach ($blocks as $blockName) { + $defined[$blockName] = ($defined[$blockName] ?? 0) + 1; + } + } + } + + foreach ($results as $result) { + $usages = $result->getMetric('used_blocks') ?? $result->getMetric('blocksUsage') ?? []; + if (is_array($usages)) { + foreach ($usages as $blockName) { + $used[$blockName] = ($used[$blockName] ?? 0) + 1; + } + } + } + + foreach ($defined as $blockName => $count) { + if (!isset($used[$blockName])) { + $orphaned[] = (string) $blockName; + } + } + + $usageCount = count($used); + $definedCount = count($defined); + + return new BlockMetrics( + totalDefined: array_sum($defined), + totalUsed: array_sum($used), + orphanedBlocks: $orphaned, + usageRatio: $definedCount > 0 ? $usageCount / $definedCount : 0.0, + averageReuse: $usageCount > 0 ? array_sum($used) / $usageCount : 0.0, + ); + } +} diff --git a/src/Analyzer/CallableSecurityAnalyzer.php b/src/Analyzer/CallableSecurityAnalyzer.php new file mode 100644 index 0000000..1cdc776 --- /dev/null +++ b/src/Analyzer/CallableSecurityAnalyzer.php @@ -0,0 +1,68 @@ + + */ +final class CallableSecurityAnalyzer +{ + private const RISKY_FUNCTIONS = ['dump', 'eval', 'include_raw']; + private const RISKY_FILTERS = ['raw', 'unsafe']; + + /** + * @param AnalysisResult[] $results + */ + public function analyzeSecurityScore(array $results): SecurityMetrics + { + $risks = []; + $score = 100; + + foreach ($results as $result) { + $functions = $result->getMetric('functions_detail') ?? []; + $filters = $result->getMetric('filters_detail') ?? []; + + if (is_array($functions)) { + foreach ($functions as $func => $count) { + if (in_array((string) $func, self::RISKY_FUNCTIONS, true)) { + $risks[(string) $func] = ($risks[(string) $func] ?? 0) + (int) $count; + $score -= 5; + } + } + } + + if (is_array($filters)) { + foreach ($filters as $filter => $count) { + if (in_array((string) $filter, self::RISKY_FILTERS, true)) { + $risks[(string) $filter] = ($risks[(string) $filter] ?? 0) + (int) $count; + $score -= 3; + } + } + } + } + + return new SecurityMetrics( + score: max(0, $score), + risks: $risks, + deprecatedCount: $this->countDeprecated($results), + ); + } + + /** + * @param AnalysisResult[] $results + */ + private function countDeprecated(array $results): int + { + $count = 0; + foreach ($results as $result) { + $deprecated = $result->getMetric('deprecated_callables') ?? 0; + $count += (int) (is_numeric($deprecated) ? $deprecated : 0); + } + + return $count; + } +} diff --git a/src/Analyzer/CouplingAnalyzer.php b/src/Analyzer/CouplingAnalyzer.php new file mode 100644 index 0000000..71b3874 --- /dev/null +++ b/src/Analyzer/CouplingAnalyzer.php @@ -0,0 +1,144 @@ + + */ +final class CouplingAnalyzer +{ + /** + * @param AnalysisResult[] $results + */ + public function analyzeCoupling(array $results): CouplingMetrics + { + $graph = $this->buildDependencyGraph($results); + + $fanIn = array_fill_keys(array_keys($graph), 0); + $fanOut = []; + + foreach ($graph as $src => $deps) { + $uniqueDeps = array_values(array_unique($deps)); + $fanOut[$src] = count($uniqueDeps); + foreach ($uniqueDeps as $dst) { + if (!array_key_exists($dst, $fanIn)) { + $fanIn[$dst] = 0; + } + ++$fanIn[$dst]; + } + } + + $avgFanIn = $this->avg($fanIn); + $avgFanOut = $this->avg($fanOut); + + $maxCoupling = 0; + foreach (array_keys($graph) as $node) { + $coupling = ($fanIn[$node] ?? 0) + ($fanOut[$node] ?? 0); + $maxCoupling = max($maxCoupling, $coupling); + } + + $instability = $this->instabilityIndex($fanIn, $fanOut); + $circular = $this->detectCircularReferences($graph); + + return new CouplingMetrics( + avgFanIn: $avgFanIn, + avgFanOut: $avgFanOut, + maxCoupling: $maxCoupling, + instabilityIndex: $instability, + circularRefs: $circular, + ); + } + + /** + * @param AnalysisResult[] $results + * + * @return array> + */ + private function buildDependencyGraph(array $results): array + { + $graph = []; + foreach ($results as $result) { + $templatePath = $result->getRelativePath(); + $graph[$templatePath] = []; + $dependencies = $result->getMetric('dependencies') ?? []; + if (is_array($dependencies)) { + foreach ($dependencies as $dep) { + if (is_array($dep) && isset($dep['template'])) { + $graph[$templatePath][] = (string) $dep['template']; + } elseif (is_string($dep)) { + $graph[$templatePath][] = $dep; + } + } + } + } + + return $graph; + } + + /** + * @param array $fanIn + * @param array $fanOut + */ + private function instabilityIndex(array $fanIn, array $fanOut): float + { + $nodes = array_unique([...array_keys($fanIn), ...array_keys($fanOut)]); + $sum = 0.0; + $n = max(1, count($nodes)); + foreach ($nodes as $node) { + $in = $fanIn[$node] ?? 0; + $out = $fanOut[$node] ?? 0; + $den = $in + $out; + $sum += $den > 0 ? $out / $den : 0.0; + } + + return $sum / $n; + } + + /** + * @param array> $graph + */ + private function detectCircularReferences(array $graph): int + { + $visited = []; + $stack = []; + $cycles = 0; + + $visit = function (string $node) use (&$visit, &$visited, &$stack, $graph, &$cycles): void { + if (($visited[$node] ?? false) === true) { + return; + } + $visited[$node] = true; + $stack[$node] = true; + foreach ($graph[$node] ?? [] as $nbr) { + if (!isset($visited[$nbr])) { + $visit($nbr); + } elseif (($stack[$nbr] ?? false) === true) { + ++$cycles; + } + } + $stack[$node] = false; + }; + + foreach (array_keys($graph) as $node) { + if (!isset($visited[$node])) { + $visit($node); + } + } + + return $cycles; + } + + /** + * @param array $arr + */ + private function avg(array $arr): float + { + $n = count($arr); + + return $n > 0 ? array_sum($arr) / $n : 0.0; + } +} diff --git a/src/Analyzer/StyleConsistencyAnalyzer.php b/src/Analyzer/StyleConsistencyAnalyzer.php new file mode 100644 index 0000000..9ff28e7 --- /dev/null +++ b/src/Analyzer/StyleConsistencyAnalyzer.php @@ -0,0 +1,117 @@ + + */ +final class StyleConsistencyAnalyzer +{ + /** + * @param AnalysisResult[] $results + */ + public function analyze(array $results): StyleMetrics + { + $violations = [ + 'longLines' => [], + 'trailingSpaces' => [], + 'mixedIndent' => [], + ]; + $consistencyScores = []; + + foreach ($results as $result) { + $maxLine = (int) ($result->getMetric('max_line_length') ?? 0); + if ($maxLine > 120) { + $violations['longLines'][] = $result->getRelativePath(); + } + + $trailing = (int) ($result->getMetric('trailing_spaces') ?? 0); + if ($trailing > 0) { + $violations['trailingSpaces'][] = $result->getRelativePath(); + } + + $mixed = (int) ($result->getMetric('mixed_indentation_lines') ?? 0); + if ($mixed > 0) { + $violations['mixedIndent'][] = $result->getRelativePath(); + } + + $consistencyScores[] = $this->calculateFileConsistency($result); + } + + $avgConsistency = !empty($consistencyScores) ? array_sum($consistencyScores) / count($consistencyScores) : 100.0; + + return new StyleMetrics( + violations: $violations, + consistencyScore: $avgConsistency, + formattingEntropy: $this->calculateFormattingEntropy($results), + readabilityScore: $this->calculateReadabilityScore($results), + ); + } + + private function calculateFileConsistency(AnalysisResult $result): float + { + $score = 100.0; + $lines = max(1, (int) ($result->getMetric('lines') ?? 1)); + + $mixed = (int) ($result->getMetric('mixed_indentation_lines') ?? 0); + $score -= min(15.0, ($mixed / $lines) * 30.0); + + $trailing = (int) ($result->getMetric('trailing_spaces') ?? 0); + $score -= min(10.0, ($trailing / $lines) * 20.0); + + $maxLen = (int) ($result->getMetric('max_line_length') ?? 0); + if ($maxLen > 120) { + $score -= min(10.0, ($maxLen - 120) / 10.0); + } + + $commentDensity = (float) ($result->getMetric('comment_density') ?? 0.0); + if ($commentDensity < 5 || $commentDensity > 50) { + $score -= 5.0; + } + + return max(0.0, round($score, 1)); + } + + /** + * @param AnalysisResult[] $results + */ + private function calculateFormattingEntropy(array $results): float + { + $total = 0; + $anomaly = 0; + foreach ($results as $r) { + $lines = (int) ($r->getMetric('lines') ?? 0); + $total += $lines; + $anomaly += (int) ($r->getMetric('mixed_indentation_lines') ?? 0); + $anomaly += (int) ($r->getMetric('trailing_spaces') ?? 0); + } + if (0 === $total) { + return 0.0; + } + $p = min(1.0, $anomaly / max(1, $total)); + + return -($p > 0 ? ($p * log($p, 2)) : 0.0); + } + + /** + * @param AnalysisResult[] $results + */ + private function calculateReadabilityScore(array $results): float + { + $scores = []; + foreach ($results as $r) { + $avgLen = (float) ($r->getMetric('avg_line_length') ?? 0.0); + $blank = (int) ($r->getMetric('blank_lines') ?? 0); + $lines = max(1, (int) ($r->getMetric('lines') ?? 1)); + $spacing = min(1.0, $blank / $lines); + $penalty = max(0.0, ($avgLen - 80) / 80.0); + $scores[] = max(0.0, 1.0 - $penalty) * (0.5 + 0.5 * $spacing) * 100.0; + } + + return !empty($scores) ? array_sum($scores) / count($scores) : 100.0; + } +} diff --git a/src/Analyzer/TemplateAnalyzer.php b/src/Analyzer/TemplateAnalyzer.php index fe9f8df..efdc373 100644 --- a/src/Analyzer/TemplateAnalyzer.php +++ b/src/Analyzer/TemplateAnalyzer.php @@ -80,11 +80,11 @@ public function analyze(\SplFileInfo $file): AnalysisResult } $allMetrics = array_merge($astMetrics, $sourceMetrics); - $enhancedMetrics = $this->enhanceMetrics($allMetrics, $file); + $derivedMetrics = $this->computeDerivedMetrics($allMetrics, $file); $analysisTime = microtime(true) - $startTime; - return new AnalysisResult($file, $enhancedMetrics, $analysisTime); + return new AnalysisResult($file, $derivedMetrics, $analysisTime); } private function setCollectorContext(\SplFileInfo $file): void @@ -117,7 +117,7 @@ private function aggregateMetrics(array $collectors): array * * @return array */ - private function enhanceMetrics(array $metrics, \SplFileInfo $file): array + private function computeDerivedMetrics(array $metrics, \SplFileInfo $file): array { $lines = $metrics['lines'] ?? 0; $complexity = $metrics['complexity_score'] ?? 0; diff --git a/src/Calculator/ComplexityCalculator.php b/src/Calculator/ComplexityCalculator.php new file mode 100644 index 0000000..da9223a --- /dev/null +++ b/src/Calculator/ComplexityCalculator.php @@ -0,0 +1,52 @@ + + */ +final class ComplexityCalculator +{ + public function calculateLogicRatio(AnalysisResult $result): float + { + $ifCount = (int) ($result->getMetric('conditions') ?? 0); + $loopCount = (int) ($result->getMetric('loops') ?? 0); + + $whileCount = (int) ($result->getMetric('whileCount') ?? 0); + $switchCount = (int) ($result->getMetric('switchCount') ?? 0); + + $logicNodes = $ifCount + $loopCount + $whileCount + $switchCount; + $totalLines = (int) ($result->getMetric('lines') ?? 1); + $blank = (int) ($result->getMetric('blank_lines') ?? 0); + $comment = (int) ($result->getMetric('comment_lines') ?? 0); + $codeLines = max(1, $totalLines - $blank - $comment); + + return $logicNodes / max(1, $codeLines); + } + + public function calculateDecisionDensity(AnalysisResult $result): float + { + $decisions = (int) ($result->getMetric('complexity_score') ?? 0); + $lines = (int) ($result->getMetric('lines') ?? 1); + + return $decisions / max(1, $lines); + } + + public function calculateMaintainabilityIndex(AnalysisResult $result): float + { + $volume = (float) ($result->getMetric('total_line_length') ?? 0.0); + if ($volume <= 0) { + $volume = 1.0; + } + $complexity = (int) ($result->getMetric('complexity_score') ?? 0); + $lines = (int) ($result->getMetric('lines') ?? 1); + + $mi = 171 - 5.2 * log($volume) - 0.23 * $complexity - 16.2 * log(max(1, $lines)); + + return max(0.0, (float) $mi); + } +} diff --git a/src/Calculator/DistributionCalculator.php b/src/Calculator/DistributionCalculator.php new file mode 100644 index 0000000..5808cf8 --- /dev/null +++ b/src/Calculator/DistributionCalculator.php @@ -0,0 +1,51 @@ + + */ +final class DistributionCalculator +{ + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function calculateSizeDistribution(array $results): array + { + $buckets = [ + '0-50' => 0, + '51-100' => 0, + '101-200' => 0, + '201-500' => 0, + '500+' => 0, + ]; + + foreach ($results as $result) { + $lines = (int) ($result->getMetric('lines') ?? 0); + match (true) { + $lines <= 50 => ++$buckets['0-50'], + $lines <= 100 => ++$buckets['51-100'], + $lines <= 200 => ++$buckets['101-200'], + $lines <= 500 => ++$buckets['201-500'], + default => ++$buckets['500+'], + }; + } + + $total = max(1, count($results)); + $out = []; + foreach ($buckets as $range => $count) { + $out[$range] = [ + 'count' => $count, + 'percentage' => ($count / $total) * 100.0, + ]; + } + + return $out; + } +} diff --git a/src/Calculator/DiversityCalculator.php b/src/Calculator/DiversityCalculator.php new file mode 100644 index 0000000..86b9e5f --- /dev/null +++ b/src/Calculator/DiversityCalculator.php @@ -0,0 +1,50 @@ + + */ +final class DiversityCalculator +{ + /** + * @param array $usage + */ + public function calculateSimpsonDiversity(array $usage): float + { + $total = array_sum($usage); + if ($total <= 1) { + return 0.0; + } + + $sum = 0.0; + foreach ($usage as $count) { + $sum += $count * ($count - 1); + } + + return 1.0 - ($sum / ($total * ($total - 1))); + } + + /** + * @param array $usage + */ + public function calculateUsageEntropy(array $usage): float + { + $total = array_sum($usage); + if (0 === $total) { + return 0.0; + } + + $entropy = 0.0; + foreach ($usage as $count) { + if ($count > 0) { + $p = $count / $total; + $entropy -= $p * log($p, 2); + } + } + + return $entropy; + } +} diff --git a/src/Calculator/StatisticalCalculator.php b/src/Calculator/StatisticalCalculator.php new file mode 100644 index 0000000..089a996 --- /dev/null +++ b/src/Calculator/StatisticalCalculator.php @@ -0,0 +1,185 @@ + + */ +final class StatisticalCalculator +{ + /** + * @param array $values + */ + public function calculate(array $values): StatisticalSummary + { + $vals = array_map(static fn ($v): float => (float) $v, $values); + $count = count($vals); + + if (0 === $count) { + return new StatisticalSummary( + count: 0, + sum: 0.0, + mean: 0.0, + median: 0.0, + stdDev: 0.0, + coefficientOfVariation: 0.0, + p25: 0.0, + p75: 0.0, + p95: 0.0, + giniIndex: 0.0, + entropy: 0.0, + min: 0.0, + max: 0.0, + range: 0.0, + ); + } + + sort($vals, SORT_NUMERIC); + + return new StatisticalSummary( + count: $count, + sum: array_sum($vals), + mean: $this->mean($vals), + median: $this->median($vals), + stdDev: $this->standardDeviation($vals), + coefficientOfVariation: $this->cv($vals), + p25: $this->percentile($vals, 25), + p75: $this->percentile($vals, 75), + p95: $this->percentile($vals, 95), + giniIndex: $this->giniCoefficient($vals), + entropy: $this->shannonEntropy($vals), + min: (float) $vals[0], + max: (float) $vals[$count - 1], + range: (float) ($vals[$count - 1] - $vals[0]), + ); + } + + /** + * @param array $values + */ + private function mean(array $values): float + { + $n = count($values); + + return $n > 0 ? array_sum($values) / $n : 0.0; + } + + /** + * @param array $values + */ + private function median(array $values): float + { + $n = count($values); + if (0 === $n) { + return 0.0; + } + $mid = intdiv($n, 2); + if (0 === $n % 2) { + return ($values[$mid - 1] + $values[$mid]) / 2.0; + } + + return $values[$mid]; + } + + /** + * @param array $values + */ + private function standardDeviation(array $values): float + { + $n = count($values); + if ($n <= 1) { + return 0.0; + } + $mean = $this->mean($values); + $variance = 0.0; + foreach ($values as $v) { + $variance += ($v - $mean) ** 2; + } + $variance /= ($n - 1); + + return sqrt($variance); + } + + /** + * @param array $values + */ + private function cv(array $values): float + { + $mean = $this->mean($values); + if ($mean <= 0.0) { + return 0.0; + } + + return $this->standardDeviation($values) / $mean; + } + + /** + * @param array $values Sorted values + */ + private function percentile(array $values, int $p): float + { + $n = count($values); + if (0 === $n) { + return 0.0; + } + $rank = ($p / 100) * ($n - 1); + $lower = (int) floor($rank); + $upper = (int) ceil($rank); + if ($lower === $upper) { + return $values[$lower]; + } + $weight = $rank - $lower; + + return $values[$lower] * (1 - $weight) + $values[$upper] * $weight; + } + + /** + * @param array $values Sorted values + */ + private function giniCoefficient(array $values): float + { + $n = count($values); + if (0 === $n) { + return 0.0; + } + $sum = array_sum($values); + if ($sum <= 0.0) { + return 0.0; + } + + $mad = 0.0; + for ($i = 0; $i < $n; ++$i) { + for ($j = 0; $j < $n; ++$j) { + $mad += abs($values[$i] - $values[$j]); + } + } + $mean = $sum / $n; + + return $mad / (2 * $n * $n * $mean); + } + + /** + * @param array $values + */ + private function shannonEntropy(array $values): float + { + $sum = array_sum($values); + if ($sum <= 0.0) { + return 0.0; + } + $entropy = 0.0; + foreach ($values as $v) { + if ($v <= 0.0) { + continue; + } + $p = $v / $sum; + $entropy -= $p * log($p, 2); + } + + return $entropy; + } +} diff --git a/src/Collector/FileMetricsCollector.php b/src/Collector/FileMetricsCollector.php new file mode 100644 index 0000000..697925e --- /dev/null +++ b/src/Collector/FileMetricsCollector.php @@ -0,0 +1,37 @@ + + */ +/** + * Lightweight helper to derive file-level ratios from existing code style metrics. + * Not wired into the AST traversal; intended for post-processing or tests. + */ +final class FileMetricsCollector +{ + /** + * @param array $metrics Expecting keys: lines, blank_lines, comment_lines + * + * @return array + */ + public function compute(array $metrics): array + { + $lines = (int) ($metrics['lines'] ?? 0); + $empty = (int) ($metrics['blank_lines'] ?? 0); + $comments = (int) ($metrics['comment_lines'] ?? 0); + $code = max(0, $lines - $empty - $comments); + + return [ + 'emptyLines' => $empty, + 'commentLines' => $comments, + 'codeLines' => $code, + 'emptyRatio' => $lines > 0 ? $empty / $lines : 0.0, + 'commentRatio' => $lines > 0 ? $comments / $lines : 0.0, + 'commentDensity' => $code > 0 ? $comments / $code : 0.0, + ]; + } +} diff --git a/src/Command/AnalyzeCommand.php b/src/Command/AnalyzeCommand.php index dffb4fc..d02916e 100644 --- a/src/Command/AnalyzeCommand.php +++ b/src/Command/AnalyzeCommand.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -21,7 +22,6 @@ use TwigMetrics\Collector\FunctionCollector; use TwigMetrics\Collector\InheritanceCollector; use TwigMetrics\Collector\RelationshipCollector; -use TwigMetrics\Config\ErrorMessages; use TwigMetrics\Renderer\ConsoleRenderer; use TwigMetrics\Renderer\Helper\ConsoleOutputHelper; use TwigMetrics\Reporter\Dimension\ArchitectureReporter; @@ -51,35 +51,32 @@ protected function configure(): void $this ->addArgument('target', InputArgument::OPTIONAL, 'Dimension or path to analyze (default: current directory)', '.') ->addArgument('path', InputArgument::OPTIONAL, 'Path to analyze when first argument is a dimension') - ->setHelp( - <<<'HELP' - TwigMetrics analyzes Twig templates with a simple, intuitive CLI interface. - - Simple Usage: - twigmetrics # Default analysis of current directory - twigmetrics templates/ # Analyze templates/ directory - twigmetrics complexity # Complexity analysis on current directory - twigmetrics complexity templates/ # Complexity analysis on templates/ - twigmetrics code-style src/ # Code style analysis on src/ - - Available Dimensions: - template-files - Template size, structure, and distribution - complexity - Logical complexity and nesting analysis - callables - Twig functions, filters, macros, variables - code-style - Formatting and naming conventions - architecture - Template roles and reusability patterns - maintainability - Code quality, duplication, tech debt - - Usage Examples: - # Quick analysis - twigmetrics # Default analysis - twigmetrics templates/ # Analyze specific directory - - # Focused reports - twigmetrics complexity # Complexity analysis - twigmetrics code-style templates/ # Code style for templates/ - - HELP + ->addOption('dir-depth', 'd', InputOption::VALUE_REQUIRED, 'Directory charts max depth (0 to disable)', '1') + ->setHelp(<<<'HELP' +TwigMetrics analyzes Twig templates with a simple, intuitive CLI interface. + + twigmetrics Analyze current directory + twigmetrics templates/ Analyze templates/ directory + +Template size, structure, and distribution + twigmetrics template-files templates/ + +Logical complexity and nesting analysis + twigmetrics complexity templates/ + +Twig functions, filters, macros, variables + twigmetrics callables templates/ + +Formatting & code style conventions + twigmetrics code-style templates/ + +Inclusion and inheritance relationships + twigmetrics architecture templates/ + +Code quality, duplication, tech debt + twigmetrics maintainability templates/ + +HELP ); } @@ -92,46 +89,36 @@ protected function execute(InputInterface $input, OutputInterface $output): int [$path, $reportType, $dimension] = $this->parseArguments($target, $pathArg); if (!is_dir($path)) { - $io->error(sprintf(ErrorMessages::DIRECTORY_NOT_FOUND, $path)); + $io->error(sprintf('Directory not found "%s".', $path)); return Command::FAILURE; } $validDimensions = [ - 'template-files', 'complexity', /* 'relationships', */ 'callables', + 'template-files', 'complexity', 'callables', 'code-style', 'architecture', 'maintainability', ]; if ($dimension && !in_array($dimension, $validDimensions, true)) { - $io->error(sprintf(ErrorMessages::INVALID_DIMENSION, implode(', ', $validDimensions))); + $io->error(sprintf('Invalid dimension. Use: %s', implode(', ', $validDimensions))); return Command::FAILURE; } - /** @var ConsoleSectionOutput|null $topSection */ - $topSection = null; $headerHelper = new ConsoleOutputHelper($output); $headerHelper->writeMainHeader(); $headerHelper->writeEmptyLine(); $headerHelper->writeBetaWarning(); - if ($output instanceof ConsoleOutputInterface) { - $topSection = $output->section(); - $lines = [ - " Path: {$path} ", - ]; - if ($dimension) { - $lines[] = " Dimension: {$dimension} "; - } - $topSection->writeln($lines); - $topSection->writeln(''); - } else { - $output->writeln(" Path: {$path} "); - if ($dimension) { - $output->writeln(" Dimension: {$dimension} "); - } - $output->writeln(''); + $topSection = $this->createSectionOutput($output); + $lines = [ + " Path: {$path} ", + ]; + if ($dimension) { + $lines[] = " Dimension: {$dimension} "; } + $topSection->writeln($lines); + $topSection->writeln(''); $startTime = microtime(true); @@ -139,26 +126,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $files = iterator_to_array($templateFinder->find($path)); if (empty($files)) { - $io->error(sprintf(ErrorMessages::NO_TEMPLATES_FOUND, $path)); + $io->error(sprintf('No Twig templates found in: %s', $path)); return Command::FAILURE; } - $templatesFoundLine = ErrorMessages::consoleInfo(sprintf(ErrorMessages::TEMPLATES_FOUND, count($files))); - if ($topSection instanceof ConsoleSectionOutput) { - $topSection->writeln(' '.$templatesFoundLine.' '); - } else { - $output->writeln(' '.$templatesFoundLine.' '); - } + $templatesFoundLine = sprintf('Found %d template(s) to analyze...', count($files)); + $topSection->writeln(' '.$templatesFoundLine.' '); - /** @var ConsoleSectionOutput|null $progressSection */ - $progressSection = null; - $progressOutput = $output; - if ($output instanceof ConsoleOutputInterface) { - $progressSection = $output->section(); - $progressOutput = $progressSection; - } - $progressBar = new ProgressBar($progressOutput, count($files)); + $progressSection = $this->createSectionOutput($output); + $progressBar = new ProgressBar($progressSection, count($files)); $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%'); $progressBar->start(); @@ -181,11 +158,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $batchResult = $batchAnalyzer->analyze($files); $progressBar->finish(); - if ($progressSection instanceof ConsoleSectionOutput) { - $progressSection->clear(); - } else { - $output->writeln("\n"); - } + $this->clearSection($progressSection, $output); $results = $batchResult->getTemplateResults(); $endTime = microtime(true); @@ -194,35 +167,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int $reporter = $this->createReporter($reportType, $dimension); $report = $reporter->generate($results); - $renderer = $this->createRenderer($output); - $renderer->setHeaderPrinted(true); - $renderer->setGlobalResults($results); - if ($topSection instanceof ConsoleSectionOutput) { - $topSection->clear(); + $dirDepthOpt = (string) ($input->getOption('dir-depth') ?? '1'); + $dirDepth = 1; + if (is_numeric($dirDepthOpt)) { + $dirDepth = (int) $dirDepthOpt; + } elseif (in_array(strtolower($dirDepthOpt), ['false', 'off', 'no'], true)) { + $dirDepth = 0; } - $renderedOutput = $renderer->render($report); - $totalLines = 0; - $total = max(1, count($results)); - $large = $highCx = $deep = 0; - foreach ($results as $result) { - $lines = (int) ($result->getMetric('lines') ?? 0); - $cx = (int) ($result->getMetric('complexity_score') ?? 0); - $depth = (int) ($result->getMetric('max_depth') ?? 0); - $totalLines += $lines; - if ($lines > 200) { - ++$large; - } - if ($cx > 20) { - ++$highCx; - } - if ($depth > 5) { - ++$deep; - } + $renderer = $this->createRenderer($output, $dirDepth); + if ('dimension-focused' === $reportType && $dimension) { + $renderer->setActiveDimension($dimension); } - $penalty = ($highCx / $total) * 40.0 + ($deep / $total) * 25.0 + ($large / $total) * 20.0; - $score = max(0.0, 100.0 - $penalty); - $grade = $score >= 95 ? 'A+' : ($score >= 85 ? 'A' : ($score >= 75 ? 'B' : ($score >= 65 ? 'C+' : 'C'))); + $renderer->setHeaderPrinted(true); + $renderer->setGlobalResults($results); + $this->clearSection($topSection, $output); + $renderedOutput = $renderer->render($report); $leftPad = ' '; $rightPad = ' '; @@ -232,7 +192,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sep = $leftPad.str_repeat('─', $faceWidth).$rightPad; $output->writeln(''.$sep.''); - $footerText = sprintf('Twig Metrics • %d files • %.2f sec • Grade %s', count($results), $totalTime, $grade); + $peakMemMb = memory_get_peak_usage(true) / (1024 * 1024); + $footerText = sprintf('Twig Metrics • %d files • %.2f sec • %.1f MB', count($results), $totalTime, $peakMemMb); $len = mb_strlen($footerText); $left = (int) floor(max(0, ($faceWidth - $len) / 2)); $right = max(0, $faceWidth - $left - $len); @@ -260,13 +221,37 @@ private function createDimensionReporter(string $dimension): DimensionReporter 'code-style' => new CodeStyleReporter(), 'architecture' => new ArchitectureReporter(), 'maintainability' => new MaintainabilityReporter(), - default => throw new \InvalidArgumentException(sprintf(ErrorMessages::INVALID_DIMENSION_REPORTER, $dimension)), + default => throw new \InvalidArgumentException(sprintf('Invalid dimension: %s', $dimension)), }; } - private function createRenderer(OutputInterface $output): ConsoleRenderer + private function createRenderer(OutputInterface $output, int $dirDepth): ConsoleRenderer + { + return new ConsoleRenderer($output, $dirDepth); + } + + /** + * Create a console section if supported, otherwise return the original output. + */ + private function createSectionOutput(OutputInterface $output): OutputInterface + { + if ($output instanceof ConsoleOutputInterface) { + return $output->section(); + } + + return $output; + } + + /** + * Clear a section if possible, otherwise add a newline to keep spacing readable. + */ + private function clearSection(OutputInterface $section, OutputInterface $fallbackOutput): void { - return new ConsoleRenderer($output); + if ($section instanceof ConsoleSectionOutput) { + $section->clear(); + } else { + $fallbackOutput->writeln("\n"); + } } /** @@ -274,15 +259,37 @@ private function createRenderer(OutputInterface $output): ConsoleRenderer */ private function parseArguments(string $target, ?string $pathArg): array { - $validDimensions = [ - 'template-files', 'complexity', /* 'relationships', */ 'callables', - 'code-style', 'architecture', 'maintainability', + $norm = $this->normalizeDimensionSlug($target); + if (null !== $norm) { + return [$pathArg ?? '.', 'dimension-focused', $norm]; + } + + return [$target, 'default', null]; + } + + private function normalizeDimensionSlug(?string $slug): ?string + { + if (null === $slug) { + return null; + } + + $s = strtolower($slug); + + $canonical = [ + 'template-files' => ['template-files', 'templates', 'files', 'tpl', 'tfiles'], + 'code-style' => ['code-style', 'codestyle', 'cs', 'style'], + 'complexity' => ['complexity', 'comp', 'complex', 'logic'], + 'callables' => ['callables', 'call', 'function', 'functions', 'filter', 'filters', 'callable'], + 'architecture' => ['architecture', 'archi', 'arch'], + 'maintainability' => ['maintainability', 'dept', 'maintain', 'maintenance'], ]; - if (in_array($target, $validDimensions)) { - return [$pathArg ?? '.', 'dimension-focused', $target]; + foreach ($canonical as $canon => $aliases) { + if (in_array($s, $aliases, true)) { + return $canon; + } } - return [$target, 'default', null]; + return null; } } diff --git a/src/Config/ErrorMessages.php b/src/Config/ErrorMessages.php deleted file mode 100644 index d6914eb..0000000 --- a/src/Config/ErrorMessages.php +++ /dev/null @@ -1,134 +0,0 @@ - - */ -final class ErrorMessages -{ - /** Error when specified directory does not exist */ - public const DIRECTORY_NOT_FOUND = 'Directory not found: %s'; - - /** Error when no Twig templates found in directory */ - public const NO_TEMPLATES_FOUND = 'No Twig templates found in: %s'; - - /** Error when invalid output format is specified */ - public const INVALID_FORMAT = 'Invalid format. Use: %s'; - - /** Error when invalid analysis dimension is specified */ - public const INVALID_DIMENSION = 'Invalid dimension. Use: %s'; - - /** Exception message for invalid dimension in reporter */ - public const INVALID_DIMENSION_REPORTER = 'Invalid dimension: %s'; - - /** Error when unable to read template file */ - public const UNABLE_TO_READ_FILE = 'Unable to read template file: %s'; - - /** Error when unable to write output file */ - public const UNABLE_TO_WRITE_FILE = 'Unable to write output file: %s'; - - /** Error when template parsing fails */ - public const TEMPLATE_PARSE_ERROR = 'Failed to parse template %s: %s'; - - /** Warning when analysis fails for a template */ - public const ANALYSIS_FAILED = 'Analysis failed for template %s: %s'; - - /** Error when no analyzers are configured */ - public const NO_ANALYZERS_CONFIGURED = 'No analyzers have been configured'; - - /** Error when batch analysis fails completely */ - public const BATCH_ANALYSIS_FAILED = 'Batch analysis failed: %s'; - - /** Error when invalid configuration option is provided */ - public const INVALID_CONFIGURATION = 'Invalid configuration option: %s'; - - /** Error when required configuration is missing */ - public const MISSING_CONFIGURATION = 'Missing required configuration: %s'; - - /** Error when configuration file cannot be loaded */ - public const CONFIGURATION_LOAD_ERROR = 'Failed to load configuration file: %s'; - - /** Error when output rendering fails */ - public const RENDER_ERROR = 'Failed to render output: %s'; - - /** Error when unsupported output format is requested */ - public const UNSUPPORTED_FORMAT = 'Unsupported output format: %s'; - - /** Generic error for unexpected failures */ - public const UNEXPECTED_ERROR = 'An unexpected error occurred: %s'; - - /** Error when required dependency is missing */ - public const MISSING_DEPENDENCY = 'Missing required dependency: %s'; - - /** Error when memory limit is exceeded */ - public const MEMORY_LIMIT_EXCEEDED = 'Memory limit exceeded during analysis. Consider analyzing fewer templates or increasing PHP memory limit.'; - - /** Success message when report is saved to file */ - public const REPORT_SAVED = 'Report saved to: %s'; - - /** Success message when analysis completes */ - public const ANALYSIS_COMPLETED = 'Analysis completed in %.2fs'; - - /** Info message showing number of templates found */ - public const TEMPLATES_FOUND = 'Found %d template(s) to analyze...'; - - /** Info message showing analysis progress */ - public const ANALYSIS_PROGRESS = 'Analyzing %d templates...'; - - /** - * Format an error message with parameters. - * - * @param string $message The error message template - * @param mixed ...$args Arguments to format into the message - * - * @return string The formatted error message - */ - public static function format(string $message, ...$args): string - { - return sprintf($message, ...$args); - } - - /** - * Create a console error message with proper styling. - * - * @param string $message The error message - * - * @return string The styled error message for console output - */ - public static function consoleError(string $message): string - { - return "{$message}"; - } - - /** - * Create a console info message with proper styling. - * - * @param string $message The info message - * - * @return string The styled info message for console output - */ - public static function consoleInfo(string $message): string - { - return "{$message}"; - } - - /** - * Create a console comment message with proper styling. - * - * @param string $message The comment message - * - * @return string The styled comment message for console output - */ - public static function consoleComment(string $message): string - { - return "{$message}"; - } -} diff --git a/src/Console/Helper/DualOutputHelper.php b/src/Console/Helper/DualOutputHelper.php deleted file mode 100644 index d7b8d67..0000000 --- a/src/Console/Helper/DualOutputHelper.php +++ /dev/null @@ -1,84 +0,0 @@ - - */ -final class DualOutputHelper -{ - private BufferedOutput $left; - private BufferedOutput $right; - private SymfonyStyle $leftIo; - private SymfonyStyle $rightIo; - - public function __construct( - private readonly OutputInterface $finalOutput, - private readonly int $spacing = 4, - private readonly int $totalWidth = 80, - ) { - $this->left = new BufferedOutput(BufferedOutput::VERBOSITY_NORMAL, true); - $this->right = new BufferedOutput(BufferedOutput::VERBOSITY_NORMAL, true); - - $this->leftIo = new SymfonyStyle(new ArgvInput(), $this->left); - $this->rightIo = new SymfonyStyle(new ArgvInput(), $this->right); - } - - public function getLeftCol(): SymfonyStyle - { - return $this->leftIo; - } - - public function getRightCol(): SymfonyStyle - { - return $this->rightIo; - } - - public function render(): void - { - $leftBuffer = rtrim($this->left->fetch(), "\n"); - $rightBuffer = rtrim($this->right->fetch(), "\n"); - $leftLines = explode("\n", $leftBuffer); - $rightLines = explode("\n", $rightBuffer); - - $leftWidth = (int) floor(($this->totalWidth - $this->spacing) / 2); - $rightWidth = $this->totalWidth - $this->spacing - $leftWidth; - - $maxLines = max(count($leftLines), count($rightLines)); - $leftLines = array_pad($leftLines, $maxLines, ''); - $rightLines = array_pad($rightLines, $maxLines, ''); - - for ($i = 0; $i < $maxLines; ++$i) { - $left = $this->clipAndPad($leftLines[$i], $leftWidth); - $right = $this->clipAndPad($rightLines[$i], $rightWidth); - $this->finalOutput->writeln($left.str_repeat(' ', $this->spacing).$right); - } - } - - private function clipAndPad(string $text, int $width): string - { - $stripped = $this->stripAnsi($text); - $trimmed = mb_strimwidth($stripped, 0, $width, ''); - $delta = $width - mb_strwidth($trimmed); - - if ($stripped !== $text) { - $text = preg_replace('/'.preg_quote($stripped, '/').'/', $trimmed, $text, 1) ?? $trimmed; - } else { - $text = $trimmed; - } - - return $text.str_repeat(' ', max(0, $delta)); - } - - private function stripAnsi(string $text): string - { - return preg_replace('/\e\\[[0-9;]*m/', '', $text) ?? ''; - } -} diff --git a/src/Detector/ComplexityHotspotDetector.php b/src/Detector/ComplexityHotspotDetector.php new file mode 100644 index 0000000..91817bf --- /dev/null +++ b/src/Detector/ComplexityHotspotDetector.php @@ -0,0 +1,41 @@ + + */ +final class ComplexityHotspotDetector +{ + public function __construct(private readonly ComplexityCalculator $calculator = new ComplexityCalculator()) + { + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function detectHotspots(array $results, int $limit = 5): array + { + $complexities = []; + + foreach ($results as $result) { + $complexities[] = [ + 'file' => basename($result->getRelativePath()), + 'complexity' => (int) ($result->getMetric('complexity_score') ?? 0), + 'logicRatio' => $this->calculator->calculateLogicRatio($result), + 'maintainability' => $this->calculator->calculateMaintainabilityIndex($result), + ]; + } + + usort($complexities, static fn ($a, $b) => $b['complexity'] <=> $a['complexity']); + + return array_slice($complexities, 0, $limit); + } +} diff --git a/src/Metric/Aggregator/DirectoryMetrics.php b/src/Metric/Aggregator/DirectoryMetrics.php new file mode 100644 index 0000000..0d48eed --- /dev/null +++ b/src/Metric/Aggregator/DirectoryMetrics.php @@ -0,0 +1,96 @@ + + */ +final class DirectoryMetrics +{ + public int $fileCount = 0; + public int $totalLines = 0; + public int $totalBlank = 0; + public int $totalComments = 0; + public int $maxComplexity = 0; + public int $sumComplexity = 0; + public float $sumDepth = 0.0; + public int $criticalCount = 0; + public int $sumMaxLineLength = 0; + public float $sumFormatScore = 0.0; + public int $sumMixedIndent = 0; + + public function __construct(public readonly string $path) + { + } + + public function addResult(AnalysisResult $result): void + { + ++$this->fileCount; + + $lines = (int) ($result->getMetric('lines') ?? 0); + $blank = (int) ($result->getMetric('blank_lines') ?? 0); + $comments = (int) ($result->getMetric('comment_lines') ?? 0); + $cx = (int) ($result->getMetric('complexity_score') ?? 0); + $depth = (int) ($result->getMetric('max_depth') ?? 0); + $maxLen = (int) ($result->getMetric('max_line_length') ?? 0); + $format = (float) ($result->getMetric('formatting_consistency_score') ?? 100.0); + $mixed = (int) ($result->getMetric('mixed_indentation_lines') ?? 0); + + $this->totalLines += $lines; + $this->totalBlank += $blank; + $this->totalComments += $comments; + $this->maxComplexity = max($this->maxComplexity, $cx); + $this->sumComplexity += $cx; + $this->sumDepth += $depth; + $this->sumMaxLineLength += $maxLen; + $this->sumFormatScore += $format; + $this->sumMixedIndent += $mixed; + if ($cx > 25) { + ++$this->criticalCount; + } + } + + public function getEmptyLinesRatio(): float + { + return $this->totalLines > 0 ? $this->totalBlank / $this->totalLines : 0.0; + } + + public function getCommentRatio(): float + { + return $this->totalLines > 0 ? $this->totalComments / $this->totalLines : 0.0; + } + + public function getAverageLines(): float + { + return $this->fileCount > 0 ? $this->totalLines / $this->fileCount : 0.0; + } + + public function getAverageDepth(): float + { + return $this->fileCount > 0 ? $this->sumDepth / $this->fileCount : 0.0; + } + + public function getAverageComplexity(): float + { + return $this->fileCount > 0 ? $this->sumComplexity / $this->fileCount : 0.0; + } + + public function getAverageMaxLineLength(): float + { + return $this->fileCount > 0 ? $this->sumMaxLineLength / $this->fileCount : 0.0; + } + + public function getAverageFormatScore(): float + { + return $this->fileCount > 0 ? $this->sumFormatScore / $this->fileCount : 0.0; + } + + public function getIndentationConsistency(): float + { + return $this->totalLines > 0 ? max(0.0, 100.0 - ($this->sumMixedIndent / $this->totalLines) * 100.0) : 100.0; + } +} diff --git a/src/Metric/Aggregator/DirectoryMetricsAggregator.php b/src/Metric/Aggregator/DirectoryMetricsAggregator.php new file mode 100644 index 0000000..650cf18 --- /dev/null +++ b/src/Metric/Aggregator/DirectoryMetricsAggregator.php @@ -0,0 +1,40 @@ + + */ +final class DirectoryMetricsAggregator +{ + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function aggregateByDirectory(array $results, int $maxDepth = 2): array + { + $directories = []; + + foreach ($results as $result) { + $path = $result->getRelativePath(); + $parts = explode('/', $path); + $levels = min($maxDepth, max(1, count($parts) - 1)); + for ($depth = 1; $depth <= $levels; ++$depth) { + $dirPath = implode('/', array_slice($parts, 0, $depth)); + if (!isset($directories[$dirPath])) { + $directories[$dirPath] = new DirectoryMetrics($dirPath); + } + $directories[$dirPath]->addResult($result); + } + } + + ksort($directories); + + return $directories; + } +} diff --git a/src/Metric/BlockMetrics.php b/src/Metric/BlockMetrics.php new file mode 100644 index 0000000..7c047ba --- /dev/null +++ b/src/Metric/BlockMetrics.php @@ -0,0 +1,23 @@ + + */ +final readonly class BlockMetrics +{ + /** + * @param list $orphanedBlocks + */ + public function __construct( + public int $totalDefined, + public int $totalUsed, + public array $orphanedBlocks, + public float $usageRatio, + public float $averageReuse, + ) { + } +} diff --git a/src/Metric/CouplingMetrics.php b/src/Metric/CouplingMetrics.php new file mode 100644 index 0000000..552ae6d --- /dev/null +++ b/src/Metric/CouplingMetrics.php @@ -0,0 +1,20 @@ + + */ +final readonly class CouplingMetrics +{ + public function __construct( + public float $avgFanIn, + public float $avgFanOut, + public int $maxCoupling, + public float $instabilityIndex, + public int $circularRefs, + ) { + } +} diff --git a/src/Metric/DimensionMetrics.php b/src/Metric/DimensionMetrics.php new file mode 100644 index 0000000..b7670bc --- /dev/null +++ b/src/Metric/DimensionMetrics.php @@ -0,0 +1,28 @@ + + */ +final readonly class DimensionMetrics +{ + /** + * @param array $coreMetrics + * @param array $detailMetrics + * @param array $distributions + * @param array|array $insights + */ + public function __construct( + public string $name, + public float $score, + public string $grade, + public array $coreMetrics, + public array $detailMetrics, + public array $distributions, + public array $insights, + ) { + } +} diff --git a/src/Metric/SecurityMetrics.php b/src/Metric/SecurityMetrics.php new file mode 100644 index 0000000..ba84092 --- /dev/null +++ b/src/Metric/SecurityMetrics.php @@ -0,0 +1,21 @@ + + */ +final readonly class SecurityMetrics +{ + /** + * @param array $risks + */ + public function __construct( + public int $score, + public array $risks, + public int $deprecatedCount, + ) { + } +} diff --git a/src/Metric/StatisticalSummary.php b/src/Metric/StatisticalSummary.php new file mode 100644 index 0000000..2b4536e --- /dev/null +++ b/src/Metric/StatisticalSummary.php @@ -0,0 +1,29 @@ + + */ +final readonly class StatisticalSummary +{ + public function __construct( + public int $count, + public float $sum, + public float $mean, + public float $median, + public float $stdDev, + public float $coefficientOfVariation, + public float $p25, + public float $p75, + public float $p95, + public float $giniIndex, + public float $entropy, + public float $min, + public float $max, + public float $range, + ) { + } +} diff --git a/src/Metric/StyleMetrics.php b/src/Metric/StyleMetrics.php new file mode 100644 index 0000000..da15ea9 --- /dev/null +++ b/src/Metric/StyleMetrics.php @@ -0,0 +1,22 @@ + + */ +final readonly class StyleMetrics +{ + /** + * @param array> $violations + */ + public function __construct( + public array $violations, + public float $consistencyScore, + public float $formattingEntropy, + public float $readabilityScore, + ) { + } +} diff --git a/src/Renderer/Component/DirectoryTreeRenderer.php b/src/Renderer/Component/DirectoryTreeRenderer.php deleted file mode 100644 index f753d4a..0000000 --- a/src/Renderer/Component/DirectoryTreeRenderer.php +++ /dev/null @@ -1,901 +0,0 @@ -, files: list, path: non-empty-string, metrics: array}> - */ - private function buildDirectoryTree(array $results, int $maxDepth): array - { - /** @var array, files: list, path: non-empty-string, metrics: array}> $tree */ - $tree = []; - - foreach ($results as $result) { - $path = $result->getRelativePath(); - $parts = explode('/', dirname($path)); - - if (count($parts) > $maxDepth) { - $parts = array_slice($parts, 0, $maxDepth); - } - - $current = &$tree; - /** @var array, files: list, path: non-empty-string, metrics: array}> $current */ - $currentPath = ''; - - foreach ($parts as $i => $part) { - /* - * @phpstan-var array, files: list, path: non-empty-string, metrics: array}> $current - */ - if ('.' === $part || '' === $part) { - continue; - } - - $currentPath = $currentPath ? $currentPath.'/'.$part : $part; - - if (!isset($current[$part])) { - $current[$part] = [ - 'children' => [], - 'files' => [], - 'path' => $currentPath, - 'metrics' => [], - ]; - } - - if ($i === count($parts) - 1 || ($i === $maxDepth - 1 && count($parts) > $maxDepth)) { - $current[$part]['files'][] = $result; - } - - $current = &$current[$part]['children']; - } - } - - return $tree; - } - - /** - * Render a compact metrics table for directories. - * - * @param AnalysisResult[] $results - * @param array $metrics Array of metric_name => display_label - */ - public function renderDirectoryMetricsTable( - array $results, - array $metrics, - int $maxDepth = 2, - ): void { - if (empty($results) || empty($metrics)) { - return; - } - - $tree = $this->buildDirectoryTree($results, $maxDepth); - $directoriesData = []; - - foreach ($tree as $name => $node) { - $data = ['name' => $name, 'files' => 0]; - - foreach ($metrics as $metricName => $label) { - $total = 0; - $files = $node['files']; - - if (!empty($node['children'])) { - $files = array_merge($files, $this->getFilesRecursive($node['children'])); - } - - foreach ($files as $file) { - $total += (float) ($file->getMetric($metricName) ?? 0); - } - - $data[$metricName] = $total; - $data['files'] = count($files); - } - - $directoriesData[] = $data; - } - - usort($directoriesData, fn ($a, $b) => strcmp($a['name'], $b['name'])); - - $this->output->writeln(''); - $this->renderCompactMetricsTable($directoriesData, $metrics); - } - - /** - * Render a heatmap-style tree visualization for callables/architecture metrics. - * - * @param AnalysisResult[] $results - * @param array $metrics Array of metric_name => display_label - */ - public function renderDirectoryHeatmapTree( - array $results, - array $metrics, - string $title = 'Directory Metrics', - int $maxDepth = 2, - ): void { - if (empty($results) || empty($metrics)) { - return; - } - - $tree = $this->buildDirectoryTree($results, $maxDepth); - $directoriesData = []; - $maxValues = []; - - foreach ($tree as $name => $node) { - $data = ['name' => $name, 'files' => 0]; - - foreach ($metrics as $metricName => $label) { - $total = 0; - $files = $node['files']; - - if (!empty($node['children'])) { - $files = array_merge($files, $this->getFilesRecursive($node['children'])); - } - - foreach ($files as $file) { - $total += (float) ($file->getMetric($metricName) ?? 0); - } - - $data[$metricName] = $total; - $maxValues[$metricName] = max($maxValues[$metricName] ?? 0, $total); - } - - $data['files'] = count($files); - $directoriesData[] = $data; - } - - usort($directoriesData, fn ($a, $b) => strcmp($a['name'], $b['name'])); - - $this->output->writeln(''); - - $this->renderHeatmapTreeHeader($metrics); - $this->renderHeatmapTreeNodes($directoriesData, $metrics, $maxValues); - } - - /** - * @param array, files: list, path: non-empty-string, metrics: array}> $children - * - * @return list - */ - private function getFilesRecursive(array $children): array - { - $files = []; - - foreach ($children as $child) { - foreach ($child['files'] as $file) { - $files[] = $file; - } - $files = [...$files, ...$this->getFilesRecursive($child['children'])]; - } - - return $files; - } - - /** - * @param array> $directoriesData - * @param array $metrics - */ - private function renderCompactMetricsTable(array $directoriesData, array $metrics): void - { - if (empty($directoriesData)) { - return; - } - - $maxValues = []; - foreach (array_keys($metrics) as $metricName) { - $maxValues[$metricName] = max(array_column($directoriesData, $metricName)); - } - - $header = ' Directory '; - foreach ($metrics as $metricName => $label) { - $header .= sprintf('%8s', substr($label, 0, 8)); - } - $header .= ' Grade'; - - $this->output->writeln(''.$header.''); - - $this->output->writeln(' '.str_repeat('─', 70).''); - - foreach ($directoriesData as $data) { - $line = sprintf(' %-20s', $data['name'].'/'); - - foreach ($metrics as $metricName => $label) { - $value = $data[$metricName]; - $maxValue = $maxValues[$metricName]; - - $cell = $this->createArchitectureCell($value, $maxValue); - $line .= $cell; - } - - $avgPercentage = array_sum(array_map(fn ($metric) => $maxValues[$metric] > 0 ? $data[$metric] / $maxValues[$metric] : 0, - array_keys($metrics) - )) / count($metrics); - - $grade = match (true) { - $avgPercentage >= 0.9 => 'A+', - $avgPercentage >= 0.8 => 'A', - $avgPercentage >= 0.7 => 'B+', - $avgPercentage >= 0.6 => 'B', - $avgPercentage >= 0.5 => 'C+', - default => 'C', - }; - - $line .= sprintf(' %s', $grade); - - $this->output->writeln($line); - } - } - - /** - * @param array $metrics - */ - private function renderHeatmapTreeHeader(array $metrics): void - { - $header = sprintf(' %-24s', 'Directory'); - - foreach ($metrics as $metricName => $label) { - $header .= sprintf(' %4s', $label); - } - - $this->output->writeln($header); - - $separatorLine = sprintf(' %s', str_repeat('─', 24)); - foreach ($metrics as $metricName => $label) { - $separatorLine .= sprintf(' %s', str_repeat('─', 4)); - } - $this->output->writeln($separatorLine); - } - - /** - * @param array> $directoriesData - * @param array $metrics - * @param array $maxValues - */ - private function renderHeatmapTreeNodes(array $directoriesData, array $metrics, array $maxValues): void - { - $totalEntries = count($directoriesData); - - foreach ($directoriesData as $index => $data) { - $isLast = $index === $totalEntries - 1; - $symbol = $isLast ? '└─' : '├─'; - - $fileCount = $data['files'] ?? 0; - $line = sprintf( - ' %s %-18s/ ( %d )', - $symbol, - $data['name'], - $fileCount, - ); - - foreach ($metrics as $metricName => $label) { - $value = $data[$metricName] ?? 0; - - if ($value > 0) { - $line .= sprintf(' %3.0f', $value); - } else { - $line .= ' 0'; - } - } - - $this->output->writeln($line); - } - } - - /** - * Render a compact dual-metric progress bar visualization. - * - * @param AnalysisResult[] $results - * @param array $metrics Array of exactly 2 metrics: metric_name => display_label - */ - public function renderDirectoryDualProgressBars( - array $results, - array $metrics, - string $title = 'Directory Comparison', - int $maxDepth = 2, - int $maxScore = 100, - ): void { - if (empty($results) || 2 !== count($metrics)) { - return; - } - - $tree = $this->buildDirectoryTree($results, $maxDepth); - $directoriesData = []; - $maxValues = []; - - foreach ($tree as $name => $node) { - $data = ['name' => $name, 'files' => 0]; - - foreach ($metrics as $metricName => $label) { - $total = 0; - $files = $node['files']; - - if (!empty($node['children'])) { - $files = array_merge($files, $this->getFilesRecursive($node['children'])); - } - - foreach ($files as $file) { - $total += (float) ($file->getMetric($metricName) ?? 0); - } - - $avgTotal = count($files) > 0 ? $total / count($files) : 0; - $data[$metricName] = $avgTotal; - $maxValues[$metricName] = max($maxValues[$metricName] ?? 0, $avgTotal); - } - - $data['files'] = count($files); - $directoriesData[] = $data; - } - - usort($directoriesData, fn ($a, $b) => strcmp($a['name'], $b['name'])); - - $this->output->writeln(''); - - $this->renderDualProgressBarLines($directoriesData, $metrics, $maxValues, $maxScore); - } - - /** - * @param array> $directoriesData - * @param array $metrics - * @param array $maxValues - */ - private function renderDualProgressBarLines(array $directoriesData, array $metrics, array $maxValues, int $maxScore): void - { - $metricNames = array_keys($metrics); - $metric1 = $metricNames[0]; - $metric2 = $metricNames[1]; - - foreach ($directoriesData as $data) { - $name = $data['name']; - $value1 = $data[$metric1] ?? 0; - $value2 = $data[$metric2] ?? 0; - - $score1 = min($maxScore, (int) ($value1 / max($maxValues[$metric1], 1) * $maxScore)); - $score2 = min($maxScore, (int) ($value2 / max($maxValues[$metric2], 1) * $maxScore)); - - $bar1 = $this->createCompactProgressBar($score1, $maxScore, 13); - $bar2 = $this->createCompactProgressBar($score2, $maxScore, 13); - - $dirCol = sprintf('%-18s', $name.'/'); - $line = sprintf( - ' %s %3d/%d [%s] %3d/%d [%s]', - $dirCol, - $score1, - $maxScore, - $bar1, - $score2, - $maxScore, - $bar2 - ); - - $this->output->writeln($line.' '); - } - } - - private function createCompactProgressBar(int $score, int $maxScore, int $width): string - { - $percentage = $maxScore > 0 ? $score / $maxScore : 0; - $filled = (int) ($percentage * $width); - $empty = $width - $filled; - - $color = match (true) { - $percentage >= 0.8 => 'green', - $percentage >= 0.6 => 'yellow', - $percentage >= 0.4 => 'red', - default => 'gray', - }; - - $bar = sprintf( - '%s%s', - $color, - str_repeat('█', $filled), - str_repeat('░', $empty) - ); - - return $bar; - } - - /** - * Render stacked horizontal bars showing multiple metrics per directory. - * - * @param AnalysisResult[] $results - * @param array $metrics Array of metric_name => display_label - */ - public function renderDirectoryStackedBars( - array $results, - array $metrics, - string $title = 'Directory Overview', - int $maxDepth = 2, - int $barWidth = 30, - ): void { - if (empty($results) || empty($metrics)) { - return; - } - - $tree = $this->buildDirectoryTree($results, $maxDepth); - $directoriesData = []; - $maxValues = []; - - foreach ($tree as $name => $node) { - $data = ['name' => $name, 'files' => 0]; - - foreach ($metrics as $metricName => $label) { - $total = 0; - $files = $node['files']; - - if (!empty($node['children'])) { - $files = array_merge($files, $this->getFilesRecursive($node['children'])); - } - - foreach ($files as $file) { - $total += (float) ($file->getMetric($metricName) ?? 0); - } - - $data[$metricName] = match ($metricName) { - 'lines', 'chars', 'nodes' => $total, - default => count($files) > 0 ? $total / count($files) : 0, - }; - - $maxValues[$metricName] = max($maxValues[$metricName] ?? 0, $data[$metricName]); - } - - $data['files'] = count($files); - $directoriesData[] = $data; - } - - usort($directoriesData, fn ($a, $b) => strcmp($a['name'], $b['name'])); - - $this->output->writeln(''); - - $this->renderStackedBarLines($directoriesData, $metrics, $maxValues, $barWidth); - } - - /** - * @param array> $directoriesData - * @param array $metrics - * @param array $maxValues - */ - private function renderStackedBarLines(array $directoriesData, array $metrics, array $maxValues, int $barWidth): void - { - $totalEntries = count($directoriesData); - - foreach ($directoriesData as $index => $data) { - $name = $data['name']; - $fileCount = $data['files']; - $isLast = $index === $totalEntries - 1; - $symbol = $isLast ? '└─' : '├─'; - - $stackedBar = $this->createStackedBar($data, $metrics, $maxValues, $barWidth); - - $line = sprintf( - ' %s %-18s/ %s ', - $symbol, - $name, - $stackedBar - ); - - $this->output->writeln($line); - } - } - - /** - * Create a stacked bar with gradient segments for multiple metrics. - * - * @param array $data - * @param array $metrics - * @param array $maxValues - */ - private function createStackedBar(array $data, array $metrics, array $maxValues, int $barWidth): string - { - $totalValue = 0; - $values = []; - - foreach ($metrics as $metricName => $label) { - $value = $data[$metricName] ?? 0; - $values[$metricName] = $value; - $totalValue += $value; - } - - if (0 == $totalValue) { - return ''.str_repeat('░', $barWidth).''; - } - - $segments = []; - $colors = ['#2E8B57', '#4682B4', '#9370DB', '#CD853F']; - $usedWidth = 0; - $metricIndex = 0; - - foreach ($metrics as $metricName => $label) { - $value = $values[$metricName]; - - if ($value > 0) { - $proportion = $value / $totalValue; - $segmentWidth = (int) round($proportion * $barWidth); - - if ($segmentWidth > 0 && $usedWidth + $segmentWidth <= $barWidth) { - $color = $colors[$metricIndex % count($colors)]; - $intensity = min(1.0, $value / max($maxValues[$metricName], 1)); - - $segment = $this->createGradientSegment($segmentWidth, $intensity, $color, '█'); - $segments[] = $segment; - $usedWidth += $segmentWidth; - } - } - - ++$metricIndex; - } - - $remainingWidth = $barWidth - $usedWidth; - if ($remainingWidth > 0) { - $segments[] = ''.str_repeat('░', $remainingWidth).''; - } - - return implode('', $segments); - } - - private function createGradientSegment(int $width, float $intensity, string $color, string $pattern): string - { - if ($width <= 0) { - return ''; - } - - $segment = ''; - - for ($i = 0; $i < $width; ++$i) { - $position = $i / max(1, $width - 1); - - if ($intensity > 0.8) { - $char = match (true) { - $position < 0.6 => '█', - $position < 0.8 => '▓', - default => '▒', - }; - } elseif ($intensity > 0.5) { - $char = match (true) { - $position < 0.4 => '▓', - $position < 0.7 => '▒', - default => '░', - }; - } else { - $char = match (true) { - $position < 0.3 => '▒', - $position < 0.6 => '░', - default => '░', - }; - } - - $segment .= $char; - } - - return sprintf('%s', $color, $segment); - } - - /** - * Render a detailed table with multiple metrics and visual indicators. - * - * @param AnalysisResult[] $results - * @param array $metrics Array of metric_name => display_label - */ - public function renderDirectoryDetailedTable( - array $results, - array $metrics, - string $title = 'Directory Analysis', - int $maxDepth = 2, - ): void { - if (empty($results) || empty($metrics)) { - return; - } - - $tree = $this->buildDirectoryTree($results, $maxDepth); - $directoriesData = []; - $maxValues = []; - - foreach ($tree as $name => $node) { - $data = ['name' => $name, 'files' => 0]; - - foreach ($metrics as $metricName => $label) { - $total = 0; - $files = $node['files']; - $count = 0; - - if (!empty($node['children'])) { - $files = array_merge($files, $this->getFilesRecursive($node['children'])); - } - - foreach ($files as $file) { - $value = (float) ($file->getMetric($metricName) ?? 0); - $total += $value; - if ($value > 0) { - ++$count; - } - } - - $data[$metricName] = match ($metricName) { - 'lines', 'chars', 'nodes' => $total, - default => count($files) > 0 ? $total / count($files) : 0, - }; - - $maxValues[$metricName] = max($maxValues[$metricName] ?? 0, $data[$metricName]); - } - - $data['files'] = count($files); - $directoriesData[] = $data; - } - - usort($directoriesData, fn ($a, $b) => strcmp($a['name'], $b['name'])); - - $this->output->writeln(''); - - $this->renderDetailedTableHeader($metrics); - $this->renderDetailedTableRows($directoriesData, $metrics, $maxValues); - } - - /** - * @param array $metrics - */ - private function renderDetailedTableHeader(array $metrics): void - { - $header = ' Directory '; - - foreach ($metrics as $metricName => $label) { - $width = match ($label) { - 'Lines' => 8, - 'File' => 20, - 'Size' => 8, - default => 8, - }; - $header .= sprintf(' %'.$width.'s', $label); - } - - $this->output->writeln(''.$header.''); - - $separator = ' '.str_repeat('─', 18); - foreach ($metrics as $metricName => $label) { - $width = match ($label) { - 'Lines' => 8, - 'File' => 20, - 'Size' => 8, - default => 8, - }; - $separator .= ' '.str_repeat('─', $width); - } - $this->output->writeln(''.$separator.''); - } - - /** - * @param array> $directoriesData - * @param array $metrics - * @param array $maxValues - */ - private function renderDetailedTableRows(array $directoriesData, array $metrics, array $maxValues): void - { - foreach ($directoriesData as $data) { - $name = $data['name']; - $totalLines = $data['lines'] ?? 0; - $isHighRisk = $totalLines > 1000; - - if ($isHighRisk) { - $dirName = ' '.sprintf('%-18s', $name.'/'); - } else { - $dirName = ' '.sprintf('%-18s', $name.'/'); - } - $line = $dirName; - - foreach ($metrics as $metricName => $label) { - $value = $data[$metricName] ?? 0; - $maxValue = $maxValues[$metricName] ?? 1; - - $width = match ($label) { - 'Lines' => 8, - 'File' => 20, - 'Size' => 8, - default => 8, - }; - - $formatted = match ($label) { - 'Lines' => $this->formatLinesWithRisk((int) $value, $isHighRisk), - 'File' => $this->createThinVisualization($value, $maxValue, 20), - 'Size' => $this->formatSizeWithRiskRightAlign($value, $isHighRisk, $width), - default => sprintf('%6.1f', $value), - }; - - $line .= ' '.$formatted; - } - - $this->output->writeln($line); - } - } - - private function createThinVisualization(float $value, float $maxValue, int $width): string - { - $intensity = $maxValue > 0 ? $value / $maxValue : 0; - - $result = ''; - for ($i = 0; $i < $width; ++$i) { - $pos = $i / max(1, $width - 1); - - if ($pos <= $intensity) { - if ($intensity >= 0.8) { - $result .= 'o'; - } elseif ($intensity >= 0.6) { - $result .= 'o'; - } elseif ($intensity >= 0.4) { - $result .= 'o'; - } else { - $result .= 'o'; - } - } else { - $result .= '·'; - } - } - - return $result; - } - - private function formatLinesWithRisk(int $lines, bool $isHighRisk): string - { - if ($isHighRisk) { - return sprintf('%6d', $lines); - } elseif ($lines > 500) { - return sprintf('%6d', $lines); - } else { - return sprintf('%6d', $lines); - } - } - - private function formatSizeWithRiskRightAlign(float $value, bool $isHighRisk, int $width): string - { - $formatted = ''; - if ($isHighRisk) { - $formatted = sprintf('%.0f', $value); - } elseif ($value > 5) { - $formatted = sprintf('%.0f', $value); - } else { - $formatted = sprintf('%.0f', $value); - } - - $cleanFormatted = preg_replace('/\033\[[0-9;]*m/', '', $formatted); - $actualWidth = mb_strlen($cleanFormatted); - $padding = max(0, $width - $actualWidth); - - return str_repeat(' ', $padding).$formatted; - } - - /** - * Create architecture cell with 8-char width, background 5% opacity simulation, and shade/saturation variations. - */ - private function createArchitectureCell(float $value, float $maxValue): string - { - if (0 == $value) { - return ' '; - } - - $intensity = $maxValue > 0 ? min(1.0, $value / $maxValue) : 0; - - $valueStr = $value < 100 ? sprintf('%.0f', $value) : sprintf('%.0f', min(999, $value)); - - if ($intensity >= 0.8) { - $color = 'red'; - $options = ';options=bold'; - $bgChar = '█'; - } elseif ($intensity >= 0.6) { - $color = 'yellow'; - $options = ';options=bold'; - $bgChar = '▓'; - } elseif ($intensity >= 0.4) { - $color = 'blue'; - $options = ''; - $bgChar = '▒'; - } elseif ($intensity >= 0.2) { - $color = 'cyan'; - $options = ''; - $bgChar = '░'; - } else { - $color = 'gray'; - $options = ''; - $bgChar = '·'; - } - - $valueWidth = strlen($valueStr); - - $spacedValue = ' '.$valueStr.' '; - $spacedWidth = strlen($spacedValue); - - if ($spacedWidth <= 8) { - $bgWidth = 8 - $spacedWidth; - $cell = str_repeat($bgChar, $bgWidth).$spacedValue; - } else { - $cell = str_pad(' '.$valueStr, 8); - } - - return sprintf('%s', $color, $options, $cell); - } - - /** - * Render directory tree in clean lines format following user's example: - * L Components 87 ( 36.8%) ■■■■■■■■■■■■■■■■■■■■■■■■■■■■── - * - * @param AnalysisResult[] $results - */ - public function renderDirectoryCleanLines( - array $results, - string $metricName, - string $metricLabel, - int $maxDepth = 2, - ): void { - if (empty($results)) { - return; - } - - $tree = $this->buildDirectoryTree($results, $maxDepth); - $directoriesData = []; - $totalValue = 0; - - foreach ($tree as $name => $node) { - $files = $node['files']; - - if (!empty($node['children'])) { - $files = array_merge($files, $this->getFilesRecursive($node['children'])); - } - - $total = 0; - foreach ($files as $file) { - $total += (float) ($file->getMetric($metricName) ?? 0); - } - - $directoriesData[] = [ - 'name' => $name, - 'value' => $total, - 'files' => count($files), - ]; - $totalValue += $total; - } - - if (empty($directoriesData) || 0 == $totalValue) { - return; - } - - usort($directoriesData, fn ($a, $b) => strcmp($a['name'], $b['name'])); - - $this->output->writeln(''); - $this->output->writeln('Directory Distribution - '.$metricLabel.''); - $this->output->writeln(''.str_repeat('─', 76).''); - $this->output->writeln(''); - - foreach ($directoriesData as $data) { - $name = $data['name']; - $value = $data['value']; - $percentage = $totalValue > 0 ? ($value / $totalValue * 100) : 0; - - $barWidth = 30; - $filledWidth = (int) round($percentage / 100 * $barWidth); - $emptyWidth = $barWidth - $filledWidth; - $bar = str_repeat('■', $filledWidth).str_repeat('─', $emptyWidth); - - $line = sprintf( - ' L %-12s %3.0f (%5.1f%%) %s', - $name, - $value, - $percentage, - $bar - ); - - $this->output->writeln($line); - } - - $this->output->writeln(''); - } -} diff --git a/src/Renderer/Component/TopUsageRenderer.php b/src/Renderer/Component/TopUsageRenderer.php deleted file mode 100644 index f0c37b8..0000000 --- a/src/Renderer/Component/TopUsageRenderer.php +++ /dev/null @@ -1,175 +0,0 @@ - - */ -final readonly class TopUsageRenderer -{ - private const int TOTAL_WIDTH = 80; - private const int GAP = 2; - private const int COLUMN_WIDTH = (self::TOTAL_WIDTH - self::GAP) / 2; - private const int PADDING_LEFT = 1; - private const int BAR_WIDTH = 10; - - public function __construct( - private OutputInterface $output, - ) { - } - - /** - * @param array $functions - * @param array $variables - */ - public function render(array $functions, array $variables, int $limit = 10): void - { - $topFunctions = $this->getTopItems($functions, $limit); - $topVariables = $this->getTopItems($variables, $limit); - - $this->renderHeaders(); - $this->renderRows($topFunctions, $topVariables); - } - - private function renderHeaders(): void - { - $functionsHeader = $this->formatColumn('Most used Functions (top 10)'); - $variablesHeader = $this->formatColumn('Most used Variables (top 10)'); - - $this->output->writeln( - sprintf('%s%s%s', - $functionsHeader, - str_repeat(' ', self::GAP), - $variablesHeader - ) - ); - } - - /** - * @param array> $functions - * @param array> $variables - */ - private function renderRows(array $functions, array $variables): void - { - $maxCount = max(count($functions), count($variables)); - $maxFunctionUsage = !empty($functions) ? max(array_column($functions, 'usage')) : 0; - $maxVariableUsage = !empty($variables) ? max(array_column($variables, 'usage')) : 0; - - for ($i = 0; $i < $maxCount; ++$i) { - $functionLine = $this->renderItem( - $functions[$i] ?? null, - $maxFunctionUsage - ); - - $variableLine = $this->renderItem( - $variables[$i] ?? null, - $maxVariableUsage - ); - - $this->output->writeln( - sprintf('%s%s%s', - $functionLine, - str_repeat(' ', self::GAP), - $variableLine - ) - ); - } - } - - /** - * @param array|null $item - */ - private function renderItem(?array $item, int $maxUsage): string - { - if (null === $item) { - return str_repeat(' ', (int) self::COLUMN_WIDTH); - } - - $name = $item['name']; - $usage = $item['usage']; - - $barLength = $maxUsage > 0 ? ($usage / $maxUsage) * self::BAR_WIDTH : 0; - $bar = $this->createProgressBar($barLength); - - $nameAndUsage = sprintf('%s %d', $name, $usage); - - $availableNameSpace = (int) self::COLUMN_WIDTH - self::PADDING_LEFT - self::BAR_WIDTH - 2; - - if (strlen($nameAndUsage) > $availableNameSpace) { - $nameAndUsage = substr($nameAndUsage, 0, $availableNameSpace - 1).'…'; - } - - $spacesBeforeBar = $availableNameSpace - strlen($nameAndUsage); - - $content = sprintf('%s%s %s', - $nameAndUsage, - str_repeat(' ', max(1, $spacesBeforeBar)), - $bar - ); - - return $this->formatColumn($content); - } - - private function createProgressBar(float $length): string - { - $fullBlocks = (int) $length; - $remainder = $length - $fullBlocks; - - $bar = str_repeat('█', $fullBlocks); - - if ($remainder > 0 && $fullBlocks < self::BAR_WIDTH) { - $bar .= $this->getPartialBlock($remainder); - } - - $bar = str_pad($bar, self::BAR_WIDTH, ' ', STR_PAD_RIGHT); - - return sprintf('%s', $bar); - } - - private function getPartialBlock(float $fraction): string - { - return match (true) { - $fraction >= 0.875 => '█', - $fraction >= 0.75 => '▉', - $fraction >= 0.625 => '▊', - $fraction >= 0.5 => '▋', - $fraction >= 0.375 => '▌', - $fraction >= 0.25 => '▍', - $fraction >= 0.125 => '▎', - default => '▏', - }; - } - - private function formatColumn(string $content): string - { - $paddedContent = str_repeat(' ', self::PADDING_LEFT).$content; - - return str_pad($paddedContent, (int) self::COLUMN_WIDTH, ' ', STR_PAD_RIGHT); - } - - /** - * @param array $usageData - * - * @return array> - */ - private function getTopItems(array $usageData, int $limit): array - { - arsort($usageData); - - $topItems = array_slice($usageData, 0, $limit, true); - - $result = []; - foreach ($topItems as $name => $usage) { - $result[] = [ - 'name' => $name, - 'usage' => $usage, - ]; - } - - return $result; - } -} diff --git a/src/Renderer/ConsoleRenderer.php b/src/Renderer/ConsoleRenderer.php index 844fb4a..f05d572 100644 --- a/src/Renderer/ConsoleRenderer.php +++ b/src/Renderer/ConsoleRenderer.php @@ -6,8 +6,7 @@ use Symfony\Component\Console\Output\OutputInterface; use TwigMetrics\Analyzer\AnalysisResult; -use TwigMetrics\Console\Helper\DualOutputHelper; -use TwigMetrics\Renderer\Component\DirectoryTreeRenderer; +use TwigMetrics\Renderer\Dimension\DimensionRendererFactory; use TwigMetrics\Renderer\Helper\ConsoleOutputHelper; use TwigMetrics\Report\Report; use TwigMetrics\Report\Section\ReportSection; @@ -18,24 +17,21 @@ final class ConsoleRenderer implements RendererInterface { private ConsoleOutputHelper $outputHelper; - private DirectoryTreeRenderer $directoryTreeRenderer; private bool $headerPrinted = false; /** * @var array */ private array $globalResults = []; - private int $dimensionBlockIndex = 0; - private int $maxDepth = 3; - private ?Report $currentReport = null; + private int $maxDepth = 1; + private ?string $activeDimension = null; public function __construct( private OutputInterface $output, - int $maxDepth = 3, + int $maxDepth = 1, ) { $this->maxDepth = $maxDepth; $this->outputHelper = new ConsoleOutputHelper($output); - $this->directoryTreeRenderer = new DirectoryTreeRenderer($output); } public function setHeaderPrinted(bool $printed): void @@ -43,202 +39,34 @@ public function setHeaderPrinted(bool $printed): void $this->headerPrinted = $printed; } - /** - * @param AnalysisResult[] $results - */ - public function setGlobalResults(array $results): void - { - $this->globalResults = $results; - } - - private function barWithPartials(float $value, float $max, int $width): string - { - $max = $max > 0 ? $max : 1; - $ratio = max(0.0, min(1.0, $value / $max)); - $full = (int) floor($ratio * $width); - $remRatio = ($ratio * $width) - $full; - $partials = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; - $partialIndex = (int) floor($remRatio * (count($partials) - 1)); - $bar = str_repeat('█', $full); - if ($partialIndex > 0) { - $bar .= $partials[$partialIndex]; - } - - return $bar; - } - - /** - * @param array>> $overview - */ - private function renderCallableTops(array $overview): void + public function setActiveDimension(?string $dimensionSlug): void { - $functions = $overview['functions'] ?? []; - $filters = $overview['filters'] ?? []; - $variables = $overview['variables'] ?? []; - $tests = $overview['tests'] ?? []; - $macros = $overview['macros'] ?? []; - $blocks = $overview['blocks'] ?? []; - - $this->output->writeln(''); - - $filters = array_values(array_filter($filters, fn ($row) => 'escape' !== (string) ($row['name'] ?? ''))); - - $dual1 = new DualOutputHelper($this->output, spacing: 4, totalWidth: 72); - $l1 = $dual1->getLeftCol(); - $r1 = $dual1->getRightCol(); - - $l1->writeln(' Functions'); - $r1->writeln(' Filters'); - $topFn = array_slice($functions, 0, 10); - $topFl = array_slice($filters, 0, 10); - $maxFn = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $topFn) ?: [1]); - $maxFl = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $topFl) ?: [1]); - foreach ($topFn as $row) { - $name = mb_strimwidth((string) ($row['name'] ?? ''), 0, 16, '…'); - $cnt = (int) ($row['count'] ?? 0); - $l1->writeln(sprintf(' %-16s %3d %s', $name, $cnt, $this->barWithPartials($cnt, $maxFn, 8))); - } - foreach ($topFl as $row) { - $name = mb_strimwidth((string) ($row['name'] ?? ''), 0, 16, '…'); - $cnt = (int) ($row['count'] ?? 0); - $r1->writeln(sprintf(' %-16s %3d %s', $name, $cnt, $this->barWithPartials($cnt, $maxFl, 8))); - } - $dual1->render(); - - $this->output->writeln(''); - - if (!empty($macros) || !empty($blocks)) { - $dual2 = new DualOutputHelper($this->output, spacing: 4, totalWidth: 72); - $l2 = $dual2->getLeftCol(); - $r2 = $dual2->getRightCol(); - - $l2->writeln(' Macros'); - $r2->writeln(' Blocks'); - $topMacro = array_slice($macros, 0, 6); - $topBlock = array_slice($blocks, 0, 6); - $maxMacro = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $topMacro) ?: [1]); - $maxBlock = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $topBlock) ?: [1]); - foreach ($topMacro as $row) { - $name = mb_strimwidth((string) ($row['name'] ?? ''), 0, 14, '…'); - $cnt = (int) ($row['count'] ?? 0); - $l2->writeln(sprintf(' %-14s %3d %s', $name, $cnt, $this->barWithPartials($cnt, $maxMacro, 8))); - } - foreach ($topBlock as $row) { - $name = mb_strimwidth((string) ($row['name'] ?? ''), 0, 14, '…'); - $cnt = (int) ($row['count'] ?? 0); - $r2->writeln(sprintf(' %-14s %3d %s', $name, $cnt, $this->barWithPartials($cnt, $maxBlock, 8))); - } - $dual2->render(); - } - - $this->output->writeln(''); - - $dual3 = new DualOutputHelper($this->output, spacing: 4, totalWidth: 72); - $l3 = $dual3->getLeftCol(); - $r3 = $dual3->getRightCol(); - - $l3->writeln(' Variables'); - $r3->writeln(' Tests'); - $topVar = array_slice($variables, 0, 6); - $topTest = array_slice($tests, 0, 6); - $maxVar = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $topVar) ?: [1]); - $maxTest = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $topTest) ?: [1]); - foreach ($topVar as $row) { - $name = mb_strimwidth((string) ($row['name'] ?? ''), 0, 14, '…'); - $cnt = (int) ($row['count'] ?? 0); - $l3->writeln(sprintf(' %-14s %3d %s', $name, $cnt, $this->barWithPartials($cnt, $maxVar, 8))); - } - foreach ($topTest as $row) { - $name = mb_strimwidth((string) ($row['name'] ?? ''), 0, 14, '…'); - $cnt = (int) ($row['count'] ?? 0); - $r3->writeln(sprintf(' %-14s %3d %s', $name, $cnt, $this->barWithPartials($cnt, $maxTest, 8))); - } - $dual3->render(); + $this->activeDimension = $dimensionSlug; } /** * @param AnalysisResult[] $results */ - public function renderModernSummary(array $results, float $analysisTime = 0): void + public function setGlobalResults(array $results): void { - $templateCount = count($results); - $totalLines = 0; - $maxDepth = 0; - $directories = []; - - foreach ($results as $result) { - $lines = $result->getMetric('lines') ?? 0; - $depth = $result->getMetric('max_depth') ?? 0; - $totalLines += $lines; - $maxDepth = max($maxDepth, $depth); - - $path = $result->getRelativePath(); - $dir = dirname($path); - if ('.' !== $dir) { - $directories[$dir] = ($directories[$dir] ?? 0) + 1; - } - } - - $avgLines = $templateCount > 0 ? round($totalLines / $templateCount) : 0; - $avgLinesPerFile = $templateCount > 0 ? round($totalLines / $templateCount, 1) : 0; - $avgFilesPerDir = count($directories) > 0 ? round($templateCount / count($directories), 1) : 0; - - $largestTemplate = ''; - $largestLines = 0; - foreach ($results as $result) { - $lines = $result->getMetric('lines') ?? 0; - if ($lines > $largestLines) { - $largestLines = $lines; - $largestTemplate = basename($result->getRelativePath()); - } - } - - $leftSummary = [ - 'Templates' => (string) $templateCount, - 'Max Depth' => (string) $maxDepth, - 'Avg Lines/Template' => (string) $avgLines, - 'Analysis Time' => sprintf('%.2fs', $analysisTime), - ]; - - $rightSummary = [ - 'Directories' => (string) count($directories), - '', - '', - '', - ]; - - $this->outputHelper->writeTwoColumnKeyValues($leftSummary, $rightSummary, 'Summary'); - - $leftArch = [ - 'Total Lines' => (string) $totalLines, - 'Avg line per file' => (string) $avgLinesPerFile, - 'Largest (lines)' => (string) $largestLines, - ]; - - $rightArch = [ - 'Total Directories' => (string) count($directories), - 'Avg file per dir' => (string) $avgFilesPerDir, - 'Largest (files)' => $largestTemplate, - ]; - - $this->outputHelper->writeTwoColumnKeyValues($leftArch, $rightArch, 'File Architecture'); + $this->globalResults = $results; } private function renderDimensionVisualRecap(ReportSection $section): void { $data = $section->getData(); $dimensions = $data['dimensions'] ?? []; + $overall = $data['overall'] ?? null; if (empty($dimensions)) { return; } - $this->renderImprovedDimensionCards($dimensions); + $this->renderImprovedDimensionCards($dimensions, is_array($overall) ? $overall : null); } public function render(Report $report): string { - $this->currentReport = $report; if (!$this->headerPrinted) { $this->outputHelper->writeHeader(); } @@ -247,9 +75,8 @@ public function render(Report $report): string $deferredVisualRecap = null; - for ($i = 0; $i < count($sections); ++$i) { - $currentSection = $sections[$i]; - + foreach ($sections as $section) { + $currentSection = $section; if ('dimension_visual_recap' === $currentSection->getType()) { $deferredVisualRecap = $currentSection; break; @@ -259,7 +86,6 @@ public function render(Report $report): string $this->renderDimensionalAnalysisBlocks(); if ($deferredVisualRecap instanceof ReportSection) { - $this->outputHelper->writeSectionTitle('Dimension'); $this->renderDimensionVisualRecap($deferredVisualRecap); } @@ -275,27 +101,10 @@ private function getTextLengthWithoutAnsi(string $text): int } /** - * @param array $dimensionScores + * @param array $dimensionScores + * @param array|null $overall */ - public function renderDimensionRecap(array $dimensionScores): void - { - $this->outputHelper->writeSeparatorLine(); - $this->outputHelper->writeSection('Twig Metrics'); - - $dimensionGroups = array_chunk($dimensionScores, 2, true); - - foreach ($dimensionGroups as $group) { - $this->renderDimensionRow($group); - $this->outputHelper->writeEmptyLine(); - } - - $this->outputHelper->writeEmptyLine(); - } - - /** - * @param array $dimensionScores - */ - public function renderImprovedDimensionCards(array $dimensionScores): void + public function renderImprovedDimensionCards(array $dimensionScores, ?array $overall = null): void { $dimensionGroups = array_chunk($dimensionScores, 3, true); foreach ($dimensionGroups as $i => $group) { @@ -305,6 +114,16 @@ public function renderImprovedDimensionCards(array $dimensionScores): void $this->output->writeln(''); } } + + if (null !== $overall) { + $this->output->writeln(''); + + $score = (int) ($overall['score'] ?? 0); + $grade = (string) ($overall['grade'] ?? $this->scoreToGrade($score)); + $color = $this->getScoreColor($score); + $text = sprintf('Final Grade: %s (%d/100)', $color, $grade, $score, $color); + $this->output->writeln($this->centerFaceLine(' '.$text)); + } } /** @@ -329,7 +148,7 @@ private function renderImprovedDimensionRow(array $dimensions, bool $showMini = $contentLine = ' '; foreach ($dimensions as $name => $data) { $score = $data['score'] ?? 0; - $grade = $this->scoreToGrade($score); + $grade = $data['grade'] ?? $this->scoreToGrade($score); $dotsIndicator = $this->getDotsIndicator($score); $coloredGrade = $this->getColoredGradeWithScore($grade, $score); @@ -397,25 +216,11 @@ private function getDotsIndicator(int $score): string $color = $this->getScoreColor($score); $filled = str_repeat(" ", $filledDots); - $empty = str_repeat(' ', $emptyDots); + $empty = str_repeat(' ', $emptyDots); return trim($filled.$empty); } - private function getColoredGrade(string $grade): string - { - $color = match ($grade) { - 'A' => 'green', - 'B' => 'cyan', - 'C' => 'yellow', - 'D' => 'magenta', - 'E' => 'red', - default => 'white', - }; - - return "{$grade}"; - } - private function getColoredGradeWithScore(string $grade, int $score): string { $color = $this->getScoreColor($score); @@ -437,51 +242,6 @@ private function getScoreColor(int $score): string }; } - /** - * @param array $dimensions - */ - private function renderDimensionRow(array $dimensions): void - { - $boxWidth = 38; - $spacing = 4; - - $topLine = ''; - foreach ($dimensions as $name => $data) { - $topLine .= '╭'.str_repeat('─', $boxWidth - 2).'╮'; - if (count($dimensions) > 1 && $name !== array_key_last($dimensions)) { - $topLine .= str_repeat(' ', $spacing); - } - } - $this->output->writeln(' '.$topLine); - - $contentLine = ''; - foreach ($dimensions as $name => $data) { - $score = $data['score'] ?? 0; - $grade = $this->scoreToGrade($score); - $ledIndicator = $this->getLedIndicator($grade); - - $maxNameLength = $boxWidth - 12; - $displayName = strlen($name) > $maxNameLength ? substr($name, 0, $maxNameLength - 3).'...' : $name; - - $content = sprintf('│ %-*s %s %s │', $maxNameLength, $displayName, $ledIndicator, $grade); - $contentLine .= $content; - - if (count($dimensions) > 1 && $name !== array_key_last($dimensions)) { - $contentLine .= str_repeat(' ', $spacing); - } - } - $this->output->writeln(' '.$contentLine); - - $bottomLine = ''; - foreach ($dimensions as $name => $data) { - $bottomLine .= '╰'.str_repeat('─', $boxWidth - 2).'╯'; - if (count($dimensions) > 1 && $name !== array_key_last($dimensions)) { - $bottomLine .= str_repeat(' ', $spacing); - } - } - $this->output->writeln(' '.$bottomLine); - } - private function scoreToGrade(int $score): string { return match (true) { @@ -493,18 +253,6 @@ private function scoreToGrade(int $score): string }; } - private function getLedIndicator(string $grade): string - { - return match ($grade) { - 'A' => '●●●●', - 'B' => '●●●', - 'C' => '●●○○', - 'D' => '○○○', - 'E' => '○○○○', - default => '○○○○', - }; - } - /** * Renders the 6 dimensional analysis blocks. */ @@ -512,701 +260,26 @@ public function renderDimensionalAnalysisBlocks(): void { $this->output->writeln(''); $this->output->writeln(''); + $slug = $this->activeDimension; + $factory = new DimensionRendererFactory(); - $this->renderTemplatesDimensionBlock(); - $this->renderDirectoryTreeForDimension('Template Files'); - $this->output->writeln(''); - - $this->renderReadabilityDimensionBlock(); - $this->renderDirectoryTreeForDimension('Code Style'); - - $this->renderDirectoryTreeForDimension('Code Quality'); - $this->output->writeln(''); + if (null === $slug) { + $dimensions = ['template-files', 'code-style', 'callables', 'architecture', 'complexity', 'maintainability']; - $this->renderCallablesDimensionBlock(); - $this->renderDirectoryTreeForDimension('Twig Callables'); - $this->output->writeln(''); - - $this->renderArchitectureDimensionBlock(); - $this->renderDirectoryTreeForDimension('Architecture'); - $this->output->writeln(''); - - $this->renderComplexityDimensionBlock(); - $this->output->writeln(''); - - $this->renderMaintainabilityDimensionBlock(); - $this->output->writeln(''); - } - - private function renderTemplatesDimensionBlock(): void - { - [ - 'Templates' => $templates, - 'Directories' => $dirs, - 'Total Lines' => $totalLines, - 'Avg Lines/Template' => $avgLines, - 'Characters' => $chars, - 'Chars/Template' => $charsPerTpl, - 'Dir Depth Avg' => $dirDepthAvg, - 'File Size CV %' => $fileSizeCv, - ] = $this->computeTemplatesStats(); - - $this->dimensionBlock('Template Files', [ - 'Templates' => number_format($templates), - 'Directories' => number_format($dirs), - 'Total Lines' => number_format($totalLines), - 'Avg Lines/Template' => number_format($avgLines, 1), - 'Characters' => $this->formatHuman($chars), - 'Chars/Template' => number_format((float) $charsPerTpl, 0), - 'Dir Depth Avg' => number_format((float) $dirDepthAvg, 1), - 'File Size CV %' => number_format((float) $fileSizeCv, 1).'%', - ]); - } - - private function renderReadabilityDimensionBlock(): void - { - [ - 'avgLineLen' => $avgLineLen, - 'commentRatio' => $commentRatio, - 'trailingPerLine' => $trailingPerLine, - 'emptyPct' => $emptyPct, - 'formatScore' => $formatScore, - 'commentsPerTpl' => $commentsPerTpl, - 'indentConsistency' => $indentConsistency, - 'namingScore' => $namingScore, - ] = $this->computeReadabilityStats(); - - $this->dimensionBlock('Code Style', [ - 'Avg Line Length' => number_format($avgLineLen, 1), - 'Comments/Template' => number_format($commentsPerTpl, 1), - 'Comment Ratio' => number_format($commentRatio, 1).'%', - 'Trail Spaces/Line' => number_format($trailingPerLine, 2), - 'Empty Lines %' => number_format($emptyPct, 1).'%', - 'Formatting Score' => number_format($formatScore, 1), - 'Indent Consistency %' => number_format((float) $indentConsistency, 1).'%', - 'Naming Conv. Score %' => number_format((float) $namingScore, 1).'%', - ]); - } - - private function renderComplexityDimensionBlock(): void - { - [ - 'avgCx' => $avgCx, - 'maxCx' => $maxCx, - 'avgDepth' => $avgDepth, - 'maxDepth' => $maxDepth, - 'ifsPerTpl' => $ifsPerTpl, - 'forsPerTpl' => $forsPerTpl, - 'nestedControlDepth' => $nestedControlDepth, - ] = $this->computeComplexityStats(); - - $this->dimensionBlock('Logical Complexity', [ - 'Avg Complexity' => number_format($avgCx, 1), - 'Max Complexity' => (string) $maxCx, - 'Avg Depth' => number_format($avgDepth, 1), - 'Max Depth' => (string) $maxDepth, - 'IFs/Template' => number_format($ifsPerTpl, 1), - 'FORs/Template' => number_format($forsPerTpl, 1), - 'Nested Control Depth' => (string) $nestedControlDepth, - ]); - - $this->renderComplexityTop5List(5); - } - - private function renderArchitectureDimensionBlock(): void - { - [ - 'avgExt' => $avgExt, - 'avgInc' => $avgInc, - 'avgEmb' => $avgEmb, - 'avgImp' => $avgImp, - 'inheritDepth' => $inheritDepth, - 'standalone' => $standalone, - ] = $this->computeArchitectureStats(); - - $this->dimensionBlock('Architecture', [ - 'Extends/Template' => number_format($avgExt, 2), - 'Includes/Template' => number_format($avgInc, 2), - 'Embeds/Template' => number_format($avgEmb, 2), - 'Imports/Template' => number_format($avgImp, 2), - 'Avg Inherit Depth' => number_format($inheritDepth, 1), - 'Standalone Files' => (string) $standalone, - ]); - } - - private function renderCallablesDimensionBlock(): void - { - [ - 'funcsPerTpl' => $funcsPerTpl, - 'filtersPerTpl' => $filtersPerTpl, - 'varsPerTpl' => $varsPerTpl, - 'uniqueFns' => $uniqueFns, - 'uniqueFilters' => $uniqueFilters, - 'macrosDefined' => $macrosDefined, - ] = $this->computeCallablesStats(); - - $this->dimensionBlock('Twig Callables', [ - 'Funcs/Template' => number_format($funcsPerTpl, 1), - 'Filters/Template' => number_format($filtersPerTpl, 1), - 'Vars/Template' => number_format($varsPerTpl, 1), - 'Unique Funcs' => (string) $uniqueFns, - 'Unique Filters' => (string) $uniqueFilters, - 'Macros Defined' => (string) $macrosDefined, - ]); - } - - private function renderMaintainabilityDimensionBlock(): void - { - [ - 'largeTemplates' => $largeTemplates, - 'highCx' => $highCx, - 'deepNesting' => $deepNesting, - 'emptyTemplates' => $emptyTemplates, - 'standalone' => $standalone, - ] = $this->computeMaintainabilityStats(); - - $this->dimensionBlock('Maintainability', [ - 'Large Templates (>200L)' => (string) $largeTemplates, - 'High Complexity (>20)' => (string) $highCx, - 'Deep Nesting (>5)' => (string) $deepNesting, - 'Empty Templates' => (string) $emptyTemplates, - 'Standalone' => (string) $standalone, - 'Risk Score' => $this->scoreToGrade((int) round($this->calculateOverallScoreEstimate())), - ]); - - $this->renderMaintainabilityTopWorstLists(5); - } - - private function renderComplexityTop5List(int $limit = 5): void - { - if (empty($this->globalResults)) { - return; - } - $complex = []; - $depths = []; - foreach ($this->globalResults as $res) { - $name = basename($res->getRelativePath()); - $complex[] = ['name' => $name, 'val' => (int) ($res->getMetric('complexity_score') ?? 0)]; - $depths[] = ['name' => $name, 'val' => (int) ($res->getMetric('max_depth') ?? 0)]; - } - usort($complex, fn ($a, $b) => $b['val'] <=> $a['val']); - usort($depths, fn ($a, $b) => $b['val'] <=> $a['val']); - $complex = array_slice($complex, 0, $limit); - $depths = array_slice($depths, 0, $limit); - $maxDepthVal = 1; - foreach ($depths as $d) { - $maxDepthVal = max($maxDepthVal, (int) $d['val']); - } - - $this->output->writeln(''); - $dual = new DualOutputHelper($this->output, spacing: 4, totalWidth: 78); - $l = $dual->getLeftCol(); - $r = $dual->getRightCol(); - $l->writeln(' Top Complexity'); - $r->writeln(' Top Depth'); - - foreach ($complex as $row) { - $l->writeln(sprintf(' %-24s %3d', mb_strimwidth($row['name'], 0, 24, '…'), $row['val'])); - } - foreach ($depths as $row) { - $bar = $this->barWithPartials((int) $row['val'], (int) $maxDepthVal, 10); - $r->writeln(sprintf(' %-24s %3d %s', mb_strimwidth($row['name'], 0, 24, '…'), $row['val'], $bar)); - } - $dual->render(); - } - - private function renderMaintainabilityTopWorstLists(int $limit = 5): void - { - if (empty($this->globalResults)) { - return; - } - $rows = []; - foreach ($this->globalResults as $res) { - $lines = (int) ($res->getMetric('lines') ?? 0); - $cx = (int) ($res->getMetric('complexity_score') ?? 0); - $depth = (int) ($res->getMetric('max_depth') ?? 0); - $score = max(0.0, 100.0 - ( - min(1.0, $cx / 20.0) * 40.0 + - min(1.0, $depth / 5.0) * 25.0 + - min(1.0, $lines / 200.0) * 20.0 - )); - $grade = $this->getColoredGrade($this->scoreToGrade((int) round($score))); - $rows[] = [ - 'name' => basename($res->getRelativePath()), - 'score' => (int) round($score), - 'grade' => $grade, - ]; - } - usort($rows, fn ($a, $b) => $b['score'] <=> $a['score']); - $best = array_slice($rows, 0, $limit); - $worst = array_slice(array_reverse($rows), 0, $limit); - - $this->output->writeln(''); - $dual = new DualOutputHelper($this->output, spacing: 4, totalWidth: 78); - $left = $dual->getLeftCol(); - $right = $dual->getRightCol(); - $left->writeln(' Top 5 Maintainability'); - $right->writeln(' Worst 5 Maintainability'); - - foreach ($best as $r) { - $left->writeln(sprintf(' %-24s %3d %s', mb_strimwidth($r['name'], 0, 24, '…'), $r['score'], $r['grade'])); - } - foreach ($worst as $r) { - $right->writeln(sprintf(' %-24s %3d %s', mb_strimwidth($r['name'], 0, 24, '…'), $r['score'], $r['grade'])); - } - $dual->render(); - } - - /** - * @param array $values - */ - /** - * @param array $values - */ - private function dimensionBlock(string $title, array $values, int $colWidth = 32): void - { - $borderColor = '#ddd'; - $dotColor = 'gray'; - ++$this->dimensionBlockIndex; - $titleColor = (1 === $this->dimensionBlockIndex % 2) ? '#f1c40f' : '#2ecc71'; - - $boxWidth = 78; - $insideWidth = $boxWidth - 2; - - $titleSeg = sprintf('─ %s ', $titleColor, $title); - $titleFill = str_repeat('─', max(0, $insideWidth - $this->getTextLengthWithoutAnsi($titleSeg))); - $top = sprintf('╭%s%s╮', $borderColor, $titleSeg, $titleFill); - $bottom = sprintf('╰%s╯', $borderColor, str_repeat('─', $insideWidth)); - $empty = sprintf('│%s│', $borderColor, str_repeat(' ', $insideWidth)); - - $this->output->writeln(' '.$top); - $this->output->writeln(' '.$empty); - - $rows = array_chunk($values, 2, true); - foreach ($rows as $pair) { - $line = ''; - $first = true; - foreach ($pair as $k => $v) { - $key = (string) $k; - $val = (string) $v; - $visibleKeyLen = mb_strlen($key); - $visibleValLen = $this->getTextLengthWithoutAnsi($val); - $dotsCount = max(1, $colWidth - $visibleKeyLen - $visibleValLen - 3); - $dots = ''.str_repeat('.', $dotsCount).''; - $column = sprintf('%s %s %s', $key, $dots, $val); - if (!$first) { - $line .= str_repeat(' ', 7); - } - $line .= $column; - $first = false; + foreach ($dimensions as $dimension) { + $presenter = $factory->createPresenter($dimension); + $renderer = $factory->createRenderer($dimension, $this->output); + $data = $presenter->present($this->globalResults, $this->maxDepth, $this->maxDepth > 0); + $renderer->render($data); + $this->output->writeln(''); } - $visibleLen = $this->getTextLengthWithoutAnsi($line); - $contentLen = 1 + $visibleLen + 1; - $padLen = max(0, $insideWidth - $contentLen); - $pad = str_repeat(' ', $padLen); - $this->output->writeln(' '.sprintf('│ %s%s │', $borderColor, $line, $pad, $borderColor)); - } - - $this->output->writeln(' '.$empty); - $this->output->writeln(' '.$bottom); - } - - private function renderDirectoryTreeForDimension(string $dimensionTitle): void - { - if (empty($this->globalResults)) { - return; - } - - $maxDepth = $this->maxDepth; - - switch ($dimensionTitle) { - case 'Template Files': - $this->directoryTreeRenderer->renderDirectoryStackedBars( - $this->globalResults, - [ - 'lines' => 'Lines', - 'chars' => 'Characters', - 'nodes' => 'Nodes', - ], - 'Template Files by Directory', - $maxDepth, - 35 - ); - break; - - case 'Code Style': - $this->directoryTreeRenderer->renderDirectoryDetailedTable( - $this->globalResults, - [ - 'lines' => 'Lines', - 'avg_line_length' => 'File', - 'chars' => 'Size', - ], - 'Code Style Metrics by Directory', - $maxDepth - ); - break; - - case 'Code Quality': - $this->directoryTreeRenderer->renderDirectoryDualProgressBars( - $this->globalResults, - [ - 'avg_line_length' => 'Readability', - 'comments_count' => 'Documentation', - ], - 'Code Quality by Directory', - $maxDepth, - 100 - ); - break; - - case 'Twig Callables': - $this->directoryTreeRenderer->renderDirectoryHeatmapTree( - $this->globalResults, - [ - 'functions' => 'func', - 'filters' => 'filt', - 'variables' => 'vars', - 'macros' => 'macr', - ], - 'Twig Callables Usage by Directory', - $maxDepth - ); - - $this->renderCallableTopsForDimension(); - break; - - case 'Architecture': - $this->directoryTreeRenderer->renderDirectoryMetricsTable( - $this->globalResults, - [ - 'extends' => 'ext', - 'includes' => 'inc', - 'embeds' => 'emb', - 'blocks' => 'blk', - ], - $maxDepth - ); - break; - - case 'Logical Complexity': - $this->directoryTreeRenderer->renderDirectoryCleanLines( - $this->globalResults, - 'complexity', - 'Complexity Distribution', - $maxDepth - ); - break; - - case 'Maintainability': - $this->directoryTreeRenderer->renderDirectoryMetricsTable( - $this->globalResults, - [ - 'lines' => 'ext', - 'complexity' => 'inc', - 'max_depth' => 'emb', - 'blocks' => 'blk', - ], - $maxDepth - ); - break; - } - } - - private function renderCallableTopsForDimension(): void - { - if (!$this->currentReport) { return; } - $sections = $this->currentReport->getSections(); - $overview = []; - - foreach ($sections as $section) { - if ('callables_dir_chart' === $section->getType()) { - $data = $section->getData(); - $overview = $data['overview'] ?? []; - break; - } - } - - if (!empty($overview)) { - $this->renderCallableTops($overview); - } - } - - /** - * @return array - */ - private function computeTemplatesStats(): array - { - $count = count($this->globalResults); - $totalLines = 0; - $totalChars = 0; - $dirs = []; - $depthSum = 0.0; - $lineVals = []; - foreach ($this->globalResults as $res) { - $l = (int) ($res->getMetric('lines') ?? 0); - $c = (int) ($res->getMetric('chars') ?? 0); - $totalLines += $l; - $totalChars += $c; - $path = $res->getRelativePath(); - $parts = explode('/', $path, 2); - $dir = $parts[1] ?? false ? $parts[0] : '(root)'; - $dirs[$dir] = true; - $depthSum += max(0, substr_count($path, '/')); - $lineVals[] = $l; - } - $avgLines = $count > 0 ? $totalLines / $count : 0.0; - $charsPerTpl = $count > 0 ? $totalChars / $count : 0.0; - $dirDepthAvg = $count > 0 ? $depthSum / $count : 0.0; - $fileSizeCv = 0.0; - if ($count > 0 && $avgLines > 0) { - $mean = $avgLines; - $sumSq = 0.0; - foreach ($lineVals as $v) { - $sumSq += ($v - $mean) * ($v - $mean); - } - $std = sqrt($sumSq / $count); - $fileSizeCv = ($std / $mean) * 100.0; - } - - return [ - 'Templates' => $count, - 'Directories' => count($dirs), - 'Total Lines' => $totalLines, - 'Avg Lines/Template' => $avgLines, - 'Characters' => $totalChars, - 'Chars/Template' => $charsPerTpl, - 'Dir Depth Avg' => $dirDepthAvg, - 'File Size CV %' => $fileSizeCv, - ]; - } - - /** - * @return array - */ - private function computeReadabilityStats(): array - { - $count = max(1, count($this->globalResults)); - $sumAvgLine = 0.0; - $sumComments = 0; - $sumLines = 0; - $sumTrailing = 0; - $sumBlank = 0; - $sumFormat = 0.0; - $sumMixedIndent = 0; - $sumBlockNaming = 0.0; - $sumVarNaming = 0.0; - $resultsCount = 0; - foreach ($this->globalResults as $res) { - $sumAvgLine += (float) ($res->getMetric('avg_line_length') ?? 0.0); - $sumComments += (int) ($res->getMetric('comment_lines') ?? 0); - $sumLines += (int) ($res->getMetric('lines') ?? 0); - $sumTrailing += (int) ($res->getMetric('trailing_spaces') ?? 0); - $sumBlank += (int) ($res->getMetric('blank_lines') ?? 0); - $sumFormat += (float) ($res->getMetric('formatting_consistency_score') ?? 100.0); - $sumMixedIndent += (int) ($res->getMetric('mixed_indentation_lines') ?? 0); - $sumBlockNaming += (float) ($res->getMetric('block_naming_consistency') ?? 100.0); - $sumVarNaming += (float) ($res->getMetric('variable_naming_consistency') ?? 100.0); - ++$resultsCount; - } - $avgLineLen = $sumAvgLine / $count; - $commentsPerTpl = $sumComments / $count; - $commentRatio = $sumLines > 0 ? ($sumComments / $sumLines) * 100 : 0.0; - $trailingPerLine = $sumLines > 0 ? $sumTrailing / $sumLines : 0.0; - $emptyPct = $sumLines > 0 ? ($sumBlank / $sumLines) * 100 : 0.0; - $formatScore = $sumFormat / $count; - $indentConsistency = $sumLines > 0 ? max(0.0, 100.0 - ($sumMixedIndent / $sumLines) * 100.0) : 100.0; - $namingScore = $resultsCount > 0 ? (($sumBlockNaming / $resultsCount) + ($sumVarNaming / $resultsCount)) / 2.0 : 100.0; - - return compact('avgLineLen', 'commentRatio', 'trailingPerLine', 'emptyPct', 'formatScore', 'commentsPerTpl', 'indentConsistency', 'namingScore'); - } - - /** - * @return array - */ - private function computeComplexityStats(): array - { - $count = max(1, count($this->globalResults)); - $sumCx = 0.0; - $maxCx = 0; - $sumDepth = 0.0; - $maxDepth = 0; - $sumIfs = 0.0; - $sumFors = 0.0; - foreach ($this->globalResults as $res) { - $cx = (int) ($res->getMetric('complexity_score') ?? 0); - $sumCx += $cx; - $maxCx = max($maxCx, $cx); - $d = (int) ($res->getMetric('max_depth') ?? 0); - $sumDepth += $d; - $maxDepth = max($maxDepth, $d); - $sumIfs += (int) ($res->getMetric('ifs') ?? 0); - $sumFors += (int) ($res->getMetric('fors') ?? 0); - } - $avgCx = $sumCx / $count; - $avgDepth = $sumDepth / $count; - $ifsPerTpl = $sumIfs / $count; - $forsPerTpl = $sumFors / $count; - $nestedControlDepth = $maxDepth; - - return compact('avgCx', 'maxCx', 'avgDepth', 'maxDepth', 'ifsPerTpl', 'forsPerTpl', 'nestedControlDepth'); - } - - /** - * @return array - */ - private function computeArchitectureStats(): array - { - $count = max(1, count($this->globalResults)); - $sumExt = 0; - $sumInc = 0; - $sumEmb = 0; - $sumImp = 0; - $sumInherit = 0; - $standalone = 0; - foreach ($this->globalResults as $res) { - $deps = $res->getMetric('dependency_types') ?? []; - if (!is_array($deps)) { - $deps = []; - } - $ext = (int) ($deps['extends'] ?? 0); - $inc = (int) (($deps['includes'] ?? 0) + ($deps['includes_function'] ?? 0)); - $emb = (int) ($deps['embeds'] ?? 0); - $imp = (int) ($deps['imports'] ?? 0); - $sumExt += $ext; - $sumInc += $inc; - $sumEmb += $emb; - $sumImp += $imp; - $sumInherit += (int) ($res->getMetric('inheritance_depth') ?? 0); - if (($ext + $inc + $emb + $imp) === 0) { - ++$standalone; - } - } - $avgExt = $sumExt / $count; - $avgInc = $sumInc / $count; - $avgEmb = $sumEmb / $count; - $avgImp = $sumImp / $count; - $inheritDepth = $sumInherit / $count; - - return compact('avgExt', 'avgInc', 'avgEmb', 'avgImp', 'inheritDepth', 'standalone'); - } - - /** - * @return array - */ - private function computeCallablesStats(): array - { - $count = max(1, count($this->globalResults)); - $sumFn = 0; - $sumFl = 0; - $sumVar = 0; - $macrosDefined = 0; - $uniqueFns = []; - $uniqueFilters = []; - foreach ($this->globalResults as $res) { - $fns = $res->getMetric('functions_detail') ?? []; - if (is_array($fns)) { - $sumFn += array_sum($fns); - foreach ($fns as $name => $_) { - $uniqueFns[$name] = true; - } - } - $fls = $res->getMetric('filters_detail') ?? []; - if (is_array($fls)) { - $sumFl += array_sum($fls); - foreach ($fls as $name => $_) { - $uniqueFilters[$name] = true; - } - } - $vars = $res->getMetric('variables_detail') ?? []; - if (is_array($vars)) { - $sumVar += array_sum($vars); - } - $macDefs = $res->getMetric('macro_definitions_detail') ?? []; - if (is_array($macDefs)) { - $macrosDefined += count($macDefs); - } - } - $funcsPerTpl = $sumFn / $count; - $filtersPerTpl = $sumFl / $count; - $varsPerTpl = $sumVar / $count; - - return [ - 'funcsPerTpl' => $funcsPerTpl, - 'filtersPerTpl' => $filtersPerTpl, - 'varsPerTpl' => $varsPerTpl, - 'uniqueFns' => count($uniqueFns), - 'uniqueFilters' => count($uniqueFilters), - 'macrosDefined' => $macrosDefined, - ]; - } - - /** - * @return array - */ - private function computeMaintainabilityStats(): array - { - $largeTemplates = 0; - $highCx = 0; - $deepNesting = 0; - $emptyTemplates = 0; - $standalone = 0; - foreach ($this->globalResults as $res) { - $lines = (int) ($res->getMetric('lines') ?? 0); - $cx = (int) ($res->getMetric('complexity_score') ?? 0); - $depth = (int) ($res->getMetric('max_depth') ?? 0); - $deps = $res->getMetric('dependency_types') ?? []; - if (!is_array($deps)) { - $deps = []; - } - $depSum = (int) ($deps['extends'] ?? 0) + (int) ($deps['includes'] ?? 0) + (int) ($deps['includes_function'] ?? 0) + (int) ($deps['embeds'] ?? 0) + (int) ($deps['imports'] ?? 0); - if ($lines > 200) { - ++$largeTemplates; - } - if ($cx > 20) { - ++$highCx; - } - if ($depth > 5) { - ++$deepNesting; - } - if (0 === $lines) { - ++$emptyTemplates; - } - if (0 === $depSum) { - ++$standalone; - } - } - - return compact('largeTemplates', 'highCx', 'deepNesting', 'emptyTemplates', 'standalone'); - } - - private function formatHuman(int|float $n): string - { - $n = (float) $n; - if ($n >= 1_000_000) { - return number_format($n / 1_000_000, 1).'M'; - } - if ($n >= 1_000) { - return number_format($n / 1_000, 1).'k'; - } - - return (string) ((int) $n); - } - - private function calculateOverallScoreEstimate(): float - { - $total = max(1, count($this->globalResults)); - $m = $this->computeMaintainabilityStats(); - $penalty = 0.0; - $penalty += ($m['highCx'] / $total) * 40.0; - $penalty += ($m['deepNesting'] / $total) * 25.0; - $penalty += ($m['largeTemplates'] / $total) * 20.0; - $score = max(0.0, 100.0 - $penalty); - - return $score; + $presenter = $factory->createPresenter($slug); + $renderer = $factory->createRenderer($slug, $this->output); + $data = $presenter->present($this->globalResults, $this->maxDepth, $this->maxDepth > 0); + $renderer->render($data); } } diff --git a/src/Renderer/Dimension/Box/AbstractDimensionBoxRenderer.php b/src/Renderer/Dimension/Box/AbstractDimensionBoxRenderer.php new file mode 100644 index 0000000..f91361b --- /dev/null +++ b/src/Renderer/Dimension/Box/AbstractDimensionBoxRenderer.php @@ -0,0 +1,331 @@ + + */ +abstract class AbstractDimensionBoxRenderer +{ + protected int $boxWidth = 68; + + public function __construct(protected readonly OutputInterface $output) + { + } + + /** + * Render the dimension box with provided data. + * + * @param array $data + */ + abstract public function render(array $data): void; + + final protected function header(string $title): void + { + $titleLength = mb_strlen($title); + $borderWidth = $this->boxWidth + 6; + $availableSpace = $borderWidth - 2; + + if ($titleLength + 2 > $availableSpace) { + $this->output->writeln(' ╭'.str_repeat('─', $borderWidth).'╮ '); + $this->contentLine(str_pad(''.$title.'', $this->boxWidth, ' ', STR_PAD_BOTH)); + } else { + $remainingSpace = $availableSpace - $titleLength; + $leftDashes = (int) floor($remainingSpace / 2); + $rightDashes = $remainingSpace - $leftDashes; + + $headerLine = '╭'.str_repeat('─', $leftDashes).' ' + .''.$title.' ' + .''.str_repeat('─', $rightDashes).'╮'; + $this->output->writeln(' '.$headerLine.' '); + } + + $this->blank(); + } + + final protected function footer(): void + { + $borderWidth = $this->boxWidth + 6; + $this->output->writeln(' ╰'.str_repeat('─', $borderWidth).'╯ '); + } + + final protected function divider(string $label): void + { + $this->blank(); + + $labelLength = mb_strlen($label); + $availableSpace = $this->boxWidth - 2; + + $remainingSpace = $availableSpace - $labelLength; + $leftDashes = (int) floor($remainingSpace / 2); + $rightDashes = $remainingSpace - $leftDashes; + + $line = ''.str_repeat('─', $leftDashes + 2).' ' + .''.$label.' ' + .' '.str_repeat('─', $rightDashes + 2).''; + + $this->output->writeln(' │'.$line.'│ '); + } + + final protected function blank(): void + { + $this->contentLine(''); + } + + final protected function twoCols(string $l, string $lv, string $r, string $rv, bool $raw = false): void + { + $left = sprintf('%-20s %10s', $this->trim($l, 20), $lv); + + $right = sprintf('%-20s %10s', $this->trim($r, 20), $rv); + + $left = $this->highlightRightAlignedValue($left); + $right = $this->highlightRightAlignedValue($right); + $content = $this->padVisual($left, 31).' '.$this->padVisual($right, 31); + $this->contentLine($content); + } + + private function highlightRightAlignedValue(string $col): string + { + return preg_replace('/(\s*)(\S+)$/u', '$1$2', $col, 1) ?? $col; + } + + protected function padVisual(string $text, int $width): string + { + $visual = $this->getVisualLength($text); + $pad = max(0, $width - $visual); + + return $text.str_repeat(' ', $pad); + } + + final protected function gauge(float $score): string + { + $width = 20; + $filled = (int) round(max(0.0, min(100.0, $score)) / 5); + $empty = $width - $filled; + + $color = $this->getScoreColor((int) round($score)); + + $filledBar = str_repeat('█', $filled); + $emptyBar = str_repeat('█', $empty); + + return "{$filledBar}{$emptyBar}"; + } + + /** + * Get color based on score thresholds. + */ + private function getScoreColor(int $score): string + { + return match (true) { + $score >= 90 => 'green', + $score >= 80 => 'cyan', + $score >= 70 => 'yellow', + $score >= 60 => 'magenta', + default => 'red', + }; + } + + /** + * Calculate visual length of string excluding color tags. + */ + protected function getVisualLength(string $text): int + { + $stripped = preg_replace('/\e\[[0-9;]*m/', '', $text) ?? ''; + $stripped = preg_replace('/<[^>]+>/', '', $stripped) ?? $stripped; + + return mb_strlen($stripped); + } + + /** + * Trim string to visual width while preserving color tags. + */ + protected function trimWithColorTags(string $text, int $maxWidth): string + { + $visualLength = $this->getVisualLength($text); + if ($visualLength <= $maxWidth) { + return $text; + } + $tokens = preg_split('/(<[^>]+>)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$text]; + $result = ''; + $current = 0; + $open = 0; + + foreach ($tokens as $token) { + if (preg_match('/^<[^>]+>$/', $token)) { + if ('' === $token || str_starts_with($token, ' 0) { + --$open; + } + $result .= $token; + } else { + ++$open; + $result .= $token; + } + continue; + } + + $len = mb_strlen($token); + $remain = $maxWidth - $current; + if ($len <= $remain) { + $result .= $token; + $current += $len; + continue; + } + + if ($remain > 1) { + $result .= mb_substr($token, 0, $remain - 1).'…'; + } + + $result .= str_repeat('', $open); + + return $result; + } + + return $result; + } + + protected function truncateWithColorTags(string $text, int $maxWidth): string + { + $visualLength = $this->getVisualLength($text); + if ($visualLength <= $maxWidth) { + return $text; + } + + $tokens = preg_split('/(<[^>]+>)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$text]; + $result = ''; + $current = 0; + $open = 0; + + foreach ($tokens as $token) { + if (preg_match('/^<[^>]+>$/', $token)) { + if ('' === $token || str_starts_with($token, ' 0) { + --$open; + } + $result .= $token; + } else { + ++$open; + $result .= $token; + } + continue; + } + + $len = mb_strlen($token); + $remain = $maxWidth - $current; + if ($len <= $remain) { + $result .= $token; + $current += $len; + continue; + } + + if ($remain > 0) { + $result .= mb_substr($token, 0, $remain); + } + + $result .= str_repeat('', $open); + + return $result; + } + + return $result; + } + + final protected function contentLine(string $content): void + { + $visualLength = $this->getVisualLength($content); + if ($visualLength > $this->boxWidth) { + $content = $this->truncateWithColorTags($content, $this->boxWidth); + } + + $currentVisualLength = $this->getVisualLength($content); + $paddingNeeded = max(0, $this->boxWidth - $currentVisualLength); + $content = $content.str_repeat(' ', $paddingNeeded); + + $padded = ' '.$content.' '; + $this->output->writeln(' │'.$padded.'│ '); + } + + final protected function f(float|int $n, int $dec = 1): string + { + return is_int($n) ? (string) $n : number_format((float) $n, $dec); + } + + final protected function i(int|float $n): string + { + return number_format((int) $n); + } + + final protected function trim(string $s, int $w): string + { + if (mb_strlen($s) <= $w) { + return $s; + } + + return rtrim(mb_substr($s, 0, max(0, $w - 1))).'…'; + } + + protected function formatDirectoryPath(string $path): string + { + return $path.'/'; + } + + final protected function renderAnalysisFooter(float $score, string $grade): void + { + $this->divider('Analysis'); + $this->blank(); + + $scoreText = sprintf('%.0f/100', $this->gradeFg($grade), $score); + $gauge = $this->gauge($score); + + $gradeText = sprintf('Grade: %s', $this->gradeFg($grade), $grade); + + $analysisContent = sprintf('%s %s %s', $scoreText, $gauge, $gradeText); + + $visualLength = $this->getVisualLength($analysisContent); + $totalPadding = max(0, $this->boxWidth - $visualLength); + $leftPadding = (int) floor($totalPadding / 2); + $rightPadding = $totalPadding - $leftPadding; + + $analysisLine = str_repeat(' ', $leftPadding).$analysisContent.str_repeat(' ', $rightPadding); + $this->contentLine($analysisLine); + + $this->blank(); + } + + protected function riskColor(string $risk): string + { + $label = strtoupper($risk); + + return match ($risk) { + 'low' => ''.$label.'', + 'moderate', 'medium' => ''.$label.'', + 'high' => ''.$label.'', + 'critical' => ''.$label.'', + default => ''.$label.'', + }; + } + + protected function gradeColor(string $grade): string + { + return match ($grade) { + 'A+', 'A' => ''.$grade.'', + 'B' => ''.$grade.'', + 'C+', 'C' => ''.$grade.'', + default => ''.$grade.'', + }; + } + + protected function gradeFg(string $grade): string + { + return match ($grade) { + 'A+', 'A' => 'green', + 'B' => 'cyan', + 'C+', 'C' => 'bright-yellow', + default => 'red', + }; + } +} diff --git a/src/Renderer/Dimension/Box/ArchitectureDimensionBoxRenderer.php b/src/Renderer/Dimension/Box/ArchitectureDimensionBoxRenderer.php new file mode 100644 index 0000000..cd00ede --- /dev/null +++ b/src/Renderer/Dimension/Box/ArchitectureDimensionBoxRenderer.php @@ -0,0 +1,171 @@ + + */ +final class ArchitectureDimensionBoxRenderer extends AbstractDimensionBoxRenderer +{ + private readonly TreeDirectoryFormatter $treeFormatter; + + public function __construct(OutputInterface $output) + { + parent::__construct($output); + $this->treeFormatter = new TreeDirectoryFormatter(); + } + + /** + * @param array $data + */ + public function render(array $data): void + { + $this->header('ARCHITECTURE'); + + $s = $data['summary'] ?? []; + + $this->twoCols('Imports/template', $this->f($s['imports_per_template'] ?? 0, 2), 'Extends/template', $this->f($s['extends_per_template'] ?? 0, 2)); + $this->twoCols('Avg Inherit Depth', $this->f($s['avg_inheritance_depth'] ?? 0, 1), 'Include/template', $this->f($s['includes_per_template'] ?? 0, 2)); + $this->twoCols('Embed (total)', $this->i($s['embeds_total'] ?? 0), 'Embed/template', $this->f($s['embeds_per_template'] ?? 0, 2)); + $this->twoCols('Blocks (total)', $this->i($s['blocks_total'] ?? 0), 'Blocks/template', $this->f($s['blocks_per_template'] ?? 0, 2)); + + $this->divider('Construct'); + $this->blank(); + $ext = $s['extends_total'] ?? 0; + $inc = $s['includes_total'] ?? 0; + $emb = $s['embeds_total'] ?? 0; + $blk = $s['blocks_total'] ?? 0; + $bar = $this->constructBar($inc, $blk, $ext, $emb); + $this->contentLine($bar); + $this->blank(); + $legend = sprintf( + '▒ Ext.: %s █ Inc.: %s ░ Emb.: %s ▓ Block: %s', + str_pad($this->i($ext), 3, ' ', STR_PAD_LEFT), + str_pad($this->i($inc), 3, ' ', STR_PAD_LEFT), + str_pad($this->i($emb), 3, ' ', STR_PAD_LEFT), + str_pad($this->i($blk), 3, ' ', STR_PAD_LEFT) + ); + $this->contentLine($legend); + + if (!empty($data['directories'])) { + $this->divider('Heatmap by directory'); + $this->blank(); + + $header = sprintf('%-31s %-7s %-7s %-7s %-7s', 'Directory', 'Extends', 'Includes', 'Embeds', 'Blocks'); + $this->contentLine(str_pad($header, 68)); + $this->blank(); + + $treeDirectories = $this->treeFormatter->formatAsTree($data['directories']); + + foreach ($treeDirectories as $row) { + $treePath = $this->trim((string) ($row['tree_path'] ?? ''), 31); + $col1 = str_pad($treePath, 31); + + $exBar = $this->truncateWithColorTags(''.$this->heatmapBar((float) ($row['extends_ratio'] ?? 0.0)).'', 7); + $incBar = $this->truncateWithColorTags(''.$this->heatmapBar((float) ($row['includes_ratio'] ?? 0.0)).'', 7); + $embBar = $this->truncateWithColorTags(''.$this->heatmapBar((float) ($row['embeds_ratio'] ?? 0.0)).'', 7); + $blkBar = $this->truncateWithColorTags(''.$this->heatmapBar((float) ($row['blocks_ratio'] ?? 0.0)).'', 7); + + $col2 = sprintf('%-7s %-7s %-7s %-7s', $exBar, $incBar, $embBar, $blkBar); + + $line = sprintf('%-31s %-31s', $col1, $col2); + $this->contentLine($line); + } + } + + if (!empty($data['top_referenced'])) { + $this->divider('Most included templates'); + $this->blank(); + $rank = 1; + foreach (array_slice($data['top_referenced'], 0, 5) as $item) { + $template = $this->trim((string) ($item['template'] ?? ''), 50); + $count = $this->i($item['count'] ?? 0); + $line = sprintf('%d. %-50s %15s', $rank, $template, $count); + $this->contentLine($line); + ++$rank; + } + } + + if (!empty($data['top_blocks'])) { + $this->divider('Most used block names'); + $this->blank(); + $topBlocks = array_slice($data['top_blocks'], 0, 8, true); + $maxCount = max(1, ...array_map(fn ($b) => (int) ($b['count'] ?? 0), $topBlocks)); + + foreach (array_chunk($topBlocks, 2) as $blocks) { + [$leftItem, $rightItem] = $blocks + [1 => null]; + + $name = str_pad((string) $leftItem['name'], 20); + $bar = $this->blockUsageBar((int) $leftItem['count'], $maxCount, 6); + $count = str_pad($this->i($leftItem['count']), 3, ' ', STR_PAD_LEFT); + $leftText = sprintf('%s %s %s', $name, $count, $bar); + + if (null !== $rightItem) { + $name = str_pad((string) $rightItem['name'], 20); + $bar = $this->blockUsageBar((int) $rightItem['count'], $maxCount, 6); + $count = str_pad($this->i($rightItem['count']), 3, ' ', STR_PAD_LEFT); + $rightText = sprintf('%s %s %s', $name, $count, $bar); + } + + $line = sprintf('%-36s %-36s', $leftText, $rightText ?? ''); + $this->contentLine($line); + } + } + + $final = $data['final'] ?? []; + $score = (float) ($final['score'] ?? 0.0); + $grade = (string) ($final['grade'] ?? ''); + $this->renderAnalysisFooter($score, $grade); + $this->footer(); + } + + private function constructBar(int $inc, int $blk, int $ext, int $emb): string + { + $total = $inc + $blk + $ext + $emb; + if (0 === $total) { + return str_repeat('█', 68); + } + + $width = 68; + $incWidth = (int) floor(($inc / $total) * $width); + $blkWidth = (int) floor(($blk / $total) * $width); + $extWidth = (int) floor(($ext / $total) * $width); + $embWidth = max(0, $width - $incWidth - $blkWidth - $extWidth); + + return ''.str_repeat('█', $incWidth).'' + .''.str_repeat('█', $blkWidth).'' + .''.str_repeat('█', $extWidth).'' + .''.str_repeat('█', $embWidth).''; + } + + private function heatmapBar(float $ratio): string + { + $width = 10; + $intensity = (int) round($ratio * $width); + + if ($ratio < 0.1) { + return str_repeat('░', 2); + } elseif ($ratio < 0.4) { + return str_repeat('█', min(3, max(1, $intensity))); + } elseif ($ratio < 0.7) { + return str_repeat('█', min(5, max(4, $intensity))); + } else { + return str_repeat('█', min(8, max(6, $intensity))); + } + } + + private function blockUsageBar(int $count, int $max, int $width): string + { + if (0 === $max) { + return str_repeat('░', $width); + } + + $filled = (int) round(($count / $max) * $width); + + return str_repeat('█', $filled).str_repeat('░', $width - $filled); + } +} diff --git a/src/Renderer/Dimension/Box/ArchitectureDimensionPresenter.php b/src/Renderer/Dimension/Box/ArchitectureDimensionPresenter.php new file mode 100644 index 0000000..24acb09 --- /dev/null +++ b/src/Renderer/Dimension/Box/ArchitectureDimensionPresenter.php @@ -0,0 +1,434 @@ + + */ +final class ArchitectureDimensionPresenter implements DimensionPresenterInterface +{ + public function __construct( + private readonly ArchitectureMetricsReporter $reporter, + private readonly DirectoryMetricsAggregator $dirAgg, + private readonly StatisticalCalculator $stats, + ) { + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function present(array $results, int $maxDepth = 4, bool $includeDirs = true): array + { + $card = $this->reporter->generateMetrics($results); + + $core = $card->coreMetrics; + $detail = $card->detailMetrics; + + $totalTemplates = count($results); + $relationshipCounts = $this->calculateRelationships($results); + + $blockUsage = $this->calculateBlockUsage($results); + $macroUsage = $this->calculateMacroUsage($results); + + $inheritance = $this->calculateInheritanceMetrics($results); + + $dirs = []; + if ($includeDirs && $maxDepth > 0) { + $dirs = $this->calculateDirectoryHeatmap($results, $maxDepth); + } + + $topReferenced = $this->findMostReferencedTemplates($results); + + $topBlockNames = $this->findMostUsedBlockNames($results); + + return [ + 'summary' => [ + 'total_templates' => $totalTemplates, + 'extends_total' => $relationshipCounts['extends_total'], + 'includes_total' => $relationshipCounts['includes_total'], + 'embeds_total' => $relationshipCounts['embeds_total'], + 'blocks_total' => $blockUsage['definitions'], + 'extends_per_template' => $relationshipCounts['extends_per_template'], + 'includes_per_template' => $relationshipCounts['includes_per_template'], + 'embeds_per_template' => $relationshipCounts['embeds_per_template'], + + 'imports_per_template' => $relationshipCounts['imports_per_template'] ?? 0.0, + 'avg_inheritance_depth' => $inheritance['avg_depth'] ?? 0.0, + 'blocks_per_template' => $blockUsage['definitions_per_template'], + ], + 'inheritance' => [ + 'max_depth' => $inheritance['max_depth'], + 'avg_depth' => $inheritance['avg_depth'], + 'root_templates' => $inheritance['root_templates'], + 'orphan_files' => $inheritance['orphan_files'], + ], + 'blocks' => [ + 'definitions' => $blockUsage['definitions'], + 'calls' => $blockUsage['calls'], + 'overrides' => $blockUsage['overrides'], + 'unused' => $blockUsage['unused'], + ], + 'macros' => [ + 'definitions' => $macroUsage['definitions'], + 'calls' => $macroUsage['calls'], + 'external_calls' => $macroUsage['external_calls'], + 'unused' => $macroUsage['unused'], + ], + 'directories' => $dirs, + 'top_referenced' => $topReferenced, + 'top_blocks' => $topBlockNames, + 'inheritance_patterns' => $this->buildInheritanceTree($results), + 'final' => [ + 'score' => (float) $card->score, + 'grade' => (string) $card->grade, + ], + ]; + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function calculateRelationships(array $results): array + { + $extends = 0; + $includes = 0; + $embeds = 0; + $uses = 0; + $imports = 0; + + foreach ($results as $result) { + $deps = $result->getMetric('dependency_types') ?? []; + if (is_array($deps)) { + $extends += (int) ($deps['extends'] ?? 0); + $includes += (int) ($deps['includes'] ?? 0) + (int) ($deps['includes_function'] ?? 0); + $embeds += (int) ($deps['embeds'] ?? 0); + $uses += (int) ($deps['uses'] ?? 0); + $imports += (int) ($deps['imports'] ?? 0); + } + } + + $total = count($results); + + return [ + 'extends_total' => $extends, + 'includes_total' => $includes, + 'embeds_total' => $embeds, + 'uses_total' => $uses, + 'imports_total' => $imports, + 'extends_per_template' => $total > 0 ? round($extends / $total, 2) : 0.0, + 'includes_per_template' => $total > 0 ? round($includes / $total, 2) : 0.0, + 'embeds_per_template' => $total > 0 ? round($embeds / $total, 2) : 0.0, + 'imports_per_template' => $total > 0 ? round($imports / $total, 2) : 0.0, + ]; + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function calculateBlockUsage(array $results): array + { + $definitions = 0; + $calls = 0; + $overrides = 0; + + foreach ($results as $result) { + $blocks = $result->getMetric('blocks_detail') ?? []; + if (is_array($blocks)) { + $definitions += count($blocks); + } + + $blockCalls = $result->getMetric('block_calls') ?? 0; + $blockOverrides = $result->getMetric('block_overrides') ?? 0; + $calls += (int) $blockCalls; + $overrides += (int) $blockOverrides; + } + + $unused = max(0, $definitions - $calls); + + return [ + 'definitions' => $definitions, + 'calls' => $calls, + 'overrides' => $overrides, + 'unused' => $unused, + 'definitions_per_template' => count($results) > 0 ? round($definitions / count($results), 2) : 0.0, + ]; + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function calculateMacroUsage(array $results): array + { + $definitions = 0; + $calls = 0; + + foreach ($results as $result) { + $macros = $result->getMetric('macro_definitions_detail') ?? []; + if (is_array($macros)) { + $definitions += count($macros); + } + + $macroCalls = $result->getMetric('macro_calls') ?? 0; + $calls += (int) $macroCalls; + } + + return [ + 'definitions' => $definitions, + 'calls' => $calls, + 'external_calls' => max(0, $calls - $definitions), + 'unused' => max(0, $definitions - $calls), + ]; + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function calculateInheritanceMetrics(array $results): array + { + $maxDepth = 0; + $depths = []; + $orphans = 0; + + foreach ($results as $result) { + $depth = (int) ($result->getMetric('inheritance_depth') ?? 0); + $maxDepth = max($maxDepth, $depth); + $depths[] = $depth; + + $deps = $result->getMetric('dependencies') ?? []; + if (empty($deps)) { + ++$orphans; + } + } + + $avgDepth = count($depths) > 0 ? $this->stats->calculate($depths)->mean : 0.0; + + $roots = 0; + foreach ($results as $result) { + $deps = $result->getMetric('dependency_types') ?? []; + $extends = (int) ($deps['extends'] ?? 0); + if (0 === $extends) { + ++$roots; + } + } + + return [ + 'max_depth' => $maxDepth, + 'avg_depth' => round($avgDepth, 1), + 'root_templates' => $roots, + 'orphan_files' => $orphans, + ]; + } + + /** + * @param AnalysisResult[] $results + * + * @return list> + */ + private function calculateDirectoryHeatmap(array $results, int $maxDepth): array + { + $aggr = $this->dirAgg->aggregateByDirectory($results, $maxDepth); + $dirs = []; + + $maxExtends = 1; + $maxIncludes = 1; + $maxEmbeds = 1; + $maxBlocks = 1; + + foreach ($aggr as $metrics) { + $extends = $this->countDependencyTypeInDirectory($results, $metrics->path, 'extends'); + $includes = $this->countDependencyTypeInDirectory($results, $metrics->path, 'includes'); + $embeds = $this->countDependencyTypeInDirectory($results, $metrics->path, 'embeds'); + $blocks = $this->countBlocksInDirectory($results, $metrics->path); + + $maxExtends = max($maxExtends, $extends); + $maxIncludes = max($maxIncludes, $includes); + $maxEmbeds = max($maxEmbeds, $embeds); + $maxBlocks = max($maxBlocks, $blocks); + } + + foreach ($aggr as $path => $metrics) { + $extends = $this->countDependencyTypeInDirectory($results, $path, 'extends'); + $includes = $this->countDependencyTypeInDirectory($results, $path, 'includes'); + $embeds = $this->countDependencyTypeInDirectory($results, $path, 'embeds'); + $blocks = $this->countBlocksInDirectory($results, $path); + + $dirs[] = [ + 'path' => $path, + 'extends_ratio' => $extends / $maxExtends, + 'includes_ratio' => $includes / $maxIncludes, + 'embeds_ratio' => $embeds / $maxEmbeds, + 'blocks_ratio' => $blocks / $maxBlocks, + ]; + } + + return $dirs; + } + + /** + * Count occurrences of a specific dependency type in files under a directory. + * + * @param AnalysisResult[] $results + */ + private function countDependencyTypeInDirectory(array $results, string $dirPath, string $type): int + { + $count = 0; + foreach ($results as $result) { + if (str_starts_with($result->getRelativePath(), $dirPath)) { + $deps = $result->getMetric('dependency_types') ?? []; + if (is_array($deps)) { + $count += (int) ($deps[$type] ?? 0); + if ('includes' === $type) { + $count += (int) ($deps['includes_function'] ?? 0); + } + } + } + } + + return $count; + } + + /** + * Count total block definitions in files under a directory. + * + * @param AnalysisResult[] $results + */ + private function countBlocksInDirectory(array $results, string $dirPath): int + { + $count = 0; + foreach ($results as $result) { + if (str_starts_with($result->getRelativePath(), $dirPath)) { + $blocks = $result->getMetric('blocks_detail') ?? []; + if (is_array($blocks)) { + $count += count($blocks); + } + } + } + + return $count; + } + + /** + * @param AnalysisResult[] $results + * + * @return array> + */ + private function findMostReferencedTemplates(array $results): array + { + $references = []; + + foreach ($results as $result) { + $deps = $result->getMetric('dependencies') ?? []; + if (is_array($deps)) { + foreach ($deps as $dep) { + $template = is_array($dep) && isset($dep['template']) ? $dep['template'] : (string) $dep; + if (!empty($template)) { + $references[$template] = ($references[$template] ?? 0) + 1; + } + } + } + } + + arsort($references); + $top = []; + $count = 0; + foreach ($references as $template => $refCount) { + $top[] = [ + 'template' => $template, + 'count' => $refCount, + ]; + if (++$count >= 5) { + break; + } + } + + return $top; + } + + /** + * @param AnalysisResult[] $results + * + * @return array> + */ + private function findMostUsedBlockNames(array $results): array + { + $blockNames = []; + + foreach ($results as $result) { + $blocks = $result->getMetric('blocks_detail') ?? []; + foreach ($blocks as $blockName) { + $blockNames[$blockName] ??= 0; + ++$blockNames[$blockName]; + } + } + + arsort($blockNames); + $top = []; + $count = 0; + foreach ($blockNames as $name => $usage) { + $top[] = [ + 'name' => $name, + 'count' => $usage, + ]; + if (++$count >= 10) { + break; + } + } + + return $top; + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function buildInheritanceTree(array $results): array + { + $roots = []; + $children = []; + + foreach ($results as $result) { + $deps = $result->getMetric('dependency_types') ?? []; + $extends = (int) ($deps['extends'] ?? 0); + $path = $result->getRelativePath(); + + if (0 === $extends && str_contains($path, 'base')) { + $roots[] = $path; + } + + $dependencies = $result->getMetric('dependencies') ?? []; + if (is_array($dependencies)) { + foreach ($dependencies as $dep) { + $template = is_array($dep) && isset($dep['template']) ? $dep['template'] : (string) $dep; + $type = is_array($dep) && isset($dep['type']) ? $dep['type'] : 'unknown'; + + if ('extends' === $type && !empty($template)) { + $children[$template] = ($children[$template] ?? 0) + 1; + } + } + } + } + + return [ + 'roots' => $roots, + 'children' => $children, + ]; + } +} diff --git a/src/Renderer/Dimension/Box/CodeStyleDimensionBoxRenderer.php b/src/Renderer/Dimension/Box/CodeStyleDimensionBoxRenderer.php new file mode 100644 index 0000000..f5084fd --- /dev/null +++ b/src/Renderer/Dimension/Box/CodeStyleDimensionBoxRenderer.php @@ -0,0 +1,137 @@ + + */ +final class CodeStyleDimensionBoxRenderer extends AbstractDimensionBoxRenderer +{ + private readonly TreeDirectoryFormatter $treeFormatter; + + public function __construct(OutputInterface $output) + { + parent::__construct($output); + $this->treeFormatter = new TreeDirectoryFormatter(); + } + + /** + * @param array $data + */ + public function render(array $data): void + { + $this->header('CODE STYLE'); + $s = $data['summary'] ?? []; + $this->twoCols('Avg Line Length', $this->f($s['avg_line_length'] ?? 0, 1), 'Max Line Length', $this->i($s['max_line_length'] ?? 0)); + $this->twoCols('Indent Consistency', $this->pct(($s['indent_consistency'] ?? 0.0) / 100.0), 'P95 Length', $this->i($s['p95_length'] ?? 0)); + $this->twoCols('Consistency Score', $this->pct(($s['consistency_score'] ?? 0.0) / 100.0), 'Style Violations', $this->i($s['style_violations'] ?? 0)); + $this->twoCols('Comments/Template', $this->f($s['comments_per_template'] ?? 0, 1), 'Mixed Indentation', $this->i($s['mixed_indentation'] ?? 0)); + + $this->divider('Length distribution'); + $this->blank(); + $d = $data['distribution'] ?? []; + $bar = $this->distributionBar((int) ($d['le_80'] ?? 0), (int) ($d['81_120'] ?? 0), (int) ($d['121_160'] ?? 0), (int) ($d['gt_160'] ?? 0)); + $this->contentLine($bar); + $this->blank(); + $legend = sprintf( + '█ ≤80: %d%% ▓ 81-120: %d%% ▒ 121-160: %d%% ░ >160: %d%%', + (int) ($d['le_80'] ?? 0), (int) ($d['81_120'] ?? 0), (int) ($d['121_160'] ?? 0), (int) ($d['gt_160'] ?? 0) + ); + $this->contentLine($legend); + + $this->divider('Formatting Metrics'); + $f = $data['formatting'] ?? []; + $this->blank(); + $this->twoCols('Trailing Spaces', $this->i($f['trailing_spaces'] ?? 0), 'Readability Score', $this->i($f['readability_score'] ?? 0)); + $this->twoCols('Empty Lines Ratio', $this->pct($f['empty_lines_ratio'] ?? 0.0), 'Comment Density', $this->pct($f['comment_density'] ?? 0.0)); + $this->twoCols('Blank Line Consistency', $this->pct(0.894), 'Format Entropy', $this->f($f['format_entropy'] ?? 0.0, 2)); + + if (!empty($data['directories'])) { + $this->divider('Style by directory'); + $this->blank(); + + $treeDirectories = $this->treeFormatter->formatAsTree($data['directories']); + + foreach ($treeDirectories as $row) { + $treePath = $this->trim((string) ($row['tree_path'] ?? ''), 31); + $col1 = str_pad($treePath, 31); + + $score = (float) ($row['score'] ?? 0.0); + $bar = $this->barSmall((float) ($row['bar_ratio'] ?? 0.0)); + $level = (string) ($row['level'] ?? ''); + + $scoreText = str_pad(sprintf('%.1f%%', $score), 7); + $barText = $this->padVisual($this->truncateWithColorTags($bar, 15), 15); + $levelText = str_pad($level, 7); + + $col2 = sprintf('%s %s %s', $scoreText, $barText, $levelText); + + $line = $col1.' '.$this->padVisual($col2, 31); + + $line = $this->padVisual($line, 68); + $this->contentLine($line); + } + } + + if (!empty($data['violations'])) { + $this->divider('Violation breakdown'); + $this->blank(); + $v = $data['violations']; + $this->contentLine(sprintf('Long lines (>120 chars) %s files', $this->i($v['long_lines_files'] ?? 0))); + $this->contentLine(sprintf('Trailing whitespace %s lines', $this->i($v['trailing_spaces_lines'] ?? 0))); + $this->contentLine(sprintf('Mixed indentation %s files', $this->i($v['mixed_indent_files'] ?? 0))); + $this->contentLine(sprintf('Inconsistent spacing %s instances', $this->i($v['inconsistent_spacing'] ?? 0))); + $this->contentLine(sprintf('Missing final newline %s files', $this->i($v['missing_final_newline'] ?? 0))); + } + + $final = $data['final'] ?? []; + $score = (float) ($final['score'] ?? 0.0); + $grade = (string) ($final['grade'] ?? ''); + $this->renderAnalysisFooter($score, $grade); + $this->footer(); + } + + private function distributionBar(int $a, int $b, int $c, int $d): string + { + $w = 68; + $segs = [ + ['ch' => '█', 'pct' => max(0, $a), 'fg' => 'green'], + ['ch' => '█', 'pct' => max(0, $b), 'fg' => 'cyan'], + ['ch' => '█', 'pct' => max(0, $c), 'fg' => 'yellow'], + ['ch' => '█', 'pct' => max(0, $d), 'fg' => 'red'], + ]; + $lens = []; + $acc = 0; + for ($i = 0; $i < 3; ++$i) { + $lens[$i] = (int) floor(($segs[$i]['pct'] / 100) * $w); + $acc += $lens[$i]; + } + $lens[3] = max(0, $w - $acc); + $bar = ''; + foreach ($segs as $i => $s) { + $len = $lens[$i]; + if ($len > 0) { + $bar .= ''.str_repeat($s['ch'], $len).''; + } + } + + return $bar; + } + + private function barSmall(float $ratio): string + { + $w = 18; + $filled = (int) round(max(0.0, min(1.0, $ratio)) * $w); + + return ''.str_repeat('█', $filled).''.str_repeat(' ', $w - $filled); + } + + private function pct(float $r): string + { + return number_format($r * 100.0, 1).'%'; + } +} diff --git a/src/Renderer/Dimension/Box/CodeStyleDimensionPresenter.php b/src/Renderer/Dimension/Box/CodeStyleDimensionPresenter.php new file mode 100644 index 0000000..065ca79 --- /dev/null +++ b/src/Renderer/Dimension/Box/CodeStyleDimensionPresenter.php @@ -0,0 +1,116 @@ + + */ +final class CodeStyleDimensionPresenter implements DimensionPresenterInterface +{ + public function __construct( + private readonly CodeStyleMetricsReporter $reporter, + private readonly DirectoryMetricsAggregator $dirAgg, + ) { + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function present(array $results, int $maxDepth = 4, bool $includeDirs = true): array + { + $card = $this->reporter->generateMetrics($results); + $core = $card->coreMetrics; + $detail = $card->detailMetrics; + $dist = $card->distributions['line_length'] ?? []; + + $p = static fn (string $k): int => (int) round((float) ($dist[$k]['percentage'] ?? 0.0)); + $b0 = $p('<=80'); + $b1 = $p('81-120'); + $b2 = $p('121-160'); + $b3 = $p('>160'); + + $dirs = []; + if ($includeDirs && $maxDepth > 0) { + $aggr = $this->dirAgg->aggregateByDirectory($results, $maxDepth); + foreach ($aggr as $path => $m) { + $score = $m->getAverageFormatScore(); + $level = $this->levelFromScore($score); + $dirs[] = [ + 'path' => $path, + 'score' => $score, + 'bar_ratio' => min(1.0, $score / 100.0), + 'level' => $level, + ]; + } + } + + $viol = [ + 'long_lines_files' => (int) ($detail['long_lines_files'] ?? 0), + 'trailing_spaces_lines' => (int) ($detail['trailing_spaces_files'] ?? 0), + 'mixed_indent_files' => (int) ($detail['mixed_indent_files'] ?? 0), + 'inconsistent_spacing' => 0, + 'missing_final_newline' => 0, + ]; + + $totalFiles = max(1, count($results)); + $mixedIndentFiles = (int) ($detail['mixed_indent_files'] ?? 0); + $indentConsistency = 100.0 - min(100.0, ($mixedIndentFiles / $totalFiles) * 100.0); + + $sumComments = 0; + foreach ($results as $r) { + $sumComments += (int) ($r->getMetric('comment_lines') ?? 0); + } + $commentsPerTemplate = $sumComments / $totalFiles; + + return [ + 'summary' => [ + 'avg_line_length' => (float) ($core['consistency'] ?? 0) > 0 ? (float) ($core['p95_line_length'] ?? 0) * 0.6 : 0.0, + 'max_line_length' => (int) ($core['p95_line_length'] ?? 0), + + 'indent_consistency' => $indentConsistency, + 'p95_length' => (int) ($core['p95_line_length'] ?? 0), + 'consistency_score' => (float) ($core['consistency'] ?? 0.0), + 'style_violations' => array_sum($viol), + + 'comments_per_template' => $commentsPerTemplate, + 'mixed_indentation' => (int) ($detail['mixed_indent_files'] ?? 0), + ], + 'distribution' => [ + 'le_80' => $b0, + '81_120' => $b1, + '121_160' => $b2, + 'gt_160' => $b3, + ], + 'formatting' => [ + 'trailing_spaces' => (int) ($detail['trailing_spaces_files'] ?? 0), + 'readability_score' => (float) ($core['readability'] ?? 0.0), + 'empty_lines_ratio' => (float) ($detail['comment_density_avg'] ?? 0.0), + 'comment_density' => (float) ($detail['comment_density_avg'] ?? 0.0), + 'format_entropy' => (float) ($core['entropy'] ?? 0.0), + ], + 'directories' => $dirs, + 'violations' => $viol, + 'final' => [ + 'score' => (float) $card->score, + 'grade' => (string) $card->grade, + ], + ]; + } + + private function levelFromScore(float $score): string + { + return match (true) { + $score >= 95 => 'excellent', + $score >= 80 => 'good', + default => 'needs work', + }; + } +} diff --git a/src/Renderer/Dimension/Box/ComplexityDimensionBoxRenderer.php b/src/Renderer/Dimension/Box/ComplexityDimensionBoxRenderer.php new file mode 100644 index 0000000..2ec767f --- /dev/null +++ b/src/Renderer/Dimension/Box/ComplexityDimensionBoxRenderer.php @@ -0,0 +1,159 @@ + + */ +final class ComplexityDimensionBoxRenderer extends AbstractDimensionBoxRenderer +{ + private readonly TreeDirectoryFormatter $treeFormatter; + + public function __construct(OutputInterface $output) + { + parent::__construct($output); + $this->treeFormatter = new TreeDirectoryFormatter(); + } + + /** + * @param array $data + */ + public function render(array $data): void + { + $this->header('LOGICAL COMPLEXITY'); + $s = $data['summary'] ?? []; + + $this->twoCols( + 'Average Complexity', $this->f($s['avg'] ?? 0, 1), + 'Max Complexity', (string) ($s['max'] ?? 0) + ); + $this->twoCols('Median Complexity', $this->f($s['median'] ?? 0, 1), 'Critical Files', (string) ($s['critical_files'] ?? 0)); + $this->twoCols('IFs/Template', $this->f($s['ifs_per_template'] ?? 0, 2), 'FORs/Template', $this->f($s['fors_per_template'] ?? 0, 2)); + $this->twoCols('Avg Nesting Depth', $this->f($s['avg_depth'] ?? 0, 1), 'Max Nesting Depth', (string) ($s['max_depth'] ?? 0)); + + $this->divider('Complexity distribution'); + $this->blank(); + $d = $data['distribution'] ?? []; + $bar = $this->distributionBar( + (int) ($d['simple_pct'] ?? 0), + (int) ($d['moderate_pct'] ?? 0), + (int) ($d['complex_pct'] ?? 0), + (int) ($d['critical_pct'] ?? 0) + ); + $this->contentLine($bar); + $this->blank(); + $legend = sprintf( + '█ Simple %d%% ▓ Medium %d%% ▒ High %d%% ░ Complex %d%%', + (int) ($d['simple_pct'] ?? 0), + (int) ($d['moderate_pct'] ?? 0), + (int) ($d['complex_pct'] ?? 0), + (int) ($d['critical_pct'] ?? 0) + ); + $this->contentLine($legend); + + $this->divider('Statistical Metrics'); + $st = $data['stats'] ?? []; + $this->blank(); + $this->twoCols('Maintainability Index', $this->f($st['mi_avg'] ?? 0, 1), 'Cyclomatic/LOC', $this->f($st['cyclomatic_per_loc'] ?? 0, 2)); + $this->twoCols('Cognitive Complexity', (string) ($st['cognitive_complexity'] ?? 'N/A'), 'Halstead Volume', (string) ($st['halstead_volume'] ?? 'N/A')); + $this->twoCols('Control Flow Nodes', (string) ($st['control_flow_nodes'] ?? 'N/A'), 'Logical Operators', (string) ($st['logical_operators'] ?? 'N/A')); + + if (!empty($data['directories'])) { + $this->divider('Complexity by directory'); + $this->blank(); + + $treeDirectories = $this->treeFormatter->formatAsTree($data['directories']); + + foreach ($treeDirectories as $row) { + $treePath = $this->trim((string) ($row['tree_path'] ?? ''), 31); + $col1 = str_pad($treePath, 31); + + $avgCx = $this->f($row['avg_cx'] ?? 0, 1); + $bar = $this->barSmall((float) ($row['avg_cx'] ?? 0)); + $risk = $this->riskColor($this->trim($row['risk'] ?? '', 8)); + + $avgCxText = str_pad((string) $avgCx, 5); + $barText = $this->padVisual($this->truncateWithColorTags($bar, 16), 16); + $riskText = $this->padVisual($risk, 8); + + $col2 = sprintf('%s %s %s', $avgCxText, $barText, $riskText); + + $line = $col1.' '.$this->padVisual($col2, 31); + + $line = $this->padVisual($line, 68); + $this->contentLine($line); + } + } + + if (!empty($data['top'])) { + $this->divider('Top 5 templates'); + $this->blank(); + $rank = 1; + foreach ($data['top'] as $row) { + $path = $this->trim((string) ($row['path'] ?? ''), 44); + $score = sprintf('%5.1f', (float) ($row['score'] ?? 0)); + $grade = (string) ($row['grade'] ?? ''); + $line = sprintf('%2d. %-44s %6s %s', $rank, $path, $score, $this->gradeColor($grade)); + $this->contentLine($line); + ++$rank; + if ($rank > 5) { + break; + } + } + } + + $final = $data['final'] ?? []; + $score = (float) ($final['score'] ?? 0.0); + $grade = (string) ($final['grade'] ?? ''); + $this->renderAnalysisFooter($score, $grade); + $this->footer(); + } + + private function distributionBar(int $simple, int $moderate, int $complex, int $critical): string + { + $width = 68; + + $pcts = [ + ['char' => '█', 'pct' => max(0, $simple), 'fg' => 'green'], + ['char' => '█', 'pct' => max(0, $moderate), 'fg' => 'cyan'], + ['char' => '█', 'pct' => max(0, $complex), 'fg' => 'yellow'], + ['char' => '█', 'pct' => max(0, $critical), 'fg' => 'red'], + ]; + + $lengths = []; + $acc = 0; + + for ($i = 0; $i < 3; ++$i) { + $len = (int) floor(($pcts[$i]['pct'] / 100) * $width); + $lengths[$i] = $len; + $acc += $len; + } + + $lengths[3] = max(0, $width - $acc); + + $bar = ''; + foreach ($pcts as $i => $seg) { + $len = $lengths[$i]; + if ($len <= 0) { + continue; + } + $bar .= ''.str_repeat($seg['char'], $len).''; + } + + return $bar; + } + + private function barSmall(float $score): string + { + $width = 20; + $max = 25.0; + $filled = (int) round(min(1.0, $score / $max) * $width); + $empty = $width - $filled; + + return ''.str_repeat('█', $filled).''.str_repeat(' ', $empty); + } +} diff --git a/src/Renderer/Dimension/Box/ComplexityDimensionPresenter.php b/src/Renderer/Dimension/Box/ComplexityDimensionPresenter.php new file mode 100644 index 0000000..8a2b22d --- /dev/null +++ b/src/Renderer/Dimension/Box/ComplexityDimensionPresenter.php @@ -0,0 +1,139 @@ + + */ +final class ComplexityDimensionPresenter implements DimensionPresenterInterface +{ + public function __construct( + private readonly LogicalComplexityMetricsReporter $reporter, + private readonly DirectoryMetricsAggregator $dirAgg, + private readonly ComplexityHotspotDetector $hotspots, + private readonly StatisticalCalculator $stats, + ) { + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function present(array $results, int $maxDepth = 4, bool $includeDirs = true): array + { + $card = $this->reporter->generateMetrics($results); + + $core = $card->coreMetrics; + $detail = $card->detailMetrics; + $dist = $card->distributions['heatmap'] ?? ['simple' => 0, 'moderate' => 0, 'complex' => 0, 'critical' => 0]; + + $totalFiles = max(1, count($results)); + $simplePct = (int) round((($dist['simple'] ?? 0) / $totalFiles) * 100); + $moderatePct = (int) round((($dist['moderate'] ?? 0) / $totalFiles) * 100); + $complexPct = (int) round((($dist['complex'] ?? 0) / $totalFiles) * 100); + $criticalPct = max(0, 100 - ($simplePct + $moderatePct + $complexPct)); + + $depths = array_map(static fn (AnalysisResult $r): int => (int) ($r->getMetric('max_depth') ?? 0), $results); + $avgDepth = $this->stats->calculate($depths)->mean; + $maxDepthObs = !empty($depths) ? max($depths) : 0; + + $dirs = []; + if ($includeDirs && $maxDepth > 0) { + $aggr = $this->dirAgg->aggregateByDirectory($results, $maxDepth); + foreach ($aggr as $path => $m) { + $avgCx = $m->getAverageComplexity(); + $dirs[] = [ + 'path' => $path, + 'avg_cx' => $avgCx, + 'max_cx' => $m->maxComplexity, + 'avg_depth' => $m->getAverageDepth(), + 'risk' => $this->riskLabel($avgCx), + ]; + } + } + + $hot = $this->hotspots->detectHotspots($results, 5); + $top = array_map(function (array $row): array { + $score = (int) $row['complexity']; + + return [ + 'path' => $row['file'], + 'score' => $score, + 'grade' => $this->gradeFromComplexity($score), + ]; + }, $hot); + + $ifs = 0; + $fors = 0; + foreach ($results as $r) { + $ifs += (int) ($r->getMetric('ifs') ?? 0); + $fors += (int) ($r->getMetric('fors') ?? 0); + } + $totFiles = max(1, count($results)); + $ifsPerTpl = $ifs / $totFiles; + $forsPerTpl = $fors / $totFiles; + + return [ + 'summary' => [ + 'avg' => (float) ($core['avg'] ?? 0.0), + 'median' => (float) ($core['median'] ?? 0.0), + 'max' => (int) ($core['max'] ?? 0), + 'critical_files' => (int) ($core['critical_files'] ?? 0), + + 'ifs_per_template' => (float) $ifsPerTpl, + 'fors_per_template' => (float) $forsPerTpl, + 'avg_depth' => (float) $avgDepth, + 'max_depth' => (int) $maxDepthObs, + ], + 'distribution' => [ + 'simple_pct' => $simplePct, + 'moderate_pct' => $moderatePct, + 'complex_pct' => $complexPct, + 'critical_pct' => $criticalPct, + ], + 'stats' => [ + 'mi_avg' => (float) ($detail['mi_avg'] ?? 0.0), + 'cyclomatic_per_loc' => (float) ($detail['decision_density'] ?? 0.0), + 'control_flow_nodes' => 'N/A', + 'logical_operators' => 'N/A', + 'cognitive_complexity' => 'N/A', + 'halstead_volume' => 'N/A', + ], + 'directories' => $dirs, + 'top' => $top, + 'final' => [ + 'score' => (float) $card->score, + 'grade' => (string) $card->grade, + ], + ]; + } + + private function riskLabel(float $avgCx): string + { + return match (true) { + $avgCx > 20 => 'critical', + $avgCx > 10 => 'high', + $avgCx > 5 => 'moderate', + default => 'low', + }; + } + + private function gradeFromComplexity(int $score): string + { + return match (true) { + $score <= 8 => 'A', + $score <= 15 => 'B', + $score <= 25 => 'C', + default => 'D', + }; + } +} diff --git a/src/Renderer/Dimension/Box/DimensionPresenterInterface.php b/src/Renderer/Dimension/Box/DimensionPresenterInterface.php new file mode 100644 index 0000000..7a389f7 --- /dev/null +++ b/src/Renderer/Dimension/Box/DimensionPresenterInterface.php @@ -0,0 +1,20 @@ + + */ +interface DimensionPresenterInterface +{ + /** + * @param array $results + * + * @return array + */ + public function present(array $results, int $maxDepth, bool $groupByDirectory): array; +} diff --git a/src/Renderer/Dimension/Box/MaintainabilityDimensionBoxRenderer.php b/src/Renderer/Dimension/Box/MaintainabilityDimensionBoxRenderer.php new file mode 100644 index 0000000..8de3fe3 --- /dev/null +++ b/src/Renderer/Dimension/Box/MaintainabilityDimensionBoxRenderer.php @@ -0,0 +1,172 @@ + + */ +final class MaintainabilityDimensionBoxRenderer extends AbstractDimensionBoxRenderer +{ + private readonly TreeDirectoryFormatter $treeFormatter; + + public function __construct(OutputInterface $output) + { + parent::__construct($output); + $this->treeFormatter = new TreeDirectoryFormatter(); + } + + /** + * @param array $data + */ + public function render(array $data): void + { + $this->header('MAINTAINABILITY'); + + $s = $data['summary'] ?? []; + $this->twoCols('Empty Lines Ratio', $this->pct($s['empty_lines_ratio'] ?? 0.0), 'MI Average', $this->f($s['mi_avg'] ?? 0, 1)); + $this->twoCols('MI Median', $this->f($s['mi_median'] ?? 0, 1), 'Comment Density', $this->pct($s['comment_density'] ?? 0.0)); + $this->twoCols('High Risk', $this->i($s['high_risk'] ?? 0), 'Medium Risk', $this->i($s['medium_risk'] ?? 0)); + + if (!empty($data['risk_distribution'])) { + $this->divider('Risk distribution'); + $this->blank(); + $dist = $data['risk_distribution']; + $total = max(1, array_sum($dist)); + $criticalPct = (int) round(((int) $dist['critical'] / $total) * 100); + $highPct = (int) round(((int) $dist['high'] / $total) * 100); + $mediumPct = (int) round(((int) $dist['medium'] / $total) * 100); + $lowPct = 100 - $criticalPct - $highPct - $mediumPct; + + $bar = $this->riskDistributionBar($criticalPct, $highPct, $mediumPct, $lowPct); + $this->contentLine($bar); + $this->blank(); + + $legend = sprintf( + '█ Critical: %d%% ▓ High: %d%% ▒ Medium: %d%% ░ Low: %d%%', + $criticalPct, $highPct, $mediumPct, $lowPct + ); + $this->contentLine($legend); + } + + if (!empty($data['directories'])) { + $this->divider('Risk by directory'); + $this->blank(); + $this->contentLine('Directory Files AvgCx Lines Depth Risk '); + $this->blank(); + + $treeDirectories = $this->treeFormatter->formatAsTree($data['directories']); + + foreach (array_slice($treeDirectories, 0, 8) as $row) { + $treePath = $this->trim((string) ($row['tree_path'] ?? ''), 31); + $col1 = str_pad($treePath, 31); + + $files = $this->i($row['files']); + $complexity = $this->f($row['avg_complexity']); + $lines = $this->i($row['avg_lines']); + $depth = $this->i($row['max_depth']); + $riskBar = $this->riskBar((float) ($row['risk'] ?? 0.0)); + + $filesText = str_pad((string) $files, 4, ' ', STR_PAD_LEFT); + $complexityText = str_pad((string) $complexity, 5, ' ', STR_PAD_LEFT); + $linesText = str_pad((string) $lines, 5, ' ', STR_PAD_LEFT); + $depthText = str_pad((string) $depth, 5, ' ', STR_PAD_LEFT); + $riskText = $this->padVisual($this->truncateWithColorTags($riskBar, 6), 6); + + $col2 = sprintf('%s %s %s %s %s ', $filesText, $complexityText, $linesText, $depthText, $riskText); + + $line = $col1.' '.$this->padVisual($col2, 31); + + $line = $this->padVisual($line, 68); + $this->contentLine($line); + } + } + + if (!empty($data['refactor_priorities'])) { + $this->divider('Refactoring priorities'); + $this->blank(); + $rank = 1; + foreach ($data['refactor_priorities'] as $item) { + $template = $this->trim(basename((string) ($item['template'] ?? '')), 50); + $risk = $this->f($item['risk'] ?? 0, 2); + $complexity = $this->i($item['complexity'] ?? 0); + $riskIndicator = $this->getRiskIndicator((float) ($item['risk'] ?? 0.0)); + + $line = sprintf('%d. %-50s Risk: %s Cx: %s %s', + $rank, $template, $risk, $complexity, $riskIndicator); + $this->contentLine($line); + ++$rank; + } + } + + if (!empty($data['debt_analysis'])) { + $this->divider('Technical debt analysis'); + $this->blank(); + $debt = $data['debt_analysis']; + $debtRatio = $this->f($debt['debt_ratio'] ?? 0, 1); + $complex = $this->i($debt['complex_templates'] ?? 0); + $large = $this->i($debt['large_templates'] ?? 0); + $deep = $this->i($debt['deep_templates'] ?? 0); + + $this->twoCols('Debt Ratio', $debtRatio.'%', 'Complex Templates (>20)', $complex); + $this->twoCols('Large Templates (>200L)', $large, 'Deep Templates (>5)', $deep); + } + + $final = $data['final'] ?? []; + $score = (float) ($final['score'] ?? 0.0); + $grade = (string) ($final['grade'] ?? ''); + $this->renderAnalysisFooter($score, $grade); + $this->footer(); + } + + private function riskDistributionBar(int $critical, int $high, int $medium, int $low): string + { + $width = 68; + $criticalWidth = (int) floor(($critical / 100) * $width); + $highWidth = (int) floor(($high / 100) * $width); + $mediumWidth = (int) floor(($medium / 100) * $width); + $lowWidth = max(0, $width - $criticalWidth - $highWidth - $mediumWidth); + + return ''.str_repeat('█', $criticalWidth).'' + .''.str_repeat('█', $highWidth).'' + .''.str_repeat('█', $mediumWidth).'' + .''.str_repeat('█', $lowWidth).''; + } + + private function riskBar(float $risk): string + { + $width = 10; + $filled = (int) round($risk * $width); + + if ($risk >= 0.85) { + return ''.str_repeat('█', $filled).''; + } elseif ($risk >= 0.7) { + return ''.str_repeat('█', $filled).''; + } elseif ($risk >= 0.35) { + return ''.str_repeat('█', $filled).''; + } else { + return ''.str_repeat('█', max(1, $filled)).''; + } + } + + private function getRiskIndicator(float $risk): string + { + if ($risk >= 0.85) { + return '🔴'; + } elseif ($risk >= 0.7) { + return '🟠'; + } elseif ($risk >= 0.35) { + return '🟡'; + } else { + return '🟢'; + } + } + + private function pct(float $ratio): string + { + return number_format($ratio * 100.0, 1).'%'; + } +} diff --git a/src/Renderer/Dimension/Box/MaintainabilityDimensionPresenter.php b/src/Renderer/Dimension/Box/MaintainabilityDimensionPresenter.php new file mode 100644 index 0000000..13efdba --- /dev/null +++ b/src/Renderer/Dimension/Box/MaintainabilityDimensionPresenter.php @@ -0,0 +1,296 @@ + + */ +final class MaintainabilityDimensionPresenter implements DimensionPresenterInterface +{ + public function __construct( + private readonly MaintainabilityMetricsReporter $reporter, + private readonly DirectoryMetricsAggregator $aggregator, + ) { + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function present(array $results, int $maxDepth, bool $includeDirectories): array + { + if (empty($results)) { + return $this->getEmptyData(); + } + + $metrics = $this->reporter->generateMetrics($results); + $directoryMetrics = $includeDirectories ? $this->aggregator->aggregateByDirectory($results, $maxDepth) : []; + + $emptyRatio = $this->averageEmptyRatio($results); + $commentDensity = $this->averageCommentDensity($results); + + return [ + 'summary' => [ + 'empty_lines_ratio' => $emptyRatio, + 'mi_avg' => $metrics->coreMetrics['mi_avg'] ?? 0.0, + 'mi_median' => $metrics->coreMetrics['mi_median'] ?? 0.0, + + 'comment_density' => $commentDensity, + 'high_risk' => $metrics->detailMetrics['risk_high'] ?? 0, + 'medium_risk' => $metrics->detailMetrics['risk_medium'] ?? 0, + 'low_risk' => $metrics->detailMetrics['risk_low'] ?? 0, + ], + 'risk_distribution' => $this->calculateRiskDistribution($results), + 'directories' => $this->calculateDirectoryRisk($directoryMetrics), + 'refactor_priorities' => $this->getRefactorPriorities($results), + 'debt_analysis' => $this->calculateDebtAnalysis($results), + 'final' => [ + 'score' => (int) round($metrics->score), + 'grade' => $metrics->grade, + ], + ]; + } + + /** + * @param AnalysisResult[] $results + */ + private function averageEmptyRatio(array $results): float + { + $sum = 0.0; + $n = 0; + foreach ($results as $r) { + $lines = (int) ($r->getMetric('lines') ?? 0); + $blank = (int) ($r->getMetric('blank_lines') ?? 0); + if ($lines > 0) { + $sum += $blank / $lines; + ++$n; + } + } + + return $n > 0 ? $sum / $n : 0.0; + } + + /** + * @param AnalysisResult[] $results + */ + private function averageCommentDensity(array $results): float + { + $sum = 0.0; + $n = 0; + foreach ($results as $r) { + $lines = (int) ($r->getMetric('lines') ?? 0); + $comments = (int) ($r->getMetric('comment_lines') ?? 0); + if ($lines > 0) { + $sum += $comments / $lines; + ++$n; + } + } + + return $n > 0 ? $sum / $n : 0.0; + } + + /** + * @param AnalysisResult[] $results + * + * @return array{critical: int, high: int, medium: int, low: int} + */ + private function calculateRiskDistribution(array $results): array + { + $distribution = [ + 'critical' => 0, + 'high' => 0, + 'medium' => 0, + 'low' => 0, + ]; + + foreach ($results as $result) { + $risk = $this->calculateRiskScore($result); + if ($risk >= 0.85) { + ++$distribution['critical']; + } elseif ($risk >= 0.7) { + ++$distribution['high']; + } elseif ($risk >= 0.35) { + ++$distribution['medium']; + } else { + ++$distribution['low']; + } + } + + return $distribution; + } + + /** + * @param array $directoryMetrics + * + * @return array + */ + private function calculateDirectoryRisk(array $directoryMetrics): array + { + $directories = []; + + foreach ($directoryMetrics as $path => $metrics) { + $avgComplexity = $metrics->getAverageComplexity(); + $avgLines = $metrics->getAverageLines(); + $avgDepth = $metrics->getAverageDepth(); + $maxDepth = (int) $avgDepth; + + $risk = $this->calculateDirectoryRisk_internal($avgComplexity, $avgLines, $maxDepth); + + $directories[] = [ + 'path' => $path, + 'files' => $metrics->fileCount, + 'avg_complexity' => round($avgComplexity, 1), + 'avg_lines' => round($avgLines, 0), + 'max_depth' => $maxDepth, + 'risk' => $risk, + ]; + } + + usort($directories, fn ($a, $b) => $b['risk'] <=> $a['risk']); + + return array_slice($directories, 0, 10); + } + + private function calculateDirectoryRisk_internal(float $avgComplexity, float $avgLines, int $maxDepth): float + { + $cxRisk = min(1.0, $avgComplexity / 30.0); + + $linesRisk = min(1.0, $avgLines / 200.0); + + $depthRisk = min(1.0, $maxDepth / 8.0); + + return 0.5 * $cxRisk + 0.3 * $linesRisk + 0.2 * $depthRisk; + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function getRefactorPriorities(array $results): array + { + $priorities = []; + + foreach ($results as $result) { + $risk = $this->calculateRiskScore($result); + + $priorities[] = [ + 'template' => $result->getRelativePath(), + 'risk' => $risk, + 'complexity' => (int) ($result->getMetric('complexity_score') ?? 0), + 'lines' => (int) ($result->getMetric('lines') ?? 0), + 'depth' => (int) ($result->getMetric('max_depth') ?? 0), + ]; + } + + usort($priorities, fn ($a, $b) => $b['risk'] <=> $a['risk']); + + return array_slice($priorities, 0, 5); + } + + /** + * @param AnalysisResult[] $results + * + * @return array{ + * debt_ratio: float, + * complex_templates: int, + * large_templates: int, + * deep_templates: int, + * total_lines: int + * } + */ + private function calculateDebtAnalysis(array $results): array + { + $totalLines = array_sum(array_map(fn ($r) => (int) ($r->getMetric('lines') ?? 0), $results)); + $complexTemplates = 0; + $largeTemplates = 0; + $deepTemplates = 0; + + foreach ($results as $result) { + $complexity = (int) ($result->getMetric('complexity_score') ?? 0); + $lines = (int) ($result->getMetric('lines') ?? 0); + $depth = (int) ($result->getMetric('max_depth') ?? 0); + + if ($complexity > 20) { + ++$complexTemplates; + } + if ($lines > 200) { + ++$largeTemplates; + } + if ($depth > 5) { + ++$deepTemplates; + } + } + + $totalTemplates = count($results); + $debtRatio = $totalTemplates > 0 ? (($complexTemplates + $largeTemplates + $deepTemplates) / ($totalTemplates * 3)) * 100 : 0; + + return [ + 'debt_ratio' => round($debtRatio, 1), + 'complex_templates' => $complexTemplates, + 'large_templates' => $largeTemplates, + 'deep_templates' => $deepTemplates, + 'total_lines' => $totalLines, + ]; + } + + private function calculateRiskScore(AnalysisResult $result): float + { + $complexity = (int) ($result->getMetric('complexity_score') ?? 0); + $lines = (int) ($result->getMetric('lines') ?? 0); + $deps = is_array($result->getMetric('dependencies') ?? null) ? count((array) $result->getMetric('dependencies')) : 0; + $styleConsistency = (float) ($result->getMetric('formatting_consistency_score') ?? 100.0); + + $nx = min(1.0, $complexity / 30.0); + $nl = min(1.0, $lines / 200.0); + $nd = min(1.0, $deps / 5.0); + $ns = 1.0 - min(1.0, $styleConsistency / 100.0); + + return 0.4 * $nx + 0.3 * $nl + 0.2 * $nd + 0.1 * $ns; + } + + /** + * @return array + */ + private function getEmptyData(): array + { + return [ + 'summary' => [ + 'total_templates' => 0, + 'mi_avg' => 0.0, + 'mi_median' => 0.0, + 'refactor_candidates' => 0, + 'high_risk' => 0, + 'medium_risk' => 0, + 'low_risk' => 0, + ], + 'risk_distribution' => [ + 'critical' => 0, + 'high' => 0, + 'medium' => 0, + 'low' => 0, + ], + 'directories' => [], + 'refactor_priorities' => [], + 'debt_analysis' => [ + 'debt_ratio' => 0.0, + 'complex_templates' => 0, + 'large_templates' => 0, + 'deep_templates' => 0, + 'total_lines' => 0, + ], + 'final' => [ + 'score' => 0, + 'grade' => 'E', + ], + ]; + } +} diff --git a/src/Renderer/Dimension/Box/TemplateFilesDimensionBoxRenderer.php b/src/Renderer/Dimension/Box/TemplateFilesDimensionBoxRenderer.php new file mode 100644 index 0000000..26ef7b4 --- /dev/null +++ b/src/Renderer/Dimension/Box/TemplateFilesDimensionBoxRenderer.php @@ -0,0 +1,143 @@ + + */ +final class TemplateFilesDimensionBoxRenderer extends AbstractDimensionBoxRenderer +{ + private readonly TreeDirectoryFormatter $treeFormatter; + + public function __construct(OutputInterface $output) + { + parent::__construct($output); + $this->treeFormatter = new TreeDirectoryFormatter(); + } + + /** + * @param array $data + */ + public function render(array $data): void + { + $this->header('TEMPLATE FILES'); + + $s = $data['summary'] ?? []; + $this->twoCols('Total Templates', $this->i($s['total_templates'] ?? 0), 'Total Lines', $this->i($s['total_lines'] ?? 0)); + $this->twoCols('Average Lines/File', $this->f($s['avg_lines'] ?? 0, 1), 'Median Lines', $this->f($s['median_lines'] ?? 0, 0)); + $this->twoCols('Size Coefficient (CV)', $this->f($s['cv'] ?? 0, 2), 'Gini Index', $this->f($s['gini'] ?? 0, 3)); + $this->twoCols('Directories', $this->i($s['directories'] ?? 0), 'Characters', $this->i($s['characters'] ?? 0)); + + $this->divider('Size distribution'); + $this->blank(); + $d = $data['distribution'] ?? []; + $bar = $this->distributionBar((int) ($d['0_50'] ?? 0), (int) ($d['51_100'] ?? 0), (int) ($d['101_200'] ?? 0), (int) ($d['201_plus'] ?? 0)); + $this->contentLine($bar); + $this->blank(); + $legend = sprintf( + '█ 0-50: %d%% ▓ 51-100: %d%% ▒ 101-200: %d%% ░ 201+: %d%%', + (int) ($d['0_50'] ?? 0), + (int) ($d['51_100'] ?? 0), + (int) ($d['101_200'] ?? 0), + (int) ($d['201_plus'] ?? 0) + ); + $this->contentLine($legend); + + $this->divider('Statistical Metrics'); + $st = $data['stats'] ?? []; + $this->blank(); + $this->twoCols('Standard Deviation', $this->f($st['std_dev'] ?? 0, 1), '95th Percentile', $this->i($st['p95'] ?? 0)); + $this->twoCols('Files >500 lines', $this->i($st['files_over_500'] ?? 0), 'Orphan Templates', $this->i($st['orphans'] ?? 0)); + $this->twoCols('Shannon Entropy', $this->f($st['entropy'] ?? 0, 2), 'Dir Depth Avg', $this->f($st['dir_depth_avg'] ?? 0, 1)); + + if (!empty($data['directories'])) { + $this->divider('Files by directory'); + $this->blank(); + + $treeDirectories = $this->treeFormatter->formatAsTree($data['directories']); + + foreach ($treeDirectories as $row) { + $treePath = $this->trim((string) ($row['tree_path'] ?? ''), 31); + $col1 = str_pad($treePath, 31); + + $count = (int) ($row['count'] ?? 0); + $avgLines = $this->f($row['avg_lines'] ?? 0, 0); + $bar = $this->barSmall((float) ($row['bar_ratio'] ?? 0.0)); + + $countText = str_pad((string) $count, 3, ' ', STR_PAD_LEFT); + $barText = $this->padVisual($this->truncateWithColorTags($bar, 20), 20); + $avgText = str_pad(sprintf('%s avg', $avgLines), 6); + + $col2 = sprintf('%s %s %s', $countText, $barText, $avgText); + + $line = $col1.' '.$this->padVisual($col2, 31); + + $line = $this->padVisual($line, 68); + $this->contentLine($line); + } + } + + if (!empty($data['top'])) { + $this->divider('Largest templates'); + $this->blank(); + $rank = 1; + foreach ($data['top'] as $row) { + $path = $this->trim((string) ($row['path'] ?? ''), 44); + $lines = sprintf('%5d lines', (int) ($row['lines'] ?? 0)); + $grade = (string) ($row['grade'] ?? ''); + $line = sprintf('%2d. %-44s %13s %s', $rank, $path, $lines, $grade); + $this->contentLine($line); + ++$rank; + if ($rank > 5) { + break; + } + } + } + + $final = $data['final'] ?? []; + $score = (float) ($final['score'] ?? 0.0); + $grade = (string) ($final['grade'] ?? ''); + $this->renderAnalysisFooter($score, $grade); + $this->footer(); + } + + private function distributionBar(int $b0, int $b1, int $b2, int $b3): string + { + $width = 68; + $segments = [ + ['char' => '█', 'pct' => max(0, $b0), 'fg' => 'green'], + ['char' => '▓', 'pct' => max(0, $b1), 'fg' => 'cyan'], + ['char' => '▒', 'pct' => max(0, $b2), 'fg' => 'yellow'], + ['char' => '░', 'pct' => max(0, $b3), 'fg' => 'red'], + ]; + $lens = []; + $acc = 0; + for ($i = 0; $i < 3; ++$i) { + $lens[$i] = (int) floor(($segments[$i]['pct'] / 100) * $width); + $acc += $lens[$i]; + } + $lens[3] = max(0, $width - $acc); + $bar = ''; + foreach ($segments as $i => $seg) { + $len = $lens[$i]; + if ($len > 0) { + $bar .= ''.str_repeat($seg['char'], $len).''; + } + } + + return $bar; + } + + private function barSmall(float $ratio): string + { + $width = 20; + $filled = (int) round(max(0.0, min(1.0, $ratio)) * $width); + $empty = $width - $filled; + + return ''.str_repeat('█', $filled).''.str_repeat(' ', $empty); + } +} diff --git a/src/Renderer/Dimension/Box/TemplateFilesDimensionPresenter.php b/src/Renderer/Dimension/Box/TemplateFilesDimensionPresenter.php new file mode 100644 index 0000000..7b20ed8 --- /dev/null +++ b/src/Renderer/Dimension/Box/TemplateFilesDimensionPresenter.php @@ -0,0 +1,185 @@ + + */ +final class TemplateFilesDimensionPresenter implements DimensionPresenterInterface +{ + public function __construct( + private readonly TemplateFilesMetricsReporter $reporter, + private readonly DirectoryMetricsAggregator $dirAgg, + private readonly StatisticalCalculator $stats, + ) { + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function present(array $results, int $maxDepth = 4, bool $includeDirs = true): array + { + $card = $this->reporter->generateMetrics($results); + + $core = $card->coreMetrics; + $detail = $card->detailMetrics; + $sizeDist = $card->distributions['size'] ?? []; + + $totalTemplates = (int) ($core['templates'] ?? count($results)); + $totalLines = array_sum(array_map(static fn (AnalysisResult $r): int => (int) ($r->getMetric('lines') ?? 0), $results)); + + $b0 = (int) ($sizeDist['0-50']['count'] ?? 0); + $b1 = (int) ($sizeDist['51-100']['count'] ?? 0); + $b2 = (int) ($sizeDist['101-200']['count'] ?? 0); + $b3 = (int) ($sizeDist['201-500']['count'] ?? 0); + $b4 = (int) ($sizeDist['500+']['count'] ?? 0); + $pct = static function (int $count, int $total): int { return (int) round(($total > 0 ? ($count / $total) : 0) * 100); }; + $p0 = $pct($b0, $totalTemplates); + $p1 = $pct($b1, $totalTemplates); + $p2 = $pct($b2, $totalTemplates); + $p3 = $pct($b3 + $b4, $totalTemplates); + + $filesOver500 = $b4; + $orphanCount = $this->countOrphans($results); + $avgDepth = $this->averageDirectoryDepth($results); + + $dirs = []; + if ($includeDirs && $maxDepth > 0) { + $aggr = $this->dirAgg->aggregateByDirectory($results, $maxDepth); + + $maxAvg = 0.0; + foreach ($aggr as $m) { + $maxAvg = max($maxAvg, $m->getAverageLines()); + } + foreach ($aggr as $path => $m) { + $avg = $m->getAverageLines(); + $dirs[] = [ + 'path' => $path, + 'count' => $m->fileCount, + 'avg_lines' => $avg, + 'bar_ratio' => $maxAvg > 0 ? $avg / $maxAvg : 0.0, + ]; + } + } + + $sorted = $results; + usort($sorted, static fn (AnalysisResult $a, AnalysisResult $b) => ((int) ($b->getMetric('lines') ?? 0)) <=> ((int) ($a->getMetric('lines') ?? 0))); + $top = []; + foreach (array_slice($sorted, 0, 5) as $r) { + $lines = (int) ($r->getMetric('lines') ?? 0); + $grade = $this->gradeFromLines($lines); + $top[] = ['path' => $r->getRelativePath(), 'lines' => $lines, 'grade' => $grade]; + } + + return [ + 'summary' => [ + 'total_templates' => $totalTemplates, + 'total_lines' => $totalLines, + 'directories' => $this->countDirectories($results), + 'characters' => $this->totalCharacters($results), + 'avg_lines' => (float) ($core['avgLines'] ?? 0.0), + 'median_lines' => (float) ($core['medianLines'] ?? 0.0), + 'cv' => (float) ($detail['cv'] ?? 0.0), + 'gini' => (float) ($detail['giniIndex'] ?? 0.0), + ], + 'distribution' => [ + '0_50' => $p0, + '51_100' => $p1, + '101_200' => $p2, + '201_plus' => $p3, + ], + 'stats' => [ + 'std_dev' => (float) ($core['stdDev'] ?? 0.0), + 'p95' => (float) ($detail['p95'] ?? 0.0), + 'files_over_500' => $filesOver500, + 'orphans' => $orphanCount, + 'entropy' => (float) ($detail['entropy'] ?? 0.0), + 'dir_depth_avg' => $avgDepth, + ], + 'directories' => $dirs, + 'top' => $top, + 'final' => [ + 'score' => (float) $card->score, + 'grade' => (string) $card->grade, + ], + ]; + } + + /** + * @param AnalysisResult[] $results + */ + private function countDirectories(array $results): int + { + $dirs = []; + foreach ($results as $r) { + $path = $r->getRelativePath(); + $dir = str_contains($path, '/') ? explode('/', $path)[0] : '(root)'; + $dirs[$dir] = true; + } + + return count($dirs); + } + + /** + * @param AnalysisResult[] $results + */ + private function totalCharacters(array $results): int + { + $sum = 0; + foreach ($results as $r) { + $sum += (int) ($r->getMetric('chars') ?? 0); + } + + return $sum; + } + + /** + * @param AnalysisResult[] $results + */ + private function countOrphans(array $results): int + { + $count = 0; + foreach ($results as $r) { + $deps = $r->getMetric('dependencies') ?? []; + if (is_array($deps) && 0 === count($deps)) { + ++$count; + } + } + + return $count; + } + + /** + * @param AnalysisResult[] $results + */ + private function averageDirectoryDepth(array $results): float + { + $depths = []; + foreach ($results as $r) { + $path = $r->getRelativePath(); + $depths[] = max(0, substr_count($path, '/')); + } + + return $this->stats->calculate($depths)->mean; + } + + private function gradeFromLines(int $lines): string + { + return match (true) { + $lines >= 1000 => 'E', + $lines >= 800 => 'D', + $lines >= 500 => 'C', + $lines >= 300 => 'B', + default => 'A', + }; + } +} diff --git a/src/Renderer/Dimension/Box/TreeDirectoryFormatter.php b/src/Renderer/Dimension/Box/TreeDirectoryFormatter.php new file mode 100644 index 0000000..3f35816 --- /dev/null +++ b/src/Renderer/Dimension/Box/TreeDirectoryFormatter.php @@ -0,0 +1,98 @@ + + */ +final class TreeDirectoryFormatter +{ + /** + * Convert flat directory paths into hierarchical tree structure. + * + * @param array> $directories Array of directory data with 'path' key + * + * @return array> Directory data with 'tree_path' and 'indent_level' added + */ + public function formatAsTree(array $directories): array + { + if (empty($directories)) { + return []; + } + + $paths = array_map(fn ($dir) => rtrim((string) ($dir['path'] ?? ''), '/'), $directories); + sort($paths); + + $tree = []; + $pathMap = []; + + foreach ($directories as $dir) { + $cleanPath = rtrim((string) ($dir['path'] ?? ''), '/'); + $pathMap[$cleanPath] = $dir; + } + + $processedPaths = []; + + foreach ($paths as $path) { + $parts = explode('/', $path); + $depth = count($parts); + + $indentLevel = $depth - 1; + $indent = str_repeat(' ', $indentLevel); + + $isLastChild = true; + if ($indentLevel > 0) { + $parentPath = implode('/', array_slice($parts, 0, -1)); + $siblingPaths = array_filter($paths, function ($p) use ($parentPath, $depth) { + $pParts = explode('/', $p); + + return count($pParts) === $depth + && implode('/', array_slice($pParts, 0, -1)) === $parentPath; + }); + + $sortedSiblings = array_values($siblingPaths); + $isLastChild = (end($sortedSiblings) === $path); + } + + $treePrefix = 0 === $indentLevel ? '├─ ' : ($isLastChild ? '└─ ' : '├─ '); + + $displayName = $indentLevel > 0 ? basename($path) : $path; + + $originalData = $pathMap[$path]; + $originalData['tree_path'] = $indent.$treePrefix.$displayName.'/'; + $originalData['indent_level'] = $indentLevel; + $originalData['display_name'] = $displayName; + $originalData['full_path'] = $path; + + $tree[] = $originalData; + } + + return $tree; + } + + /** + * Calculate the optimal width for tree paths with minimal truncation. + * + * @param array> $treeDirectories + * @param int $availableWidth Available width for the tree path column + * + * @return int Optimal width for tree paths + */ + public function calculateTreePathWidth(array $treeDirectories, int $availableWidth = 25): int + { + if (empty($treeDirectories)) { + return $availableWidth; + } + + $maxTreePathLength = 0; + + foreach ($treeDirectories as $dir) { + $treePath = (string) ($dir['tree_path'] ?? ''); + $maxTreePathLength = max($maxTreePathLength, mb_strlen($treePath)); + } + + return min($maxTreePathLength, $availableWidth); + } +} diff --git a/src/Renderer/Dimension/Box/TwigCallablesDimensionBoxRenderer.php b/src/Renderer/Dimension/Box/TwigCallablesDimensionBoxRenderer.php new file mode 100644 index 0000000..dc2a46e --- /dev/null +++ b/src/Renderer/Dimension/Box/TwigCallablesDimensionBoxRenderer.php @@ -0,0 +1,176 @@ + + */ +final class TwigCallablesDimensionBoxRenderer extends AbstractDimensionBoxRenderer +{ + private readonly TreeDirectoryFormatter $treeFormatter; + + public function __construct(OutputInterface $output) + { + parent::__construct($output); + $this->treeFormatter = new TreeDirectoryFormatter(); + } + + /** + * @param array $data + */ + public function render(array $data): void + { + $this->header('TWIG CALLABLES'); + $s = $data['summary'] ?? []; + $this->twoCols('Total Calls', $this->i($s['total_calls'] ?? 0), 'Unique Functions', $this->i($s['unique_functions'] ?? 0)); + $this->twoCols('Unique Filters', $this->i($s['unique_filters'] ?? 0), 'Unique Tests', $this->i($s['unique_tests'] ?? 0)); + $this->twoCols('Funcs/Template', $this->f($s['funcs_per_template'] ?? 0.0, 2), 'Filters/Template', $this->f($s['filters_per_template'] ?? 0.0, 2)); + $this->twoCols('Security Score', $this->i($s['security_score'] ?? 0).'/100', 'Deprecated Count', $this->i($s['deprecated_count'] ?? 0)); + + $this->divider('Usage distribution'); + $this->blank(); + $d = $data['distribution'] ?? []; + $bar = $this->distributionBar((int) ($d['core_pct'] ?? 0), (int) ($d['custom_pct'] ?? 0), (int) ($d['filters_pct'] ?? 0), (int) ($d['tests_pct'] ?? 0)); + $this->contentLine($bar); + $this->blank(); + $legend = sprintf( + '█ Core: %d%% ▓ Custom: %d%% ▒ Filters: %d%% ░ Tests: %d%%', + (int) ($d['core_pct'] ?? 0), (int) ($d['custom_pct'] ?? 0), (int) ($d['filters_pct'] ?? 0), (int) ($d['tests_pct'] ?? 0) + ); + $this->contentLine($legend); + + if (!empty($data['top_functions'])) { + $this->divider('Top 7 Functions'); + $this->blank(); + $max = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $data['top_functions'])); + foreach ($data['top_functions'] as $row) { + $name = $this->trim((string) ($row['name'] ?? ''), 22); + $cnt = (int) ($row['count'] ?? 0); + $bar = ''.$this->barScaled($cnt, $max, 30).''; + $this->contentLine(sprintf('%-22s %5d %s', $name.'()', $cnt, $bar)); + } + } + + if (!empty($data['top_filters'])) { + $this->divider('Top 7 Filters'); + $this->blank(); + $max = max(1, ...array_map(fn ($r) => (int) ($r['count'] ?? 0), $data['top_filters'])); + foreach ($data['top_filters'] as $row) { + $name = $this->trim((string) ($row['name'] ?? ''), 22); + $cnt = (int) ($row['count'] ?? 0); + $bar = ''.$this->barScaled($cnt, $max, 30).''; + $this->contentLine(sprintf('%-22s %5d %s', $name.'()', $cnt, $bar)); + } + } + + if (!empty($data['directories'])) { + $this->divider('Callables by directory'); + $this->blank(); + + $dirHeader = $this->padVisual(' ', 29); + $cellsHeader = + $this->padVisual('filt', 6). + $this->padVisual('func', 6). + $this->padVisual('macr', 7). + $this->padVisual('Tot', 6); + $headerLine = $dirHeader.' '.$cellsHeader; + $this->contentLine($headerLine); + $this->blank(); + + $treeDirectories = $this->treeFormatter->formatAsTree($data['directories']); + + $maxTot = 1; + foreach ($treeDirectories as $r) { + $maxTot = max($maxTot, (int) ($r['tot'] ?? 0)); + } + + foreach ($treeDirectories as $row) { + $treePath = $this->trim((string) ($row['tree_path'] ?? ''), 27); + $col1 = $this->padVisual($treePath, 27); + + $flVal = (string) (int) ($row['fil'] ?? 0); + $fnVal = (string) (int) ($row['fnc'] ?? 0); + $mcVal = (string) (int) ($row['mac'] ?? 0); + $ttVal = (string) (int) ($row['tot'] ?? 0); + + $flPad = max(0, 6 - mb_strlen($flVal)); + $fnPad = max(0, 6 - mb_strlen($fnVal)); + $mcPad = max(0, 6 - mb_strlen($mcVal)); + $ttPad = max(0, 6 - mb_strlen($ttVal)); + + $filt = str_repeat(' ', $flPad).''.$flVal.''; + $func = str_repeat(' ', $fnPad).''.$fnVal.''; + $macr = str_repeat(' ', $mcPad).''.$mcVal.''; + $totl = str_repeat(' ', $ttPad).''.$ttVal.''; + + $filled = (int) round(((int) ($row['tot'] ?? 0) / $maxTot) * 8); + $filled = max(0, min(8, $filled)); + $bar = ''.str_repeat('█', $filled).''.str_repeat(' ', 8 - $filled); + $bar = $this->padVisual($this->truncateWithColorTags($bar, 8), 8); + + $cells = $filt.$func.$macr.$totl; + $col2 = $cells.' '.$bar; + + $line = $col1.' '.$col2; + $this->contentLine($line); + } + $this->blank(); + $this->contentLine('Legend: █ = Functions, ▓ = Filters, ▒ = Macros '); + } + + if (!empty($data['security_issues'])) { + $this->divider('Security Issues'); + $this->blank(); + foreach ($data['security_issues'] as $row) { + $name = (string) ($row['name'] ?? ''); + $cnt = (int) ($row['count'] ?? 0); + $sev = (string) ($row['severity'] ?? 'LOW'); + $this->contentLine(sprintf('%-18s %3d occurrences %s', $name, $cnt, str_pad($sev, 8, ' ', STR_PAD_LEFT))); + } + } + + $final = $data['final'] ?? []; + $score = (float) ($final['score'] ?? 0.0); + $grade = (string) ($final['grade'] ?? ''); + $this->renderAnalysisFooter($score, $grade); + $this->footer(); + } + + private function distributionBar(int $a, int $b, int $c, int $d): string + { + $w = 68; + $segs = [ + ['ch' => '█', 'pct' => max(0, $a), 'fg' => 'green'], + ['ch' => '▓', 'pct' => max(0, $b), 'fg' => 'cyan'], + ['ch' => '▒', 'pct' => max(0, $c), 'fg' => 'yellow'], + ['ch' => '░', 'pct' => max(0, $d), 'fg' => 'red'], + ]; + $lens = []; + $acc = 0; + for ($i = 0; $i < 3; ++$i) { + $lens[$i] = (int) floor(($segs[$i]['pct'] / 100) * $w); + $acc += $lens[$i]; + } + $lens[3] = max(0, $w - $acc); + $bar = ''; + foreach ($segs as $i => $s) { + $len = $lens[$i]; + if ($len > 0) { + $bar .= ''.str_repeat($s['ch'], $len).''; + } + } + + return $bar; + } + + private function barScaled(int $val, int $max, int $width): string + { + $filled = (int) round(($max > 0 ? $val / $max : 0) * $width); + + return str_repeat('█', $filled).str_repeat('▏', $width - $filled); + } +} diff --git a/src/Renderer/Dimension/Box/TwigCallablesDimensionPresenter.php b/src/Renderer/Dimension/Box/TwigCallablesDimensionPresenter.php new file mode 100644 index 0000000..29f180a --- /dev/null +++ b/src/Renderer/Dimension/Box/TwigCallablesDimensionPresenter.php @@ -0,0 +1,190 @@ + + */ +final class TwigCallablesDimensionPresenter implements DimensionPresenterInterface +{ + public function __construct( + private readonly TwigCallablesMetricsReporter $reporter, + private readonly CallableSecurityAnalyzer $security, + ) { + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + public function present(array $results, int $maxDepth = 4, bool $includeDirs = true): array + { + $card = $this->reporter->generateMetrics($results); + $core = $card->coreMetrics; + $detail = $card->detailMetrics; + + [$fnTotals, $flTotals, $tsTotals, $macroTotals] = $this->aggregateByKind($results); + $totalCalls = array_sum($fnTotals) + array_sum($flTotals) + array_sum($tsTotals); + + $p = static fn (int $v, int $tot): int => (int) round(($tot > 0 ? $v / $tot : 0) * 100); + $fnPct = $p(array_sum($fnTotals), $totalCalls); + $flPct = $p(array_sum($flTotals), $totalCalls); + $tsPct = $p(array_sum($tsTotals), $totalCalls); + $customPct = max(0, 100 - ($fnPct + $flPct + $tsPct)); + + $topFns = $this->topList($fnTotals, 7); + $topFilters = $this->topList($flTotals, 7); + + $dirs = []; + if ($includeDirs && $maxDepth > 0) { + $dirs = $this->aggregateByDirectory($results); + } + + $sec = $this->security->analyzeSecurityScore($results); + $securityIssues = $this->formatSecurityIssues($sec->risks); + + return [ + 'summary' => [ + 'total_calls' => (int) ($core['total_calls'] ?? 0), + 'unique_functions' => (int) ($core['unique_functions'] ?? 0), + 'unique_filters' => (int) ($core['unique_filters'] ?? 0), + 'unique_tests' => (int) ($core['unique_tests'] ?? 0), + + 'funcs_per_template' => $totalCalls > 0 ? round(array_sum($fnTotals) / max(1, count($results)), 2) : 0.0, + 'filters_per_template' => $totalCalls > 0 ? round(array_sum($flTotals) / max(1, count($results)), 2) : 0.0, + 'security_score' => (int) ($detail['security_score'] ?? 0), + 'deprecated_count' => (int) ($detail['deprecated_count'] ?? 0), + ], + 'distribution' => [ + 'core_pct' => $fnPct, + 'custom_pct' => $customPct, + 'filters_pct' => $flPct, + 'tests_pct' => $tsPct, + ], + 'top_functions' => $topFns, + 'top_filters' => $topFilters, + 'directories' => $dirs, + 'security_issues' => $securityIssues, + 'final' => [ + 'score' => (float) $card->score, + 'grade' => (string) $card->grade, + ], + ]; + } + + /** + * @param AnalysisResult[] $results + * + * @return array{0: array, 1: array, 2: array, 3: array} + */ + private function aggregateByKind(array $results): array + { + $fn = $fl = $ts = $mac = []; + foreach ($results as $r) { + foreach ((array) ($r->getMetric('functions_detail') ?? []) as $name => $count) { + $fn[(string) $name] = ($fn[(string) $name] ?? 0) + (int) $count; + } + foreach ((array) ($r->getMetric('filters_detail') ?? []) as $name => $count) { + $fl[(string) $name] = ($fl[(string) $name] ?? 0) + (int) $count; + } + foreach ((array) ($r->getMetric('tests_detail') ?? []) as $name => $count) { + $ts[(string) $name] = ($ts[(string) $name] ?? 0) + (int) $count; + } + foreach ((array) ($r->getMetric('macro_calls_detail') ?? []) as $name => $count) { + $mac[(string) $name] = ($mac[(string) $name] ?? 0) + (int) $count; + } + } + + return [$fn, $fl, $ts, $mac]; + } + + /** + * @param array $totals + * + * @return array + */ + private function topList(array $totals, int $limit): array + { + arsort($totals); + $out = []; + foreach (array_slice($totals, 0, $limit, true) as $name => $count) { + $out[] = ['name' => (string) $name, 'count' => (int) $count]; + } + + return $out; + } + + /** + * @param AnalysisResult[] $results + * + * @return array> + */ + private function aggregateByDirectory(array $results): array + { + $dirs = []; + $riskyFns = ['dump', 'eval']; + $riskyFilters = ['raw', 'unsafe']; + foreach ($results as $r) { + $path = $r->getRelativePath(); + $dir = str_contains($path, '/') ? explode('/', $path)[0] : '(root)'; + if (!isset($dirs[$dir])) { + $dirs[$dir] = ['path' => $dir, 'fnc' => 0, 'fil' => 0, 'mac' => 0, 'tot' => 0, 'risk' => 0]; + } + $fns = (array) ($r->getMetric('functions_detail') ?? []); + $dirs[$dir]['fnc'] += array_sum($fns); + $fls = (array) ($r->getMetric('filters_detail') ?? []); + $dirs[$dir]['fil'] += array_sum($fls); + $mac = (array) ($r->getMetric('macro_calls_detail') ?? []); + $dirs[$dir]['mac'] += array_sum($mac); + $dirs[$dir]['tot'] += array_sum($fns) + array_sum($fls) + array_sum($mac); + foreach ($fns as $name => $count) { + if (in_array((string) $name, $riskyFns, true)) { + $dirs[$dir]['risk'] += (int) $count; + } + } + foreach ($fls as $name => $count) { + if (in_array((string) $name, $riskyFilters, true)) { + $dirs[$dir]['risk'] += (int) $count; + } + } + } + + foreach ($dirs as &$row) { + $tot = max(1, $row['tot']); + $fncW = (int) round(($row['fnc'] / $tot) * 10); + $filW = (int) round(($row['fil'] / $tot) * 10); + $macW = max(0, 10 - $fncW - $filW); + $row['bar'] = str_repeat('█', $fncW).str_repeat('▓', $filW).str_repeat('▒', $macW); + } + + return array_values($dirs); + } + + /** + * @param array $risks + * + * @return array> + */ + private function formatSecurityIssues(array $risks): array + { + arsort($risks); + $out = []; + foreach (array_slice($risks, 0, 5, true) as $name => $count) { + $sev = match ((string) $name) { + 'dump', 'eval', 'unsafe' => 'HIGH', + 'raw' => 'MEDIUM', + default => 'LOW', + }; + $out[] = ['name' => (string) $name, 'count' => (int) $count, 'severity' => $sev]; + } + + return $out; + } +} diff --git a/src/Renderer/Dimension/DimensionRendererFactory.php b/src/Renderer/Dimension/DimensionRendererFactory.php new file mode 100644 index 0000000..3125332 --- /dev/null +++ b/src/Renderer/Dimension/DimensionRendererFactory.php @@ -0,0 +1,134 @@ + + */ +final class DimensionRendererFactory +{ + public function createRenderer(string $dimension, OutputInterface $output): AbstractDimensionBoxRenderer + { + return match ($dimension) { + 'template-files' => new TemplateFilesDimensionBoxRenderer($output), + 'complexity' => new ComplexityDimensionBoxRenderer($output), + 'code-style' => new CodeStyleDimensionBoxRenderer($output), + 'callables' => new TwigCallablesDimensionBoxRenderer($output), + 'architecture' => new ArchitectureDimensionBoxRenderer($output), + 'maintainability' => new MaintainabilityDimensionBoxRenderer($output), + default => throw new \InvalidArgumentException("Unknown dimension: $dimension"), + }; + } + + public function createPresenter(string $dimension): DimensionPresenterInterface + { + return match ($dimension) { + 'template-files' => $this->createTemplateFilesPresenter(), + 'complexity' => $this->createComplexityPresenter(), + 'code-style' => $this->createCodeStylePresenter(), + 'callables' => $this->createCallablesPresenter(), + 'architecture' => $this->createArchitecturePresenter(), + 'maintainability' => $this->createMaintainabilityPresenter(), + default => throw new \InvalidArgumentException("Unknown dimension: $dimension"), + }; + } + + private function createTemplateFilesPresenter(): TemplateFilesDimensionPresenter + { + return new TemplateFilesDimensionPresenter( + new TemplateFilesMetricsReporter(new StatisticalCalculator(), new DistributionCalculator()), + new DirectoryMetricsAggregator(), + new StatisticalCalculator() + ); + } + + private function createComplexityPresenter(): ComplexityDimensionPresenter + { + return new ComplexityDimensionPresenter( + new LogicalComplexityMetricsReporter( + new StatisticalCalculator(), + new ComplexityCalculator(), + new ComplexityHotspotDetector(), + new DimensionGrader(), + ), + new DirectoryMetricsAggregator(), + new ComplexityHotspotDetector(), + new StatisticalCalculator() + ); + } + + private function createCodeStylePresenter(): CodeStyleDimensionPresenter + { + return new CodeStyleDimensionPresenter( + new CodeStyleMetricsReporter(new StatisticalCalculator(), new StyleConsistencyAnalyzer()), + new DirectoryMetricsAggregator(), + ); + } + + private function createCallablesPresenter(): TwigCallablesDimensionPresenter + { + return new TwigCallablesDimensionPresenter( + new TwigCallablesMetricsReporter(new DiversityCalculator(), new CallableSecurityAnalyzer()), + new CallableSecurityAnalyzer() + ); + } + + private function createArchitecturePresenter(): ArchitectureDimensionPresenter + { + return new ArchitectureDimensionPresenter( + new ArchitectureMetricsReporter( + new CouplingAnalyzer(), + new BlockUsageAnalyzer(), + new DimensionGrader(), + ), + new DirectoryMetricsAggregator(), + new StatisticalCalculator() + ); + } + + private function createMaintainabilityPresenter(): MaintainabilityDimensionPresenter + { + return new MaintainabilityDimensionPresenter( + new MaintainabilityMetricsReporter( + new ComplexityCalculator(), + new DimensionGrader(), + ), + new DirectoryMetricsAggregator(), + ); + } +} diff --git a/src/Renderer/Helper/ConsoleOutputHelper.php b/src/Renderer/Helper/ConsoleOutputHelper.php index ba10f96..32597fc 100644 --- a/src/Renderer/Helper/ConsoleOutputHelper.php +++ b/src/Renderer/Helper/ConsoleOutputHelper.php @@ -26,7 +26,7 @@ public function writeHeader(): void public function writeMainHeader(): void { - $borderInside = 76; + $borderInside = 74; $text = 'T W I G 🌿 M E T R I C S'; $reserved = 3; $contentWidth = max(0, $borderInside - 2 * $reserved); @@ -35,9 +35,9 @@ public function writeMainHeader(): void $right = ($pad - (int) floor($pad / 2)) + $reserved; $centered = str_repeat(' ', $left).$text.str_repeat(' ', $right); - $top = ' '.sprintf('╭%s╮', str_repeat('─', $borderInside)).' '; - $middle = ' '.sprintf('│%s│', $centered).' '; - $bottom = ' '.sprintf('╰%s╯', str_repeat('─', $borderInside)).' '; + $top = ' '.sprintf('╭%s╮', str_repeat('─', $borderInside)).' '; + $middle = ' '.sprintf('│%s│', $centered).' '; + $bottom = ' '.sprintf('╰%s╯', str_repeat('─', $borderInside)).' '; $this->output->writeln($top); $this->output->writeln($middle); @@ -56,36 +56,21 @@ public function writeBetaWarning(): void $this->output->writeln(' | TwigMetrics is in development. Use with caution. |'); $this->output->writeln(' | |'); $this->output->writeln(' | Report issue / feedback Support the development |'); - $this->output->writeln(' | github.com/smnandre/twig-metrics github.com/sponsor/smnandre |'); + $this->output->writeln(' | github.com/smnandre/twigmetrics github.com/sponsor/smnandre |'); $this->output->writeln(' | |'); $this->output->writeln(' - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - '); } public function writeSeparatorLine(): void { - $this->output->writeln('───────────────────────────────────────────────────────────────────────────────'); + $this->output->writeln(' ─────────────────────────────────────────────────────────────────────────── '); $this->output->writeln(''); } public function writeSection(string $title): void { $this->output->writeln(''); - $this->output->writeln(" {$title}"); - $this->output->writeln(''); - } - - public function writeDimensionSection(string $title, int $number): void - { - $this->output->writeln(''); - $this->output->writeln(''); - - $prefix = "── {$number}. ".strtoupper($title).' '; - $totalWidth = 79; - $remainingWidth = $totalWidth - mb_strlen($prefix); - $suffix = str_repeat('─', max(0, $remainingWidth)); - - $this->output->writeln($prefix.$suffix); - $this->output->writeln(''); + $this->output->writeln(" {$title} "); $this->output->writeln(''); } @@ -100,7 +85,7 @@ public function writeSectionTitle(string $title, ?int $number = null): void $title = sprintf('%s. %s', $number, $title); } - $prefix = \sprintf(' ━━━ %s ', $title); + $prefix = \sprintf(' ━━━ %s ', $title); $totalWidth = 78; $remainingWidth = $totalWidth - mb_strlen(strip_tags($prefix)); $suffix = str_repeat('━', max(0, $remainingWidth)); @@ -225,4 +210,48 @@ public function writeDistribution(array $distribution, string $title): void $this->output->writeln(''); } + + public function color(string $text, string $fg, ?string $options = null): string + { + $opt = $options ? ";options={$options}" : ''; + + return sprintf('%s', $fg, $opt, $text); + } + + public function gray(string $text): string + { + return $this->color($text, '#cccccc'); + } + + public function white(string $text, bool $bold = false): string + { + return $this->color($text, '#ffffff', $bold ? 'bold' : null); + } + + public function gradeColor(string $grade): string + { + $map = [ + 'A+' => 'green', 'A' => 'green', + 'B' => 'cyan', + 'C+' => 'yellow', 'C' => 'yellow', + 'D' => 'red', 'E' => 'red', + ]; + $fg = $map[$grade] ?? 'red'; + $opts = in_array($grade, ['A+', 'A'], true) ? 'bold' : null; + + return $this->color($grade, $fg, $opts); + } + + public function riskLabel(string $risk): string + { + $label = strtoupper($risk); + + return match ($risk) { + 'low' => $this->color($label, 'green'), + 'moderate', 'medium' => $this->color($label, 'yellow'), + 'high' => $this->color($label, 'red'), + 'critical' => $this->color($label, 'red', 'bold'), + default => $this->gray($label), + }; + } } diff --git a/src/Reporter/Dimension/ArchitectureMetricsReporter.php b/src/Reporter/Dimension/ArchitectureMetricsReporter.php new file mode 100644 index 0000000..80f5f9f --- /dev/null +++ b/src/Reporter/Dimension/ArchitectureMetricsReporter.php @@ -0,0 +1,193 @@ + + */ +final class ArchitectureMetricsReporter +{ + public function __construct( + private readonly CouplingAnalyzer $coupling, + private readonly BlockUsageAnalyzer $blockUsage, + private readonly DimensionGrader $grader = new DimensionGrader(), + ) { + } + + /** + * @param AnalysisResult[] $results + */ + public function generateMetrics(array $results): DimensionMetrics + { + $coupling = $this->coupling->analyzeCoupling($results); + $roles = $this->classifyRoles($results); + [$graph, $in, $out] = $this->buildGraphStats($results); + $orphans = $this->findOrphans($in, $out); + + $maxDepth = $this->maxInheritanceDepth($results); + $componentsTotal = $roles['components'] ?? 0; + $rolesTotal = array_sum($roles); + $componentsRatio = $rolesTotal > 0 ? $componentsTotal / $rolesTotal : 0.0; + $orphanRatio = $rolesTotal > 0 ? count($orphans) / $rolesTotal : 0.0; + + [$score, $grade] = $this->grader->gradeArchitecture( + componentsRatio: $componentsRatio, + orphanRatio: $orphanRatio, + circularRefs: $coupling->circularRefs, + maxDepth: $maxDepth, + ); + + $blockMetrics = $this->blockUsage->analyzeBlockUsage($results); + + return new DimensionMetrics( + name: 'Architecture', + score: $score, + grade: $grade, + coreMetrics: [ + 'avg_fan_in' => round($coupling->avgFanIn, 2), + 'avg_fan_out' => round($coupling->avgFanOut, 2), + 'instability' => round($coupling->instabilityIndex, 2), + 'circular_refs' => $coupling->circularRefs, + 'orphans' => count($orphans), + 'max_inheritance_depth' => $maxDepth, + ], + detailMetrics: [ + 'components_ratio' => round($componentsRatio, 3), + 'orphan_ratio' => round($orphanRatio, 3), + 'block_usage_ratio' => round($blockMetrics->usageRatio, 3), + 'orphaned_blocks' => count($blockMetrics->orphanedBlocks), + ], + distributions: [ + 'roles' => $roles, + ], + insights: $this->insights($orphans, $coupling->circularRefs, $maxDepth, $roles), + ); + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function classifyRoles(array $results): array + { + $roles = ['components' => 0, 'pages' => 0, 'layouts' => 0, 'other' => 0]; + foreach ($results as $r) { + $category = (string) ($r->getMetric('file_category') ?? ''); + $path = $r->getRelativePath(); + if ('component' === $category || str_contains($path, 'component')) { + ++$roles['components']; + } elseif ('page' === $category || str_contains($path, 'page')) { + ++$roles['pages']; + } elseif ('layout' === $category || str_contains($path, 'layout') || str_contains($path, 'base')) { + ++$roles['layouts']; + } else { + ++$roles['other']; + } + } + + return $roles; + } + + /** + * @param AnalysisResult[] $results + * + * @return array{0: array>, 1: array, 2: array} + */ + private function buildGraphStats(array $results): array + { + $graph = []; + $in = []; + $out = []; + foreach ($results as $r) { + $src = $r->getRelativePath(); + $deps = $r->getMetric('dependencies') ?? []; + $graph[$src] = []; + foreach (is_array($deps) ? $deps : [] as $d) { + $tpl = is_array($d) && isset($d['template']) ? (string) $d['template'] : (string) $d; + if ('' === $tpl) { + continue; + } + $graph[$src][] = $tpl; + } + } + + foreach ($graph as $src => $deps) { + $out[$src] = count(array_unique($deps)); + foreach (array_unique($deps) as $dst) { + $in[$dst] = ($in[$dst] ?? 0) + 1; + } + $in[$src] = $in[$src] ?? 0; + } + + return [$graph, $in, $out]; + } + + /** + * @param array $in + * @param array $out + * + * @return list + */ + private function findOrphans(array $in, array $out): array + { + $orphans = []; + foreach ($out as $node => $o) { + $i = $in[$node] ?? 0; + if (0 === $i && 0 === $o) { + $orphans[] = $node; + } + } + + return $orphans; + } + + /** + * @param AnalysisResult[] $results + */ + private function maxInheritanceDepth(array $results): int + { + $max = 0; + foreach ($results as $r) { + $depth = (int) ($r->getMetric('inheritance_depth') ?? 0); + if ($depth > $max) { + $max = $depth; + } + } + + return $max; + } + + /** + * @param list $orphans + * @param array $roles + * + * @return list + */ + private function insights(array $orphans, int $circularRefs, int $maxDepth, array $roles): array + { + $insights = []; + if ($circularRefs > 0) { + $insights[] = sprintf('Circular refs detected: %d', $circularRefs); + } + if (!empty($orphans)) { + $insights[] = sprintf('Orphaned templates: %d', count($orphans)); + } + if ($maxDepth > 4) { + $insights[] = sprintf('Deep inheritance chains (max depth: %d)', $maxDepth); + } + if (($roles['components'] ?? 0) > ($roles['pages'] ?? 0)) { + $insights[] = 'Component-oriented architecture'; + } + + return $insights; + } +} diff --git a/src/Reporter/Dimension/CodeStyleMetricsReporter.php b/src/Reporter/Dimension/CodeStyleMetricsReporter.php new file mode 100644 index 0000000..db6835e --- /dev/null +++ b/src/Reporter/Dimension/CodeStyleMetricsReporter.php @@ -0,0 +1,183 @@ + + */ +final class CodeStyleMetricsReporter +{ + public function __construct( + private readonly StatisticalCalculator $stats, + private readonly StyleConsistencyAnalyzer $styleAnalyzer, + private readonly DimensionGrader $grader = new DimensionGrader(), + ) { + } + + /** + * @param AnalysisResult[] $results + */ + public function generateMetrics(array $results): DimensionMetrics + { + $maxLineValues = $this->extractIntMetric($results, 'max_line_length'); + $summary = $this->stats->calculate($maxLineValues); + + $style = $this->styleAnalyzer->analyze($results); + + $violations = $this->aggregateViolations($results); + $mixedIndentRatio = $violations['files_mixed_indent'] / max(1, count($results)); + $commentDensityAvg = $this->averageFloatMetric($results, 'comment_density'); + + [$score, $grade] = $this->grader->gradeCodeStyle( + consistency: $style->consistencyScore, + maxLine: (int) $summary->p95, + commentDensity: $commentDensityAvg, + mixedIndentRatio: $mixedIndentRatio, + ); + + return new DimensionMetrics( + name: 'Code Style', + score: $score, + grade: $grade, + coreMetrics: [ + 'consistency' => round($style->consistencyScore, 1), + 'readability' => round($style->readabilityScore, 1), + 'entropy' => round($style->formattingEntropy, 3), + 'p95_line_length' => (int) round($summary->p95), + ], + detailMetrics: [ + 'long_lines_files' => $violations['files_long_lines'], + 'trailing_spaces_files' => $violations['files_trailing_spaces'], + 'mixed_indent_files' => $violations['files_mixed_indent'], + 'comment_density_avg' => round($commentDensityAvg, 2), + ], + distributions: [ + 'line_length' => $this->bucketLineLengths($maxLineValues), + ], + insights: $this->makeInsights((int) round($summary->p95), $violations['files_mixed_indent']), + ); + } + + /** + * @param AnalysisResult[] $results + * + * @return array{files_long_lines:int,files_trailing_spaces:int,files_mixed_indent:int} + */ + private function aggregateViolations(array $results): array + { + $long = 0; + $trail = 0; + $mixed = 0; + foreach ($results as $r) { + $maxLine = (int) ($r->getMetric('max_line_length') ?? 0); + $trailing = (int) ($r->getMetric('trailing_spaces') ?? 0); + $mixedLines = (int) ($r->getMetric('mixed_indentation_lines') ?? 0); + if ($maxLine > 120) { + ++$long; + } + if ($trailing > 0) { + ++$trail; + } + if ($mixedLines > 0) { + ++$mixed; + } + } + + return [ + 'files_long_lines' => $long, + 'files_trailing_spaces' => $trail, + 'files_mixed_indent' => $mixed, + ]; + } + + /** + * @param list $values + * + * @return array + */ + private function bucketLineLengths(array $values): array + { + $buckets = [ + '<=80' => 0, + '81-120' => 0, + '121-160' => 0, + '>160' => 0, + ]; + foreach ($values as $v) { + $n = (int) $v; + if ($n <= 80) { + ++$buckets['<=80']; + } elseif ($n <= 120) { + ++$buckets['81-120']; + } elseif ($n <= 160) { + ++$buckets['121-160']; + } else { + ++$buckets['>160']; + } + } + $total = max(1, count($values)); + $out = []; + foreach ($buckets as $k => $c) { + $out[$k] = ['count' => $c, 'percentage' => ($c / $total) * 100.0]; + } + + return $out; + } + + /** + * @param AnalysisResult[] $results + * + * @return list + */ + private function extractIntMetric(array $results, string $name): array + { + $values = []; + foreach ($results as $result) { + $values[] = (int) ($result->getMetric($name) ?? 0); + } + + return $values; + } + + /** + * @param AnalysisResult[] $results + */ + private function averageFloatMetric(array $results, string $name): float + { + $sum = 0.0; + $n = 0; + foreach ($results as $r) { + $val = $r->getMetric($name); + if (is_numeric($val)) { + $sum += (float) $val; + ++$n; + } + } + + return $n > 0 ? $sum / $n : 0.0; + } + + /** + * @return list + */ + private function makeInsights(int $p95, int $mixedIndentFiles): array + { + $insights = []; + if ($p95 > 120) { + $insights[] = sprintf('High p95 line length (%d)', $p95); + } + if ($mixedIndentFiles > 0) { + $insights[] = sprintf('Mixed indentation in %d file(s)', $mixedIndentFiles); + } + + return $insights; + } +} diff --git a/src/Reporter/Dimension/LogicalComplexityMetricsReporter.php b/src/Reporter/Dimension/LogicalComplexityMetricsReporter.php new file mode 100644 index 0000000..a657a31 --- /dev/null +++ b/src/Reporter/Dimension/LogicalComplexityMetricsReporter.php @@ -0,0 +1,156 @@ + + */ +final class LogicalComplexityMetricsReporter +{ + public function __construct( + private readonly StatisticalCalculator $stats, + private readonly ComplexityCalculator $complexityCalc, + private readonly ComplexityHotspotDetector $hotspots, + private readonly DimensionGrader $grader = new DimensionGrader(), + ) { + } + + /** + * @param AnalysisResult[] $results + */ + public function generateMetrics(array $results): DimensionMetrics + { + $complexities = $this->extractIntMetric($results, 'complexity_score'); + $summary = $this->stats->calculate($complexities); + + $buckets = $this->bucketComplexity($complexities); + $criticalCount = $buckets['critical']; + $criticalRatio = count($results) > 0 ? $criticalCount / count($results) : 0.0; + + [$avgLogic, $avgDecisionDensity, $avgMi] = $this->averages($results); + + [$score, $grade] = $this->grader->gradeComplexity( + avg: $summary->mean, + max: (int) $summary->max, + criticalRatio: $criticalRatio, + logicRatio: $avgLogic, + ); + + $hot = $this->hotspots->detectHotspots($results, 5); + + return new DimensionMetrics( + name: 'Logical Complexity', + score: $score, + grade: $grade, + coreMetrics: [ + 'avg' => round($summary->mean, 2), + 'median' => $summary->median, + 'max' => (int) $summary->max, + 'critical_files' => $criticalCount, + ], + detailMetrics: [ + 'logic_ratio' => round($avgLogic, 3), + 'decision_density' => round($avgDecisionDensity, 3), + 'mi_avg' => round($avgMi, 2), + ], + distributions: [ + 'heatmap' => [ + 'simple' => $buckets['simple'], + 'moderate' => $buckets['moderate'], + 'complex' => $buckets['complex'], + 'critical' => $buckets['critical'], + ], + ], + insights: $this->makeInsights($criticalCount, (int) $summary->max, $hot), + ); + } + + /** + * @param AnalysisResult[] $results + * + * @return array{0: float, 1: float, 2: float} + */ + private function averages(array $results): array + { + $n = max(1, count($results)); + $sumLogic = 0.0; + $sumDensity = 0.0; + $sumMi = 0.0; + foreach ($results as $r) { + $sumLogic += $this->complexityCalc->calculateLogicRatio($r); + $sumDensity += $this->complexityCalc->calculateDecisionDensity($r); + $sumMi += $this->complexityCalc->calculateMaintainabilityIndex($r); + } + + return [$sumLogic / $n, $sumDensity / $n, $sumMi / $n]; + } + + /** + * @param list $values + * + * @return array{simple:int,moderate:int,complex:int,critical:int} + */ + private function bucketComplexity(array $values): array + { + $b = ['simple' => 0, 'moderate' => 0, 'complex' => 0, 'critical' => 0]; + foreach ($values as $v) { + $c = (int) $v; + if ($c <= 5) { + ++$b['simple']; + } elseif ($c <= 15) { + ++$b['moderate']; + } elseif ($c <= 25) { + ++$b['complex']; + } else { + ++$b['critical']; + } + } + + return $b; + } + + /** + * @param AnalysisResult[] $results + * + * @return list + */ + private function extractIntMetric(array $results, string $name): array + { + $values = []; + foreach ($results as $result) { + $values[] = (int) ($result->getMetric($name) ?? 0); + } + + return $values; + } + + /** + * @param array $hot + * + * @return list + */ + private function makeInsights(int $critical, int $max, array $hot): array + { + $insights = []; + if ($critical > 0) { + $insights[] = sprintf('%d critical file(s)', $critical); + } + if ($max > 25) { + $insights[] = sprintf('Max complexity: %d', $max); + } + if (!empty($hot)) { + $insights[] = sprintf('Hotspot: %s [%d]', $hot[0]['file'], $hot[0]['complexity']); + } + + return $insights; + } +} diff --git a/src/Reporter/Dimension/MaintainabilityMetricsReporter.php b/src/Reporter/Dimension/MaintainabilityMetricsReporter.php new file mode 100644 index 0000000..c4d1f7d --- /dev/null +++ b/src/Reporter/Dimension/MaintainabilityMetricsReporter.php @@ -0,0 +1,150 @@ + + */ +final class MaintainabilityMetricsReporter +{ + public function __construct( + private readonly ComplexityCalculator $complexity, + private readonly DimensionGrader $grader = new DimensionGrader(), + ) { + } + + /** + * @param AnalysisResult[] $results + */ + public function generateMetrics(array $results): DimensionMetrics + { + $miValues = []; + $riskRows = []; + + foreach ($results as $r) { + $mi = $this->complexity->calculateMaintainabilityIndex($r); + $miValues[] = $mi; + $riskRows[] = [ + 'template' => $r->getRelativePath(), + 'risk' => $this->priorityScore($r), + 'complexity' => (int) ($r->getMetric('complexity_score') ?? 0), + 'lines' => (int) ($r->getMetric('lines') ?? 0), + ]; + } + + sort($miValues, SORT_NUMERIC); + $miAvg = $this->avg($miValues); + $miMedian = $this->median($miValues); + + [$score, $grade] = $this->grader->gradeMaintainability($miAvg); + + usort($riskRows, static fn ($a, $b) => $b['risk'] <=> $a['risk']); + $top = array_slice($riskRows, 0, 5); + + $buckets = $this->bucketRisk(array_column($riskRows, 'risk')); + + return new DimensionMetrics( + name: 'Maintainability', + score: $score, + grade: $grade, + coreMetrics: [ + 'mi_avg' => round($miAvg, 1), + 'mi_median' => round($miMedian, 1), + 'refactor_candidates' => count($top), + ], + detailMetrics: [ + 'risk_high' => $buckets['high'], + 'risk_medium' => $buckets['medium'], + 'risk_low' => $buckets['low'], + ], + distributions: [ + 'risk' => $buckets, + ], + insights: $this->insights($top), + ); + } + + private function priorityScore(AnalysisResult $r): float + { + $complexity = (int) ($r->getMetric('complexity_score') ?? 0); + $lines = (int) ($r->getMetric('lines') ?? 0); + $deps = is_array($r->getMetric('dependencies') ?? null) ? count((array) $r->getMetric('dependencies')) : 0; + $styleConsistency = (float) ($r->getMetric('formatting_consistency_score') ?? 100.0); + + $nx = min(1.0, $complexity / 30.0); + $nl = min(1.0, $lines / 200.0); + $nd = min(1.0, $deps / 5.0); + $ns = 1.0 - min(1.0, $styleConsistency / 100.0); + + return 0.4 * $nx + 0.3 * $nl + 0.2 * $nd + 0.1 * $ns; + } + + /** + * @param list $values + */ + private function avg(array $values): float + { + $n = count($values); + + return $n > 0 ? array_sum($values) / $n : 0.0; + } + + /** + * @param list $values + */ + private function median(array $values): float + { + $n = count($values); + if (0 === $n) { + return 0.0; + } + $mid = intdiv($n, 2); + + return 0 === $n % 2 ? ($values[$mid - 1] + $values[$mid]) / 2.0 : $values[$mid]; + } + + /** + * @param list $risks + * + * @return array{high:int,medium:int,low:int} + */ + private function bucketRisk(array $risks): array + { + $b = ['high' => 0, 'medium' => 0, 'low' => 0]; + foreach ($risks as $r) { + if ($r >= 0.7) { + ++$b['high']; + } elseif ($r >= 0.35) { + ++$b['medium']; + } else { + ++$b['low']; + } + } + + return $b; + } + + /** + * @param array $top + * + * @return list + */ + private function insights(array $top): array + { + if (empty($top)) { + return []; + } + $first = $top[0]; + + return [ + sprintf('Top refactor: %s (risk %.2f)', $first['template'], $first['risk']), + ]; + } +} diff --git a/src/Reporter/Dimension/TemplateFilesMetricsReporter.php b/src/Reporter/Dimension/TemplateFilesMetricsReporter.php new file mode 100644 index 0000000..dcff330 --- /dev/null +++ b/src/Reporter/Dimension/TemplateFilesMetricsReporter.php @@ -0,0 +1,152 @@ + + */ +final class TemplateFilesMetricsReporter +{ + public function __construct( + private readonly StatisticalCalculator $stats, + private readonly DistributionCalculator $distribution, + private readonly DimensionGrader $grader = new DimensionGrader(), + ) { + } + + /** + * @param AnalysisResult[] $results + */ + public function generateMetrics(array $results): DimensionMetrics + { + $lineValues = $this->extractMetricValues($results, 'lines'); + $summary = $this->stats->calculate($lineValues); + + $dirDist = $this->calculateDirectoryBalance($results); + $dirDominance = $this->maxShare($dirDist); + $maxLines = max([0, ...$lineValues]); + + [$score, $grade] = $this->grader->gradeTemplateFiles($summary, $dirDominance, $maxLines); + + return new DimensionMetrics( + name: 'Template Files', + score: $score, + grade: $grade, + coreMetrics: [ + 'templates' => count($results), + 'avgLines' => $summary->mean, + 'medianLines' => $summary->median, + 'stdDev' => $summary->stdDev, + ], + detailMetrics: [ + 'cv' => $summary->coefficientOfVariation, + 'giniIndex' => $summary->giniIndex, + 'entropy' => $summary->entropy, + 'p95' => $summary->p95, + 'dirDominance' => $dirDominance, + ], + distributions: [ + 'size' => $this->distribution->calculateSizeDistribution($results), + 'directory' => $this->formatDirDistribution($dirDist, count($results)), + ], + insights: $this->generateInsights($summary, $dirDominance, $maxLines), + ); + } + + /** + * @param AnalysisResult[] $results + * + * @return array + */ + private function calculateDirectoryBalance(array $results): array + { + $counts = []; + foreach ($results as $result) { + $path = $result->getRelativePath(); + $dir = str_contains($path, '/') ? dirname($path) : '.'; + $counts[$dir] = ($counts[$dir] ?? 0) + 1; + } + arsort($counts); + + return $counts; + } + + /** + * @param array $dirCounts + * + * @return array + */ + private function formatDirDistribution(array $dirCounts, int $total): array + { + $total = max(1, $total); + $out = []; + foreach ($dirCounts as $dir => $count) { + $out[$dir] = [ + 'count' => $count, + 'percentage' => ($count / $total) * 100.0, + ]; + } + + return $out; + } + + /** + * @param AnalysisResult[] $results + * + * @return list + */ + private function extractMetricValues(array $results, string $name): array + { + $values = []; + foreach ($results as $result) { + $val = $result->getMetric($name) ?? 0; + $values[] = (int) $val; + } + + return $values; + } + + /** + * @param array $dirDist + */ + private function maxShare(array $dirDist): float + { + $total = array_sum($dirDist); + if ($total <= 0) { + return 0.0; + } + $max = 0; + foreach ($dirDist as $count) { + $max = max($max, $count); + } + + return $max / $total; + } + + /** + * @return list + */ + private function generateInsights(\TwigMetrics\Metric\StatisticalSummary $s, float $dirDominance, int $maxLines): array + { + $insights = []; + if ($s->giniIndex < 0.35) { + $insights[] = sprintf('Balanced sizes (Gini: %.2f)', $s->giniIndex); + } + if ($dirDominance > 0.5) { + $insights[] = sprintf('One directory dominates (%.0f%%)', $dirDominance * 100); + } + if ($maxLines > 200) { + $insights[] = sprintf('Large templates present (max: %d lines)', $maxLines); + } + + return $insights; + } +} diff --git a/src/Reporter/Dimension/TwigCallablesMetricsReporter.php b/src/Reporter/Dimension/TwigCallablesMetricsReporter.php new file mode 100644 index 0000000..de4496b --- /dev/null +++ b/src/Reporter/Dimension/TwigCallablesMetricsReporter.php @@ -0,0 +1,142 @@ + + */ +final class TwigCallablesMetricsReporter +{ + public function __construct( + private readonly DiversityCalculator $diversity, + private readonly CallableSecurityAnalyzer $security, + private readonly DimensionGrader $grader = new DimensionGrader(), + ) { + } + + /** + * @param AnalysisResult[] $results + */ + public function generateMetrics(array $results): DimensionMetrics + { + [$functions, $filters, $tests] = $this->aggregateUsage($results); + $mergedUsage = $this->mergeUsage($functions, $filters); + + $diversityIndex = $this->diversity->calculateSimpsonDiversity($mergedUsage); + $usageEntropy = $this->diversity->calculateUsageEntropy($mergedUsage); + + $security = $this->security->analyzeSecurityScore($results); + $debugCalls = $security->risks['dump'] ?? 0; + + [$score, $grade] = $this->grader->gradeCallables( + securityScore: (float) $security->score, + diversityIndex: $diversityIndex, + deprecatedCount: (int) $security->deprecatedCount, + debugCalls: (int) $debugCalls, + ); + + $totalCalls = array_sum($functions) + array_sum($filters) + array_sum($tests); + $templates = max(1, count($results)); + + return new DimensionMetrics( + name: 'Twig Callables', + score: $score, + grade: $grade, + coreMetrics: [ + 'total_calls' => $totalCalls, + 'unique_functions' => count($functions), + 'unique_filters' => count($filters), + 'unique_tests' => count($tests), + 'avg_calls_per_template' => round($totalCalls / $templates, 2), + ], + detailMetrics: [ + 'diversity_index' => round($diversityIndex, 3), + 'usage_entropy' => round($usageEntropy, 3), + 'security_score' => $security->score, + 'deprecated_count' => $security->deprecatedCount, + ], + distributions: [ + 'usage_breakdown' => [ + 'functions' => array_sum($functions), + 'filters' => array_sum($filters), + 'tests' => array_sum($tests), + ], + ], + insights: $this->buildInsights($security->risks), + ); + } + + /** + * @param AnalysisResult[] $results + * + * @return array{0: array, 1: array, 2: array} + */ + private function aggregateUsage(array $results): array + { + $functions = []; + $filters = []; + $tests = []; + + foreach ($results as $result) { + foreach ((array) ($result->getMetric('functions_detail') ?? []) as $name => $count) { + $functions[(string) $name] = ($functions[(string) $name] ?? 0) + (int) $count; + } + foreach ((array) ($result->getMetric('filters_detail') ?? []) as $name => $count) { + $filters[(string) $name] = ($filters[(string) $name] ?? 0) + (int) $count; + } + foreach ((array) ($result->getMetric('tests_detail') ?? []) as $name => $count) { + $tests[(string) $name] = ($tests[(string) $name] ?? 0) + (int) $count; + } + } + + ksort($functions); + ksort($filters); + ksort($tests); + + return [$functions, $filters, $tests]; + } + + /** + * @param array $a + * @param array $b + * + * @return array + */ + private function mergeUsage(array $a, array $b): array + { + $out = $a; + foreach ($b as $k => $v) { + $out[$k] = ($out[$k] ?? 0) + $v; + } + + return $out; + } + + /** + * @param array $risks + * + * @return list + */ + private function buildInsights(array $risks): array + { + if (empty($risks)) { + return []; + } + arsort($risks); + $top = array_slice($risks, 0, 3, true); + $lines = []; + foreach ($top as $name => $count) { + $lines[] = sprintf('Risky: %s (%d)', $name, $count); + } + + return $lines; + } +} diff --git a/src/Reporter/Helper/DimensionGrader.php b/src/Reporter/Helper/DimensionGrader.php new file mode 100644 index 0000000..b4fa358 --- /dev/null +++ b/src/Reporter/Helper/DimensionGrader.php @@ -0,0 +1,181 @@ + + */ +final class DimensionGrader +{ + /** + * Grade template files based on statistical measures and size. + * + * @param StatisticalSummary $stats statistical summary of template file sizes + * @param float $dirDominance proportion of files in the largest directory + * @param int $maxLines maximum number of lines in a single template file + * + * @return array{float, string} tuple containing the score and grade (A, B, C, D) + */ + public function gradeTemplateFiles(StatisticalSummary $stats, float $dirDominance, int $maxLines): array + { + $cv = $stats->coefficientOfVariation; + $gini = $stats->giniIndex; + + $grade = 'D'; + $score = 60.0; + + if ($cv < 0.6 && $gini < 0.35 && $maxLines < 200 && $dirDominance < 0.45) { + $grade = 'A'; + $score = 95.0; + } elseif ($cv < 0.9 && $gini < 0.50 && $maxLines < 300 && $dirDominance < 0.55) { + $grade = 'B'; + $score = 85.0; + } elseif ($cv < 1.2 && $gini < 0.65 && $maxLines < 400 && $dirDominance < 0.65) { + $grade = 'C'; + $score = 75.0; + } + + return [$score, $grade]; + } + + /** + * Grade logical complexity based on average and maximum complexity, critical ratio, and logic ratio. + * + * @param float $avg average complexity score across all templates + * @param int $max maximum complexity score found in any single template + * @param float $criticalRatio proportion of templates with complexity > 20 + * @param float $logicRatio proportion of lines that are logical (conditions + loops) vs total lines + * + * @return array{float, string} tuple containing the score and grade (A, B, C, D) + */ + public function gradeComplexity(float $avg, int $max, float $criticalRatio, float $logicRatio): array + { + $grade = 'D'; + $score = 60.0; + + if ($avg < 10 && $max < 40 && $criticalRatio <= 0.02 && $logicRatio < 0.20) { + $grade = 'A'; + $score = 95.0; + } elseif ($avg < 15 && $max < 80 && $criticalRatio < 0.08 && $logicRatio < 0.30) { + $grade = 'B'; + $score = 85.0; + } elseif ($avg < 25 && $max < 120 && $criticalRatio < 0.15 && $logicRatio < 0.40) { + $grade = 'C'; + $score = 75.0; + } + + return [$score, $grade]; + } + + /** + * Grade code style based on consistency, line length, comment density, and mixed indentation ratio. + * + * @param float $consistency percentage of files following the dominant style + * @param int $maxLine maximum line length found in any template + * @param float $commentDensity average percentage of comment lines vs total lines + * @param float $mixedIndentRatio proportion of lines with mixed indentation vs total lines + * + * @return array{float, string} tuple containing the score and grade (A, B, C, D) + */ + public function gradeCodeStyle(float $consistency, int $maxLine, float $commentDensity, float $mixedIndentRatio): array + { + $grade = 'D'; + $score = 60.0; + + if ($consistency > 95 && $maxLine < 150 && $commentDensity >= 5 && $commentDensity <= 25 && 0.0 === $mixedIndentRatio) { + $grade = 'A'; + $score = 95.0; + } elseif ($consistency > 85 && $maxLine < 200 && $commentDensity >= 2 && $commentDensity <= 30 && $mixedIndentRatio < 0.05) { + $grade = 'B'; + $score = 85.0; + } elseif ($consistency > 75 && $maxLine < 250 && $commentDensity >= 1 && $commentDensity <= 35 && $mixedIndentRatio < 0.10) { + $grade = 'C'; + $score = 75.0; + } + + return [$score, $grade]; + } + + /** + * Grade callables based on security score, diversity index, deprecated count, and debug calls. + * + * @return array{float, string} tuple containing the score and grade (A, B, C, D) + */ + public function gradeCallables(float $securityScore, float $diversityIndex, int $deprecatedCount, int $debugCalls): array + { + $grade = 'D'; + $score = 60.0; + + if (0 === $debugCalls && $securityScore > 95 && $diversityIndex > 0.7 && 0 === $deprecatedCount) { + $grade = 'A'; + $score = 95.0; + } elseif ($debugCalls < 5 && $securityScore > 85 && $diversityIndex > 0.5 && $deprecatedCount < 5) { + $grade = 'B'; + $score = 85.0; + } elseif ($debugCalls < 20 && $securityScore > 70 && $diversityIndex > 0.3 && $deprecatedCount < 10) { + $grade = 'C'; + $score = 75.0; + } + + return [$score, $grade]; + } + + /** + * Grade architecture based on components ratio, orphan ratio, circular references, and max depth. + * + * @param float $componentsRatio proportion of templates that are part of a component + * @param float $orphanRatio proportion of templates not included by any other template + * @param int $circularRefs number of circular references detected + * @param int $maxDepth maximum inclusion depth found in any template + * + * @return array{float, string} tuple containing the score and grade (A, B, C, D) + */ + public function gradeArchitecture(float $componentsRatio, float $orphanRatio, int $circularRefs, int $maxDepth): array + { + $grade = 'D'; + $score = 60.0; + + if ($componentsRatio > 0.40 && $orphanRatio < 0.30 && 0 === $circularRefs && $maxDepth < 5) { + $grade = 'A'; + $score = 95.0; + } elseif ($componentsRatio > 0.30 && $orphanRatio < 0.50 && $circularRefs < 2 && $maxDepth < 6) { + $grade = 'B'; + $score = 85.0; + } elseif ($componentsRatio > 0.20 && $orphanRatio < 0.70 && $circularRefs < 5 && $maxDepth < 8) { + $grade = 'C'; + $score = 75.0; + } + + return [$score, $grade]; + } + + /** + * Grade maintainability based on maintainability index average. + * + * @param float $miAverage average maintainability index across all templates + * + * @return array{float, string} tuple containing the score and grade (A, B, C, D) + */ + public function gradeMaintainability(float $miAverage): array + { + $grade = 'D'; + $score = 60.0; + + if ($miAverage > 85) { + $grade = 'A'; + $score = 95.0; + } elseif ($miAverage > 70) { + $grade = 'B'; + $score = 85.0; + } elseif ($miAverage > 55) { + $grade = 'C'; + $score = 75.0; + } + + return [$score, $grade]; + } +} diff --git a/src/Reporter/QualityReporter.php b/src/Reporter/QualityReporter.php index 70b9f61..8acf014 100644 --- a/src/Reporter/QualityReporter.php +++ b/src/Reporter/QualityReporter.php @@ -116,11 +116,32 @@ private function createWeightedHealthSummary(array $results): TableSection private function createVisualDimensionRecap(array $results): ReportSection { $dimensionScores = []; + + $dimensionMapping = [ + 'Template Files' => 'template-files', + 'Code Style' => 'code-style', + 'Twig Callables' => 'callables', + 'Architecture' => 'architecture', + 'Logical Complexity' => 'complexity', + 'Maintainability' => 'maintainability', + ]; + + $factory = new \TwigMetrics\Renderer\Dimension\DimensionRendererFactory(); + foreach ($this->dimensionReporters as $reporter) { - $score = $reporter->calculateDimensionScore($results); - $data = ['score' => (int) round($score)]; + $reporterName = $reporter->getDimensionName(); + $dimensionSlug = $dimensionMapping[$reporterName] ?? null; + + if ($dimensionSlug) { + $presenter = $factory->createPresenter($dimensionSlug); + $presentedData = $presenter->present($results, 2, true); + $actualScore = (float) ($presentedData['final']['score'] ?? 100); + } else { + $actualScore = $reporter->calculateDimensionScore($results); + } - $name = $reporter->getDimensionName(); + $data = ['score' => (int) round($actualScore)]; + $name = $reporterName; if ('Logical Complexity' === $name) { $complexities = []; $depthMax = 0; @@ -156,10 +177,25 @@ private function createVisualDimensionRecap(array $results): ReportSection $dimensionScores[$reporter->getDimensionName()] = $data; } + $overallScore = $this->calculateOverallHealthScore($results); + $overallGrade = match (true) { + $overallScore >= 90 => 'A', + $overallScore >= 80 => 'B', + $overallScore >= 70 => 'C', + $overallScore >= 60 => 'D', + default => 'E', + }; + return new ReportSection( 'Twig Metrics', 'dimension_visual_recap', - ['dimensions' => $dimensionScores] + [ + 'dimensions' => $dimensionScores, + 'overall' => [ + 'score' => (int) round($overallScore), + 'grade' => $overallGrade, + ], + ] ); } diff --git a/tests/Analyzer/BlockUsageAnalyzerTest.php b/tests/Analyzer/BlockUsageAnalyzerTest.php new file mode 100644 index 0000000..562ed73 --- /dev/null +++ b/tests/Analyzer/BlockUsageAnalyzerTest.php @@ -0,0 +1,39 @@ +makeResult(['provided_blocks' => ['a', 'b'], 'used_blocks' => ['a']], 't1.twig'), + $this->makeResult(['provided_blocks' => ['c'], 'used_blocks' => ['a', 'b']], 't2.twig'), + ]; + + $analyzer = new BlockUsageAnalyzer(); + $metrics = $analyzer->analyzeBlockUsage($results); + + $this->assertSame(3, $metrics->totalDefined); + $this->assertSame(3, $metrics->totalUsed); + $this->assertContains('c', $metrics->orphanedBlocks); + $this->assertGreaterThan(0.0, $metrics->usageRatio); + $this->assertGreaterThan(0.0, $metrics->averageReuse); + } + + private function makeResult(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Analyzer/CallableSecurityAnalyzerTest.php b/tests/Analyzer/CallableSecurityAnalyzerTest.php new file mode 100644 index 0000000..e1475af --- /dev/null +++ b/tests/Analyzer/CallableSecurityAnalyzerTest.php @@ -0,0 +1,40 @@ +makeResult([ + 'functions_detail' => ['dump' => 2, 'include' => 1], + 'filters_detail' => ['raw' => 1, 'upper' => 3], + 'deprecated_callables' => 2, + ], 'a.twig'), + ]; + + $analyzer = new CallableSecurityAnalyzer(); + $metrics = $analyzer->analyzeSecurityScore($results); + + $this->assertLessThan(100, $metrics->score); + $this->assertArrayHasKey('dump', $metrics->risks); + $this->assertSame(2, $metrics->deprecatedCount); + } + + private function makeResult(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Analyzer/CouplingAnalyzerTest.php b/tests/Analyzer/CouplingAnalyzerTest.php new file mode 100644 index 0000000..78304b6 --- /dev/null +++ b/tests/Analyzer/CouplingAnalyzerTest.php @@ -0,0 +1,37 @@ +makeResult(['dependencies' => ['b.twig', ['template' => 'c.twig']]], 'a.twig'); + $b = $this->makeResult(['dependencies' => ['c.twig']], 'b.twig'); + $c = $this->makeResult(['dependencies' => []], 'c.twig'); + + $analyzer = new CouplingAnalyzer(); + $metrics = $analyzer->analyzeCoupling([$a, $b, $c]); + + $this->assertGreaterThanOrEqual(0.0, $metrics->avgFanIn); + $this->assertGreaterThanOrEqual(0.0, $metrics->avgFanOut); + $this->assertGreaterThanOrEqual(0, $metrics->maxCoupling); + $this->assertGreaterThanOrEqual(0.0, $metrics->instabilityIndex); + } + + private function makeResult(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Analyzer/StyleConsistencyAnalyzerTest.php b/tests/Analyzer/StyleConsistencyAnalyzerTest.php new file mode 100644 index 0000000..59b7553 --- /dev/null +++ b/tests/Analyzer/StyleConsistencyAnalyzerTest.php @@ -0,0 +1,56 @@ +makeResult([ + 'lines' => 10, + 'avg_line_length' => 90, + 'max_line_length' => 130, + 'blank_lines' => 2, + 'comment_lines' => 2, + 'trailing_spaces' => 1, + 'mixed_indentation_lines' => 1, + 'comment_density' => 20.0, + ], 'a.twig'), + $this->makeResult([ + 'lines' => 8, + 'avg_line_length' => 70, + 'max_line_length' => 80, + 'blank_lines' => 1, + 'comment_lines' => 1, + 'trailing_spaces' => 0, + 'mixed_indentation_lines' => 0, + 'comment_density' => 10.0, + ], 'b.twig'), + ]; + + $analyzer = new StyleConsistencyAnalyzer(); + $metrics = $analyzer->analyze($results); + + $this->assertNotEmpty($metrics->violations); + $this->assertGreaterThan(0.0, $metrics->consistencyScore); + $this->assertGreaterThanOrEqual(0.0, $metrics->formattingEntropy); + $this->assertGreaterThan(0.0, $metrics->readabilityScore); + } + + private function makeResult(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Calculator/ComplexityCalculatorTest.php b/tests/Calculator/ComplexityCalculatorTest.php new file mode 100644 index 0000000..a5ebeec --- /dev/null +++ b/tests/Calculator/ComplexityCalculatorTest.php @@ -0,0 +1,40 @@ +makeResult([ + 'conditions' => 3, + 'loops' => 2, + 'blank_lines' => 1, + 'comment_lines' => 1, + 'lines' => 10, + 'complexity_score' => 7, + 'total_line_length' => 100, + ], 'x.twig'); + + $this->assertGreaterThan(0.0, $calc->calculateLogicRatio($res)); + $this->assertEquals(0.7, $calc->calculateDecisionDensity($res)); + $this->assertGreaterThan(0.0, $calc->calculateMaintainabilityIndex($res)); + } + + private function makeResult(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Calculator/DistributionCalculatorTest.php b/tests/Calculator/DistributionCalculatorTest.php new file mode 100644 index 0000000..7bf79aa --- /dev/null +++ b/tests/Calculator/DistributionCalculatorTest.php @@ -0,0 +1,43 @@ +makeResult(['lines' => 10], 'a.twig'), + $this->makeResult(['lines' => 60], 'b.twig'), + $this->makeResult(['lines' => 120], 'c.twig'), + $this->makeResult(['lines' => 220], 'd.twig'), + $this->makeResult(['lines' => 800], 'e.twig'), + ]; + + $calc = new DistributionCalculator(); + $dist = $calc->calculateSizeDistribution($results); + + $this->assertSame(1, $dist['0-50']['count']); + $this->assertSame(1, $dist['51-100']['count']); + $this->assertSame(1, $dist['101-200']['count']); + $this->assertSame(1, $dist['201-500']['count']); + $this->assertSame(1, $dist['500+']['count']); + $this->assertEquals(20.0, $dist['201-500']['percentage']); + } + + private function makeResult(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Calculator/DiversityCalculatorTest.php b/tests/Calculator/DiversityCalculatorTest.php new file mode 100644 index 0000000..b601eab --- /dev/null +++ b/tests/Calculator/DiversityCalculatorTest.php @@ -0,0 +1,24 @@ + 10, 'b' => 10, 'c' => 0]; + $div = $calc->calculateSimpsonDiversity($usage); + $ent = $calc->calculateUsageEntropy($usage); + + $this->assertGreaterThan(0.0, $div); + $this->assertGreaterThan(0.0, $ent); + } +} diff --git a/tests/Calculator/StatisticalCalculatorTest.php b/tests/Calculator/StatisticalCalculatorTest.php new file mode 100644 index 0000000..cb9a3c5 --- /dev/null +++ b/tests/Calculator/StatisticalCalculatorTest.php @@ -0,0 +1,52 @@ +calculate([1, 2, 3, 4, 5, 10]); + + $this->assertSame(6, $stats->count); + $this->assertEquals(25.0, $stats->sum); + $this->assertEquals(25.0 / 6.0, $stats->mean); + $this->assertEquals(3.5, $stats->median); + $this->assertGreaterThan(0.0, $stats->stdDev); + $this->assertGreaterThanOrEqual(0.0, $stats->coefficientOfVariation); + $this->assertEquals(2.25, $stats->p25); + $this->assertEquals(4.75, $stats->p75); + $this->assertEqualsWithDelta(8.75, $stats->p95, 0.15); + $this->assertEquals(1.0, $stats->min); + $this->assertEquals(10.0, $stats->max); + $this->assertEquals(9.0, $stats->range); + $this->assertGreaterThanOrEqual(0.0, $stats->giniIndex); + $this->assertGreaterThanOrEqual(0.0, $stats->entropy); + } + + public function testCalculateExtendedEmpty(): void + { + $calc = new StatisticalCalculator(); + $stats = $calc->calculate([]); + $this->assertSame(0, $stats->count); + $this->assertSame(0.0, $stats->sum); + $this->assertSame(0.0, $stats->mean); + $this->assertSame(0.0, $stats->median); + $this->assertSame(0.0, $stats->stdDev); + $this->assertSame(0.0, $stats->coefficientOfVariation); + $this->assertSame(0.0, $stats->p25); + $this->assertSame(0.0, $stats->p75); + $this->assertSame(0.0, $stats->p95); + $this->assertSame(0.0, $stats->min); + $this->assertSame(0.0, $stats->max); + $this->assertSame(0.0, $stats->range); + } +} diff --git a/tests/Collector/FileMetricsCollectorTest.php b/tests/Collector/FileMetricsCollectorTest.php new file mode 100644 index 0000000..c79bde1 --- /dev/null +++ b/tests/Collector/FileMetricsCollectorTest.php @@ -0,0 +1,30 @@ +compute([ + 'lines' => 10, + 'blank_lines' => 2, + 'comment_lines' => 3, + ]); + + $this->assertSame(2, $out['emptyLines']); + $this->assertSame(3, $out['commentLines']); + $this->assertSame(5, $out['codeLines']); + $this->assertEquals(0.2, $out['emptyRatio']); + $this->assertEquals(0.3, $out['commentRatio']); + $this->assertEquals(0.6, $out['commentDensity']); + } +} diff --git a/tests/Command/AnalyzeCommandIntegrationTest.php b/tests/Command/AnalyzeCommandIntegrationTest.php index 43eb29d..4e6df29 100644 --- a/tests/Command/AnalyzeCommandIntegrationTest.php +++ b/tests/Command/AnalyzeCommandIntegrationTest.php @@ -155,7 +155,7 @@ public function testComplexityDimensionWithRealTemplates(): void $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Dimension: complexity', $output); - $this->assertStringContainsString('Logical Complexity', $output); + $this->assertStringContainsString('LOGICAL COMPLEXITY', $output); } public function testCallablesDimensionWithMacrosAndFilters(): void @@ -169,7 +169,7 @@ public function testCallablesDimensionWithMacrosAndFilters(): void $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Dimension: callables', $output); - $this->assertStringContainsString('Twig Callables', $output); + $this->assertStringContainsString('TWIG CALLABLES', $output); } public function testArchitectureDimensionWithLayeredStructure(): void @@ -183,7 +183,7 @@ public function testArchitectureDimensionWithLayeredStructure(): void $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Dimension: architecture', $output); - $this->assertStringContainsString('Architecture', $output); + $this->assertStringContainsString('ARCHITECTURE', $output); } public function testArgumentParsingComplexCases(): void diff --git a/tests/Detector/ComplexityHotspotDetectorTest.php b/tests/Detector/ComplexityHotspotDetectorTest.php new file mode 100644 index 0000000..6f0d320 --- /dev/null +++ b/tests/Detector/ComplexityHotspotDetectorTest.php @@ -0,0 +1,38 @@ +makeResult(['complexity_score' => 1, 'lines' => 10], 'a.twig'), + $this->makeResult(['complexity_score' => 5, 'lines' => 20], 'b.twig'), + $this->makeResult(['complexity_score' => 3, 'lines' => 15], 'c.twig'), + ]; + + $detector = new ComplexityHotspotDetector(); + $hot = $detector->detectHotspots($results, 2); + + $this->assertCount(2, $hot); + $this->assertSame('b.twig', $hot[0]['file']); + $this->assertSame(5, $hot[0]['complexity']); + } + + private function makeResult(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Metric/Aggregator/DirectoryMetricsAggregatorTest.php b/tests/Metric/Aggregator/DirectoryMetricsAggregatorTest.php new file mode 100644 index 0000000..95b0810 --- /dev/null +++ b/tests/Metric/Aggregator/DirectoryMetricsAggregatorTest.php @@ -0,0 +1,37 @@ +res(['lines' => 10, 'blank_lines' => 1, 'comment_lines' => 1], 'components/card.html.twig'), + $this->res(['lines' => 20, 'blank_lines' => 2, 'comment_lines' => 2], 'pages/home.html.twig'), + ]; + + $agg = new DirectoryMetricsAggregator(); + $dirs = $agg->aggregateByDirectory($results, 2); + + $this->assertArrayHasKey('components', $dirs); + $this->assertArrayHasKey('pages', $dirs); + $this->assertGreaterThan(0, $dirs['components']->fileCount); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Metric/Aggregator/DirectoryMetricsTest.php b/tests/Metric/Aggregator/DirectoryMetricsTest.php new file mode 100644 index 0000000..47fc5cd --- /dev/null +++ b/tests/Metric/Aggregator/DirectoryMetricsTest.php @@ -0,0 +1,193 @@ +assertSame('/path/to/dir', $metrics->path); + } + + public function testInitialState(): void + { + $metrics = new DirectoryMetrics('/path/to/dir'); + + $this->assertSame(0, $metrics->fileCount); + $this->assertSame(0, $metrics->totalLines); + $this->assertSame(0, $metrics->totalBlank); + $this->assertSame(0, $metrics->totalComments); + $this->assertSame(0, $metrics->maxComplexity); + $this->assertSame(0, $metrics->sumComplexity); + $this->assertSame(0.0, $metrics->sumDepth); + $this->assertSame(0, $metrics->criticalCount); + $this->assertSame(0, $metrics->sumMaxLineLength); + $this->assertSame(0.0, $metrics->sumFormatScore); + $this->assertSame(0, $metrics->sumMixedIndent); + + $this->assertSame(0.0, $metrics->getEmptyLinesRatio()); + $this->assertSame(0.0, $metrics->getCommentRatio()); + $this->assertSame(0.0, $metrics->getAverageLines()); + $this->assertSame(0.0, $metrics->getAverageDepth()); + $this->assertSame(0.0, $metrics->getAverageComplexity()); + $this->assertSame(0.0, $metrics->getAverageMaxLineLength()); + $this->assertSame(0.0, $metrics->getAverageFormatScore()); + $this->assertSame(100.0, $metrics->getIndentationConsistency()); + } + + public function testAddSingleResult(): void + { + $metrics = new DirectoryMetrics('/path/to/dir'); + $result = $this->createAnalysisResult([ + 'lines' => 100, + 'blank_lines' => 10, + 'comment_lines' => 5, + 'complexity_score' => 15, + 'max_depth' => 4, + 'max_line_length' => 120, + 'formatting_consistency_score' => 95.5, + 'mixed_indentation_lines' => 2, + ]); + + $metrics->addResult($result); + + $this->assertSame(1, $metrics->fileCount); + $this->assertSame(100, $metrics->totalLines); + $this->assertSame(10, $metrics->totalBlank); + $this->assertSame(5, $metrics->totalComments); + $this->assertSame(15, $metrics->maxComplexity); + $this->assertSame(15, $metrics->sumComplexity); + $this->assertSame(4.0, $metrics->sumDepth); + $this->assertSame(0, $metrics->criticalCount); + $this->assertSame(120, $metrics->sumMaxLineLength); + $this->assertSame(95.5, $metrics->sumFormatScore); + $this->assertSame(2, $metrics->sumMixedIndent); + } + + public function testAddSingleResultWithHighComplexity(): void + { + $metrics = new DirectoryMetrics('/path/to/dir'); + $result = $this->createAnalysisResult(['complexity_score' => 30]); + + $metrics->addResult($result); + + $this->assertSame(1, $metrics->fileCount); + $this->assertSame(30, $metrics->maxComplexity); + $this->assertSame(30, $metrics->sumComplexity); + $this->assertSame(1, $metrics->criticalCount); + } + + public function testAddResultWithMissingMetrics(): void + { + $metrics = new DirectoryMetrics('/path/to/dir'); + $result = $this->createAnalysisResult([]); + + $metrics->addResult($result); + + $this->assertSame(1, $metrics->fileCount); + $this->assertSame(0, $metrics->totalLines); + $this->assertSame(0, $metrics->totalBlank); + $this->assertSame(0, $metrics->totalComments); + $this->assertSame(0, $metrics->maxComplexity); + $this->assertSame(0, $metrics->sumComplexity); + $this->assertSame(0.0, $metrics->sumDepth); + $this->assertSame(0, $metrics->criticalCount); + $this->assertSame(0, $metrics->sumMaxLineLength); + $this->assertSame(100.0, $metrics->sumFormatScore); + $this->assertSame(0, $metrics->sumMixedIndent); + } + + public function testAddMultipleResults(): void + { + $metrics = new DirectoryMetrics('/path/to/dir'); + + $result1 = $this->createAnalysisResult([ + 'lines' => 100, + 'blank_lines' => 10, + 'comment_lines' => 5, + 'complexity_score' => 15, + 'max_depth' => 4, + 'max_line_length' => 120, + 'formatting_consistency_score' => 95.5, + 'mixed_indentation_lines' => 2, + ]); + + $result2 = $this->createAnalysisResult([ + 'lines' => 50, + 'blank_lines' => 5, + 'comment_lines' => 2, + 'complexity_score' => 30, + 'max_depth' => 6, + 'max_line_length' => 80, + 'formatting_consistency_score' => 90.0, + 'mixed_indentation_lines' => 3, + ]); + + $metrics->addResult($result1); + $metrics->addResult($result2); + + $this->assertSame(2, $metrics->fileCount); + $this->assertSame(150, $metrics->totalLines); + $this->assertSame(15, $metrics->totalBlank); + $this->assertSame(7, $metrics->totalComments); + $this->assertSame(30, $metrics->maxComplexity); + $this->assertSame(45, $metrics->sumComplexity); + $this->assertSame(10.0, $metrics->sumDepth); + $this->assertSame(1, $metrics->criticalCount); + $this->assertSame(200, $metrics->sumMaxLineLength); + $this->assertSame(185.5, $metrics->sumFormatScore); + $this->assertSame(5, $metrics->sumMixedIndent); + } + + public function testGettersWithData(): void + { + $metrics = new DirectoryMetrics('/path/to/dir'); + + $result1 = $this->createAnalysisResult(['lines' => 100, 'blank_lines' => 10, 'comment_lines' => 5, 'complexity_score' => 15, 'max_depth' => 4, 'max_line_length' => 120, 'formatting_consistency_score' => 95.5, 'mixed_indentation_lines' => 2]); + $result2 = $this->createAnalysisResult(['lines' => 50, 'blank_lines' => 5, 'comment_lines' => 2, 'complexity_score' => 30, 'max_depth' => 6, 'max_line_length' => 80, 'formatting_consistency_score' => 90.0, 'mixed_indentation_lines' => 3]); + + $metrics->addResult($result1); + $metrics->addResult($result2); + + $this->assertSame(15 / 150, $metrics->getEmptyLinesRatio()); + $this->assertSame(7 / 150, $metrics->getCommentRatio()); + $this->assertSame(150.0 / 2, $metrics->getAverageLines()); + $this->assertSame(10.0 / 2, $metrics->getAverageDepth()); + $this->assertSame(45.0 / 2, $metrics->getAverageComplexity()); + $this->assertSame(200.0 / 2, $metrics->getAverageMaxLineLength()); + $this->assertSame(185.5 / 2, $metrics->getAverageFormatScore()); + $this->assertSame(100.0 - (5 / 150) * 100.0, $metrics->getIndentationConsistency()); + } + + public function testIndentationConsistencyAtZero(): void + { + $metrics = new DirectoryMetrics('/path/to/dir'); + $result = $this->createAnalysisResult([ + 'lines' => 100, + 'mixed_indentation_lines' => 150, + ]); + + $metrics->addResult($result); + + $this->assertSame(0.0, $metrics->getIndentationConsistency()); + } + + /** + * @param array $metrics + */ + private function createAnalysisResult(array $metrics): AnalysisResult + { + $fileInfo = $this->createMock(\SplFileInfo::class); + + return new AnalysisResult($fileInfo, $metrics, 0.1); + } +} diff --git a/tests/Renderer/Component/TopUsageRendererTest.php b/tests/Renderer/Component/TopUsageRendererTest.php deleted file mode 100644 index 64c239c..0000000 --- a/tests/Renderer/Component/TopUsageRendererTest.php +++ /dev/null @@ -1,149 +0,0 @@ -output = new BufferedOutput(); - $this->renderer = new TopUsageRenderer($this->output); - } - - public function testRenderEmptyData(): void - { - $this->renderer->render([], []); - - $result = $this->output->fetch(); - - self::assertStringContainsString('Most used Functions (top 10)', $result); - self::assertStringContainsString('Most used Variables (top 10)', $result); - } - - public function testRenderWithFunctionsAndVariables(): void - { - $functions = [ - 'form_row' => 105, - 'include' => 105, - 'path' => 77, - 'form_start' => 36, - 'form_end' => 36, - ]; - - $variables = [ - 'form' => 489, - 'sample' => 107, - 'dossier' => 92, - '_key' => 91, - 'id' => 79, - ]; - - $this->renderer->render($functions, $variables); - - $result = $this->output->fetch(); - - self::assertStringContainsString('Most used Functions (top 10)', $result); - self::assertStringContainsString('Most used Variables (top 10)', $result); - - self::assertStringContainsString('form_row', $result); - self::assertStringContainsString('105', $result); - self::assertStringContainsString('form', $result); - self::assertStringContainsString('489', $result); - - self::assertMatchesRegularExpression('/[█▉▊▋▌▍▎▏]/', $result); - } - - public function testRenderWithUnequalCounts(): void - { - $functions = [ - 'form_row' => 105, - 'include' => 77, - ]; - - $variables = [ - 'form' => 489, - 'sample' => 107, - 'dossier' => 92, - '_key' => 91, - 'id' => 79, - ]; - - $this->renderer->render($functions, $variables); - - $result = $this->output->fetch(); - $lines = explode("\n", $result); - - $nonEmptyLines = array_filter($lines, fn ($line) => '' !== trim($line)); - self::assertGreaterThanOrEqual(6, count($nonEmptyLines)); - } - - public function testRenderWithLongNames(): void - { - $functions = [ - 'very_long_function_name_that_exceeds_normal_limits' => 100, - ]; - - $variables = [ - 'extremely_long_variable_name_that_should_be_truncated' => 200, - ]; - - $this->renderer->render($functions, $variables); - - $result = $this->output->fetch(); - - self::assertStringContainsString('…', $result); - } - - public function testRenderWithLimit(): void - { - $functions = []; - $variables = []; - - for ($i = 1; $i <= 15; ++$i) { - $functions["func_{$i}"] = $i * 10; - $variables["var_{$i}"] = $i * 20; - } - - $this->renderer->render($functions, $variables, 5); - - $result = $this->output->fetch(); - $lines = explode("\n", $result); - - $dataLines = array_filter($lines, fn ($line) => !empty(trim($line)) - && !str_contains($line, 'Most used') - ); - - self::assertCount(5, $dataLines); - } - - #[DataProvider('provideProgressBarData')] - public function testProgressBarCreation(float $length, string $expectedPattern): void - { - $functions = ['test' => 100]; - $variables = []; - - $this->renderer->render($functions, $variables); - $result = $this->output->fetch(); - - self::assertMatchesRegularExpression($expectedPattern, $result); - } - - public static function provideProgressBarData(): iterable - { - yield 'full bar' => [10.0, '/█+/']; - yield 'partial bar' => [5.5, '/█+[▉▊▋▌▍▎▏]/']; - yield 'minimal bar' => [0.5, '/[▉▊▋▌▍▎▏]/']; - } -} diff --git a/tests/Renderer/Dimension/Box/ArchitectureDimensionBoxRendererTest.php b/tests/Renderer/Dimension/Box/ArchitectureDimensionBoxRendererTest.php new file mode 100644 index 0000000..3f8f449 --- /dev/null +++ b/tests/Renderer/Dimension/Box/ArchitectureDimensionBoxRendererTest.php @@ -0,0 +1,140 @@ +render([ + 'summary' => [ + 'extends_total' => 222, + 'includes_total' => 984, + 'embeds_total' => 35, + 'blocks_total' => 127, + 'extends_per_template' => 0.60, + 'includes_per_template' => 2.40, + 'embeds_per_template' => 0.04, + 'blocks_per_template' => 0.31, + ], + 'inheritance' => [ + 'max_depth' => 4, + 'avg_depth' => 2.1, + 'root_templates' => 8, + 'orphan_files' => 7, + ], + 'blocks' => [ + 'definitions' => 127, + 'calls' => 89, + 'overrides' => 45, + 'unused' => 12, + ], + 'macros' => [ + 'definitions' => 23, + 'calls' => 67, + 'external_calls' => 34, + 'unused' => 5, + ], + 'directories' => [ + ['path' => 'components', 'extends_ratio' => 0.1, 'includes_ratio' => 0.9, 'embeds_ratio' => 0.0, 'blocks_ratio' => 0.8], + ['path' => 'layouts', 'extends_ratio' => 0.0, 'includes_ratio' => 0.2, 'embeds_ratio' => 0.7, 'blocks_ratio' => 1.0], + ], + 'top_referenced' => [ + ['template' => 'layouts/base.html.twig', 'count' => 41], + ['template' => 'components/form/input.html.twig', 'count' => 23], + ['template' => 'components/navigation/menu.html.twig', 'count' => 18], + ], + 'top_blocks' => [ + ['name' => 'content', 'count' => 67], + ['name' => 'sidebar', 'count' => 34], + ['name' => 'meta', 'count' => 23], + ['name' => 'title', 'count' => 45], + ['name' => 'javascripts', 'count' => 28], + ['name' => 'stylesheets', 'count' => 19], + ], + 'inheritance_patterns' => [ + 'roots' => ['layouts/base.html.twig'], + 'children' => ['layouts/base.html.twig' => 18], + ], + 'final' => ['score' => 78.0, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('ARCHITECTURE', $content); + $this->assertStringContainsString('Construct', $content); + $this->assertStringContainsString('Heatmap by directory', $content); + $this->assertStringContainsString('Most included templates', $content); + $this->assertStringContainsString('Most used block names', $content); + $this->assertStringContainsString('Analysis', $content); + } + + public function testRenderHandlesEmptyData(): void + { + $out = new BufferedOutput(); + $renderer = new ArchitectureDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'extends_total' => 0, + 'includes_total' => 0, + 'embeds_total' => 0, + 'blocks_total' => 0, + 'extends_per_template' => 0.0, + 'includes_per_template' => 0.0, + 'embeds_per_template' => 0.0, + 'blocks_per_template' => 0.0, + ], + 'inheritance' => [], + 'blocks' => [], + 'macros' => [], + 'directories' => [], + 'top_referenced' => [], + 'top_blocks' => [], + 'inheritance_patterns' => [], + 'final' => ['score' => 0.0, 'grade' => 'E'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('ARCHITECTURE', $content); + $this->assertStringContainsString('Analysis', $content); + $this->assertStringContainsString('Grade: E', $content); + } + + public function testRenderFormatsNumbers(): void + { + $out = new BufferedOutput(); + $renderer = new ArchitectureDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'extends_total' => 1234, + 'includes_total' => 5678, + 'embeds_total' => 90, + 'blocks_total' => 456, + 'extends_per_template' => 1.23, + 'includes_per_template' => 4.56, + 'embeds_per_template' => 0.78, + 'blocks_per_template' => 2.34, + ], + 'directories' => [], + 'top_referenced' => [], + 'top_blocks' => [], + 'inheritance_patterns' => [], + 'final' => ['score' => 85.5, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('1,234', $content); + $this->assertStringContainsString('5,678', $content); + $this->assertStringContainsString('1.23', $content); + $this->assertStringContainsString('86/100', $content); + } +} diff --git a/tests/Renderer/Dimension/Box/ArchitectureDimensionPresenterTest.php b/tests/Renderer/Dimension/Box/ArchitectureDimensionPresenterTest.php new file mode 100644 index 0000000..912febe --- /dev/null +++ b/tests/Renderer/Dimension/Box/ArchitectureDimensionPresenterTest.php @@ -0,0 +1,135 @@ +res([ + 'dependency_types' => ['extends' => 1, 'includes' => 2, 'embeds' => 0], + 'blocks_detail' => ['content' => 1, 'title' => 1], + 'inheritance_depth' => 2, + 'dependencies' => [['template' => 'layouts/base.html.twig', 'type' => 'extends']], + ], 'components/a.twig'), + $this->res([ + 'dependency_types' => ['extends' => 0, 'includes' => 3, 'embeds' => 1], + 'blocks_detail' => ['sidebar' => 1], + 'inheritance_depth' => 0, + 'dependencies' => [['template' => 'partials/menu.html.twig', 'type' => 'includes']], + ], 'pages/b.twig'), + $this->res([ + 'dependency_types' => ['extends' => 0, 'includes' => 0, 'embeds' => 0], + 'blocks_detail' => ['content' => 1, 'meta' => 1, 'scripts' => 1], + 'inheritance_depth' => 0, + 'dependencies' => [], + ], 'layouts/base.html.twig'), + ]; + + $presenter = new ArchitectureDimensionPresenter( + new ArchitectureMetricsReporter( + new CouplingAnalyzer(), + new BlockUsageAnalyzer(), + new DimensionGrader(), + ), + new DirectoryMetricsAggregator(), + new StatisticalCalculator(), + ); + $data = $presenter->present($results, 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertArrayHasKey('inheritance', $data); + $this->assertArrayHasKey('blocks', $data); + $this->assertArrayHasKey('macros', $data); + $this->assertArrayHasKey('directories', $data); + $this->assertArrayHasKey('top_referenced', $data); + $this->assertArrayHasKey('top_blocks', $data); + $this->assertArrayHasKey('final', $data); + + $summary = $data['summary']; + $this->assertArrayHasKey('total_templates', $summary); + $this->assertArrayHasKey('extends_total', $summary); + $this->assertArrayHasKey('includes_total', $summary); + $this->assertArrayHasKey('extends_per_template', $summary); + + $this->assertEquals(3, $summary['total_templates']); + + $this->assertEquals(1, $summary['extends_total']); + + $this->assertEquals(5, $summary['includes_total']); + } + + public function testPresentWithEmptyResults(): void + { + $presenter = new ArchitectureDimensionPresenter( + new ArchitectureMetricsReporter( + new CouplingAnalyzer(), + new BlockUsageAnalyzer(), + new DimensionGrader(), + ), + new DirectoryMetricsAggregator(), + new StatisticalCalculator(), + ); + + $data = $presenter->present([], 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertEquals(0, $data['summary']['total_templates']); + $this->assertEquals(0, $data['summary']['extends_total']); + $this->assertIsArray($data['directories']); + $this->assertEmpty($data['directories']); + } + + public function testPresentCalculatesBlockUsage(): void + { + $results = [ + $this->res([ + 'dependency_types' => [], + 'blocks_detail' => ['content' => 2, 'title' => 1, 'sidebar' => 1], + 'block_calls' => 3, + 'block_overrides' => 1, + ], 'template.twig'), + ]; + + $presenter = new ArchitectureDimensionPresenter( + new ArchitectureMetricsReporter( + new CouplingAnalyzer(), + new BlockUsageAnalyzer(), + new DimensionGrader(), + ), + new DirectoryMetricsAggregator(), + new StatisticalCalculator(), + ); + + $data = $presenter->present($results, 1, true); + + $blocks = $data['blocks']; + $this->assertEquals(3, $blocks['definitions']); + $this->assertEquals(3, $blocks['calls']); + $this->assertEquals(1, $blocks['overrides']); + $this->assertEquals(0, $blocks['unused']); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Renderer/Dimension/Box/CodeStyleDimensionBoxRendererTest.php b/tests/Renderer/Dimension/Box/CodeStyleDimensionBoxRendererTest.php new file mode 100644 index 0000000..5e376df --- /dev/null +++ b/tests/Renderer/Dimension/Box/CodeStyleDimensionBoxRendererTest.php @@ -0,0 +1,110 @@ +render([ + 'summary' => [ + 'total_templates' => 156, + 'avg_line_length' => 72.3, + 'median_line_length' => 68.0, + 'max_line_length' => 247, + 'consistency_score' => 87.2, + 'trailing_spaces_total' => 156, + 'comment_density' => 0.082, + 'empty_ratio' => 0.143, + ], + 'line_length' => [ + 'p95_length' => 118, + 'style_violations' => 234, + 'mixed_indentation' => 8, + 'indentation_depth' => 2.8, + ], + 'formatting' => [ + 'readability_score' => 72, + 'format_entropy' => 3.21, + 'blank_line_consistency' => 89.4, + ], + 'distribution' => [ + '0_80' => 68, + '81_120' => 22, + '121_160' => 8, + '160_plus' => 2, + ], + 'directories' => [ + ['path' => 'components', 'score' => 92.1, 'grade' => 'A'], + ['path' => 'layouts', 'score' => 85.6, 'grade' => 'B'], + ], + 'violations' => [ + ['type' => 'Long lines (>120 chars)', 'count' => 23], + ['type' => 'Trailing whitespace', 'count' => 156], + ['type' => 'Mixed indentation', 'count' => 8], + ], + 'final' => ['score' => 82.0, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('CODE STYLE', $content); + $this->assertStringContainsString('Length distribution', $content); + $this->assertStringContainsString('Formatting Metrics', $content); + $this->assertStringContainsString('Style by directory', $content); + $this->assertStringContainsString('Violation breakdown', $content); + $this->assertStringContainsString('Analysis', $content); + } + + public function testRenderHandlesEmptyData(): void + { + $out = new BufferedOutput(); + $renderer = new CodeStyleDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'total_templates' => 0, + 'avg_line_length' => 0.0, + 'max_line_length' => 0, + 'consistency_score' => 0.0, + ], + 'directories' => [], + 'violations' => [], + 'final' => ['score' => 0.0, 'grade' => 'E'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('CODE STYLE', $content); + $this->assertStringContainsString('Analysis', $content); + $this->assertStringContainsString('Grade: E', $content); + } + + public function testRenderFormatsNumbers(): void + { + $out = new BufferedOutput(); + $renderer = new CodeStyleDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'avg_line_length' => 72.34, + 'max_line_length' => 5678, + 'consistency_score' => 87.56, + ], + 'directories' => [], + 'violations' => [], + 'final' => ['score' => 85.5, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('CODE STYLE', $content); + $this->assertStringContainsString('Grade: B', $content); + $this->assertStringContainsString('86/100', $content); + } +} diff --git a/tests/Renderer/Dimension/Box/CodeStyleDimensionPresenterTest.php b/tests/Renderer/Dimension/Box/CodeStyleDimensionPresenterTest.php new file mode 100644 index 0000000..5f547f7 --- /dev/null +++ b/tests/Renderer/Dimension/Box/CodeStyleDimensionPresenterTest.php @@ -0,0 +1,110 @@ +res([ + 'lines' => 40, + 'avg_line_length' => 72.3, + 'max_line_length' => 120, + 'trailing_spaces' => 5, + 'blank_lines' => 6, + 'comment_lines' => 3, + 'mixed_indentation_lines' => 1, + 'formatting_consistency_score' => 85.0, + ], 'components/a.twig'), + $this->res([ + 'lines' => 60, + 'avg_line_length' => 68.1, + 'max_line_length' => 247, + 'trailing_spaces' => 8, + 'blank_lines' => 9, + 'comment_lines' => 5, + 'mixed_indentation_lines' => 0, + 'formatting_consistency_score' => 92.0, + ], 'pages/b.twig'), + ]; + + $presenter = new CodeStyleDimensionPresenter( + new CodeStyleMetricsReporter(new StatisticalCalculator(), new StyleConsistencyAnalyzer()), + new DirectoryMetricsAggregator(), + ); + $data = $presenter->present($results, 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertArrayHasKey('distribution', $data); + $this->assertArrayHasKey('formatting', $data); + $this->assertArrayHasKey('directories', $data); + $this->assertArrayHasKey('violations', $data); + $this->assertArrayHasKey('final', $data); + + $summary = $data['summary']; + $this->assertArrayHasKey('avg_line_length', $summary); + $this->assertArrayHasKey('max_line_length', $summary); + $this->assertIsNumeric($summary['avg_line_length']); + $this->assertIsNumeric($summary['max_line_length']); + } + + public function testPresentWithEmptyResults(): void + { + $presenter = new CodeStyleDimensionPresenter( + new CodeStyleMetricsReporter(new StatisticalCalculator(), new StyleConsistencyAnalyzer()), + new DirectoryMetricsAggregator(), + ); + + $data = $presenter->present([], 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertIsArray($data['directories']); + $this->assertEmpty($data['directories']); + } + + public function testPresentCalculatesMetrics(): void + { + $results = [ + $this->res([ + 'lines' => 50, + 'avg_line_length' => 75.0, + 'trailing_spaces' => 10, + 'comment_lines' => 5, + 'blank_lines' => 8, + 'mixed_indentation_lines' => 2, + ], 'template.twig'), + ]; + + $presenter = new CodeStyleDimensionPresenter( + new CodeStyleMetricsReporter(new StatisticalCalculator(), new StyleConsistencyAnalyzer()), + new DirectoryMetricsAggregator(), + ); + + $data = $presenter->present($results, 1, true); + + $summary = $data['summary']; + $this->assertArrayHasKey('avg_line_length', $summary); + $this->assertIsNumeric($summary['avg_line_length']); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Renderer/Dimension/Box/ComplexityDimensionBoxRendererTest.php b/tests/Renderer/Dimension/Box/ComplexityDimensionBoxRendererTest.php new file mode 100644 index 0000000..b8bd059 --- /dev/null +++ b/tests/Renderer/Dimension/Box/ComplexityDimensionBoxRendererTest.php @@ -0,0 +1,43 @@ +render([ + 'summary' => [ + 'avg' => 12.3, 'median' => 8.0, 'max' => 47, 'critical_files' => 3, + 'logic_ratio' => 0.234, 'decision_density' => 0.08, + 'avg_depth' => 2.8, 'max_depth' => 7, + ], + 'distribution' => ['simple_pct' => 43, 'moderate_pct' => 27, 'complex_pct' => 20, 'critical_pct' => 10], + 'stats' => ['mi_avg' => 65.2, 'cyclomatic_per_loc' => 0.12, 'cognitive_complexity' => 'N/A', 'halstead_volume' => 'N/A', 'control_flow_nodes' => 'N/A', 'logical_operators' => 'N/A'], + 'directories' => [ + ['path' => 'components', 'avg_cx' => 4.2, 'max_cx' => 9, 'avg_depth' => 2.1, 'risk' => 'low'], + ], + 'top' => [ + ['path' => 'templates/pages/admin/dashboard.html.twig', 'score' => 24.2, 'grade' => 'E'], + ], + 'final' => ['score' => 65.0, 'grade' => 'C'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('LOGICAL COMPLEXITY', $content); + $this->assertStringContainsString('Average Complexity', $content); + $this->assertStringContainsString('Complexity distribution', $content); + $this->assertStringContainsString('Top 5 templates', $content); + $this->assertStringContainsString('Analysis', $content); + } +} diff --git a/tests/Renderer/Dimension/Box/ComplexityDimensionPresenterTest.php b/tests/Renderer/Dimension/Box/ComplexityDimensionPresenterTest.php new file mode 100644 index 0000000..fc3e3dc --- /dev/null +++ b/tests/Renderer/Dimension/Box/ComplexityDimensionPresenterTest.php @@ -0,0 +1,51 @@ +res(['complexity_score' => 4, 'lines' => 10, 'blank_lines' => 1, 'comment_lines' => 1, 'max_depth' => 1], 'a.twig'), + $this->res(['complexity_score' => 12, 'lines' => 30, 'blank_lines' => 2, 'comment_lines' => 2, 'max_depth' => 2], 'b.twig'), + $this->res(['complexity_score' => 28, 'lines' => 50, 'blank_lines' => 3, 'comment_lines' => 3, 'max_depth' => 3], 'c.twig'), + ]; + + $presenter = new ComplexityDimensionPresenter( + new LogicalComplexityMetricsReporter(new StatisticalCalculator(), new ComplexityCalculator(), new ComplexityHotspotDetector(), new DimensionGrader()), + new DirectoryMetricsAggregator(), + new ComplexityHotspotDetector(), + new StatisticalCalculator(), + ); + $data = $presenter->present($results, 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertArrayHasKey('distribution', $data); + $this->assertArrayHasKey('directories', $data); + $this->assertArrayHasKey('top', $data); + $this->assertArrayHasKey('final', $data); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Renderer/Dimension/Box/MaintainabilityDimensionBoxRendererTest.php b/tests/Renderer/Dimension/Box/MaintainabilityDimensionBoxRendererTest.php new file mode 100644 index 0000000..22f7e15 --- /dev/null +++ b/tests/Renderer/Dimension/Box/MaintainabilityDimensionBoxRendererTest.php @@ -0,0 +1,203 @@ +render([ + 'summary' => [ + 'total_templates' => 156, + 'mi_avg' => 68.5, + 'mi_median' => 72.0, + 'refactor_candidates' => 12, + 'high_risk' => 8, + 'medium_risk' => 24, + 'low_risk' => 124, + ], + 'risk_distribution' => [ + 'critical' => 3, + 'high' => 8, + 'medium' => 24, + 'low' => 121, + ], + 'directories' => [ + ['path' => 'pages', 'files' => 45, 'avg_complexity' => 18.5, 'avg_lines' => 180, 'max_depth' => 6, 'risk' => 0.75], + ['path' => 'components', 'files' => 78, 'avg_complexity' => 12.3, 'avg_lines' => 95, 'max_depth' => 4, 'risk' => 0.45], + ], + 'refactor_priorities' => [ + ['template' => 'pages/admin/dashboard.twig', 'risk' => 0.92, 'complexity' => 35, 'lines' => 420, 'depth' => 8], + ['template' => 'pages/reports/complex.twig', 'risk' => 0.87, 'complexity' => 28, 'lines' => 380, 'depth' => 7], + ], + 'debt_analysis' => [ + 'debt_ratio' => 15.3, + 'complex_templates' => 8, + 'large_templates' => 12, + 'deep_templates' => 5, + 'total_lines' => 45620, + ], + 'final' => ['score' => 68.0, 'grade' => 'C'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('MAINTAINABILITY', $content); + $this->assertStringContainsString('Risk distribution', $content); + $this->assertStringContainsString('Risk by directory', $content); + $this->assertStringContainsString('Refactoring priorities', $content); + $this->assertStringContainsString('Technical debt analysis', $content); + $this->assertStringContainsString('Analysis', $content); + $this->assertStringContainsString('Grade: C', $content); + } + + public function testRenderHandlesEmptyData(): void + { + $out = new BufferedOutput(); + $renderer = new MaintainabilityDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'total_templates' => 0, + 'mi_avg' => 0.0, + 'mi_median' => 0.0, + 'refactor_candidates' => 0, + 'high_risk' => 0, + 'medium_risk' => 0, + 'low_risk' => 0, + ], + 'risk_distribution' => [ + 'critical' => 0, + 'high' => 0, + 'medium' => 0, + 'low' => 0, + ], + 'directories' => [], + 'refactor_priorities' => [], + 'debt_analysis' => [ + 'debt_ratio' => 0.0, + 'complex_templates' => 0, + 'large_templates' => 0, + 'deep_templates' => 0, + 'total_lines' => 0, + ], + 'final' => ['score' => 0.0, 'grade' => 'E'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('MAINTAINABILITY', $content); + $this->assertStringContainsString('Analysis', $content); + $this->assertStringContainsString('Grade: E', $content); + } + + public function testRenderFormatsNumbers(): void + { + $out = new BufferedOutput(); + $renderer = new MaintainabilityDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'total_templates' => 1234, + 'mi_avg' => 68.456, + 'mi_median' => 72.789, + 'refactor_candidates' => 56, + 'high_risk' => 89, + 'medium_risk' => 234, + 'low_risk' => 911, + ], + 'risk_distribution' => [ + 'critical' => 12, + 'high' => 89, + 'medium' => 234, + 'low' => 899, + ], + 'directories' => [], + 'refactor_priorities' => [], + 'debt_analysis' => [ + 'debt_ratio' => 25.678, + 'complex_templates' => 123, + 'large_templates' => 234, + 'deep_templates' => 89, + 'total_lines' => 456789, + ], + 'final' => ['score' => 85.5, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('MAINTAINABILITY', $content); + $this->assertStringContainsString('68.5', $content); + $this->assertStringContainsString('72.8', $content); + $this->assertStringContainsString('25.7%', $content); + $this->assertStringContainsString('86/100', $content); + $this->assertStringContainsString('Grade: B', $content); + } + + public function testRenderShowsRiskPriorities(): void + { + $out = new BufferedOutput(); + $renderer = new MaintainabilityDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => ['total_templates' => 1], + 'risk_distribution' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 1], + 'directories' => [], + 'refactor_priorities' => [ + ['template' => 'very/long/path/to/template/file.twig', 'risk' => 0.95, 'complexity' => 45, 'lines' => 500, 'depth' => 9], + ], + 'debt_analysis' => [ + 'debt_ratio' => 33.3, + 'complex_templates' => 1, + 'large_templates' => 1, + 'deep_templates' => 1, + 'total_lines' => 500, + ], + 'final' => ['score' => 45.0, 'grade' => 'E'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('MAINTAINABILITY', $content); + $this->assertStringContainsString('Refactoring priorities', $content); + $this->assertStringContainsString('file.twig', $content); + $this->assertStringContainsString('Risk: 0.95', $content); + $this->assertStringContainsString('Grade: E', $content); + } + + public function testRenderShowsDirectoryRisk(): void + { + $out = new BufferedOutput(); + $renderer = new MaintainabilityDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => ['total_templates' => 1], + 'risk_distribution' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 1], + 'directories' => [ + ['path' => 'templates/admin', 'files' => 25, 'avg_complexity' => 22.5, 'avg_lines' => 185, 'max_depth' => 6, 'risk' => 0.82], + ['path' => 'components', 'files' => 45, 'avg_complexity' => 8.2, 'avg_lines' => 65, 'max_depth' => 3, 'risk' => 0.25], + ], + 'refactor_priorities' => [], + 'debt_analysis' => [ + 'debt_ratio' => 12.5, + 'complex_templates' => 5, + 'large_templates' => 3, + 'deep_templates' => 2, + 'total_lines' => 8500, + ], + 'final' => ['score' => 75.0, 'grade' => 'C'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('MAINTAINABILITY', $content); + $this->assertStringContainsString('Risk by directory', $content); + $this->assertStringContainsString('└─ admin/', $content); + $this->assertStringContainsString('components/', $content); + $this->assertStringContainsString('25', $content); + $this->assertStringContainsString('22.5', $content); + $this->assertStringContainsString('185', $content); + $this->assertStringContainsString('Grade: C', $content); + } +} diff --git a/tests/Renderer/Dimension/Box/MaintainabilityDimensionPresenterTest.php b/tests/Renderer/Dimension/Box/MaintainabilityDimensionPresenterTest.php new file mode 100644 index 0000000..af18553 --- /dev/null +++ b/tests/Renderer/Dimension/Box/MaintainabilityDimensionPresenterTest.php @@ -0,0 +1,78 @@ +expectNotToPerformAssertions(); + + $reporter = new MaintainabilityMetricsReporter( + new \TwigMetrics\Calculator\ComplexityCalculator(), + new \TwigMetrics\Reporter\Helper\DimensionGrader(), + ); + $aggregator = new DirectoryMetricsAggregator(); + $calculator = new StatisticalCalculator(); + + new MaintainabilityDimensionPresenter($reporter, $aggregator, $calculator); + } + + /** + * @return AnalysisResult[] + */ + private function createMockResults(): array + { + return [ + $this->res([ + 'complexity_score' => 5, + 'lines' => 50, + 'max_depth' => 2, + 'dependencies' => [], + 'formatting_consistency_score' => 90.0, + ], 'components/card.twig'), + $this->res([ + 'complexity_score' => 12, + 'lines' => 120, + 'max_depth' => 4, + 'dependencies' => ['extends' => 'parent.twig'], + 'formatting_consistency_score' => 85.0, + ], 'layouts/base.twig'), + ]; + } + + /** + * @return AnalysisResult[] + */ + private function createComplexMockResults(): array + { + return [ + $this->res([ + 'complexity_score' => 25, + 'lines' => 250, + 'max_depth' => 7, + 'dependencies' => [], + 'formatting_consistency_score' => 70.0, + ], 'complex/template.twig'), + ]; + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Renderer/Dimension/Box/TemplateFilesDimensionBoxRendererTest.php b/tests/Renderer/Dimension/Box/TemplateFilesDimensionBoxRendererTest.php new file mode 100644 index 0000000..abe6fad --- /dev/null +++ b/tests/Renderer/Dimension/Box/TemplateFilesDimensionBoxRendererTest.php @@ -0,0 +1,48 @@ +render([ + 'summary' => [ + 'total_templates' => 156, + 'total_lines' => 19642, + 'avg_lines' => 125.9, + 'median_lines' => 87, + 'cv' => 0.78, + 'gini' => 0.42, + 'empty_ratio' => 0.143, + 'comment_density' => 0.082, + ], + 'distribution' => ['0_50' => 27, '51_100' => 35, '101_200' => 23, '201_plus' => 15], + 'stats' => ['std_dev' => 98.3, 'p95' => 287, 'files_over_500' => 12, 'orphans' => 7, 'entropy' => 2.34, 'dir_depth_avg' => 2.3], + 'directories' => [ + ['path' => 'components', 'count' => 67, 'avg_lines' => 428, 'bar_ratio' => 1.0], + ], + 'top' => [ + ['path' => 'pages/admin/dashboard.html.twig', 'lines' => 1247, 'grade' => 'E'], + ], + 'final' => ['score' => 82.0, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('TEMPLATE FILES', $content); + $this->assertStringContainsString('Size distribution', $content); + $this->assertStringContainsString('Files by directory', $content); + $this->assertStringContainsString('Largest templates', $content); + $this->assertStringContainsString('Analysis', $content); + } +} diff --git a/tests/Renderer/Dimension/Box/TemplateFilesDimensionPresenterTest.php b/tests/Renderer/Dimension/Box/TemplateFilesDimensionPresenterTest.php new file mode 100644 index 0000000..7ff440f --- /dev/null +++ b/tests/Renderer/Dimension/Box/TemplateFilesDimensionPresenterTest.php @@ -0,0 +1,48 @@ +res(['lines' => 40, 'blank_lines' => 4, 'comment_lines' => 2], 'components/a.twig'), + $this->res(['lines' => 60, 'blank_lines' => 6, 'comment_lines' => 3], 'pages/b.twig'), + $this->res(['lines' => 120, 'blank_lines' => 12, 'comment_lines' => 6], 'layouts/c.twig'), + ]; + + $presenter = new TemplateFilesDimensionPresenter( + new TemplateFilesMetricsReporter(new StatisticalCalculator(), new DistributionCalculator()), + new DirectoryMetricsAggregator(), + new StatisticalCalculator(), + ); + $data = $presenter->present($results, 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertArrayHasKey('distribution', $data); + $this->assertArrayHasKey('directories', $data); + $this->assertArrayHasKey('top', $data); + $this->assertArrayHasKey('final', $data); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Renderer/Dimension/Box/TwigCallablesDimensionBoxRendererTest.php b/tests/Renderer/Dimension/Box/TwigCallablesDimensionBoxRendererTest.php new file mode 100644 index 0000000..a8a355e --- /dev/null +++ b/tests/Renderer/Dimension/Box/TwigCallablesDimensionBoxRendererTest.php @@ -0,0 +1,158 @@ +render([ + 'summary' => [ + 'total_templates' => 156, + 'unique_functions' => 47, + 'unique_filters' => 23, + 'unique_variables' => 89, + 'unique_tests' => 12, + 'functions_per_template' => 3.2, + 'filters_per_template' => 2.1, + 'variables_per_template' => 8.5, + 'macros_defined' => 15, + ], + 'diversity' => [ + 'function_diversity' => 0.78, + 'filter_diversity' => 0.65, + 'variable_diversity' => 0.82, + 'complexity_index' => 2.3, + ], + 'security' => [ + 'risky_functions' => 5, + 'unsafe_filters' => 2, + 'security_score' => 85, + ], + 'top_functions' => [ + ['name' => 'dump', 'count' => 127, 'security' => 'risky'], + ['name' => 'date', 'count' => 89, 'security' => 'safe'], + ['name' => 'url', 'count' => 67, 'security' => 'safe'], + ], + 'top_filters' => [ + ['name' => 'upper', 'count' => 156, 'security' => 'safe'], + ['name' => 'date', 'count' => 134, 'security' => 'safe'], + ], + 'top_variables' => [ + ['name' => 'app', 'count' => 289], + ['name' => 'user', 'count' => 178], + ], + 'top_tests' => [ + ['name' => 'empty', 'count' => 89], + ['name' => 'defined', 'count' => 67], + ], + 'top_macros' => [ + ['name' => 'helper', 'count' => 23], + ['name' => 'util', 'count' => 15], + ], + 'top_blocks' => [ + ['name' => 'content', 'count' => 156], + ['name' => 'title', 'count' => 134], + ], + 'final' => ['score' => 78.0, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('TWIG CALLABLES', $content); + $this->assertStringContainsString('Top 7 Functions', $content); + $this->assertStringContainsString('Top 7 Filters', $content); + $this->assertStringContainsString('Usage distribution', $content); + $this->assertStringContainsString('Analysis', $content); + } + + public function testRenderHandlesEmptyData(): void + { + $out = new BufferedOutput(); + $renderer = new TwigCallablesDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'total_templates' => 0, + 'unique_functions' => 0, + 'unique_filters' => 0, + 'functions_per_template' => 0.0, + ], + 'top_functions' => [], + 'top_filters' => [], + 'top_variables' => [], + 'top_tests' => [], + 'top_macros' => [], + 'top_blocks' => [], + 'final' => ['score' => 0.0, 'grade' => 'E'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('TWIG CALLABLES', $content); + $this->assertStringContainsString('Analysis', $content); + $this->assertStringContainsString('Grade: E', $content); + } + + public function testRenderFormatsNumbers(): void + { + $out = new BufferedOutput(); + $renderer = new TwigCallablesDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => [ + 'total_templates' => 1234, + 'unique_functions' => 567, + 'functions_per_template' => 3.45, + 'variables_per_template' => 8.76, + ], + 'top_functions' => [ + ['name' => 'dump', 'count' => 9876, 'security' => 'safe'], + ], + 'top_filters' => [], + 'top_variables' => [], + 'top_tests' => [], + 'top_macros' => [], + 'top_blocks' => [], + 'final' => ['score' => 85.5, 'grade' => 'B'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('TWIG CALLABLES', $content); + $this->assertStringContainsString('dump()', $content); + $this->assertStringContainsString('86/100', $content); + } + + public function testRenderShowsSecurityInfo(): void + { + $out = new BufferedOutput(); + $renderer = new TwigCallablesDimensionBoxRenderer($out); + $renderer->render([ + 'summary' => ['total_templates' => 1], + 'security' => [ + 'risky_functions' => 3, + 'security_score' => 75, + ], + 'top_functions' => [ + ['name' => 'dump', 'count' => 5, 'security' => 'risky'], + ], + 'top_filters' => [], + 'top_variables' => [], + 'top_tests' => [], + 'top_macros' => [], + 'top_blocks' => [], + 'final' => ['score' => 75.0, 'grade' => 'C'], + ]); + + $content = $out->fetch(); + $this->assertStringContainsString('TWIG CALLABLES', $content); + $this->assertStringContainsString('dump()', $content); + $this->assertStringContainsString('Grade: C', $content); + } +} diff --git a/tests/Renderer/Dimension/Box/TwigCallablesDimensionPresenterTest.php b/tests/Renderer/Dimension/Box/TwigCallablesDimensionPresenterTest.php new file mode 100644 index 0000000..3480796 --- /dev/null +++ b/tests/Renderer/Dimension/Box/TwigCallablesDimensionPresenterTest.php @@ -0,0 +1,81 @@ +res([ + 'functions_detail' => ['dump' => 2, 'date' => 1], + 'filters_detail' => ['upper' => 3, 'lower' => 1], + 'variables_detail' => ['app' => 5, 'user' => 2], + 'tests_detail' => ['empty' => 2, 'defined' => 1], + 'macro_definitions_detail' => ['helper' => 1], + 'blocks_detail' => ['content' => 1, 'title' => 1], + ], 'components/a.twig'), + $this->res([ + 'functions_detail' => ['url' => 4, 'path' => 2], + 'filters_detail' => ['date' => 2, 'format' => 1], + 'variables_detail' => ['request' => 3], + 'tests_detail' => ['null' => 1], + 'macro_definitions_detail' => [], + 'blocks_detail' => ['sidebar' => 1], + ], 'pages/b.twig'), + ]; + + $presenter = new TwigCallablesDimensionPresenter( + new TwigCallablesMetricsReporter(new DiversityCalculator(), new CallableSecurityAnalyzer()), + new CallableSecurityAnalyzer(), + ); + $data = $presenter->present($results, 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertArrayHasKey('distribution', $data); + $this->assertArrayHasKey('top_functions', $data); + $this->assertArrayHasKey('top_filters', $data); + $this->assertArrayHasKey('directories', $data); + $this->assertArrayHasKey('security_issues', $data); + $this->assertArrayHasKey('final', $data); + + $summary = $data['summary']; + $this->assertArrayHasKey('unique_functions', $summary); + $this->assertArrayHasKey('unique_filters', $summary); + $this->assertIsNumeric($summary['unique_functions']); + $this->assertIsNumeric($summary['unique_filters']); + } + + public function testPresentWithEmptyResults(): void + { + $presenter = new TwigCallablesDimensionPresenter( + new TwigCallablesMetricsReporter(new DiversityCalculator(), new CallableSecurityAnalyzer()), + new CallableSecurityAnalyzer(), + ); + + $data = $presenter->present([], 1, true); + + $this->assertArrayHasKey('summary', $data); + $this->assertIsArray($data['top_functions']); + $this->assertEmpty($data['top_functions']); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Reporter/Dimension/ArchitectureMetricsReporterTest.php b/tests/Reporter/Dimension/ArchitectureMetricsReporterTest.php new file mode 100644 index 0000000..f4188d3 --- /dev/null +++ b/tests/Reporter/Dimension/ArchitectureMetricsReporterTest.php @@ -0,0 +1,43 @@ +res(['dependencies' => [], 'provided_blocks' => ['header'], 'used_blocks' => [], 'file_category' => 'component', 'inheritance_depth' => 1], 'components/card.html.twig'), + $this->res(['dependencies' => ['components/card.html.twig'], 'provided_blocks' => [], 'used_blocks' => ['header'], 'file_category' => 'page', 'inheritance_depth' => 2], 'pages/home.html.twig'), + + $this->res(['dependencies' => [], 'provided_blocks' => ['body'], 'used_blocks' => [], 'file_category' => 'layout', 'inheritance_depth' => 0], 'layouts/base.html.twig'), + ]; + + $reporter = new ArchitectureMetricsReporter(new CouplingAnalyzer(), new BlockUsageAnalyzer()); + $card = $reporter->generateMetrics($results); + + $this->assertSame('Architecture', $card->name); + $this->assertArrayHasKey('avg_fan_in', $card->coreMetrics); + $this->assertArrayHasKey('components_ratio', $card->detailMetrics); + $this->assertArrayHasKey('roles', $card->distributions); + $this->assertIsArray($card->insights); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Reporter/Dimension/CodeStyleMetricsReporterTest.php b/tests/Reporter/Dimension/CodeStyleMetricsReporterTest.php new file mode 100644 index 0000000..b07ef43 --- /dev/null +++ b/tests/Reporter/Dimension/CodeStyleMetricsReporterTest.php @@ -0,0 +1,42 @@ +res(['lines' => 10, 'avg_line_length' => 70, 'max_line_length' => 80, 'blank_lines' => 1, 'comment_lines' => 1, 'trailing_spaces' => 0, 'mixed_indentation_lines' => 0, 'comment_density' => 10.0], 'a.twig'), + $this->res(['lines' => 12, 'avg_line_length' => 90, 'max_line_length' => 130, 'blank_lines' => 1, 'comment_lines' => 1, 'trailing_spaces' => 2, 'mixed_indentation_lines' => 1, 'comment_density' => 8.0], 'b.twig'), + $this->res(['lines' => 8, 'avg_line_length' => 60, 'max_line_length' => 75, 'blank_lines' => 1, 'comment_lines' => 0, 'trailing_spaces' => 0, 'mixed_indentation_lines' => 0, 'comment_density' => 5.0], 'c.twig'), + ]; + + $reporter = new CodeStyleMetricsReporter(new StatisticalCalculator(), new StyleConsistencyAnalyzer()); + $card = $reporter->generateMetrics($results); + + $this->assertSame('Code Style', $card->name); + $this->assertArrayHasKey('consistency', $card->coreMetrics); + $this->assertArrayHasKey('p95_line_length', $card->coreMetrics); + $this->assertArrayHasKey('line_length', $card->distributions); + $this->assertIsArray($card->insights); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Reporter/Dimension/LogicalComplexityMetricsReporterTest.php b/tests/Reporter/Dimension/LogicalComplexityMetricsReporterTest.php new file mode 100644 index 0000000..4bfa556 --- /dev/null +++ b/tests/Reporter/Dimension/LogicalComplexityMetricsReporterTest.php @@ -0,0 +1,48 @@ +res(['complexity_score' => 4, 'lines' => 10, 'blank_lines' => 1, 'comment_lines' => 1, 'conditions' => 1, 'loops' => 1], 'a.twig'), + $this->res(['complexity_score' => 12, 'lines' => 30, 'blank_lines' => 2, 'comment_lines' => 2, 'conditions' => 2, 'loops' => 2], 'b.twig'), + $this->res(['complexity_score' => 28, 'lines' => 50, 'blank_lines' => 3, 'comment_lines' => 3, 'conditions' => 3, 'loops' => 3], 'c.twig'), + ]; + + $reporter = new LogicalComplexityMetricsReporter( + new StatisticalCalculator(), + new ComplexityCalculator(), + new ComplexityHotspotDetector(), + ); + + $card = $reporter->generateMetrics($results); + + $this->assertSame('Logical Complexity', $card->name); + $this->assertArrayHasKey('avg', $card->coreMetrics); + $this->assertArrayHasKey('logic_ratio', $card->detailMetrics); + $this->assertArrayHasKey('heatmap', $card->distributions); + $this->assertIsArray($card->insights); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Reporter/Dimension/MaintainabilityMetricsReporterTest.php b/tests/Reporter/Dimension/MaintainabilityMetricsReporterTest.php new file mode 100644 index 0000000..035dccd --- /dev/null +++ b/tests/Reporter/Dimension/MaintainabilityMetricsReporterTest.php @@ -0,0 +1,40 @@ +res(['complexity_score' => 10, 'lines' => 80, 'formatting_consistency_score' => 90.0], 'a.twig'), + $this->res(['complexity_score' => 25, 'lines' => 200, 'formatting_consistency_score' => 70.0], 'b.twig'), + $this->res(['complexity_score' => 5, 'lines' => 40, 'formatting_consistency_score' => 100.0], 'c.twig'), + ]; + + $reporter = new MaintainabilityMetricsReporter(new ComplexityCalculator()); + $card = $reporter->generateMetrics($results); + + $this->assertSame('Maintainability', $card->name); + $this->assertArrayHasKey('mi_avg', $card->coreMetrics); + $this->assertArrayHasKey('risk', $card->distributions); + $this->assertIsArray($card->insights); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Reporter/Dimension/TemplateFilesMetricsReporterTest.php b/tests/Reporter/Dimension/TemplateFilesMetricsReporterTest.php new file mode 100644 index 0000000..502abdb --- /dev/null +++ b/tests/Reporter/Dimension/TemplateFilesMetricsReporterTest.php @@ -0,0 +1,43 @@ +res(['lines' => 40], 'components/alert.html.twig'), + $this->res(['lines' => 60], 'components/button.html.twig'), + $this->res(['lines' => 120], 'pages/home.html.twig'), + $this->res(['lines' => 200], 'layouts/base.html.twig'), + ]; + + $reporter = new TemplateFilesMetricsReporter(new StatisticalCalculator(), new DistributionCalculator()); + $card = $reporter->generateMetrics($results); + + $this->assertSame('Template Files', $card->name); + $this->assertArrayHasKey('templates', $card->coreMetrics); + $this->assertArrayHasKey('cv', $card->detailMetrics); + $this->assertArrayHasKey('size', $card->distributions); + $this->assertIsArray($card->insights); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Reporter/Dimension/TwigCallablesMetricsReporterTest.php b/tests/Reporter/Dimension/TwigCallablesMetricsReporterTest.php new file mode 100644 index 0000000..caa056e --- /dev/null +++ b/tests/Reporter/Dimension/TwigCallablesMetricsReporterTest.php @@ -0,0 +1,51 @@ +res([ + 'functions_detail' => ['include' => 2, 'dump' => 1], + 'filters_detail' => ['upper' => 3], + 'tests_detail' => ['empty' => 2], + 'deprecated_callables' => 1, + ], 'a.twig'), + $this->res([ + 'functions_detail' => ['path' => 4], + 'filters_detail' => ['lower' => 2, 'raw' => 1], + 'tests_detail' => ['defined' => 1], + 'deprecated_callables' => 0, + ], 'b.twig'), + ]; + + $reporter = new TwigCallablesMetricsReporter(new DiversityCalculator(), new CallableSecurityAnalyzer()); + $card = $reporter->generateMetrics($results); + + $this->assertSame('Twig Callables', $card->name); + $this->assertArrayHasKey('total_calls', $card->coreMetrics); + $this->assertArrayHasKey('diversity_index', $card->detailMetrics); + $this->assertArrayHasKey('usage_breakdown', $card->distributions); + $this->assertIsArray($card->insights); + } + + private function res(array $metrics, string $rel): AnalysisResult + { + $file = new SplFileInfo(__FILE__, '', $rel); + + return new AnalysisResult($file, $metrics, 0.0); + } +} diff --git a/tests/Reporter/Helper/DimensionGraderTest.php b/tests/Reporter/Helper/DimensionGraderTest.php new file mode 100644 index 0000000..6affecb --- /dev/null +++ b/tests/Reporter/Helper/DimensionGraderTest.php @@ -0,0 +1,76 @@ +stats(cv: 0.4, gini: 0.29); + [$scoreA, $gradeA] = $grader->gradeTemplateFiles($summaryA, dirDominance: 0.39, maxLines: 120); + $this->assertSame('A', $gradeA); + $this->assertGreaterThan(90.0, $scoreA); + + $summaryB = $this->stats(cv: 0.6, gini: 0.40); + [$scoreB, $gradeB] = $grader->gradeTemplateFiles($summaryB, dirDominance: 0.49, maxLines: 180); + $this->assertSame('B', $gradeB); + + $summaryC = $this->stats(cv: 0.9, gini: 0.55); + [$scoreC, $gradeC] = $grader->gradeTemplateFiles($summaryC, dirDominance: 0.59, maxLines: 250); + $this->assertSame('C', $gradeC); + + $summaryD = $this->stats(cv: 1.1, gini: 0.7); + [$scoreD, $gradeD] = $grader->gradeTemplateFiles($summaryD, dirDominance: 0.7, maxLines: 400); + $this->assertSame('D', $gradeD); + $this->assertLessThanOrEqual(60.0, $scoreD); + } + + public function testGradeComplexityABCD(): void + { + $grader = new DimensionGrader(); + + [$scoreA, $gradeA] = $grader->gradeComplexity(avg: 7.9, max: 19, criticalRatio: 0.0, logicRatio: 0.14); + $this->assertSame('A', $gradeA); + $this->assertGreaterThan(90.0, $scoreA); + + [$scoreB, $gradeB] = $grader->gradeComplexity(avg: 11.5, max: 25, criticalRatio: 0.04, logicRatio: 0.24); + $this->assertSame('B', $gradeB); + + [$scoreC, $gradeC] = $grader->gradeComplexity(avg: 17.5, max: 35, criticalRatio: 0.08, logicRatio: 0.34); + $this->assertSame('C', $gradeC); + + [$scoreD, $gradeD] = $grader->gradeComplexity(avg: 20.0, max: 50, criticalRatio: 0.2, logicRatio: 0.5); + $this->assertSame('D', $gradeD); + $this->assertLessThanOrEqual(60.0, $scoreD); + } + + private function stats(float $cv, float $gini): StatisticalSummary + { + return new StatisticalSummary( + count: 10, + sum: 100, + mean: 10.0, + median: 10.0, + stdDev: $cv * 10.0, + coefficientOfVariation: $cv, + p25: 5.0, + p75: 15.0, + p95: 20.0, + giniIndex: $gini, + entropy: 1.0, + min: 1.0, + max: 20.0, + range: 19.0, + ); + } +}