// Licensed to StellaOps under the AGPL-3.0-or-later license. using System.Collections.Immutable; using System.Globalization; namespace StellaOps.VexLens.Delta; /// /// A report summarizing changes between two vulnerability graph snapshots. /// /// /// DeltaReport groups changes by section for efficient triage: /// - Users can focus on New findings first /// - Resolved items can be quickly acknowledged /// - Confidence changes help reprioritize existing findings /// - Policy impacts highlight workflow-affecting changes /// public sealed record DeltaReport { /// /// Gets the unique identifier for this report. /// public required string ReportId { get; init; } /// /// Gets the digest of the previous snapshot. /// public required string FromSnapshotDigest { get; init; } /// /// Gets the digest of the current snapshot. /// public required string ToSnapshotDigest { get; init; } /// /// Gets the timestamp when the report was generated. /// public required DateTimeOffset GeneratedAt { get; init; } /// /// Gets all delta entries in this report. /// public required ImmutableArray Entries { get; init; } /// /// Gets the summary counts by section. /// public required DeltaSummary Summary { get; init; } /// /// Gets entries grouped by section for UI consumption. /// public ImmutableDictionary> BySection => Entries .GroupBy(e => e.Section) .ToImmutableDictionary( g => g.Key, g => g.ToImmutableArray()); /// /// Gets entries for a specific section. /// public ImmutableArray GetSection(DeltaSection section) => BySection.TryGetValue(section, out var entries) ? entries : []; /// /// Gets whether this report has any actionable changes. /// public bool HasActionableChanges => Summary.NewCount > 0 || Summary.PolicyImpactCount > 0 || Summary.ConfidenceUpCount > 0; /// /// Gets a one-line summary suitable for notifications. /// public string ToNotificationSummary() { var parts = new List(); if (Summary.NewCount > 0) { parts.Add(string.Create(CultureInfo.InvariantCulture, $"{Summary.NewCount} new")); } if (Summary.ResolvedCount > 0) { parts.Add(string.Create(CultureInfo.InvariantCulture, $"{Summary.ResolvedCount} resolved")); } if (Summary.PolicyImpactCount > 0) { parts.Add(string.Create(CultureInfo.InvariantCulture, $"{Summary.PolicyImpactCount} policy impact")); } if (Summary.ConfidenceUpCount > 0) { parts.Add(string.Create(CultureInfo.InvariantCulture, $"{Summary.ConfidenceUpCount} confidence up")); } if (Summary.ConfidenceDownCount > 0) { parts.Add(string.Create(CultureInfo.InvariantCulture, $"{Summary.ConfidenceDownCount} confidence down")); } return parts.Count == 0 ? "No significant changes" : string.Join(", ", parts); } } /// /// Summary counts for a delta report. /// public sealed record DeltaSummary { /// /// Gets the total number of entries. /// public required int TotalCount { get; init; } /// /// Gets the count of new findings. /// public required int NewCount { get; init; } /// /// Gets the count of resolved findings. /// public required int ResolvedCount { get; init; } /// /// Gets the count of confidence increases. /// public required int ConfidenceUpCount { get; init; } /// /// Gets the count of confidence decreases. /// public required int ConfidenceDownCount { get; init; } /// /// Gets the count of policy impact changes. /// public required int PolicyImpactCount { get; init; } /// /// Gets the count of damped (suppressed) changes. /// public int DampedCount { get; init; } /// /// Gets the count of evidence-only changes. /// public int EvidenceChangedCount { get; init; } /// /// Creates a summary from a list of entries. /// public static DeltaSummary FromEntries(IEnumerable entries) { var list = entries.ToList(); return new DeltaSummary { TotalCount = list.Count, NewCount = list.Count(e => e.Section == DeltaSection.New), ResolvedCount = list.Count(e => e.Section == DeltaSection.Resolved), ConfidenceUpCount = list.Count(e => e.Section == DeltaSection.ConfidenceUp), ConfidenceDownCount = list.Count(e => e.Section == DeltaSection.ConfidenceDown), PolicyImpactCount = list.Count(e => e.Section == DeltaSection.PolicyImpact), DampedCount = list.Count(e => e.Section == DeltaSection.Damped), EvidenceChangedCount = list.Count(e => e.Section == DeltaSection.EvidenceChanged) }; } }