diff --git a/.gitignore b/.gitignore
index f59977d..1989d63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ Thumbs.db
[Dd]ebug*/
*.lib
*.sbr
+.vs/
obj/
[Rr]elease*/
_ReSharper*/
@@ -32,3 +33,4 @@ Source/BitDiffer.Tests.bin/
packages
Build/scripts/.fake
Build/output
+/Rock - Rock2.cset
\ No newline at end of file
diff --git a/BitDifferSummarizeWpf/App.xaml b/BitDifferSummarizeWpf/App.xaml
new file mode 100644
index 0000000..35be1f3
--- /dev/null
+++ b/BitDifferSummarizeWpf/App.xaml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/BitDifferSummarizeWpf/App.xaml.cs b/BitDifferSummarizeWpf/App.xaml.cs
new file mode 100644
index 0000000..4e4ddf1
--- /dev/null
+++ b/BitDifferSummarizeWpf/App.xaml.cs
@@ -0,0 +1,7 @@
+using System.Windows;
+
+namespace BitDifferSummarizeWpf;
+
+public partial class App : Application
+{
+}
\ No newline at end of file
diff --git a/BitDifferSummarizeWpf/BitDifferSummarizeWpf.csproj b/BitDifferSummarizeWpf/BitDifferSummarizeWpf.csproj
new file mode 100644
index 0000000..af0d721
--- /dev/null
+++ b/BitDifferSummarizeWpf/BitDifferSummarizeWpf.csproj
@@ -0,0 +1,13 @@
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BitDifferSummarizeWpf/BitDifferSummarizeWpf.sln b/BitDifferSummarizeWpf/BitDifferSummarizeWpf.sln
new file mode 100644
index 0000000..57c80e0
--- /dev/null
+++ b/BitDifferSummarizeWpf/BitDifferSummarizeWpf.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 18
+VisualStudioVersion = 18.1.11312.151 d18.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitDifferSummarizeWpf", "BitDifferSummarizeWpf.csproj", "{65FFF96D-A540-71E1-798A-92486F8D8564}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {65FFF96D-A540-71E1-798A-92486F8D8564}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {65FFF96D-A540-71E1-798A-92486F8D8564}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {65FFF96D-A540-71E1-798A-92486F8D8564}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {65FFF96D-A540-71E1-798A-92486F8D8564}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {E3FEE00E-0066-4630-9E82-BA6BEBF187E1}
+ EndGlobalSection
+EndGlobal
diff --git a/BitDifferSummarizeWpf/MainWindow.xaml b/BitDifferSummarizeWpf/MainWindow.xaml
new file mode 100644
index 0000000..0c0a758
--- /dev/null
+++ b/BitDifferSummarizeWpf/MainWindow.xaml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BitDifferSummarizeWpf/MainWindow.xaml.cs b/BitDifferSummarizeWpf/MainWindow.xaml.cs
new file mode 100644
index 0000000..5c9d309
--- /dev/null
+++ b/BitDifferSummarizeWpf/MainWindow.xaml.cs
@@ -0,0 +1,223 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+
+using Microsoft.Web.WebView2.Core;
+using Microsoft.Win32;
+
+using static System.Net.Mime.MediaTypeNames;
+
+namespace BitDifferSummarizeWpf;
+
+public partial class MainWindow : Window
+{
+ private const string DefaultReportPath = @"C:\Github\bitdiffer\Reports\19.0.7.html";
+ private const string DefaultIgnore = "Rock.Blocks.dll,Rock.ViewModels.dll,Rock.Lava.Fluid.dll";
+
+ private string? _lastGeneratedHtml;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ ReportPathTextBox.Text = DefaultReportPath;
+ MaxSectionsTextBox.Text = "200";
+ IncludeNonBreakingCheckBox.IsChecked = false;
+ IgnoreAssembliesTextBox.Text = DefaultIgnore;
+
+ Loaded += async (_, _) => await EnsureWebViewReadyAsync();
+ }
+
+ private async Task EnsureWebViewReadyAsync()
+ {
+ try
+ {
+ await PreviewWebView.EnsureCoreWebView2Async();
+ PreviewWebView.CoreWebView2.Settings.IsStatusBarEnabled = false;
+ PreviewWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
+ PreviewWebView.CoreWebView2.Settings.AreDevToolsEnabled = true;
+ SetPreviewHtml("
Generate a report to preview it here.
");
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this,
+ "WebView2 failed to initialize. Install the Microsoft Edge WebView2 Runtime.\n\n" + ex.Message,
+ "WebView2 Initialization Failed",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ }
+ }
+
+ private void BrowseButton_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new OpenFileDialog
+ {
+ Title = "Select BitDiffer HTML Report",
+ Filter = "HTML files (*.html;*.htm)|*.html;*.htm|All files (*.*)|*.*",
+ FileName = ReportPathTextBox.Text
+ };
+
+ if (dlg.ShowDialog(this) == true)
+ {
+ ReportPathTextBox.Text = dlg.FileName;
+ }
+ }
+
+ private void GenerateButton_Click(object sender, RoutedEventArgs e)
+ {
+ StatusTextBlock.Text = "";
+ OutputTextBox.Text = "";
+ CopyButton.IsEnabled = false;
+ SaveButton.IsEnabled = false;
+ _lastGeneratedHtml = null;
+
+ var path = (ReportPathTextBox.Text ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ MessageBox.Show(this, "Please choose a report file.", "Missing Report", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ if (!File.Exists(path))
+ {
+ MessageBox.Show(this, $"Report file not found:\n{path}", "File Not Found", MessageBoxButton.OK, MessageBoxImage.Error);
+ return;
+ }
+
+ if (!TryParsePositiveInt(MaxSectionsTextBox.Text, out var maxSections))
+ {
+ MessageBox.Show(this, "Max Sections must be a positive integer.", "Invalid Setting", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ var ignoreAssemblies = ParseIgnoreAssemblies(IgnoreAssembliesTextBox.Text);
+
+ var options = new CliOptions
+ {
+ ReportPath = path,
+ IgnoreAssemblies = ignoreAssemblies,
+ MaxSections = maxSections,
+ IncludeNonBreaking = IncludeNonBreakingCheckBox.IsChecked == true
+ };
+
+ try
+ {
+ var reportHtml = File.ReadAllText(path, Encoding.UTF8);
+ var items = BitDifferParser.Parse(reportHtml);
+
+ var html = SummaryBuilder.Build(items, options, out var stats);
+
+ _lastGeneratedHtml = html;
+ OutputTextBox.Text = html;
+
+ CopyButton.IsEnabled = true;
+ SaveButton.IsEnabled = true;
+
+ SetPreviewHtml(WrapForPreview(html));
+
+ StatusTextBlock.Text =
+ $"Parsed: {stats.TotalItems} changes | " +
+ $"Sections: {stats.OutputSections} | " +
+ $"BlockAction skipped: {stats.SkippedBlockActionMethods} | " +
+ $"Ignored assemblies: {ignoreAssemblies.Count}";
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Generate Failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private void CopyButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrWhiteSpace(_lastGeneratedHtml))
+ {
+ MessageBox.Show(this, "Nothing to copy. Generate a report first.", "No Output", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ Clipboard.SetText(_lastGeneratedHtml!);
+ StatusTextBlock.Text = "Copied HTML to clipboard.";
+ }
+
+ private void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrWhiteSpace(_lastGeneratedHtml))
+ {
+ MessageBox.Show(this, "Nothing to save. Generate a report first.", "No Output", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ var defaultName = $"BitDifferSummary_{DateTime.Now:yyyyMMdd_HHmmss}.html";
+
+ var dlg = new SaveFileDialog
+ {
+ Title = "Save Summary HTML",
+ Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
+ FileName = defaultName
+ };
+
+ if (dlg.ShowDialog(this) == true)
+ {
+ File.WriteAllText(dlg.FileName, _lastGeneratedHtml!, Encoding.UTF8);
+ MessageBox.Show(this, $"Saved:\n{dlg.FileName}", "Saved", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private void SetPreviewHtml(string fullHtmlDocument)
+ {
+ if (PreviewWebView.CoreWebView2 is null)
+ {
+ // If WebView2 isn't ready yet, it will show after init.
+ return;
+ }
+
+ PreviewWebView.NavigateToString(fullHtmlDocument);
+ }
+
+ private static string WrapForPreview( string bodyHtml )
+ {
+ // Keep preview readable.
+ return $$"""
+
+
+
+
+
+
+
+{{bodyHtml}}
+
+
+""";
+ }
+ private static bool TryParsePositiveInt(string? text, out int value)
+ {
+ value = 0;
+ if (!int.TryParse((text ?? "").Trim(), out value)) return false;
+ return value > 0;
+ }
+
+ private static HashSet ParseIgnoreAssemblies(string? text)
+ {
+ var set = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var parts = (text ?? "")
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var p in parts)
+ {
+ if (!string.IsNullOrWhiteSpace(p))
+ set.Add(p.Trim());
+ }
+
+ return set;
+ }
+}
diff --git a/BitDifferSummarizeWpf/Summarizer.cs b/BitDifferSummarizeWpf/Summarizer.cs
new file mode 100644
index 0000000..81bec91
--- /dev/null
+++ b/BitDifferSummarizeWpf/Summarizer.cs
@@ -0,0 +1,460 @@
+// ================================
+// File: BitDifferSummarizeWpf/Summarizer.cs
+// (Parser + summarizer logic; same engine as console app)
+// ================================
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace BitDifferSummarizeWpf;
+
+public sealed class CliOptions
+{
+ public string? ReportPath { get; init; }
+ public HashSet IgnoreAssemblies { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+ public int MaxSections { get; init; } = 200;
+ public bool IncludeNonBreaking { get; init; }
+}
+
+public sealed record SummaryStats(
+ int TotalItems,
+ int OutputSections,
+ int SkippedBlockActionMethods);
+
+internal enum DiffSide
+{
+ None = 0,
+ Before = 1,
+ After = 2
+}
+
+internal sealed record ChangeItem(
+ int Id,
+ string Assembly,
+ string SymbolHeader,
+ string Change,
+ bool Breaking,
+ string? BeforeCode,
+ string? AfterCode)
+{
+ public string Kind
+ {
+ get
+ {
+ var m = Regex.Match(SymbolHeader, @"^\s*(public|protected|internal)\s+(\w+)\b", RegexOptions.IgnoreCase);
+ if (m.Success) return m.Groups[2].Value.ToLowerInvariant();
+
+ if (SymbolHeader.StartsWith("Embedded resource", StringComparison.OrdinalIgnoreCase)) return "resource";
+ if (SymbolHeader.StartsWith("Reference to ", StringComparison.OrdinalIgnoreCase)) return "reference";
+ return "other";
+ }
+ }
+
+ public string FqNameGuess
+ {
+ get
+ {
+ var s = Regex.Replace(SymbolHeader.Trim(), @"^\s*(public|protected|internal)\s+\w+\s+", "", RegexOptions.IgnoreCase);
+ return s.Trim();
+ }
+ }
+
+ public string DeclaringTypeGuess
+ {
+ get
+ {
+ var n = FqNameGuess;
+ var idx = n.LastIndexOf('.');
+ return idx > 0 ? n[..idx] : "";
+ }
+ }
+}
+
+internal static class BitDifferParser
+{
+ private static readonly Regex ParagraphRegex =
+ new(@"]*>.*?
", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
+
+ private static readonly Regex ClassAttrRegex =
+ new(@"class\s*=\s*['""]([^'""]+)['""]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Regex AssemblyRegex =
+ new(@"Assembly\s+'([^']+)'", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Regex BrRegex =
+ new(@"
", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Regex TagRegex =
+ new(@"<[^>]+>", RegexOptions.Singleline | RegexOptions.Compiled);
+
+ public static List Parse(string reportHtml)
+ {
+ var paragraphs = ParagraphRegex.Matches(reportHtml);
+
+ string? currentAssembly = null;
+ string? currentSymbol = null;
+
+ string? beforeCode = null;
+ string? afterCode = null;
+
+ var currentSide = DiffSide.None;
+
+ var items = new List(capacity: Math.Max(256, paragraphs.Count / 3));
+ var id = 0;
+
+ foreach (Match m in paragraphs)
+ {
+ var p = m.Value;
+
+ var clsMatch = ClassAttrRegex.Match(p);
+ var pClass = clsMatch.Success ? clsMatch.Groups[1].Value.Trim() : string.Empty;
+
+ var text = NormalizeWs(WebUtility.HtmlDecode(StripTagsPreserveNewlines(p)));
+
+ if (pClass.Contains("hdr1", StringComparison.OrdinalIgnoreCase) && text.StartsWith("Assembly", StringComparison.OrdinalIgnoreCase))
+ {
+ var am = AssemblyRegex.Match(text);
+ if (am.Success)
+ {
+ currentAssembly = am.Groups[1].Value;
+ currentSymbol = null;
+ beforeCode = null;
+ afterCode = null;
+ currentSide = DiffSide.None;
+ }
+ continue;
+ }
+
+ if (string.Equals(pClass, "hdr", StringComparison.OrdinalIgnoreCase) && currentAssembly is not null)
+ {
+ currentSymbol = text;
+ beforeCode = null;
+ afterCode = null;
+ currentSide = DiffSide.None;
+ continue;
+ }
+
+ if (string.Equals(pClass, "hdr2", StringComparison.OrdinalIgnoreCase) && currentAssembly is not null && currentSymbol is not null)
+ {
+ if (text.Contains("Rock-pre-alpha-release", StringComparison.OrdinalIgnoreCase))
+ currentSide = DiffSide.Before;
+ else if (text.Contains("Rock-develop", StringComparison.OrdinalIgnoreCase))
+ currentSide = DiffSide.After;
+ else
+ currentSide = DiffSide.None;
+
+ continue;
+ }
+
+ if (currentAssembly is not null && currentSymbol is not null && currentSide != DiffSide.None)
+ {
+ if (string.Equals(pClass, "code", StringComparison.OrdinalIgnoreCase))
+ {
+ var codeText = WebUtility.HtmlDecode(StripTagsPreserveNewlines(p)).Trim();
+ if (currentSide == DiffSide.Before) beforeCode = codeText;
+ if (currentSide == DiffSide.After) afterCode = codeText;
+ continue;
+ }
+
+ if (text.Equals("Not Defined", StringComparison.OrdinalIgnoreCase))
+ {
+ if (currentSide == DiffSide.Before) beforeCode = null;
+ if (currentSide == DiffSide.After) afterCode = null;
+ continue;
+ }
+ }
+
+ if (currentAssembly is not null && currentSymbol is not null && text.StartsWith("Changes Found", StringComparison.OrdinalIgnoreCase))
+ {
+ var idxColon = text.IndexOf(':');
+ var changeValue = idxColon >= 0 ? text[(idxColon + 1)..].Trim() : text.Trim();
+ var breaking = changeValue.Contains("(Breaking)", StringComparison.OrdinalIgnoreCase);
+
+ items.Add(new ChangeItem(++id, currentAssembly, currentSymbol, changeValue, breaking, beforeCode, afterCode));
+ continue;
+ }
+ }
+
+ return items;
+ }
+
+ private static string StripTagsPreserveNewlines(string html)
+ {
+ var withNewlines = BrRegex.Replace(html, "\n");
+ return TagRegex.Replace(withNewlines, "");
+ }
+
+ private static string NormalizeWs(string text)
+ => Regex.Replace(text, @"\s+", " ").Trim();
+}
+
+internal static class SummaryBuilder
+{
+ private static readonly Regex EnumMemberRegex =
+ new(@"\b([A-Za-z_]\w*)\s*=\s*(-?\d+)\b", RegexOptions.Compiled);
+
+ private static bool IsRemovedBreaking(ChangeItem it) =>
+ it.Breaking && it.Change.Contains("Removed", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsDeclChanged(ChangeItem it, bool includeNonBreaking) =>
+ (it.Breaking || includeNonBreaking) && it.Change.Contains("Declaration changed", StringComparison.OrdinalIgnoreCase);
+
+ private static string H(string s) => WebUtility.HtmlEncode(s);
+
+ private static bool ContainsBlockActionAttribute(string? code)
+ {
+ if (string.IsNullOrWhiteSpace(code)) return false;
+ return code.Contains("[BlockActionAttribute", StringComparison.OrdinalIgnoreCase)
+ || code.Contains("BlockActionAttribute(", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsBlockActionMethod(ChangeItem it)
+ => it.Kind.Equals("method", StringComparison.OrdinalIgnoreCase)
+ && (ContainsBlockActionAttribute(it.BeforeCode) || ContainsBlockActionAttribute(it.AfterCode));
+
+ private static string? ExtractPublicMemberSignature(string? code)
+ {
+ if (string.IsNullOrWhiteSpace(code)) return null;
+
+ var lines = code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var line in lines)
+ {
+ if (line.StartsWith("[", StringComparison.Ordinal)) continue;
+ if (!Regex.IsMatch(line, @"^\s*(public|protected|internal)\b", RegexOptions.IgnoreCase)) continue;
+ if (line.Contains('(')) return line.Trim();
+ }
+
+ foreach (var line in lines)
+ {
+ if (line.StartsWith("[", StringComparison.Ordinal)) continue;
+ if (Regex.IsMatch(line, @"^\s*(public|protected|internal)\b", RegexOptions.IgnoreCase))
+ return line.Trim();
+ }
+
+ return null;
+ }
+
+ public static string Build(List allItems, CliOptions options, out SummaryStats stats)
+ {
+ // Apply assembly ignore list
+ var items = allItems.Where(i => !options.IgnoreAssemblies.Contains(i.Assembly)).ToList();
+
+ // Remove ALL BlockActionAttribute methods (internal-only)
+ var skippedBlockAction = items.Count(IsBlockActionMethod);
+ items = items.Where(i => !IsBlockActionMethod(i)).ToList();
+
+ var removedBreaking = items.Where(IsRemovedBreaking).ToList();
+ var declChanged = items.Where(i => IsDeclChanged(i, options.IncludeNonBreaking)).ToList();
+
+ var sections = new List(capacity: 256);
+
+ // 1) Virtual added, grouped by declaring type
+ foreach (var block in GroupVirtualAdded(declChanged))
+ {
+ if (sections.Count >= options.MaxSections) break;
+ sections.Add(block);
+ }
+
+ // 2) Method signature changes grouped by declaring type
+ foreach (var block in GroupMethodSignatureChanges(declChanged))
+ {
+ if (sections.Count >= options.MaxSections) break;
+ sections.Add(block);
+ }
+
+ // 3) Enum numeric changes
+ foreach (var block in GroupEnumValueChanges(declChanged))
+ {
+ if (sections.Count >= options.MaxSections) break;
+ sections.Add(block);
+ }
+
+ // 4) Removed members grouped by declaring type
+ foreach (var block in GroupRemovedMembersByDeclaringType(removedBreaking))
+ {
+ if (sections.Count >= options.MaxSections) break;
+ sections.Add(block);
+ }
+
+ if (sections.Count == 0)
+ {
+ sections.Add("""
+
+ No breaking changes were detected by the summarizer (given the selected ignore list and filters).
+
+""");
+ }
+
+ stats = new SummaryStats(
+ TotalItems: allItems.Count,
+ OutputSections: sections.Count,
+ SkippedBlockActionMethods: skippedBlockAction);
+
+ return string.Join("\n", sections) + "\n";
+ }
+
+ private static IEnumerable GroupVirtualAdded(IEnumerable declChanged)
+ {
+ static bool HasVirtualInSignature(string? code)
+ {
+ if (string.IsNullOrWhiteSpace(code)) return false;
+ var sig = ExtractPublicMemberSignature(code);
+ if (string.IsNullOrWhiteSpace(sig)) return false;
+ return Regex.IsMatch(sig, @"\bvirtual\b", RegexOptions.IgnoreCase);
+ }
+
+ var candidates = declChanged
+ .Where(i => i.Kind == "method" && !string.IsNullOrWhiteSpace(i.BeforeCode) && !string.IsNullOrWhiteSpace(i.AfterCode))
+ .Where(i => !HasVirtualInSignature(i.BeforeCode) && HasVirtualInSignature(i.AfterCode))
+ .ToList();
+
+ foreach (var g in candidates.GroupBy(c => c.DeclaringTypeGuess, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ var sigs = g
+ .Select(i => ExtractPublicMemberSignature(i.AfterCode) ?? ExtractPublicMemberSignature(i.BeforeCode) ?? i.FqNameGuess)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (sigs.Count == 0) continue;
+
+ var joined = string.Join(" and ", sigs.Select(m => $"{H(m)} is now virtual"));
+ yield return $"{joined}.
\n";
+ }
+ }
+
+ private static IEnumerable GroupMethodSignatureChanges(IEnumerable declChanged)
+ {
+ var candidates = declChanged.Where(i => i.Kind == "method").ToList();
+
+ foreach (var g in candidates.GroupBy(c => c.DeclaringTypeGuess, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ var items = new List();
+
+ foreach (var it in g.OrderBy(x => x.FqNameGuess, StringComparer.OrdinalIgnoreCase))
+ {
+ var before = ExtractPublicMemberSignature(it.BeforeCode) ?? it.FqNameGuess;
+ var after = ExtractPublicMemberSignature(it.AfterCode) ?? it.FqNameGuess;
+
+ if (string.Equals(before, after, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ items.Add($"{H(before)} → {H(after)}");
+ }
+
+ if (items.Count == 0) continue;
+
+ yield return $"""
+
+ The following method signatures were changed on {H(g.Key)}:
+
+
+{string.Join("\n", items.Select(i => $" - {i}
"))}
+
+""";
+ }
+ }
+
+ private sealed record EnumValueChange(string Name, int OldValue, int NewValue);
+
+ private static IEnumerable GroupEnumValueChanges(IEnumerable declChanged)
+ {
+ var candidates = declChanged.Where(i => i.Kind == "enum").ToList();
+
+ foreach (var it in candidates.OrderBy(i => i.FqNameGuess, StringComparer.OrdinalIgnoreCase))
+ {
+ var changes = DiffEnumNumericValues(it.BeforeCode, it.AfterCode);
+ if (changes.Count == 0) continue;
+
+ var parts = changes.Select(c =>
+ $"{H(c.Name)} was changed from a {c.OldValue} to a {c.NewValue}").ToList();
+
+ yield return $"""
+
+ The {H(it.FqNameGuess)} enum values were updated: {string.Join(" and ", parts)}.
+
+""";
+ }
+ }
+
+ private static List DiffEnumNumericValues(string? beforeCode, string? afterCode)
+ {
+ var before = ParseEnumMembers(beforeCode);
+ var after = ParseEnumMembers(afterCode);
+
+ var changes = new List();
+ foreach (var (name, oldVal) in before)
+ {
+ if (after.TryGetValue(name, out var newVal) && newVal != oldVal)
+ changes.Add(new EnumValueChange(name, oldVal, newVal));
+ }
+
+ return changes.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ private static Dictionary ParseEnumMembers(string? code)
+ {
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(code)) return dict;
+
+ foreach (Match m in EnumMemberRegex.Matches(code))
+ {
+ var name = m.Groups[1].Value;
+ if (int.TryParse(m.Groups[2].Value, out var v))
+ dict[name] = v;
+ }
+
+ return dict;
+ }
+
+ private static IEnumerable GroupRemovedMembersByDeclaringType(IEnumerable removedBreaking)
+ {
+ static string Key(ChangeItem it)
+ {
+ if (it.Kind is "class" or "interface" or "struct" or "enum")
+ return it.FqNameGuess;
+
+ return string.IsNullOrWhiteSpace(it.DeclaringTypeGuess) ? "(global)" : it.DeclaringTypeGuess;
+ }
+
+ foreach (var g in removedBreaking.GroupBy(Key, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ var listItems = g
+ .Select(it => $"{H(it.FqNameGuess)} ({H(it.Kind)})")
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (listItems.Count == 0) continue;
+
+ var isTypeRemovalGroup =
+ g.Any(it => it.Kind is "class" or "interface" or "struct" or "enum") &&
+ g.All(it => it.Kind is "class" or "interface" or "struct" or "enum");
+
+ if (isTypeRemovalGroup && listItems.Count == 1)
+ {
+ yield return $"""
+
+ The public {H(g.First().Kind)} {H(g.Key)} was removed.
+
+""";
+ continue;
+ }
+
+ yield return $"""
+
+ The following members were removed from {H(g.Key)}:
+
+
+{string.Join("\n", listItems.Select(li => $" - {li}
"))}
+
+""";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Build/scripts/packages.config b/Build/scripts/packages.config
new file mode 100644
index 0000000..ed9ab1a
--- /dev/null
+++ b/Build/scripts/packages.config
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/Build/scripts/scripts.fsproj b/Build/scripts/scripts.fsproj
index a3321eb..4eb49dc 100644
--- a/Build/scripts/scripts.fsproj
+++ b/Build/scripts/scripts.fsproj
@@ -9,10 +9,11 @@
Library
scripts
scripts
- v4.5.2
+ v4.7.2
4.4.0.0
true
scripts
+
true
@@ -33,25 +34,6 @@
3
bin\Release\scripts.XML
-
-
-
- True
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
11
@@ -68,6 +50,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\Source\packages\FSharp.Core.5.0.0\lib\netstandard2.0\FSharp.Core.dll
+
+
+
+
+
+