mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
372 lines
9.5 KiB
Go
372 lines
9.5 KiB
Go
package quality
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// StateManager manages quality analysis state with diff tracking
|
|
type StateManager struct {
|
|
dataDir string
|
|
stateFile string
|
|
historyDir string
|
|
}
|
|
|
|
// State represents the persisted quality state
|
|
type State struct {
|
|
Findings []Finding `json:"findings"`
|
|
Scorecard *Scorecard `json:"scorecard"`
|
|
LastScan time.Time `json:"last_scan"`
|
|
ScanCount int `json:"scan_count"`
|
|
ContentHash string `json:"content_hash"`
|
|
History []StateSnapshot `json:"history,omitempty"`
|
|
Metadata map[string]string `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// StateSnapshot represents a historical state snapshot
|
|
type StateSnapshot struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Hash string `json:"hash"`
|
|
Score int `json:"score"`
|
|
StrictScore int `json:"strict_score"`
|
|
Findings int `json:"findings"`
|
|
File string `json:"file"`
|
|
}
|
|
|
|
// StateDiff represents the difference between two states
|
|
type StateDiff struct {
|
|
Added []Finding `json:"added"`
|
|
Removed []Finding `json:"removed"`
|
|
Changed []Finding `json:"changed"`
|
|
Resolved []Finding `json:"resolved"`
|
|
Regressions []Finding `json:"regressions"`
|
|
}
|
|
|
|
// NewStateManager creates a new state manager
|
|
func NewStateManager(dataDir string) *StateManager {
|
|
return &StateManager{
|
|
dataDir: dataDir,
|
|
stateFile: filepath.Join(dataDir, "state.json"),
|
|
historyDir: filepath.Join(dataDir, "history"),
|
|
}
|
|
}
|
|
|
|
// Load loads the current state from disk
|
|
func (sm *StateManager) Load() (*State, error) {
|
|
data, err := os.ReadFile(sm.stateFile)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &State{
|
|
Findings: []Finding{},
|
|
Metadata: make(map[string]string),
|
|
}, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read state: %w", err)
|
|
}
|
|
|
|
var state State
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return nil, fmt.Errorf("failed to parse state: %w", err)
|
|
}
|
|
|
|
return &state, nil
|
|
}
|
|
|
|
// Save saves the state to disk
|
|
func (sm *StateManager) Save(state *State) error {
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(sm.dataDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
// Calculate content hash
|
|
state.ContentHash = sm.calculateHash(state.Findings)
|
|
|
|
// Save history snapshot
|
|
if err := sm.saveHistory(state); err != nil {
|
|
// Log but don't fail
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to save history: %v\n", err)
|
|
}
|
|
|
|
// Marshal state
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal state: %w", err)
|
|
}
|
|
|
|
// Write to temp file first
|
|
tmpFile := sm.stateFile + ".tmp"
|
|
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write state: %w", err)
|
|
}
|
|
|
|
// Rename to final location (atomic on most filesystems)
|
|
if err := os.Rename(tmpFile, sm.stateFile); err != nil {
|
|
return fmt.Errorf("failed to rename state file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Merge merges new findings with existing state
|
|
func (sm *StateManager) Merge(state *State, newFindings []Finding) *StateDiff {
|
|
diff := &StateDiff{
|
|
Added: []Finding{},
|
|
Removed: []Finding{},
|
|
Changed: []Finding{},
|
|
Resolved: []Finding{},
|
|
}
|
|
|
|
// Create lookup maps
|
|
existingMap := make(map[string]Finding)
|
|
for _, f := range state.Findings {
|
|
existingMap[f.ID] = f
|
|
}
|
|
|
|
newMap := make(map[string]Finding)
|
|
for _, f := range newFindings {
|
|
newMap[f.ID] = f
|
|
}
|
|
|
|
// Find added and changed findings
|
|
for _, new := range newFindings {
|
|
if existing, ok := existingMap[new.ID]; ok {
|
|
// Check if changed
|
|
if !findingsEqual(existing, new) {
|
|
diff.Changed = append(diff.Changed, new)
|
|
}
|
|
} else {
|
|
// New finding
|
|
diff.Added = append(diff.Added, new)
|
|
}
|
|
}
|
|
|
|
// Find removed findings (these are resolved)
|
|
for _, existing := range state.Findings {
|
|
if _, ok := newMap[existing.ID]; !ok {
|
|
if existing.Status == StatusOpen {
|
|
diff.Resolved = append(diff.Resolved, existing)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update state
|
|
state.Findings = newFindings
|
|
state.LastScan = time.Now()
|
|
state.ScanCount++
|
|
|
|
return diff
|
|
}
|
|
|
|
// Diff compares two states
|
|
func (sm *StateManager) Diff(old, new *State) *StateDiff {
|
|
diff := &StateDiff{
|
|
Added: []Finding{},
|
|
Removed: []Finding{},
|
|
Changed: []Finding{},
|
|
Resolved: []Finding{},
|
|
Regressions: []Finding{},
|
|
}
|
|
|
|
oldMap := make(map[string]Finding)
|
|
for _, f := range old.Findings {
|
|
oldMap[f.ID] = f
|
|
}
|
|
|
|
newMap := make(map[string]Finding)
|
|
for _, f := range new.Findings {
|
|
newMap[f.ID] = f
|
|
}
|
|
|
|
for _, n := range new.Findings {
|
|
if o, ok := oldMap[n.ID]; ok {
|
|
if !findingsEqual(o, n) {
|
|
diff.Changed = append(diff.Changed, n)
|
|
// Check for regression (resolved -> open)
|
|
if o.Status != StatusOpen && n.Status == StatusOpen {
|
|
diff.Regressions = append(diff.Regressions, n)
|
|
}
|
|
}
|
|
} else {
|
|
diff.Added = append(diff.Added, n)
|
|
}
|
|
}
|
|
|
|
for _, o := range old.Findings {
|
|
if _, ok := newMap[o.ID]; !ok {
|
|
diff.Removed = append(diff.Removed, o)
|
|
}
|
|
}
|
|
|
|
return diff
|
|
}
|
|
|
|
// calculateHash calculates a content hash for findings
|
|
func (sm *StateManager) calculateHash(findings []Finding) string {
|
|
// Sort findings for consistent hashing
|
|
sort.Slice(findings, func(i, j int) bool {
|
|
return findings[i].ID < findings[j].ID
|
|
})
|
|
|
|
// Create hash from findings
|
|
data, _ := json.Marshal(findings)
|
|
hash := sha256.Sum256(data)
|
|
return fmt.Sprintf("%x", hash)[:16]
|
|
}
|
|
|
|
// saveHistory saves a historical snapshot
|
|
func (sm *StateManager) saveHistory(state *State) error {
|
|
if err := os.MkdirAll(sm.historyDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create snapshot
|
|
snapshot := StateSnapshot{
|
|
Timestamp: time.Now(),
|
|
Hash: state.ContentHash,
|
|
Score: state.Scorecard.TotalScore,
|
|
StrictScore: state.Scorecard.StrictScore,
|
|
Findings: len(state.Findings),
|
|
File: fmt.Sprintf("%s.json", state.ContentHash),
|
|
}
|
|
|
|
// Save snapshot file
|
|
snapshotFile := filepath.Join(sm.historyDir, snapshot.File)
|
|
snapshotData, _ := json.MarshalIndent(state, "", " ")
|
|
if err := os.WriteFile(snapshotFile, snapshotData, 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update history in state (keep last 50 snapshots)
|
|
state.History = append(state.History, snapshot)
|
|
if len(state.History) > 50 {
|
|
// Remove old snapshots
|
|
for _, old := range state.History[:len(state.History)-50] {
|
|
oldFile := filepath.Join(sm.historyDir, old.File)
|
|
os.Remove(oldFile) // Ignore errors
|
|
}
|
|
state.History = state.History[len(state.History)-50:]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResolveFinding updates a finding's status
|
|
func (sm *StateManager) ResolveFinding(state *State, id string, status Status, note string) error {
|
|
for i, f := range state.Findings {
|
|
if f.ID == id {
|
|
state.Findings[i].Status = status
|
|
state.Findings[i].UpdatedAt = time.Now()
|
|
if state.Findings[i].Metadata == nil {
|
|
state.Findings[i].Metadata = make(map[string]string)
|
|
}
|
|
state.Findings[i].Metadata["resolution_note"] = note
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("finding not found: %s", id)
|
|
}
|
|
|
|
// GetFinding retrieves a finding by ID
|
|
func (sm *StateManager) GetFinding(state *State, id string) *Finding {
|
|
for _, f := range state.Findings {
|
|
if f.ID == id {
|
|
return &f
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetOpenFindings returns all open findings
|
|
func (sm *StateManager) GetOpenFindings(state *State) []Finding {
|
|
var open []Finding
|
|
for _, f := range state.Findings {
|
|
if f.Status == StatusOpen {
|
|
open = append(open, f)
|
|
}
|
|
}
|
|
return open
|
|
}
|
|
|
|
// GetFindingsByTier returns findings grouped by severity
|
|
func (sm *StateManager) GetFindingsByTier(state *State) map[Severity][]Finding {
|
|
result := make(map[Severity][]Finding)
|
|
for _, f := range state.Findings {
|
|
result[f.Severity] = append(result[f.Severity], f)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetTrend returns the trend over the last N scans
|
|
func (sm *StateManager) GetTrend(state *State, n int) []StateSnapshot {
|
|
if len(state.History) < n {
|
|
return state.History
|
|
}
|
|
return state.History[len(state.History)-n:]
|
|
}
|
|
|
|
// findingsEqual checks if two findings are equal (excluding timestamps)
|
|
func findingsEqual(a, b Finding) bool {
|
|
return a.ID == b.ID &&
|
|
a.Type == b.Type &&
|
|
a.Title == b.Title &&
|
|
a.File == b.File &&
|
|
a.Line == b.Line &&
|
|
a.Severity == b.Severity &&
|
|
a.Score == b.Score &&
|
|
a.Status == b.Status
|
|
}
|
|
|
|
// FormatDiff formats a state diff for display
|
|
func FormatDiff(diff *StateDiff) string {
|
|
var sb strings.Builder
|
|
|
|
if len(diff.Added) > 0 {
|
|
sb.WriteString(fmt.Sprintf("[+] Added: %d findings\n", len(diff.Added)))
|
|
for _, f := range diff.Added {
|
|
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
|
}
|
|
}
|
|
|
|
if len(diff.Removed) > 0 {
|
|
sb.WriteString(fmt.Sprintf("[-] Removed: %d findings\n", len(diff.Removed)))
|
|
for _, f := range diff.Removed {
|
|
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
|
}
|
|
}
|
|
|
|
if len(diff.Changed) > 0 {
|
|
sb.WriteString(fmt.Sprintf("[~] Changed: %d findings\n", len(diff.Changed)))
|
|
for _, f := range diff.Changed {
|
|
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
|
}
|
|
}
|
|
|
|
if len(diff.Resolved) > 0 {
|
|
sb.WriteString(fmt.Sprintf("[OK] Resolved: %d findings\n", len(diff.Resolved)))
|
|
for _, f := range diff.Resolved {
|
|
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
|
}
|
|
}
|
|
|
|
if len(diff.Regressions) > 0 {
|
|
sb.WriteString(fmt.Sprintf("[!] Regressions: %d findings\n", len(diff.Regressions)))
|
|
for _, f := range diff.Regressions {
|
|
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
|
}
|
|
}
|
|
|
|
if sb.Len() == 0 {
|
|
sb.WriteString("No changes detected\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|