Files
Tomas Dvorak 55885a0e8f first commit
2026-02-22 10:42:17 +01:00

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()
}