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,371 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user