mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user