#!/usr/bin/env python3 """ Devour Lighthouse-Style Scorecard Generator. Creates circular gauge charts and comprehensive metrics visualization. """ from __future__ import annotations import json import logging import math import os import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Tuple, Any, Optional try: from PIL import Image, ImageDraw, ImageFont except ImportError: print("Error: PIL/Pillow required. Install with: pip install Pillow") sys.exit(1) logger = logging.getLogger(__name__) # Visual constants SCALE = 2 BG = (248, 248, 246) FRAME = (222, 222, 220) BORDER = (200, 200, 198) ACCENT = (88, 166, 255) TEXT = (40, 44, 52) DIM = (140, 140, 140) BG_SCORE = (255, 255, 255) BG_TABLE = (255, 255, 255) BG_ROW_ALT = (250, 250, 248) # Color palette for gauges COLORS = { 'excellent': (68, 120, 68), # deep sage 'good': (120, 140, 72), # olive green 'moderate': (145, 155, 80), # yellow-green 'poor': (255, 193, 7), # orange 'critical': (220, 38, 127), # red } @dataclass class LighthouseData: """Data structure for Lighthouse-style visualization.""" project_name: str version: str overall_score: float overall_grade: str categories: Dict[str, Dict[str, Any]] metrics: Dict[str, Any] timestamp: str def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont: """Load font with fallback.""" size = size * SCALE candidates = [ "/System/Library/Fonts/SFCompact.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "arial.ttf" ] if bold: candidates = [ "/System/Library/Fonts/SFCompact.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "arialbd.ttf" ] for path in candidates: try: if os.path.exists(path): return ImageFont.truetype(path, size) except OSError: continue return ImageFont.load_default() def scale(value: int) -> int: """Scale value by retina factor.""" return value * SCALE def score_to_color(score: float) -> Tuple[int, int, int]: """Convert score to color.""" if score >= 90: return COLORS['excellent'] elif score >= 70: return COLORS['good'] elif score >= 50: return COLORS['moderate'] elif score >= 30: return COLORS['poor'] else: return COLORS['critical'] def draw_circular_gauge( draw: ImageDraw.ImageDraw, cx: int, cy: int, radius: int, score: float, max_score: float = 100, label: str = "SCORE" ) -> None: """Draw a circular gauge like Lighthouse.""" # Background circle draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=BG_TABLE, outline=BORDER, width=2) # Calculate angle for score (270° to -90° range) start_angle = 270 score_angle = start_angle - (score / max_score) * 360 # Draw colored arc if score > 0: draw.pieslice([cx - radius, cy - radius, cx + radius, cy + radius], start=start_angle, end=score_angle, fill=score_to_color(score)) # Inner circle inner_radius = radius * 0.7 draw.ellipse([cx - inner_radius, cy - inner_radius, cx + inner_radius, cy + inner_radius], fill=BG_SCORE, outline=BORDER, width=1) # Score text font_score = load_font(24, bold=True) score_text = f"{int(score)}" bbox = draw.textbbox((0, 0), score_text, font=font_score) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] draw.text((cx - text_width // 2, cy - text_height // 2 + bbox[1]), score_text, fill=TEXT, font=font_score) # Label text font_label = load_font(10) label_bbox = draw.textbbox((0, 0), label, font=font_label) label_width = label_bbox[2] - label_bbox[0] draw.text((cx - label_width // 2, cy + radius * 0.4), label, fill=DIM, font=font_label) def draw_metric_bar( draw: ImageDraw.ImageDraw, x: int, y: int, width: int, height: int, value: float, max_value: float, label: str, color: Tuple[int, int, int] ) -> None: """Draw a horizontal metric bar.""" # Background draw.rectangle([x, y, x + width, y + height], fill=BG_TABLE, outline=BORDER) # Fill bar if max_value > 0: fill_width = int((value / max_value) * width) draw.rectangle([x, y, x + fill_width, y + height], fill=color) # Label font = load_font(9) text = f"{label}: {int(value)}/{int(max_value)}" draw.text((x + 5, y + 2), text, fill=TEXT, font=font) def draw_category_section( draw: ImageDraw.ImageDraw, x: int, y: int, width: int, height: int, title: str, score: float, issues: List[Dict[str, Any]] ) -> None: """Draw a category section with gauge and issues.""" # Title font_title = load_font(12, bold=True) draw.text((x, y), title, fill=TEXT, font=font_title) # Gauge gauge_y = y + 25 gauge_size = 60 draw_circular_gauge(draw, x + gauge_size // 2, gauge_y + gauge_size // 2, gauge_size // 2, score, label="") # Issues list issues_y = gauge_y + gauge_size + 20 font_issue = load_font(8) for i, issue in enumerate(issues[:5]): # Limit to 5 issues issue_text = f"• {issue.get('title', 'Unknown')}" if len(issue_text) > 35: issue_text = issue_text[:32] + "..." draw.text((x, issues_y + i * 12), issue_text, fill=DIM, font=font_issue) def generate_lighthouse_scorecard(data: LighthouseData, output_path: str | Path) -> Path: """Generate Lighthouse-style scorecard with circular gauges.""" output_path = Path(output_path) # Layout width = scale(800) height = scale(600) margin = scale(20) # Create image img = Image.new("RGB", (width, height), BG) draw = ImageDraw.Draw(img) # Header font_header = load_font(20, bold=True) title = f"{data.project_name} - Code Health Report" title_bbox = draw.textbbox((0, 0), title, font=font_header) title_width = title_bbox[2] - title_bbox[0] draw.text((margin, margin), title, fill=TEXT, font=font_header) # Overall score gauge (large, centered) overall_x = width // 2 overall_y = margin + 50 overall_radius = scale(80) draw_circular_gauge(draw, overall_x, overall_y, overall_radius, score=data.overall_score, label="OVERALL") # Grade text font_grade = load_font(32, bold=True) grade_text = data.overall_grade grade_bbox = draw.textbbox((0, 0), grade_text, font=font_grade) grade_width = grade_bbox[2] - grade_bbox[0] grade_height = grade_bbox[3] - grade_bbox[1] grade_y = overall_y + overall_radius + scale(30) draw.text((overall_x - grade_width // 2, grade_y - grade_bbox[1]), grade_text, fill=score_to_color(data.overall_score), font=font_grade) # Category gauges (2x2 grid) categories_start_y = grade_y + grade_height + scale(40) category_width = scale(180) category_height = scale(150) category_spacing = scale(20) col_x = margin row_y = categories_start_y for i, (category_name, category_data) in enumerate(data.categories.items()): if i > 0 and i % 2 == 0: col_x += category_width + category_spacing row_y = categories_start_y if i % 2 == 1: row_y += category_height + category_spacing draw_category_section( draw, col_x, row_y, category_width, category_height, title=category_name.replace('_', ' ').title(), score=category_data.get('score', 0), issues=category_data.get('issues', []) ) # Metrics summary metrics_y = row_y + category_height + scale(40) font_metrics = load_font(10) metrics_text = [ f"Generated: {data.timestamp}", f"Total Issues: {data.metrics.get('total_issues', 0)}", f"Critical: {data.metrics.get('critical_issues', 0)}", f"Resolution Rate: {data.metrics.get('resolution_rate', 0):.1f}%" ] for i, text in enumerate(metrics_text): draw.text((margin, metrics_y + i * 15), text, fill=DIM, font=font_metrics) # Save output_path.parent.mkdir(parents=True, exist_ok=True) img.save(str(output_path), "PNG", optimize=True) return output_path def load_font(size: int, *, bold: bool = False) -> ImageFont.ImageFont: """Load font with fallback.""" size = size * SCALE candidates = [ "/System/Library/Fonts/SFCompact.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "arial.ttf" ] if bold: candidates = [ "/System/Library/Fonts/SFCompact.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "arialbd.ttf" ] for path in candidates: try: if os.path.exists(path): return ImageFont.truetype(path, size) except OSError: continue return ImageFont.load_default() def load_devour_lighthouse_data(json_path: str) -> LighthouseData: """Load Devour data and convert to Lighthouse format.""" with open(json_path, 'r') as f: data = json.load(f) findings = data.get('findings', []) # Calculate overall score total_score = sum(f.get('score', 0) * int(f.get('severity', 1)) for f in findings) overall_score = max(0, 100 - (total_score / 1000 * 100)) # Grade if overall_score >= 90: grade = "A" elif overall_score >= 80: grade = "B" elif overall_score >= 70: grade = "C" elif overall_score >= 60: grade = "D" else: grade = "F" # Group by category categories = {} type_counts = {} type_scores = {} for finding in findings: ftype = finding.get('type', 'unknown') type_counts[ftype] = type_counts.get(ftype, 0) + 1 type_scores[ftype] = type_scores.get(ftype, 0) + finding.get('score', 0) # Create categories with scores and sample issues for ftype, count in type_counts.items(): avg_score = 100 - (type_scores[ftype] / max(1, count) / 10 * 100) category_score = max(0, min(100, avg_score)) # Get sample issues for this category category_issues = [ { 'title': f.get('title', 'Unknown'), 'score': f.get('score', 0) } for f in findings if f.get('type') == ftype ][:3] # Top 3 issues categories[ftype.replace('_', ' ').title()] = { 'score': category_score, 'issues': category_issues } # Metrics metrics = { 'total_issues': len(findings), 'critical_issues': len([f for f in findings if f.get('severity') == 4]), 'resolution_rate': 0.0 # Would calculate from fixed/resolved } return LighthouseData( project_name="Devour", version="1.0.0", overall_score=overall_score, overall_grade=grade, categories=categories, metrics=metrics, timestamp=data.get('timestamp', '') ) def main(): """Main entry point.""" if len(sys.argv) != 3: print("Usage: python devour_lighthouse.py ") sys.exit(1) json_path = sys.argv[1] output_path = sys.argv[2] if not os.path.exists(json_path): print(f"Error: Input file {json_path} not found") sys.exit(1) try: data = load_devour_lighthouse_data(json_path) result_path = generate_lighthouse_scorecard(data, output_path) print(f"Lighthouse scorecard generated: {result_path}") except Exception as e: print(f"Error generating Lighthouse scorecard: {e}") sys.exit(1) if __name__ == "__main__": main()