phpmetrics_check.py

Code Hygiene Score: 89

Keine Issues gefunden.

Dependencies 9

Funktionen 6

Code

#!/usr/bin/env python3
"""
PHPMetrics Integration für Cohesion/Coupling-Analyse.

Läuft PHPMetrics auf der Codebase und erzeugt Warnungen
für Klassen mit schlechten Metriken.

Prinzip: Cohesion over Coupling (#13)
"""

import json
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Tuple

# =============================================================================
# KONFIGURATION
# =============================================================================

PHPMETRICS_BIN = "/opt/php-tools/vendor/bin/phpmetrics"
SRC_PATH = "/var/www/dev.campus.systemische-tools.de/src"
CACHE_FILE = "/tmp/phpmetrics_cache.json"
CACHE_MAX_AGE_SECONDS = 3600  # 1 Stunde

# Schwellwerte für Warnungen
THRESHOLDS = {
    "lcom": 4,              # Lack of Cohesion - höher = schlechter
    "afferent_coupling": 10, # Wie viele Klassen nutzen diese
    "efferent_coupling": 15, # Wie viele Klassen nutzt diese
    "instability_low": 0.2,  # Zu stabil (schwer zu ändern)
    "instability_high": 0.8, # Zu instabil (zu viele Abhängigkeiten)
    "ccn_method_max": 15,    # Maximale Komplexität pro Methode
    "wmc": 50,               # Weighted Methods per Class
}

# Pfade die ignoriert werden
SKIP_PATHS = [
    "/vendor/",
    "/tests/",
    "/Test/",
]


# =============================================================================
# HELPER
# =============================================================================

def is_cache_valid() -> bool:
    """Prüft ob der Cache noch gültig ist."""
    cache = Path(CACHE_FILE)
    if not cache.exists():
        return False
    import time
    age = time.time() - cache.stat().st_mtime
    return age < CACHE_MAX_AGE_SECONDS


def load_metrics() -> Dict:
    """Lädt Metriken aus Cache oder führt PHPMetrics aus."""
    if is_cache_valid():
        with open(CACHE_FILE) as f:
            return json.load(f)

    # PHPMetrics ausführen
    result = subprocess.run(
        [PHPMETRICS_BIN, "--report-json=" + CACHE_FILE, SRC_PATH],
        capture_output=True,
        text=True,
        timeout=300
    )

    if result.returncode != 0 and not Path(CACHE_FILE).exists():
        return {}

    with open(CACHE_FILE) as f:
        return json.load(f)


def should_skip(class_name: str) -> bool:
    """Prüft ob die Klasse übersprungen werden soll."""
    return any(skip in class_name for skip in SKIP_PATHS)


# =============================================================================
# ANALYSE
# =============================================================================

def analyze_class(name: str, data: Dict) -> List[str]:
    """Analysiert eine Klasse und gibt Warnungen zurück."""
    warnings = []

    if not isinstance(data, dict):
        return warnings

    # LCOM - Lack of Cohesion of Methods
    lcom = data.get("lcom", 0)
    if lcom > THRESHOLDS["lcom"]:
        warnings.append(
            f"W13.1: {name} has low cohesion (LCOM={lcom}). "
            "Consider splitting into focused classes."
        )

    # Afferent Coupling (incoming dependencies)
    afferent = data.get("afferentCoupling", 0)
    if afferent > THRESHOLDS["afferent_coupling"]:
        warnings.append(
            f"W13.2: {name} has high afferent coupling ({afferent} dependents). "
            "Changes here affect many classes - consider interface."
        )

    # Efferent Coupling (outgoing dependencies)
    efferent = data.get("efferentCoupling", 0)
    if efferent > THRESHOLDS["efferent_coupling"]:
        warnings.append(
            f"W13.3: {name} has high efferent coupling ({efferent} dependencies). "
            "Consider dependency injection or facade pattern."
        )

    # Instability
    instability = data.get("instability", 0.5)
    if instability < THRESHOLDS["instability_low"] and afferent > 5:
        warnings.append(
            f"W13.4: {name} is very stable (I={instability:.2f}) with many dependents. "
            "Changes are risky - ensure good test coverage."
        )

    # Cyclomatic Complexity per Method
    ccn_max = data.get("ccnMethodMax", 0)
    if ccn_max > THRESHOLDS["ccn_method_max"]:
        warnings.append(
            f"W13.5: {name} has high method complexity (CCN={ccn_max}). "
            "Consider extracting methods or simplifying logic."
        )

    # Weighted Methods per Class
    wmc = data.get("wmc", 0)
    if wmc > THRESHOLDS["wmc"]:
        warnings.append(
            f"W13.6: {name} has high overall complexity (WMC={wmc}). "
            "Class is doing too much - consider splitting."
        )

    return warnings


def run_analysis() -> Tuple[List[str], Dict]:
    """Führt die vollständige Analyse durch."""
    metrics = load_metrics()
    all_warnings = []
    stats = {
        "classes_analyzed": 0,
        "classes_with_warnings": 0,
        "total_warnings": 0,
    }

    for name, data in metrics.items():
        if should_skip(name):
            continue

        if not isinstance(data, dict):
            continue

        stats["classes_analyzed"] += 1
        warnings = analyze_class(name, data)

        if warnings:
            stats["classes_with_warnings"] += 1
            stats["total_warnings"] += len(warnings)
            all_warnings.extend(warnings)

    return all_warnings, stats


# =============================================================================
# MAIN
# =============================================================================

def main():
    """Hauptfunktion für CLI-Aufruf."""
    import argparse

    parser = argparse.ArgumentParser(description="PHPMetrics Cohesion Check")
    parser.add_argument("--refresh", action="store_true", help="Cache neu erstellen")
    parser.add_argument("--json", action="store_true", help="JSON-Ausgabe")
    parser.add_argument("--class", dest="class_name", help="Nur eine Klasse prüfen")
    args = parser.parse_args()

    if args.refresh and Path(CACHE_FILE).exists():
        Path(CACHE_FILE).unlink()

    warnings, stats = run_analysis()

    if args.json:
        print(json.dumps({
            "warnings": warnings,
            "stats": stats
        }, indent=2))
    else:
        print(f"PHPMetrics Analysis: {stats['classes_analyzed']} classes")
        print(f"Classes with warnings: {stats['classes_with_warnings']}")
        print(f"Total warnings: {stats['total_warnings']}")
        print()

        if warnings:
            print("Warnings:")
            for w in warnings[:20]:  # Limit output
                print(f"  - {w}")

            if len(warnings) > 20:
                print(f"  ... and {len(warnings) - 20} more")


if __name__ == "__main__":
    main()
← Übersicht