Regex patterns for secret detection */ private const SECRET_PATTERNS = [ 'password' => '/["\'](?:password|passwd|pwd)["\']\\s*[=:]\\s*["\'][^"\']{3,}["\']/i', 'api_key' => '/["\'](?:api[_-]?key|apikey|secret[_-]?key)["\']\\s*[=:]\\s*["\'][^"\']{8,}["\']/i', 'token' => '/["\'](?:token|auth[_-]?token|access[_-]?token)["\']\\s*[=:]\\s*["\'][^"\']{8,}["\']/i', 'url_with_creds' => '/https?:\\/\\/[^\\s:\\/]+:[^\\s@]+@/i', ]; /** @var array Regex patterns for magic number detection */ private const MAGIC_NUMBER_PATTERNS = [ 'magic_number' => '/(? Default hygiene thresholds */ private const HYGIENE_DEFAULTS = [ 'loc' => ['optimal' => 200, 'max' => 500], 'methods' => ['optimal' => 10, 'max' => 20], 'classes' => ['optimal' => 1, 'max' => 3], 'dependencies' => ['optimal' => 5, 'max' => 15], 'secrets' => ['optimal' => 0, 'max' => 1], 'magic_numbers' => ['optimal' => 0, 'max' => 10], ]; /** @var array> File type specific modifiers */ private const FILE_TYPE_MODIFIERS = [ 'Controller' => [ 'loc' => ['optimal' => 300, 'max' => 500], 'methods' => ['optimal' => 15, 'max' => 25], ], 'Entity' => [ 'loc' => ['optimal' => 100, 'max' => 300], 'methods' => ['optimal' => 20, 'max' => 30], ], 'Repository' => [ 'loc' => ['optimal' => 250, 'max' => 400], 'dependencies' => ['optimal' => 3, 'max' => 8], ], 'Service' => [ 'loc' => ['optimal' => 150, 'max' => 350], ], 'UseCase' => [ 'loc' => ['optimal' => 150, 'max' => 350], ], ]; /** @var array Factor weights (must sum to 1.0) */ private const WEIGHTS = [ 'dependencies' => 0.25, 'loc' => 0.20, 'methods' => 0.20, 'secrets' => 0.15, 'classes' => 0.10, 'magic_numbers' => 0.10, ]; /** * Analysiert eine Datei und berechnet den Code Hygiene Score. * * @param array $analysisData Daten aus code_analysis * @return array{ * hygiene_score: int, * factor_scores: array, * issues_count: int, * warnings_count: int, * issues_json: string * } */ public function analyze(array $analysisData): array { $issues = []; $filePath = $analysisData['file_path'] ?? ''; $content = $analysisData['file_content'] ?? ''; // Determine file type for modifiers $fileType = $this->detectFileType($filePath); $thresholds = $this->getThresholdsForType($fileType); // Extract metrics $metrics = [ 'loc' => (int) ($analysisData['line_count'] ?? 0), 'methods' => $this->countMethods($analysisData), 'classes' => (int) ($analysisData['class_count'] ?? 1), 'dependencies' => $this->countDependencies($analysisData), 'secrets' => 0, 'magic_numbers' => 0, ]; // Detect secrets and magic numbers $secretIssues = $this->detectSecrets($content, $filePath); $metrics['secrets'] = count($secretIssues); $issues = array_merge($issues, $secretIssues); $magicIssues = $this->detectMagicNumbers($content, $filePath); $metrics['magic_numbers'] = count($magicIssues); $issues = array_merge($issues, $magicIssues); // Add threshold violation issues $issues = array_merge($issues, $this->detectThresholdViolations($metrics, $thresholds)); // Calculate normalized factor scores $factorScores = []; foreach (self::WEIGHTS as $factor => $weight) { $factorScores[$factor] = $this->normalize( $metrics[$factor], $thresholds[$factor]['optimal'], $thresholds[$factor]['max'] ); } // Calculate weighted hygiene score $hygieneScore = $this->calculateWeightedScore($factorScores); // Apply hard fail for secrets if ($metrics['secrets'] > 0) { $hygieneScore = min($hygieneScore, 20); } // Count warnings $warningsCount = count(array_filter($issues, fn ($i) => $i['severity'] === 'warning')); return [ 'hygiene_score' => $hygieneScore, 'factor_scores' => $factorScores, 'issues_count' => count($issues), 'warnings_count' => $warningsCount, 'issues_json' => json_encode($issues, JSON_UNESCAPED_UNICODE), ]; } /** * Normalisiert einen Wert auf 0-100 Skala. * Formel: max(0, 100 - ((value - optimal) / (max - optimal)) * 100) */ private function normalize(int $value, int $optimal, int $max): int { if ($value <= $optimal) { return 100; } if ($value >= $max) { return 0; } $range = $max - $optimal; if ($range === 0) { return 0; } $normalized = 100 - (($value - $optimal) / $range) * 100; return (int) max(0, min(100, $normalized)); } /** * Berechnet den gewichteten Gesamtscore. * * @param array $factorScores */ private function calculateWeightedScore(array $factorScores): int { $weightedSum = 0.0; foreach (self::WEIGHTS as $factor => $weight) { $weightedSum += ($factorScores[$factor] ?? 0) * $weight; } return (int) round($weightedSum); } /** * Ermittelt den Dateityp aus dem Pfad. */ private function detectFileType(string $filePath): ?string { $filename = basename($filePath); foreach (array_keys(self::FILE_TYPE_MODIFIERS) as $type) { if (str_contains($filename, $type)) { return $type; } } // Check directory if (str_contains($filePath, '/Controller/')) { return 'Controller'; } if (str_contains($filePath, '/Entity/')) { return 'Entity'; } if (str_contains($filePath, '/Repository/') || str_contains($filePath, '/Persistence/')) { return 'Repository'; } if (str_contains($filePath, '/Service/')) { return 'Service'; } if (str_contains($filePath, '/UseCase/') || str_contains($filePath, '/UseCases/')) { return 'UseCase'; } return null; } /** * Gibt die Thresholds für einen Dateityp zurück. * * @return array */ private function getThresholdsForType(?string $fileType): array { $thresholds = self::HYGIENE_DEFAULTS; if ($fileType !== null && isset(self::FILE_TYPE_MODIFIERS[$fileType])) { foreach (self::FILE_TYPE_MODIFIERS[$fileType] as $factor => $values) { $thresholds[$factor] = $values; } } return $thresholds; } /** * Zählt die Methoden aus den Analysedaten. * * @param array $analysisData */ private function countMethods(array $analysisData): int { $functions = json_decode($analysisData['functions'] ?? '[]', true); return is_array($functions) ? count($functions) : 0; } /** * Zählt die Dependencies aus den Analysedaten. * * @param array $analysisData */ private function countDependencies(array $analysisData): int { $uses = json_decode($analysisData['uses'] ?? '[]', true); return is_array($uses) ? count($uses) : 0; } /** * Erkennt Secrets im Code (Hard Fail). * * @return array */ private function detectSecrets(string $content, string $filePath): array { $issues = []; $filename = basename($filePath); // Skip config files if (preg_match('/^(config|\.env|test|spec)/i', $filename)) { return []; } foreach (self::SECRET_PATTERNS as $type => $pattern) { if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $line = substr_count(substr($content, 0, $match[1]), "\n") + 1; $issues[] = [ 'type' => 'secret', 'rule' => "hardcoded-{$type}", 'message' => "KRITISCH: Mögliches hardcoded {$type} gefunden", 'severity' => 'critical', 'line' => $line, ]; } } } return $issues; } /** * Erkennt Magic Numbers im Code. * * @return array */ private function detectMagicNumbers(string $content, string $filePath): array { $issues = []; // Skip config/constant files entirely if (preg_match('/const|config|Constants/i', $filePath)) { return []; } $lines = explode("\n", $content); foreach (self::MAGIC_NUMBER_PATTERNS as $type => $pattern) { if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $lineNum = substr_count(substr($content, 0, $match[1]), "\n"); $lineContent = $lines[$lineNum] ?? ''; // Skip constant definitions if ($this->isConstantDefinition($lineContent)) { continue; } // Skip comments if ($this->isCommentLine($lineContent)) { continue; } $issues[] = [ 'type' => 'magic_number', 'rule' => 'hardcoded-magic-number', 'message' => "Magic Number gefunden: {$match[0]}", 'severity' => 'info', 'line' => $lineNum + 1, ]; } } } return $issues; } /** * Prüft ob eine Zeile eine Konstanten-Definition ist. */ private function isConstantDefinition(string $line): bool { $patterns = [ '/\bconst\s+/i', // PHP/JS const '/\bdefine\s*\(/i', // PHP define() '/^\s*[A-Z_]+\s*=/', // Python CONSTANT = value '/public\s+const\s+/i', // PHP public const '/private\s+const\s+/i', // PHP private const '/final\s+const\s+/i', // PHP final const ]; foreach ($patterns as $pattern) { if (preg_match($pattern, $line)) { return true; } } return false; } /** * Prüft ob eine Zeile ein Kommentar ist. */ private function isCommentLine(string $line): bool { $trimmed = trim($line); return str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '/*') || str_starts_with($trimmed, '*'); } /** * Erkennt Threshold-Verletzungen. * * @param array $metrics * @param array $thresholds * @return array */ private function detectThresholdViolations(array $metrics, array $thresholds): array { $issues = []; if ($metrics['loc'] > $thresholds['loc']['max']) { $issues[] = [ 'type' => 'complexity', 'rule' => 'file-too-long', 'message' => "Datei hat {$metrics['loc']} Zeilen (max: {$thresholds['loc']['max']})", 'severity' => 'warning', ]; } if ($metrics['methods'] > $thresholds['methods']['max']) { $issues[] = [ 'type' => 'srp', 'rule' => 'too-many-methods', 'message' => "Klasse hat {$metrics['methods']} Methoden (max: {$thresholds['methods']['max']})", 'severity' => 'warning', ]; } if ($metrics['dependencies'] > $thresholds['dependencies']['max']) { $issues[] = [ 'type' => 'coupling', 'rule' => 'too-many-dependencies', 'message' => "Klasse hat {$metrics['dependencies']} Dependencies (max: {$thresholds['dependencies']['max']})", 'severity' => 'warning', ]; } return $issues; } }