From 54b9b4d990e3ec3e4c20ffd1e2c36c70cd4d399a Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Sat, 4 Jul 2026 16:49:48 +0200 Subject: [PATCH 1/9] Add report command to generate markdown and CSV reports about the registrations --- src/dvx.Tests/PluginDiscoveryTests.cs | 34 ++++++ src/dvx.Tests/PluginStepDefinitionTests.cs | 20 ++++ src/dvx.Tests/ReportGeneratorTests.cs | 93 ++++++++++++++++ src/dvx/Commands/ReportCommand.cs | 83 ++++++++++++++ src/dvx/Models/PluginStepDefinition.cs | 12 ++- src/dvx/Program.cs | 1 + src/dvx/Services/AttributeWriter.cs | 4 +- src/dvx/Services/ReportGenerator.cs | 120 +++++++++++++++++++++ src/dvx/Services/StepImporter.cs | 4 +- 9 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 src/dvx.Tests/ReportGeneratorTests.cs create mode 100644 src/dvx/Commands/ReportCommand.cs create mode 100644 src/dvx/Services/ReportGenerator.cs diff --git a/src/dvx.Tests/PluginDiscoveryTests.cs b/src/dvx.Tests/PluginDiscoveryTests.cs index c2faa0f..96da6b9 100644 --- a/src/dvx.Tests/PluginDiscoveryTests.cs +++ b/src/dvx.Tests/PluginDiscoveryTests.cs @@ -142,6 +142,18 @@ public class TestPluginCustomApi : IPlugin public void Execute(IServiceProvider serviceProvider) { } } + [PluginStep(Entity = "account", Message = "Create", Stage = Stage.PreOperation, ExecutionOrder = 10)] + public class TestPluginExplicitOrder : IPlugin + { + public void Execute(IServiceProvider serviceProvider) { } + } + + [PluginStep(Entity = "account", Message = "Create", Stage = Stage.PreOperation)] + public class TestPluginImplicitOrder : IPlugin + { + public void Execute(IServiceProvider serviceProvider) { } + } + // [CustomApi] takes precedence over [PluginStep] on the same class — must still be excluded. [CustomApi] [PluginStep("account", "Create", Stage.PostOperation)] @@ -456,5 +468,27 @@ public void CustomApiClass_WithPluginStep_ExcludedFromResults() var defs = Discover(); defs.ShouldNotContain(d => d.TypeFullName!.EndsWith(nameof(TestPluginCustomApiWithStep))); } + + // ── Execution Order Explicitness ─────────────────────────────────────── + + [Fact] + public void ExecutionOrder_WhenProvidedInAttribute_IsExplicit() + { + var def = Discover() + .Single(d => d.TypeFullName!.EndsWith(nameof(TestPluginExplicitOrder))); + + def.ExecutionOrder.ShouldBe(10); + def.IsExecutionOrderExplicit.ShouldBeTrue(); + } + + [Fact] + public void ExecutionOrder_WhenOmittedInAttribute_IsImplicit() + { + var def = Discover() + .Single(d => d.TypeFullName!.EndsWith(nameof(TestPluginImplicitOrder))); + + def.ExecutionOrder.ShouldBe(1); + def.IsExecutionOrderExplicit.ShouldBeFalse(); + } } } diff --git a/src/dvx.Tests/PluginStepDefinitionTests.cs b/src/dvx.Tests/PluginStepDefinitionTests.cs index 4c60ed5..674657d 100644 --- a/src/dvx.Tests/PluginStepDefinitionTests.cs +++ b/src/dvx.Tests/PluginStepDefinitionTests.cs @@ -91,5 +91,25 @@ public void StepName_DifferentStages_ProduceDifferentNames() pre.StepName.ShouldNotBe(post.StepName); } + + [Fact] + public void ExecutionOrder_Default_IsOneAndNotExplicit() + { + var def = new PluginStepDefinition(); + def.ExecutionOrder.ShouldBe(1); + def.IsExecutionOrderExplicit.ShouldBeFalse(); + } + + [Fact] + public void ExecutionOrder_WhenSet_IsExplicit() + { + var def = new PluginStepDefinition { ExecutionOrder = 1 }; + def.ExecutionOrder.ShouldBe(1); + def.IsExecutionOrderExplicit.ShouldBeTrue(); + + def = new PluginStepDefinition { ExecutionOrder = 5 }; + def.ExecutionOrder.ShouldBe(5); + def.IsExecutionOrderExplicit.ShouldBeTrue(); + } } } diff --git a/src/dvx.Tests/ReportGeneratorTests.cs b/src/dvx.Tests/ReportGeneratorTests.cs new file mode 100644 index 0000000..e306ae1 --- /dev/null +++ b/src/dvx.Tests/ReportGeneratorTests.cs @@ -0,0 +1,93 @@ +using dvx.Models; +using dvx.Services; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class ReportGeneratorTests + { + [Fact] + public void GenerateMarkdown_ContainsBothViews() + { + var definitions = new List + { + new() { TypeFullName = "P1", Entity = "account", Message = "Create", Stage = 20, ExecutionOrder = 1 }, + new() { TypeFullName = "P2", Entity = "contact", Message = "Update", Stage = 40, ExecutionOrder = 1 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + md.ShouldContain("## View 1: Message then Entity"); + md.ShouldContain("## View 2: Entity then Message"); + md.ShouldContain("### Message: Create"); + md.ShouldContain("#### Entity: account"); + md.ShouldContain("### Entity: contact"); + md.ShouldContain("#### Message: Update"); + } + + [Fact] + public void GenerateMarkdown_UnorderedPlugins_ShowsWarning() + { + var definitions = new List + { + new() { TypeFullName = "P1", Entity = "account", Message = "Create", Stage = 20, ExecutionOrder = 1 }, + // Second one has default order 1, implicit + new() { TypeFullName = "P2", Entity = "account", Message = "Create", Stage = 20 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + md.ShouldContain("⚠️ Warning"); + md.ShouldContain("non-deterministic"); + md.ShouldContain("1*"); // My implementation uses 1* for implicit + } + + [Fact] + public void GenerateCsv_ContainsAllColumns() + { + var definitions = new List + { + new() + { + TypeFullName = "MyPlugin", + Entity = "account", + Message = "Create", + Stage = 20, + Mode = 0, + ExecutionOrder = 5, + Description = "Some desc" + } + }; + + var gen = new ReportGenerator(); + var csv = gen.GenerateCsv(definitions); + + csv.ShouldContain("Type,Entity,Message,Stage,Mode,ExecutionOrder,IsExplicit,Description"); + csv.ShouldContain("MyPlugin,account,Create,PreOperation,Sync,5,True,Some desc"); + } + + [Fact] + public void EscapeCsv_HandlesSpecialCharacters() + { + var definitions = new List + { + new() + { + TypeFullName = "P", + Entity = "e", + Message = "m", + Stage = 20, + Description = "Comma, \"Quote\", New\nLine" + } + }; + + var gen = new ReportGenerator(); + var csv = gen.GenerateCsv(definitions); + + csv.ShouldContain("\"Comma, \"\"Quote\"\", New\nLine\""); + } + } +} diff --git a/src/dvx/Commands/ReportCommand.cs b/src/dvx/Commands/ReportCommand.cs new file mode 100644 index 0000000..af888b7 --- /dev/null +++ b/src/dvx/Commands/ReportCommand.cs @@ -0,0 +1,83 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using dvx.Commands.Shared; +using dvx.Config; +using dvx.Output; +using dvx.Services; +using Microsoft.Extensions.Logging; + +namespace dvx.Commands +{ + /// + /// dvx plugin report — scans the local project for [PluginStep] attributes, + /// discovers registrations via reflection on the built assembly, and generates a + /// registration report in Markdown and CSV formats. + /// + public static class ReportCommand + { + public static Command Build(ILoggerFactory loggerFactory) + { + var cmd = new Command("report", "Generate a report of plugin registrations in the project."); + + var project = CommandOptions.Project(); + var config = CommandOptions.Config(); + var output = new Option( + new[] { "--out", "-o" }, + () => "reports", + "Output directory for the generated reports."); + var verbose = CommandOptions.Verbose(); + + cmd.AddOptions(project, config, output, verbose); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var projectPath = ctx.ParseResult.GetValueForOption(project); + var configPath = ctx.ParseResult.GetValueForOption(config); + var outDir = ctx.ParseResult.GetValueForOption(output)!; + var isVerbose = ctx.ParseResult.GetValueForOption(verbose); + + try + { + var appConfig = ConfigLoader.TryLoad(configPath); + var resolvedProject = ConfigLoader.ResolveProject(appConfig, projectPath); + + Out.Step("Building", $"project {Path.GetFileName(resolvedProject)}..."); + var builder = new ProjectBuilder(); + var buildResult = builder.Build(resolvedProject); + + Out.Step("Discovering", "plugin registrations from assembly..."); + var discovery = new PluginDiscovery(loggerFactory.CreateLogger()); + var definitions = discovery.Discover(buildResult.DllPath, isVerbose); + + Out.Info($"Found {definitions.Count} registration(s)."); + + Out.Step("Generating", "reports..."); + var generator = new ReportGenerator(); + var md = generator.GenerateMarkdown(definitions); + var csv = generator.GenerateCsv(definitions); + + if (!Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir); + } + + var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var mdFile = Path.Combine(outDir, $"plugin-report-{timestamp}.md"); + var csvFile = Path.Combine(outDir, $"plugin-report-{timestamp}.csv"); + + await File.WriteAllTextAsync(mdFile, md); + await File.WriteAllTextAsync(csvFile, csv); + + Out.Success("Reports generated:", $"{Path.GetFullPath(mdFile)}\n{Path.GetFullPath(csvFile)}"); + } + catch (Exception ex) + { + Out.Error(ex, isVerbose); + ctx.ExitCode = 1; + } + }); + + return cmd; + } + } +} diff --git a/src/dvx/Models/PluginStepDefinition.cs b/src/dvx/Models/PluginStepDefinition.cs index 958ff62..56f1a2e 100644 --- a/src/dvx/Models/PluginStepDefinition.cs +++ b/src/dvx/Models/PluginStepDefinition.cs @@ -2,11 +2,21 @@ namespace dvx.Models { public class PluginStepDefinition { + private int? _executionOrder; + public string TypeFullName { get; set; } = string.Empty; public string Entity { get; set; } = string.Empty; public string Message { get; set; } = string.Empty; public int Stage { get; set; } // 10 / 20 / 40 - public int ExecutionOrder { get; set; } = 1; + + public int ExecutionOrder + { + get => _executionOrder ?? 1; + set => _executionOrder = value; + } + + public bool IsExecutionOrderExplicit => _executionOrder.HasValue; + public int Mode { get; set; } // 0 = sync, 1 = async public string? Description { get; set; } /// diff --git a/src/dvx/Program.cs b/src/dvx/Program.cs index 80dbd5e..f79596c 100644 --- a/src/dvx/Program.cs +++ b/src/dvx/Program.cs @@ -11,6 +11,7 @@ plugin.AddCommand(RegisterCommand.Build(loggerFactory)); plugin.AddCommand(SyncCommand.Build(loggerFactory)); plugin.AddCommand(AdoptCommand.Build(loggerFactory)); +plugin.AddCommand(ReportCommand.Build(loggerFactory)); root.AddCommand(plugin); var webresource = new Command("webresource", "Deploy and publish Dataverse web resources."); diff --git a/src/dvx/Services/AttributeWriter.cs b/src/dvx/Services/AttributeWriter.cs index a62d5da..773c15d 100644 --- a/src/dvx/Services/AttributeWriter.cs +++ b/src/dvx/Services/AttributeWriter.cs @@ -297,8 +297,8 @@ internal static string RenderAttribute(PluginStepDefinition def) StageExpr(def.Stage), }; - if (def.ExecutionOrder != 1) args.Add($"ExecutionOrder = {def.ExecutionOrder}"); - if (def.Mode == 1) args.Add("Async = true"); + if (def.IsExecutionOrderExplicit) args.Add($"ExecutionOrder = {def.ExecutionOrder}"); + if (def.Mode == 1) args.Add("Async = true"); if (!string.IsNullOrEmpty(def.Description)) args.Add($"Description = {Lit(def.Description!)}"); if (def.RunAsUser is { } ru) diff --git a/src/dvx/Services/ReportGenerator.cs b/src/dvx/Services/ReportGenerator.cs new file mode 100644 index 0000000..17a6777 --- /dev/null +++ b/src/dvx/Services/ReportGenerator.cs @@ -0,0 +1,120 @@ +using System.Text; +using dvx.Models; + +namespace dvx.Services +{ + public class ReportGenerator + { + public string GenerateMarkdown(IEnumerable definitions) + { + var sb = new StringBuilder(); + sb.AppendLine("# Plugin Registration Report"); + sb.AppendLine(); + + var list = definitions.ToList(); + + sb.AppendLine("## By Message then Entity"); + sb.AppendLine(); + RenderGroupedView(sb, list, d => d.Message, d => d.Entity, "Message", "Entity"); + + sb.AppendLine("---"); + sb.AppendLine(); + + sb.AppendLine("## By Entity then Message"); + sb.AppendLine(); + RenderGroupedView(sb, list, d => d.Entity, d => d.Message, "Entity", "Message"); + + return sb.ToString(); + } + + private void RenderGroupedView( + StringBuilder sb, + List definitions, + Func primaryKey, + Func secondaryKey, + string primaryLabel, + string secondaryLabel) + { + var groups = definitions + .GroupBy(primaryKey) + .OrderBy(g => g.Key); + + foreach (var primaryGroup in groups) + { + sb.AppendLine($"### {primaryLabel}: {primaryGroup.Key}"); + sb.AppendLine(); + + var secondaryGroups = primaryGroup + .GroupBy(secondaryKey) + .OrderBy(g => g.Key); + + foreach (var secondaryGroup in secondaryGroups) + { + sb.AppendLine($"#### {secondaryLabel}: {secondaryGroup.Key}"); + sb.AppendLine(); + + var steps = secondaryGroup + .OrderBy(d => d.Stage) + .ThenBy(d => d.ExecutionOrder) + .ToList(); + + var unordered = steps.Where(d => !d.IsExecutionOrderExplicit).ToList(); + if (unordered.Any()) + { + sb.AppendLine("> **⚠️ Warning:** The following plugins have no explicit execution order set. Their relative execution order within the same stage and mode is non-deterministic:"); + foreach (var u in unordered.Select(d => d.TypeFullName).Distinct()) + { + sb.AppendLine($"> - {u}"); + } + sb.AppendLine(); + } + + sb.AppendLine("| Stage | Order | Mode | Plugin Type | Description |"); + sb.AppendLine("| :--- | :--- | :--- | :--- | :--- |"); + + foreach (var step in steps) + { + var order = step.IsExecutionOrderExplicit ? step.ExecutionOrder.ToString() : "1*"; + var mode = step.Mode == 1 ? "Async" : "Sync"; + sb.AppendLine($"| {PluginStepDefinition.StageName(step.Stage)} | {order} | {mode} | {step.TypeFullName} | {step.Description ?? "-"} |"); + } + sb.AppendLine(); + } + } + } + + public string GenerateCsv(IEnumerable definitions) + { + var sb = new StringBuilder(); + sb.AppendLine("Type,Entity,Message,Stage,Mode,ExecutionOrder,IsExplicit,Description"); + + foreach (var def in definitions.OrderBy(d => d.TypeFullName).ThenBy(d => d.Entity).ThenBy(d => d.Message)) + { + var row = new[] + { + def.TypeFullName, + def.Entity, + def.Message, + PluginStepDefinition.StageName(def.Stage), + def.Mode == 1 ? "Async" : "Sync", + def.ExecutionOrder.ToString(), + def.IsExecutionOrderExplicit.ToString(), + def.Description ?? "" + }; + sb.AppendLine(string.Join(",", row.Select(EscapeCsv))); + } + + return sb.ToString(); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) return ""; + if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r")) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + return value; + } + } +} diff --git a/src/dvx/Services/StepImporter.cs b/src/dvx/Services/StepImporter.cs index ba5ca6b..1938637 100644 --- a/src/dvx/Services/StepImporter.cs +++ b/src/dvx/Services/StepImporter.cs @@ -83,13 +83,15 @@ public ImportResult Import(Guid assemblyId, bool verbose = false) Message = message, Stage = stage, Mode = step.GetAttributeValue("mode")?.Value ?? 0, - ExecutionOrder = step.GetAttributeValue("rank"), Description = NullIfEmpty(step.GetAttributeValue("description")), FilteringAttributes = SplitCsv(step.GetAttributeValue("filteringattributes")), Configuration = NullIfEmpty(step.GetAttributeValue("configuration")), RunAsUser = ResolveImpersonation(step.GetAttributeValue("impersonatinguserid"), meta.SystemUserId()), }; + var rank = step.GetAttributeValue("rank"); + if (rank != 1) def.ExecutionOrder = rank; + foreach (var img in LoadImages(step.Id)) { var isPre = (img.GetAttributeValue("imagetype")?.Value ?? 0) == 0; From 21bb570f847e6fea755c8bfddfbc8c503a6f8d00 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Sun, 5 Jul 2026 15:12:23 +0200 Subject: [PATCH 2/9] feat: add basic MarkdownBuilder Co-authored-by: Junie --- src/dvx.Tests/MarkdownBuilderTests.cs | 34 +++++++++++++++++++++++ src/dvx/Output/MarkdownBuilder.cs | 40 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/dvx.Tests/MarkdownBuilderTests.cs create mode 100644 src/dvx/Output/MarkdownBuilder.cs diff --git a/src/dvx.Tests/MarkdownBuilderTests.cs b/src/dvx.Tests/MarkdownBuilderTests.cs new file mode 100644 index 0000000..8fbc7c5 --- /dev/null +++ b/src/dvx.Tests/MarkdownBuilderTests.cs @@ -0,0 +1,34 @@ +using dvx.Output; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class MarkdownBuilderTests + { + [Fact] + public void BasicFormatting_Works() + { + var md = new MarkdownBuilder(); + md.Heading(1, "Title") + .Paragraph("Hello world") + .Quote("Warning") + .HorizontalLine(); + + var result = md.ToString(); + result.ShouldContain("# Title"); + result.ShouldContain("Hello world"); + result.ShouldContain("> Warning"); + result.ShouldContain("---"); + } + + [Fact] + public void Paragraph_Indentation_Works() + { + var md = new MarkdownBuilder(); + md.Paragraph("Indented text", 1); + + md.ToString().ShouldBe($" Indented text{Environment.NewLine}{Environment.NewLine}"); + } + } +} diff --git a/src/dvx/Output/MarkdownBuilder.cs b/src/dvx/Output/MarkdownBuilder.cs new file mode 100644 index 0000000..c21a87a --- /dev/null +++ b/src/dvx/Output/MarkdownBuilder.cs @@ -0,0 +1,40 @@ +using System.Text; + +namespace dvx.Output +{ + public class MarkdownBuilder + { + private readonly StringBuilder _sb = new(); + + public MarkdownBuilder Heading(int level, string text) + { + _sb.AppendLine($"{new string('#', level)} {text}"); + _sb.AppendLine(); + return this; + } + + public MarkdownBuilder Paragraph(string text, int indent = 0) + { + if (indent > 0) _sb.Append(new string(' ', indent * 2)); + _sb.AppendLine(text); + _sb.AppendLine(); + return this; + } + + public MarkdownBuilder Quote(string text) + { + _sb.AppendLine($"> {text}"); + _sb.AppendLine(); + return this; + } + + public MarkdownBuilder HorizontalLine() + { + _sb.AppendLine("---"); + _sb.AppendLine(); + return this; + } + + public override string ToString() => _sb.ToString(); + } +} From 30d5a78e34c5739136a731e522dca3fb1ea0bc19 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Sun, 5 Jul 2026 15:13:08 +0200 Subject: [PATCH 3/9] feat: add table support to MarkdownBuilder Co-authored-by: Junie --- src/dvx.Tests/MarkdownBuilderTests.cs | 15 ++++++++++ src/dvx/Output/MarkdownBuilder.cs | 43 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/dvx.Tests/MarkdownBuilderTests.cs b/src/dvx.Tests/MarkdownBuilderTests.cs index 8fbc7c5..c98d7e2 100644 --- a/src/dvx.Tests/MarkdownBuilderTests.cs +++ b/src/dvx.Tests/MarkdownBuilderTests.cs @@ -30,5 +30,20 @@ public void Paragraph_Indentation_Works() md.ToString().ShouldBe($" Indented text{Environment.NewLine}{Environment.NewLine}"); } + + [Fact] + public void Table_Alignment_Works() + { + var md = new MarkdownBuilder(); + var headers = new[] { "A", "Long Header" }; + var rows = new List { new[] { "Short", "Val" } }; + + md.Table(headers, rows); + var result = md.ToString(); + + result.ShouldContain("| A | Long Header |"); + result.ShouldContain("| ----- | ----------- |"); + result.ShouldContain("| Short | Val |"); + } } } diff --git a/src/dvx/Output/MarkdownBuilder.cs b/src/dvx/Output/MarkdownBuilder.cs index c21a87a..dca9ab1 100644 --- a/src/dvx/Output/MarkdownBuilder.cs +++ b/src/dvx/Output/MarkdownBuilder.cs @@ -35,6 +35,49 @@ public MarkdownBuilder HorizontalLine() return this; } + public MarkdownBuilder Table(string[] headers, IEnumerable rows) + { + var list = rows.ToList(); + var widths = new int[headers.Length]; + for (int i = 0; i < headers.Length; i++) + { + widths[i] = headers[i].Length; + foreach (var row in list) + { + if (row.Length > i) + widths[i] = Math.Max(widths[i], row[i].Length); + } + } + + RenderRow(headers, widths); + + _sb.Append("|"); + for (int i = 0; i < widths.Length; i++) + { + _sb.Append(" " + new string('-', widths[i]) + " |"); + } + _sb.AppendLine(); + + foreach (var row in list) + { + RenderRow(row, widths); + } + _sb.AppendLine(); + + return this; + } + + private void RenderRow(string[] cells, int[] widths) + { + _sb.Append("|"); + for (int i = 0; i < cells.Length; i++) + { + var val = i < cells.Length ? cells[i] : ""; + _sb.Append(" " + val.PadRight(widths[i]) + " |"); + } + _sb.AppendLine(); + } + public override string ToString() => _sb.ToString(); } } From fb69e98222ef1235309ecdfc63a23be90de2f792 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Sun, 5 Jul 2026 15:14:21 +0200 Subject: [PATCH 4/9] refactor: use MarkdownBuilder in ReportGenerator Co-authored-by: Junie --- src/dvx/Services/ReportGenerator.cs | 107 ++++++++++++++++------------ 1 file changed, 63 insertions(+), 44 deletions(-) diff --git a/src/dvx/Services/ReportGenerator.cs b/src/dvx/Services/ReportGenerator.cs index 17a6777..65d981a 100644 --- a/src/dvx/Services/ReportGenerator.cs +++ b/src/dvx/Services/ReportGenerator.cs @@ -1,5 +1,6 @@ using System.Text; using dvx.Models; +using dvx.Output; namespace dvx.Services { @@ -7,28 +8,29 @@ public class ReportGenerator { public string GenerateMarkdown(IEnumerable definitions) { - var sb = new StringBuilder(); - sb.AppendLine("# Plugin Registration Report"); - sb.AppendLine(); + var md = new MarkdownBuilder(); + md.Heading(1, "Plugin Registration Report"); var list = definitions.ToList(); - sb.AppendLine("## By Message then Entity"); - sb.AppendLine(); - RenderGroupedView(sb, list, d => d.Message, d => d.Entity, "Message", "Entity"); + if (list.Any(d => !d.IsExecutionOrderExplicit)) + { + md.Quote("**⚠️ Warning:** All steps marked with an asterix (*) beside the Order number have no explicit execution order set. Their relative execution order within the same stage and mode is non-deterministic."); + } + + md.Heading(2, "By Message then Entity"); + RenderGroupedView(md, list, d => d.Message, d => d.Entity, "Message", "Entity"); - sb.AppendLine("---"); - sb.AppendLine(); + md.HorizontalLine(); - sb.AppendLine("## By Entity then Message"); - sb.AppendLine(); - RenderGroupedView(sb, list, d => d.Entity, d => d.Message, "Entity", "Message"); + md.Heading(2, "By Entity then Message"); + RenderGroupedView(md, list, d => d.Entity, d => d.Message, "Entity", "Message"); - return sb.ToString(); + return md.ToString(); } private void RenderGroupedView( - StringBuilder sb, + MarkdownBuilder md, List definitions, Func primaryKey, Func secondaryKey, @@ -41,8 +43,7 @@ private void RenderGroupedView( foreach (var primaryGroup in groups) { - sb.AppendLine($"### {primaryLabel}: {primaryGroup.Key}"); - sb.AppendLine(); + md.Heading(3, $"{primaryLabel}: {primaryGroup.Key}"); var secondaryGroups = primaryGroup .GroupBy(secondaryKey) @@ -50,39 +51,57 @@ private void RenderGroupedView( foreach (var secondaryGroup in secondaryGroups) { - sb.AppendLine($"#### {secondaryLabel}: {secondaryGroup.Key}"); - sb.AppendLine(); - - var steps = secondaryGroup - .OrderBy(d => d.Stage) - .ThenBy(d => d.ExecutionOrder) - .ToList(); - - var unordered = steps.Where(d => !d.IsExecutionOrderExplicit).ToList(); - if (unordered.Any()) - { - sb.AppendLine("> **⚠️ Warning:** The following plugins have no explicit execution order set. Their relative execution order within the same stage and mode is non-deterministic:"); - foreach (var u in unordered.Select(d => d.TypeFullName).Distinct()) - { - sb.AppendLine($"> - {u}"); - } - sb.AppendLine(); - } - - sb.AppendLine("| Stage | Order | Mode | Plugin Type | Description |"); - sb.AppendLine("| :--- | :--- | :--- | :--- | :--- |"); - - foreach (var step in steps) - { - var order = step.IsExecutionOrderExplicit ? step.ExecutionOrder.ToString() : "1*"; - var mode = step.Mode == 1 ? "Async" : "Sync"; - sb.AppendLine($"| {PluginStepDefinition.StageName(step.Stage)} | {order} | {mode} | {step.TypeFullName} | {step.Description ?? "-"} |"); - } - sb.AppendLine(); + md.Heading(4, $"{secondaryLabel}: {secondaryGroup.Key}"); + + RenderStepTable(md, secondaryGroup); } } } + private void RenderStepTable(MarkdownBuilder md, IEnumerable steps) + { + var orderedSteps = steps + .OrderBy(d => d.Stage) + .ThenBy(d => d.Mode) + .ThenBy(d => d.ExecutionOrder) + .ToList(); + + var headers = new[] { "Stage", "Order", "Mode", "Plugin Type", "Description" }; + var rows = PrepareTableRows(orderedSteps); + + md.Table(headers, rows); + } + + private List PrepareTableRows(List steps) + { + var rows = new List(); + int? lastStage = null; + + foreach (var step in steps) + { + if (lastStage.HasValue && lastStage.Value != step.Stage) + { + rows.Add(new[] { "", "", "", "", "" }); + } + + var order = step.IsExecutionOrderExplicit ? step.ExecutionOrder.ToString() : $"{step.ExecutionOrder}*"; + var mode = step.Mode == 1 ? "Async" : "Sync"; + + rows.Add(new[] + { + PluginStepDefinition.StageName(step.Stage), + order, + mode, + step.TypeFullName, + step.Description ?? "-" + }); + + lastStage = step.Stage; + } + + return rows; + } + public string GenerateCsv(IEnumerable definitions) { var sb = new StringBuilder(); From 88ed1915583e7981167ef07142b313767186499a Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Sun, 5 Jul 2026 15:15:19 +0200 Subject: [PATCH 5/9] test: update ReportGeneratorTests to match new headings and add table verification Co-authored-by: Junie --- src/dvx.Tests/ReportGeneratorTests.cs | 53 +++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/dvx.Tests/ReportGeneratorTests.cs b/src/dvx.Tests/ReportGeneratorTests.cs index e306ae1..e6a21b3 100644 --- a/src/dvx.Tests/ReportGeneratorTests.cs +++ b/src/dvx.Tests/ReportGeneratorTests.cs @@ -19,8 +19,8 @@ public void GenerateMarkdown_ContainsBothViews() var gen = new ReportGenerator(); var md = gen.GenerateMarkdown(definitions); - md.ShouldContain("## View 1: Message then Entity"); - md.ShouldContain("## View 2: Entity then Message"); + md.ShouldContain("## By Message then Entity"); + md.ShouldContain("## By Entity then Message"); md.ShouldContain("### Message: Create"); md.ShouldContain("#### Entity: account"); md.ShouldContain("### Entity: contact"); @@ -41,8 +41,53 @@ public void GenerateMarkdown_UnorderedPlugins_ShowsWarning() var md = gen.GenerateMarkdown(definitions); md.ShouldContain("⚠️ Warning"); - md.ShouldContain("non-deterministic"); - md.ShouldContain("1*"); // My implementation uses 1* for implicit + md.ShouldContain("asterix (*) beside the Order number"); + md.ShouldContain("1*"); + } + + [Fact] + public void GenerateMarkdown_PadsColumnsAndInsertsEmptyRows() + { + var definitions = new List + { + new() { TypeFullName = "Short", Entity = "a", Message = "m", Stage = 10, ExecutionOrder = 1 }, + new() { TypeFullName = "VeryLongPluginNameThatShouldForcePadding", Entity = "a", Message = "m", Stage = 10, ExecutionOrder = 2 }, + new() { TypeFullName = "NextStage", Entity = "a", Message = "m", Stage = 20, ExecutionOrder = 1 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + // Check padding + md.ShouldContain("| PreValidation | 1 | Sync | Short | - |"); + md.ShouldContain("| PreValidation | 2 | Sync | VeryLongPluginNameThatShouldForcePadding | - |"); + + // Check empty row between Stage 10 and 20 + md.ShouldContain("| | | | | |"); + md.ShouldContain("| PreOperation | 1 | Sync | NextStage | - |"); + } + + [Fact] + public void GenerateMarkdown_SortsByStageThenModeThenOrder() + { + var definitions = new List + { + new() { TypeFullName = "P2", Entity = "a", Message = "m", Stage = 40, Mode = 0, ExecutionOrder = 2 }, + new() { TypeFullName = "P3", Entity = "a", Message = "m", Stage = 40, Mode = 1, ExecutionOrder = 1 }, + new() { TypeFullName = "P1", Entity = "a", Message = "m", Stage = 40, Mode = 0, ExecutionOrder = 1 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + // Should be P1 (Sync, 1), P2 (Sync, 2), P3 (Async, 1) + var lines = md.Split('\n').Select(l => l.Trim()).ToList(); + var p1Index = lines.FindIndex(l => l.Contains("P1")); + var p2Index = lines.FindIndex(l => l.Contains("P2")); + var p3Index = lines.FindIndex(l => l.Contains("P3")); + + p1Index.ShouldBeLessThan(p2Index); + p2Index.ShouldBeLessThan(p3Index); } [Fact] From 4dcaeddd0dbed6f647221987b62b3e377aaf2cfe Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Sun, 5 Jul 2026 15:39:44 +0200 Subject: [PATCH 6/9] refactor: adjust execution order logic in ReportGenerator and update MarkdownBuilder namespace --- src/dvx/Services/ReportGenerator.cs | 23 +++++++++++-------- .../{Output => Utility}/MarkdownBuilder.cs | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) rename src/dvx/{Output => Utility}/MarkdownBuilder.cs (99%) diff --git a/src/dvx/Services/ReportGenerator.cs b/src/dvx/Services/ReportGenerator.cs index 65d981a..453d4eb 100644 --- a/src/dvx/Services/ReportGenerator.cs +++ b/src/dvx/Services/ReportGenerator.cs @@ -1,6 +1,7 @@ using System.Text; using dvx.Models; using dvx.Output; +using dvx.Utility; namespace dvx.Services { @@ -18,13 +19,13 @@ public string GenerateMarkdown(IEnumerable definitions) md.Quote("**⚠️ Warning:** All steps marked with an asterix (*) beside the Order number have no explicit execution order set. Their relative execution order within the same stage and mode is non-deterministic."); } - md.Heading(2, "By Message then Entity"); - RenderGroupedView(md, list, d => d.Message, d => d.Entity, "Message", "Entity"); - - md.HorizontalLine(); - md.Heading(2, "By Entity then Message"); RenderGroupedView(md, list, d => d.Entity, d => d.Message, "Entity", "Message"); + + md.HorizontalLine(); + + md.Heading(2, "By Message then Entity"); + RenderGroupedView(md, list, d => d.Message, d => d.Entity, "Message", "Entity"); return md.ToString(); } @@ -76,27 +77,29 @@ private List PrepareTableRows(List steps) { var rows = new List(); int? lastStage = null; + int? lastMode = null; foreach (var step in steps) { - if (lastStage.HasValue && lastStage.Value != step.Stage) + if ((lastStage.HasValue && lastStage.Value != step.Stage) || + (lastMode.HasValue && lastMode != step.Mode)) { - rows.Add(new[] { "", "", "", "", "" }); + rows.Add(["", "", "", "", ""]); // Add blank row between stages/steps } var order = step.IsExecutionOrderExplicit ? step.ExecutionOrder.ToString() : $"{step.ExecutionOrder}*"; var mode = step.Mode == 1 ? "Async" : "Sync"; - rows.Add(new[] - { + rows.Add([ PluginStepDefinition.StageName(step.Stage), order, mode, step.TypeFullName, step.Description ?? "-" - }); + ]); lastStage = step.Stage; + lastMode = step.Mode; } return rows; diff --git a/src/dvx/Output/MarkdownBuilder.cs b/src/dvx/Utility/MarkdownBuilder.cs similarity index 99% rename from src/dvx/Output/MarkdownBuilder.cs rename to src/dvx/Utility/MarkdownBuilder.cs index dca9ab1..f6ef9cd 100644 --- a/src/dvx/Output/MarkdownBuilder.cs +++ b/src/dvx/Utility/MarkdownBuilder.cs @@ -1,6 +1,6 @@ using System.Text; -namespace dvx.Output +namespace dvx.Utility { public class MarkdownBuilder { From d36236b7512e976c79598539a043cecb6b8e2061 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Sun, 5 Jul 2026 15:41:59 +0200 Subject: [PATCH 7/9] Remove unnecessary using --- src/dvx.Tests/MarkdownBuilderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dvx.Tests/MarkdownBuilderTests.cs b/src/dvx.Tests/MarkdownBuilderTests.cs index c98d7e2..43c6294 100644 --- a/src/dvx.Tests/MarkdownBuilderTests.cs +++ b/src/dvx.Tests/MarkdownBuilderTests.cs @@ -1,4 +1,4 @@ -using dvx.Output; +using dvx.Utility; using Shouldly; using Xunit; From 537a72c3822771f04165588c5c6bb2558dfbef0f Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Mon, 6 Jul 2026 06:49:29 +0200 Subject: [PATCH 8/9] feat: add --filename and --types options to report command Co-authored-by: Junie --- src/dvx/Commands/ReportCommand.cs | 43 ++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/dvx/Commands/ReportCommand.cs b/src/dvx/Commands/ReportCommand.cs index af888b7..b95b2f1 100644 --- a/src/dvx/Commands/ReportCommand.cs +++ b/src/dvx/Commands/ReportCommand.cs @@ -27,7 +27,17 @@ public static Command Build(ILoggerFactory loggerFactory) "Output directory for the generated reports."); var verbose = CommandOptions.Verbose(); - cmd.AddOptions(project, config, output, verbose); + var filename = new Option( + new[] { "--filename", "-n" }, + "Custom base filename for the reports. If omitted, a timestamped name is used."); + + var types = new Option( + new[] { "--types", "-t" }, + () => "both", + "Output types to include: 'csv', 'md', or 'both' (default).") + .FromAmong("csv", "md", "both"); + + cmd.AddOptions(project, config, output, verbose, filename, types); cmd.SetHandler(async (InvocationContext ctx) => { @@ -35,6 +45,8 @@ public static Command Build(ILoggerFactory loggerFactory) var configPath = ctx.ParseResult.GetValueForOption(config); var outDir = ctx.ParseResult.GetValueForOption(output)!; var isVerbose = ctx.ParseResult.GetValueForOption(verbose); + var customFilename = ctx.ParseResult.GetValueForOption(filename); + var reportTypes = ctx.ParseResult.GetValueForOption(types)!; try { @@ -53,22 +65,35 @@ public static Command Build(ILoggerFactory loggerFactory) Out.Step("Generating", "reports..."); var generator = new ReportGenerator(); - var md = generator.GenerateMarkdown(definitions); - var csv = generator.GenerateCsv(definitions); + + string baseName = !string.IsNullOrEmpty(customFilename) + ? customFilename + : $"plugin-report-{DateTime.Now:yyyyMMdd-HHmmss}"; if (!Directory.Exists(outDir)) { Directory.CreateDirectory(outDir); } - var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); - var mdFile = Path.Combine(outDir, $"plugin-report-{timestamp}.md"); - var csvFile = Path.Combine(outDir, $"plugin-report-{timestamp}.csv"); + var generatedFiles = new List(); - await File.WriteAllTextAsync(mdFile, md); - await File.WriteAllTextAsync(csvFile, csv); + if (reportTypes is "md" or "both") + { + var md = generator.GenerateMarkdown(definitions); + var mdFile = Path.Combine(outDir, $"{baseName}.md"); + await File.WriteAllTextAsync(mdFile, md); + generatedFiles.Add(Path.GetFullPath(mdFile)); + } + + if (reportTypes is "csv" or "both") + { + var csv = generator.GenerateCsv(definitions); + var csvFile = Path.Combine(outDir, $"{baseName}.csv"); + await File.WriteAllTextAsync(csvFile, csv); + generatedFiles.Add(Path.GetFullPath(csvFile)); + } - Out.Success("Reports generated:", $"{Path.GetFullPath(mdFile)}\n{Path.GetFullPath(csvFile)}"); + Out.Success("Reports generated:", string.Join("\n", generatedFiles)); } catch (Exception ex) { From 67692e14b170dfc3e49534838f0f93d6d20120cc Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Mon, 6 Jul 2026 07:59:03 +0200 Subject: [PATCH 9/9] bump: update dvx CLI to 1.9.0 with new report command and adjusted sort logic in ReportGenerator --- src/dvx/Services/ReportGenerator.cs | 3 ++- src/dvx/dvx.csproj | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dvx/Services/ReportGenerator.cs b/src/dvx/Services/ReportGenerator.cs index 453d4eb..62d0cb6 100644 --- a/src/dvx/Services/ReportGenerator.cs +++ b/src/dvx/Services/ReportGenerator.cs @@ -110,7 +110,8 @@ public string GenerateCsv(IEnumerable definitions) var sb = new StringBuilder(); sb.AppendLine("Type,Entity,Message,Stage,Mode,ExecutionOrder,IsExplicit,Description"); - foreach (var def in definitions.OrderBy(d => d.TypeFullName).ThenBy(d => d.Entity).ThenBy(d => d.Message)) + foreach (var def in definitions.OrderBy(d => d.Entity).ThenBy(d => d.Message).ThenBy(d => d.Mode) + .ThenBy(d => d.ExecutionOrder)) { var row = new[] { diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index bebc0a1..d797e8f 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -17,8 +17,8 @@ https://github.com/beyro/dvx-cli package-icon.png MIT - 1.8.0: -- Added interactive login via `authType` in config file or `--interactive-auth` flag. + 1.9.0: +- New `report` command to generate a report of plugin registrations in Markdown and CSV