package scorecard import ( "fmt" "image" "image/png" "os" "path/filepath" "sort" "time" "github.com/yourorg/devour/internal/quality" ) type Dimension struct { Name string Score float64 Strict float64 Count int } type ScorecardData struct { ProjectName string Version string OverallScore float64 StrictScore float64 Grade string FindingsTotal int FindingsOpen int LastScan time.Time Dimensions []Dimension FindByType map[string]int FindByTier map[string]int } func Generate(data *ScorecardData, outputPath string) error { width := 780 * Scale leftPanelWidth := 260 * Scale frameInset := 5 * Scale rowCount := len(data.Dimensions) if rowCount < 4 { rowCount = 4 } cols := 2 rowsPerCol := (rowCount + cols - 1) / cols rowH := 20 * Scale tableContentH := 14*Scale + 4*Scale + 6*Scale + rowsPerCol*rowH contentH := max(tableContentH+28*Scale, 150*Scale) height := 12*Scale + contentH img := image.NewRGBA(image.Rect(0, 0, width, height)) dc := NewDrawContext(img, Scale) dc.FillBackground(BG) dc.DrawDoubleFrame(0, 0, width-1, height-1, FRAME, BORDER, 2*Scale, 1) contentTop := frameInset + Scale contentBot := height - frameInset - Scale contentMidY := (contentTop + contentBot) / 2 dividerX := leftPanelWidth drawLeftPanel(dc, data, frameInset+11*Scale, dividerX-11*Scale, contentTop+4*Scale, contentBot-4*Scale) dc.DrawVertRuleWithOrnament(dividerX, contentTop+12*Scale, contentBot-12*Scale, contentMidY, BORDER, ACCENT) drawRightPanel(dc, data, dividerX+11*Scale, width-frameInset-11*Scale, contentTop+4*Scale, contentBot-4*Scale) dir := filepath.Dir(outputPath) if dir != "" { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } } f, err := os.Create(outputPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer f.Close() if err := png.Encode(f, img); err != nil { return fmt.Errorf("failed to encode PNG: %w", err) } return nil } func drawLeftPanel(dc *DrawContext, data *ScorecardData, lpLeft, lpRight, lpTop, lpBot int) { lpCenter := (lpLeft + lpRight) / 2 panelWidth := lpRight - lpLeft panelHeight := lpBot - lpTop dc.DrawRoundedRect(lpLeft, lpTop, panelWidth, panelHeight, 4*Scale, BGScore) dc.DrawRect(lpLeft, lpTop, lpRight, lpBot, BORDER, 1) versionText := "version unknown" if data.Version != "" { versionText = "v" + data.Version } fontVersion := GetFont() versionW, _, versionOffY := dc.TextBounds(versionText, fontVersion) versionY := lpTop + 12*Scale - versionOffY dc.DrawText(versionText, lpCenter-versionW/2, versionY, fontVersion, DIM) title := "DEVOUR SCORE" fontTitle := GetFont() titleW, titleH, _ := dc.TextBounds(title, fontTitle) titleY := lpTop + 28*Scale dc.DrawText(title, lpCenter-titleW/2, titleY, fontTitle, TEXT) ruleY := titleY + titleH + 7*Scale dc.DrawRuleWithOrnament(ruleY, lpLeft+28*Scale, lpRight-28*Scale, lpCenter, BORDER, ACCENT) scoreText := FmtScore(data.OverallScore) fontBig := GetFont() scoreW, scoreH, scoreOffY := dc.TextBounds(scoreText, fontBig) scoreY := ruleY + 6*Scale + 7*Scale - scoreOffY scoreColor := GetScoreColor(int(data.OverallScore)) dc.DrawText(scoreText, lpCenter-scoreW/2, scoreY, fontBig, scoreColor) strictLabel := "strict" strictValue := FmtScore(data.StrictScore) + "%" fontStrictLabel := GetFont() fontStrictVal := GetFont() labelW, _, labelOffY := dc.TextBounds(strictLabel, fontStrictLabel) valueW, _, valueOffY := dc.TextBounds(strictValue, fontStrictVal) gap := 5 * Scale strictY := scoreY + scoreH + 6*Scale strictX := lpCenter - (labelW+gap+valueW)/2 dc.DrawText(strictLabel, strictX, strictY-labelOffY, fontStrictLabel, DIM) strictColor := GetScoreColorMuted(int(data.StrictScore)) dc.DrawText(strictValue, strictX+labelW+gap, strictY-valueOffY, fontStrictVal, strictColor) projectName := data.ProjectName if projectName == "" { projectName = "project" } fontProject := GetFont() projectW, projectH, _ := dc.TextBounds(projectName, fontProject) pillPadX := 8 * Scale pillPadY := 3 * Scale pillHeight := projectH + 2*pillPadY pillTop := strictY + projectH + 8*Scale pillLeft := lpCenter - projectW/2 - pillPadX pillRight := lpCenter + projectW/2 + pillPadX dc.DrawRoundedRect(pillLeft, pillTop, pillRight-pillLeft, pillHeight, 3*Scale, BG) dc.DrawRect(pillLeft, pillTop, pillRight, pillTop+pillHeight, BORDER, 1) projectY := pillTop + pillPadY dc.DrawText(projectName, lpCenter-projectW/2, projectY, fontProject, DIM) } func drawRightPanel(dc *DrawContext, data *ScorecardData, tableX1, tableX2, tableTop, tableBot int) { fontRow := GetFont() fontStrict := GetFont() rowCount := len(data.Dimensions) cols := 2 rowsPerCol := (rowCount + cols - 1) / cols gridGap := 8 * Scale gridWidth := (tableX2 - tableX1 - gridGap) / cols rowH := 20 * Scale for colIndex := 0; colIndex < cols; colIndex++ { gridX1 := tableX1 + colIndex*(gridWidth+gridGap) gridX2 := gridX1 + gridWidth dc.DrawRoundedRect(gridX1, tableTop, gridWidth, tableBot-tableTop, 4*Scale, BGTable) dc.DrawRect(gridX1, tableTop, gridX2, tableBot, BORDER, 1) nameColWidth := 120 * Scale valueColGap := 4 * Scale valueColWidth := 34 * Scale totalContentWidth := nameColWidth + valueColGap + valueColWidth + valueColGap + valueColWidth blockLeft := gridX1 + (gridWidth-totalContentWidth)/2 nameColX := blockLeft healthColX := nameColX + nameColWidth + valueColGap strictColX := healthColX + valueColWidth + valueColGap + 4*Scale thisColRows := rowsPerCol if colIndex == 1 && rowCount%2 != 0 { thisColRows = rowsPerCol - 1 } if colIndex*rowsPerCol+thisColRows > rowCount { thisColRows = rowCount - colIndex*rowsPerCol } contentHeight := thisColRows * rowH contentTop := (tableTop+tableBot)/2 - contentHeight/2 _, rowTextH, rowTextOff := dc.TextBounds("Xg", fontRow) startIdx := colIndex * rowsPerCol for rowIdx := 0; rowIdx < thisColRows; rowIdx++ { dimIdx := startIdx + rowIdx if dimIdx >= rowCount { break } dim := data.Dimensions[dimIdx] bandTop := contentTop + rowIdx*rowH if rowIdx%2 == 1 { dc.FillRect(gridX1+1, bandTop, gridWidth-2, rowH, BGRowAlt) } textY := bandTop + (rowH-rowTextH)/2 - rowTextOff + Scale maxNameWidth := nameColWidth - 2*Scale name := dc.TruncateText(dim.Name, maxNameWidth, fontRow) dc.DrawText(name, nameColX, textY, fontRow, TEXT) score := dim.Score if score == 0 { score = 100 } scoreText := FmtScore(score) + "%" dc.DrawText(scoreText, healthColX, textY, fontRow, GetScoreColor(int(score))) strict := dim.Strict if strict == 0 { strict = score } strictText := FmtScore(strict) + "%" _, strictTextH, strictOff := dc.TextBounds(strictText, fontStrict) strictY := bandTop + (rowH-strictTextH)/2 - strictOff dc.DrawText(strictText, strictColX, strictY, fontStrict, GetScoreColorMuted(int(strict))) } } } // FromQualityState creates ScorecardData from quality state func FromQualityState(state *quality.State, projectName, version string) *ScorecardData { data := &ScorecardData{ ProjectName: projectName, Version: version, FindingsTotal: len(state.Findings), LastScan: state.LastScan, FindByType: make(map[string]int), FindByTier: make(map[string]int), } // Get score from scorecard if state.Scorecard != nil { data.OverallScore = float64(state.Scorecard.TotalScore) data.StrictScore = float64(state.Scorecard.StrictScore) data.FindByType = state.Scorecard.FindingsByType data.FindByTier = make(map[string]int) for sev, count := range state.Scorecard.FindingsByTier { data.FindByTier[fmt.Sprintf("T%d", sev)] = count } } // Calculate grade data.Grade = GetScoreGrade(int(data.OverallScore)) // Count open findings for _, f := range state.Findings { if f.Status == quality.StatusOpen { data.FindingsOpen++ } } // Build dimensions from findings by type data.Dimensions = buildDimensions(state) return data } // buildDimensions builds dimension list from quality state func buildDimensions(state *quality.State) []Dimension { dims := []Dimension{} byType := make(map[string]*Dimension) for _, f := range state.Findings { if f.Status == quality.StatusOpen { if _, exists := byType[f.Type]; !exists { byType[f.Type] = &Dimension{ Name: formatDimensionName(f.Type), Score: 100, Count: 0, } } byType[f.Type].Count++ byType[f.Type].Score -= float64(f.Severity) if byType[f.Type].Score < 0 { byType[f.Type].Score = 0 } } } for _, dim := range byType { dim.Strict = dim.Score dims = append(dims, *dim) } sort.Slice(dims, func(i, j int) bool { return dims[i].Count > dims[j].Count }) if len(dims) > 12 { dims = dims[:12] } return dims } // formatDimensionName formats a dimension name for display func formatDimensionName(name string) string { // Map internal names to display names nameMap := map[string]string{ "complexity": "Complexity", "duplication": "Duplication", "naming": "Naming", "security": "Security", "dead_code": "Dead Code", "unused_import": "Unused Import", "unused_var": "Unused Variable", "god_component": "God Component", "mixed_concerns": "Mixed Concerns", "test_coverage": "Test Coverage", } if display, ok := nameMap[name]; ok { return display } if len(name) > 0 { return string(name[0]-32) + name[1:] } return name }