mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
332 lines
9.3 KiB
Go
332 lines
9.3 KiB
Go
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
|
|
}
|