mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
488 lines
12 KiB
Go
488 lines
12 KiB
Go
package quality
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Scorer calculates quality scores and generates scorecards
|
||
type Scorer struct {
|
||
targetScore int
|
||
}
|
||
|
||
// NewScorer creates a new scorer with the given target score
|
||
func NewScorer(targetScore int) *Scorer {
|
||
if targetScore <= 0 {
|
||
targetScore = 95 // Default target
|
||
}
|
||
return &Scorer{
|
||
targetScore: targetScore,
|
||
}
|
||
}
|
||
|
||
// CalculateScore calculates the quality score from findings
|
||
func (s *Scorer) CalculateScore(findings []Finding) (int, int) {
|
||
totalScore := 0
|
||
strictScore := 0
|
||
|
||
for _, finding := range findings {
|
||
weight := int(finding.Severity)
|
||
score := finding.Score * weight
|
||
totalScore += score
|
||
|
||
// Strict score: ONLY includes truly unresolved issues
|
||
// Excludes: fixed, false_positive, ignored, wontfix (if justified)
|
||
if s.isStrictlyRelevant(finding) {
|
||
// Apply strict multiplier for severity
|
||
strictMultiplier := s.getStrictMultiplier(finding)
|
||
strictScore += score * strictMultiplier
|
||
}
|
||
}
|
||
|
||
return totalScore, strictScore
|
||
}
|
||
|
||
// GenerateScorecard creates a scorecard from scan results
|
||
func (s *Scorer) GenerateScorecard(findings []Finding, lastScan time.Time) *Scorecard {
|
||
totalScore, strictScore := s.CalculateScore(findings)
|
||
|
||
// Group findings by type and tier
|
||
findingsByType := make(map[string]int)
|
||
findingsByTier := make(map[Severity]int)
|
||
statusByType := make(map[string]int)
|
||
|
||
for _, finding := range findings {
|
||
findingsByType[finding.Type]++
|
||
findingsByTier[finding.Severity]++
|
||
statusByType[string(finding.Status)]++
|
||
}
|
||
|
||
return &Scorecard{
|
||
TotalScore: totalScore,
|
||
StrictScore: strictScore,
|
||
TargetScore: s.targetScore,
|
||
FindingsByType: findingsByType,
|
||
FindingsByTier: findingsByTier,
|
||
StatusByType: statusByType,
|
||
LastScan: lastScan,
|
||
}
|
||
}
|
||
|
||
// isStrictlyRelevant determines if a finding should count in strict scoring
|
||
func (s *Scorer) isStrictlyRelevant(finding Finding) bool {
|
||
switch finding.Status {
|
||
case StatusOpen:
|
||
return true
|
||
case StatusFixed:
|
||
return false // Already resolved
|
||
case StatusFalsePositive:
|
||
return false // Not a real issue
|
||
case StatusIgnored:
|
||
return false // Explicitly ignored
|
||
case StatusWontfix:
|
||
// Only count wontfix if it's not justified with valid reasons
|
||
return !s.isJustifiedWontfix(finding)
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
// isJustifiedWontfix checks if wontfix has valid justification
|
||
func (s *Scorer) isJustifiedWontfix(finding Finding) bool {
|
||
if finding.Metadata == nil {
|
||
return false
|
||
}
|
||
|
||
note, exists := finding.Metadata["resolution_note"]
|
||
if !exists {
|
||
return false
|
||
}
|
||
|
||
// Valid wontfix justifications
|
||
validJustifications := []string{
|
||
"legacy", "deprecated", "external", "third-party",
|
||
"temporary", "placeholder", "documentation",
|
||
"test-only", "example", "sample",
|
||
}
|
||
|
||
note = strings.ToLower(note)
|
||
for _, justification := range validJustifications {
|
||
if strings.Contains(note, justification) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// getStrictMultiplier returns severity multiplier for strict scoring
|
||
func (s *Scorer) getStrictMultiplier(finding Finding) int {
|
||
switch finding.Severity {
|
||
case SeverityT1:
|
||
return 1 // T1 issues are less critical
|
||
case SeverityT2:
|
||
return 2 // T2 issues are moderately important
|
||
case SeverityT3:
|
||
return 3 // T3 issues need attention
|
||
case SeverityT4:
|
||
return 5 // T4 issues are critical
|
||
default:
|
||
return 1
|
||
}
|
||
}
|
||
|
||
// GetHealthGrade returns a health grade based on score
|
||
func (s *Scorer) GetHealthGrade(score int) string {
|
||
percentage := s.getScorePercentage(score)
|
||
|
||
switch {
|
||
case percentage >= 90:
|
||
return "A"
|
||
case percentage >= 80:
|
||
return "B"
|
||
case percentage >= 70:
|
||
return "C"
|
||
case percentage >= 60:
|
||
return "D"
|
||
default:
|
||
return "F"
|
||
}
|
||
}
|
||
|
||
// getScorePercentage converts score to percentage (inverted - lower is better)
|
||
func (s *Scorer) getScorePercentage(score int) int {
|
||
// Strict percentage calculation with multiple factors
|
||
if score <= 0 {
|
||
return 100
|
||
}
|
||
|
||
// Base calculation with stricter normalization
|
||
var percentage int
|
||
if score > 10000 {
|
||
// Logarithmic scaling for very high scores
|
||
percentage = 100 - int(float64(score-10000)/float64(score)*90)
|
||
} else if score > 5000 {
|
||
// Linear scaling for high scores
|
||
percentage = 100 - (score * 100 / 20000)
|
||
} else if score > 1000 {
|
||
// Linear scaling for medium scores
|
||
percentage = 100 - (score * 100 / 10000)
|
||
} else {
|
||
// Linear scaling for low scores
|
||
percentage = 100 - (score * 100 / 2000)
|
||
}
|
||
|
||
if percentage < 0 {
|
||
percentage = 0
|
||
}
|
||
return percentage
|
||
}
|
||
|
||
// GetStrictHealthMetrics returns comprehensive strict health metrics
|
||
func (s *Scorer) GetStrictHealthMetrics(findings []Finding) map[string]interface{} {
|
||
total := len(findings)
|
||
open := 0
|
||
critical := 0
|
||
high := 0
|
||
medium := 0
|
||
low := 0
|
||
resolved := 0
|
||
ignored := 0
|
||
|
||
strictScore := 0
|
||
totalScore := 0
|
||
|
||
for _, finding := range findings {
|
||
totalScore += finding.Score * int(finding.Severity)
|
||
|
||
switch finding.Status {
|
||
case StatusOpen:
|
||
open++
|
||
if s.isStrictlyRelevant(finding) {
|
||
strictScore += finding.Score * int(finding.Severity) * s.getStrictMultiplier(finding)
|
||
}
|
||
case StatusFixed:
|
||
resolved++
|
||
case StatusIgnored, StatusFalsePositive:
|
||
ignored++
|
||
}
|
||
|
||
switch finding.Severity {
|
||
case SeverityT4:
|
||
critical++
|
||
case SeverityT3:
|
||
high++
|
||
case SeverityT2:
|
||
medium++
|
||
case SeverityT1:
|
||
low++
|
||
}
|
||
}
|
||
|
||
// Calculate strict percentages
|
||
openPercentage := float64(open) / float64(total) * 100
|
||
criticalPercentage := float64(critical) / float64(total) * 100
|
||
resolutionRate := float64(resolved) / float64(total) * 100
|
||
|
||
// Strict health score (0-100)
|
||
healthScore := 100.0
|
||
healthScore -= float64(openPercentage) * 0.5 // Penalty for open issues
|
||
healthScore -= float64(criticalPercentage) * 2.0 // Higher penalty for critical
|
||
healthScore -= float64(high) * 0.1 // Penalty for high severity
|
||
healthScore += float64(resolutionRate) * 0.3 // Bonus for resolution rate
|
||
|
||
if healthScore < 0 {
|
||
healthScore = 0
|
||
}
|
||
if healthScore > 100 {
|
||
healthScore = 100
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"total_issues": total,
|
||
"open_issues": open,
|
||
"critical_issues": critical,
|
||
"high_issues": high,
|
||
"medium_issues": medium,
|
||
"low_issues": low,
|
||
"resolved_issues": resolved,
|
||
"ignored_issues": ignored,
|
||
"open_percentage": openPercentage,
|
||
"critical_percentage": criticalPercentage,
|
||
"resolution_rate": resolutionRate,
|
||
"strict_score": strictScore,
|
||
"total_score": totalScore,
|
||
"health_score": healthScore,
|
||
"grade": s.GetStrictGrade(healthScore),
|
||
}
|
||
}
|
||
|
||
// GetStrictGrade returns grade based on strict health score
|
||
func (s *Scorer) GetStrictGrade(healthScore float64) string {
|
||
switch {
|
||
case healthScore >= 95:
|
||
return "A+"
|
||
case healthScore >= 90:
|
||
return "A"
|
||
case healthScore >= 85:
|
||
return "A-"
|
||
case healthScore >= 80:
|
||
return "B+"
|
||
case healthScore >= 75:
|
||
return "B"
|
||
case healthScore >= 70:
|
||
return "B-"
|
||
case healthScore >= 65:
|
||
return "C+"
|
||
case healthScore >= 60:
|
||
return "C"
|
||
case healthScore >= 55:
|
||
return "C-"
|
||
case healthScore >= 50:
|
||
return "D+"
|
||
case healthScore >= 45:
|
||
return "D"
|
||
case healthScore >= 40:
|
||
return "D-"
|
||
default:
|
||
return "F"
|
||
}
|
||
}
|
||
|
||
// FormatScorecard formats the scorecard for display
|
||
func (s *Scorer) FormatScorecard(card *Scorecard) string {
|
||
grade := s.GetHealthGrade(card.StrictScore)
|
||
percentage := s.getScorePercentage(card.StrictScore)
|
||
|
||
output := fmt.Sprintf(`
|
||
🔍 STRICT Code Quality Scorecard
|
||
=======================================
|
||
|
||
📊 Overall Health: %s (%d%%)
|
||
🎯 Target Score: %d
|
||
⚡ Current Score: %d (strict: %d)
|
||
|
||
📈 Findings by Type:
|
||
`, grade, percentage, card.TargetScore, card.TotalScore, card.StrictScore)
|
||
|
||
for ftype, count := range card.FindingsByType {
|
||
output += fmt.Sprintf(" - %s: %d\n", ftype, count)
|
||
}
|
||
|
||
output += "\n🚨 Findings by Severity:\n"
|
||
tierNames := map[Severity]string{
|
||
SeverityT1: "T1 (Auto-fixable)",
|
||
SeverityT2: "T2 (Quick manual)",
|
||
SeverityT3: "T3 (Needs judgment)",
|
||
SeverityT4: "T4 (Major refactor)",
|
||
}
|
||
|
||
for severity, count := range card.FindingsByTier {
|
||
if name, ok := tierNames[severity]; ok {
|
||
emoji := s.getSeverityEmoji(severity)
|
||
output += fmt.Sprintf(" %s %s: %d\n", emoji, name, count)
|
||
}
|
||
}
|
||
|
||
output += "\n📋 Status Breakdown:\n"
|
||
for status, count := range card.StatusByType {
|
||
output += fmt.Sprintf(" - %s: %d\n", status, count)
|
||
}
|
||
|
||
output += fmt.Sprintf("\n⏰ Last Scan: %s\n", card.LastScan.Format("2006-01-02 15:04:05"))
|
||
|
||
return output
|
||
}
|
||
|
||
// getSeverityEmoji returns emoji for severity level
|
||
func (s *Scorer) getSeverityEmoji(severity Severity) string {
|
||
switch severity {
|
||
case SeverityT1:
|
||
return "🟢"
|
||
case SeverityT2:
|
||
return "🟡"
|
||
case SeverityT3:
|
||
return "🟠"
|
||
case SeverityT4:
|
||
return "🔴"
|
||
default:
|
||
return "⚪"
|
||
}
|
||
}
|
||
|
||
// FormatStrictScorecard formats comprehensive strict scorecard
|
||
func (s *Scorer) FormatStrictScorecard(findings []Finding, lastScan time.Time) string {
|
||
metrics := s.GetStrictHealthMetrics(findings)
|
||
|
||
output := fmt.Sprintf(`
|
||
🔬 COMPREHENSIVE STRICT ANALYSIS
|
||
=======================================
|
||
|
||
🎯 STRICT HEALTH SCORE: %.1f/100 (%s)
|
||
=======================================
|
||
|
||
📊 ISSUE BREAKDOWN:
|
||
Total Issues: %v
|
||
🔴 Critical (T4): %v (%.1f%%)
|
||
🟠 High (T3): %v
|
||
🟡 Medium (T2): %v
|
||
🟢 Low (T1): %v
|
||
|
||
📈 STATUS ANALYSIS:
|
||
✅ Resolved: %v (%.1f%%)
|
||
🔓 Open: %v (%.1f%%)
|
||
⏸️ Ignored: %v
|
||
|
||
⚖️ SCORING:
|
||
Strict Score: %v
|
||
Total Score: %v
|
||
Health Multiplier: %.2fx
|
||
|
||
🎯 STRICT CRITERIA:
|
||
✓ Only unresolved issues counted
|
||
✓ Severity-weighted scoring (T1×1, T2×2, T3×3, T4×5)
|
||
✓ Justified wontfix excluded
|
||
✓ False positives ignored
|
||
✓ Resolution rate bonus applied
|
||
|
||
📅 Last Analysis: %s
|
||
|
||
🏆 RECOMMENDATIONS:
|
||
`,
|
||
metrics["health_score"], metrics["grade"],
|
||
metrics["total_issues"],
|
||
metrics["critical_issues"], metrics["critical_percentage"],
|
||
metrics["high_issues"],
|
||
metrics["medium_issues"],
|
||
metrics["low_issues"],
|
||
metrics["resolved_issues"], metrics["resolution_rate"],
|
||
metrics["open_issues"], metrics["open_percentage"],
|
||
metrics["ignored_issues"],
|
||
metrics["strict_score"], metrics["total_score"],
|
||
float64(metrics["strict_score"].(int))/float64(metrics["total_score"].(int)),
|
||
lastScan.Format("2006-01-02 15:04:05"))
|
||
|
||
// Add recommendations based on metrics
|
||
if metrics["critical_percentage"].(float64) > 5 {
|
||
output += " 🚨 CRITICAL: Address T4 issues immediately\n"
|
||
}
|
||
if metrics["open_percentage"].(float64) > 70 {
|
||
output += " 📈 HIGH DEBT: Focus on resolving open issues\n"
|
||
}
|
||
if metrics["resolution_rate"].(float64) < 20 {
|
||
output += " ⚡ LOW RESOLUTION: Increase fix rate\n"
|
||
}
|
||
if healthScore, ok := metrics["health_score"].(float64); ok && healthScore < 50 {
|
||
output += " ❌ POOR HEALTH: Major refactoring needed\n"
|
||
} else if healthScore >= 80 {
|
||
output += " ✅ GOOD HEALTH: Maintain current practices\n"
|
||
}
|
||
|
||
return output
|
||
}
|
||
|
||
// GetNextPriority returns the next highest priority finding to fix
|
||
func (s *Scorer) GetNextPriority(findings []Finding) *Finding {
|
||
if len(findings) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var highest *Finding
|
||
highestWeight := 0
|
||
|
||
for _, finding := range findings {
|
||
if finding.Status != StatusOpen {
|
||
continue
|
||
}
|
||
|
||
weight := int(finding.Severity) * finding.Score
|
||
if weight > highestWeight {
|
||
highestWeight = weight
|
||
highest = &finding
|
||
}
|
||
}
|
||
|
||
return highest
|
||
}
|
||
|
||
// GetFindingsByTier returns findings grouped by severity tier
|
||
func (s *Scorer) GetFindingsByTier(findings []Finding) map[Severity][]Finding {
|
||
result := make(map[Severity][]Finding)
|
||
|
||
for _, finding := range findings {
|
||
if finding.Status == StatusOpen {
|
||
result[finding.Severity] = append(result[finding.Severity], finding)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// GetProgressMetrics returns progress metrics for the scan
|
||
func (s *Scorer) GetProgressMetrics(findings []Finding) map[string]interface{} {
|
||
total := len(findings)
|
||
open := 0
|
||
fixed := 0
|
||
wontfix := 0
|
||
|
||
for _, finding := range findings {
|
||
switch finding.Status {
|
||
case StatusOpen:
|
||
open++
|
||
case StatusFixed:
|
||
fixed++
|
||
case StatusWontfix:
|
||
wontfix++
|
||
}
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"total": total,
|
||
"open": open,
|
||
"fixed": fixed,
|
||
"wontfix": wontfix,
|
||
"progress": float64(fixed) / float64(total) * 100,
|
||
}
|
||
}
|