From 2db558d3e6ee9fb8131134b48e9b90b4deb2cbd3 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 13:46:45 +0200 Subject: [PATCH 1/2] feat(cli): add --version `projgraph --version` and `-v` failed with "Unexpected option" because no application version was configured. The CLI now prints the package version, e.g. 1.2.0-beta.1, without the +commit build metadata the SDK appends to the informational version. The version is read from the CLI assembly rather than through Spectre's UseAssemblyInformationalVersion, which reads the entry assembly (the test host under tests) and keeps the build metadata. Program's app configuration moves into Program.Configure, so the new test runs the real configuration against a TestConsole instead of Spectre's cached default console. The AOT smoke suite also compares --version between the native and JIT builds. Co-Authored-By: Claude Opus 5 (1M context) --- src/ProjGraph.Cli/Program.cs | 99 ++++++++++++------- src/ProjGraph.Cli/README.md | 3 + .../VersionOptionTests.cs | 45 +++++++++ .../CliParityTests.cs | 8 ++ 4 files changed, 117 insertions(+), 38 deletions(-) create mode 100644 tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs diff --git a/src/ProjGraph.Cli/Program.cs b/src/ProjGraph.Cli/Program.cs index 2ea1b3a..72dd2cf 100644 --- a/src/ProjGraph.Cli/Program.cs +++ b/src/ProjGraph.Cli/Program.cs @@ -4,6 +4,7 @@ using ProjGraph.Lib; using Spectre.Console.Cli; using System.Diagnostics.CodeAnalysis; +using System.Reflection; namespace ProjGraph.Cli; @@ -11,10 +12,6 @@ internal static class Program { public static int Main(string[] args) { - const string packageDiagramCommandName = "visualize"; - const string erdCommandName = "erd"; - const string classDiagramCommandName = "classdiagram"; - var services = new ServiceCollection(); // Register Library services @@ -25,44 +22,70 @@ public static int Main(string[] args) var app = CreateApp(new TypeRegistrar(services)); - app.Configure(config => - { - config.SetApplicationName("projgraph"); - - // Spectre's default parser silently ignores unrecognized long options: a typo like - // `--owned-mod classic` (missing 'e') exits 0 and renders with defaults, giving the user - // no signal their option was dropped. Strict parsing turns any unknown option into a - // parse error with a non-zero exit code instead. - config.Settings.StrictParsing = true; - - config.AddCommand(packageDiagramCommandName) - .WithDescription("Visualize the dependency graph of a solution or project") - .WithExample(packageDiagramCommandName, "MySolution.sln") - .WithExample(packageDiagramCommandName, "MySolution.sln", "--format", "tree") - .WithExample(packageDiagramCommandName, "MySolution.slnx", "--output", "graph.mmd"); - - config.AddCommand(erdCommandName) - .WithDescription("Generate a Mermaid ERD for an Entity Framework Core DbContext") - .WithExample(erdCommandName, "Data/AppDbContext.cs") - .WithExample(erdCommandName, "Data/AppDbContext.cs", "--context", "BlogContext") - .WithExample(erdCommandName, "Data/AppDbContext.cs", "--output", "docs/erd.md"); - - config.AddCommand(classDiagramCommandName) - .WithDescription("Generate a Mermaid Class Diagram for a C# file") - .WithExample(classDiagramCommandName, "Services/UserService.cs") - .WithExample(classDiagramCommandName, "Models/User.cs", "--inheritance", "--dependencies", "--depth", - "10") - .WithExample(classDiagramCommandName, "Models/User.cs", "--output", "user-hierarchy.mmd"); - - config.AddCommand("stats") - .WithDescription("Analyse a solution and print key architectural metrics") - .WithExample("stats", "MySolution.slnx") - .WithExample("stats", "MySolution.slnx", "--top", "10"); - }); + app.Configure(Configure); return app.Run(args); } + /// + /// Applies the application settings and registers the commands. Tests call this directly so they + /// can run the real configuration against their own console. + /// + /// The configurator of the command app. + internal static void Configure(IConfigurator config) + { + const string packageDiagramCommandName = "visualize"; + const string erdCommandName = "erd"; + const string classDiagramCommandName = "classdiagram"; + + config.SetApplicationName("projgraph"); + config.SetApplicationVersion(GetPackageVersion()); + + // Spectre's default parser silently ignores unrecognized long options: a typo like + // `--owned-mod classic` (missing 'e') exits 0 and renders with defaults, giving the user + // no signal their option was dropped. Strict parsing turns any unknown option into a + // parse error with a non-zero exit code instead. + config.Settings.StrictParsing = true; + + config.AddCommand(packageDiagramCommandName) + .WithDescription("Visualize the dependency graph of a solution or project") + .WithExample(packageDiagramCommandName, "MySolution.sln") + .WithExample(packageDiagramCommandName, "MySolution.sln", "--format", "tree") + .WithExample(packageDiagramCommandName, "MySolution.slnx", "--output", "graph.mmd"); + + config.AddCommand(erdCommandName) + .WithDescription("Generate a Mermaid ERD for an Entity Framework Core DbContext") + .WithExample(erdCommandName, "Data/AppDbContext.cs") + .WithExample(erdCommandName, "Data/AppDbContext.cs", "--context", "BlogContext") + .WithExample(erdCommandName, "Data/AppDbContext.cs", "--output", "docs/erd.md"); + + config.AddCommand(classDiagramCommandName) + .WithDescription("Generate a Mermaid Class Diagram for a C# file") + .WithExample(classDiagramCommandName, "Services/UserService.cs") + .WithExample(classDiagramCommandName, "Models/User.cs", "--inheritance", "--dependencies", "--depth", + "10") + .WithExample(classDiagramCommandName, "Models/User.cs", "--output", "user-hierarchy.mmd"); + + config.AddCommand("stats") + .WithDescription("Analyse a solution and print key architectural metrics") + .WithExample("stats", "MySolution.slnx") + .WithExample("stats", "MySolution.slnx", "--top", "10"); + } + + /// + /// Gets the version the package was built with. Spectre's UseAssemblyInformationalVersion + /// reads the entry assembly, which is the test host under tests, and keeps the +commit build + /// metadata the SDK appends, so the CLI assembly's attribute is read and trimmed here instead. + /// + /// The package version, e.g. 1.2.0 or 1.2.0-beta.1. + private static string GetPackageVersion() + { + var version = typeof(Program).Assembly + .GetCustomAttribute()!.InformationalVersion; + var metadataStart = version.IndexOf('+', StringComparison.Ordinal); + return metadataStart < 0 ? version : version[..metadataStart]; + } + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Spectre.Console.Cli reflects over the command and settings types. ProjGraph.Cli and " + "Spectre.Console.Cli are trimmer root assemblies, so that metadata is kept, and " + diff --git a/src/ProjGraph.Cli/README.md b/src/ProjGraph.Cli/README.md index d6a65e1..cb1a236 100644 --- a/src/ProjGraph.Cli/README.md +++ b/src/ProjGraph.Cli/README.md @@ -10,6 +10,9 @@ class hierarchies, and computing key solution metrics. ```bash dotnet tool install -g ProjGraph.Cli + +# Check the installed version +projgraph --version ``` Installing needs the [.NET 10 SDK](https://dotnet.microsoft.com/download) or later. On Windows x64, Linux x64 and diff --git a/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs b/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs new file mode 100644 index 0000000..8a39277 --- /dev/null +++ b/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs @@ -0,0 +1,45 @@ +using ProjGraph.Cli; +using Spectre.Console.Cli; +using Spectre.Console.Testing; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace ProjGraph.Tests.Integration.Cli; + +/// +/// Tests for projgraph --version. They run the real configuration from +/// against a rather than calling +/// , because only one test in the assembly may reach Spectre's cached default +/// console (see ). +/// +[Collection("CLI Tests")] +public sealed partial class VersionOptionTests +{ + [Theory] + [InlineData("--version")] + [InlineData("-v")] + public void VersionOption_PrintsThePackageVersionAndSucceeds(string option) + { + var console = new TestConsole(); + var app = new CommandApp(); + app.Configure(config => + { + Program.Configure(config); + config.Settings.Console = console; + }); + + var exitCode = app.Run([option]); + + exitCode.Should().Be(0, $"output:\n{console.Output}"); + var printed = console.Output.Trim(); + printed.Should().MatchRegex(PackageVersion(), + "the version must be a plain package version, without the + build metadata"); + // The assembly version is the numeric part of the package version, so this ties the printed + // value to the CLI assembly and not to the test host's entry assembly. + var assemblyVersion = typeof(Program).Assembly.GetName().Version!; + printed.Should().StartWith(assemblyVersion.ToString(3)); + } + + [GeneratedRegex(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$")] + private static partial Regex PackageVersion(); +} diff --git a/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs b/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs index 2367001..d6c17a5 100644 --- a/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs +++ b/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs @@ -179,6 +179,14 @@ public async Task Help_Visualize_ShouldMatchReference() await AssertParityAsync(expectSuccess: true, "visualize", "--help"); } + [AotSmokeFact] + public async Task Version_ShouldMatchReference() + { + // The version is read from an assembly attribute, which the native build must keep. + var reference = await AssertParityAsync(expectSuccess: true, "--version"); + reference.StandardOutput.Should().NotBeNullOrWhiteSpace("the version must be printed"); + } + private string CreateSolutionWithMalformedProject() { _temp.CreateFile("Good/Good.csproj", From 160abd94579209cec0779c928a7ea39ff5fc6dbd Mon Sep 17 00:00:00 2001 From: = Date: Tue, 15 Sep 2026 13:54:23 +0200 Subject: [PATCH 2/2] fix(cli): fall back to the assembly version without the informational attribute Program.Configure runs for every command, so the null-forgiving attribute lookup would have failed all of them, not only --version, in a build without AssemblyInformationalVersionAttribute. GetPackageVersion now takes the assembly and falls back to its version. A test covers the fallback with a dynamic assembly that has no attributes; the test's System.Reflection using is needed for it. Co-Authored-By: Claude Opus 5 (1M context) --- src/ProjGraph.Cli/Program.cs | 19 ++++++++++++------- .../VersionOptionTests.cs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/ProjGraph.Cli/Program.cs b/src/ProjGraph.Cli/Program.cs index 72dd2cf..d5a7981 100644 --- a/src/ProjGraph.Cli/Program.cs +++ b/src/ProjGraph.Cli/Program.cs @@ -39,7 +39,7 @@ internal static void Configure(IConfigurator config) const string classDiagramCommandName = "classdiagram"; config.SetApplicationName("projgraph"); - config.SetApplicationVersion(GetPackageVersion()); + config.SetApplicationVersion(GetPackageVersion(typeof(Program).Assembly)); // Spectre's default parser silently ignores unrecognized long options: a typo like // `--owned-mod classic` (missing 'e') exits 0 and renders with defaults, giving the user @@ -73,15 +73,20 @@ internal static void Configure(IConfigurator config) } /// - /// Gets the version the package was built with. Spectre's UseAssemblyInformationalVersion - /// reads the entry assembly, which is the test host under tests, and keeps the +commit build - /// metadata the SDK appends, so the CLI assembly's attribute is read and trimmed here instead. + /// Gets the package version an assembly was built with. Spectre's + /// UseAssemblyInformationalVersion reads the entry assembly, which is the test host under tests, + /// and keeps the +commit build metadata the SDK appends, so passes the + /// CLI assembly here and the metadata is trimmed. /// + /// The assembly to read the version from. /// The package version, e.g. 1.2.0 or 1.2.0-beta.1. - private static string GetPackageVersion() + internal static string GetPackageVersion(Assembly assembly) { - var version = typeof(Program).Assembly - .GetCustomAttribute()!.InformationalVersion; + // The SDK always generates the attribute, but Configure runs for every command, so a build + // without it falls back to the assembly version instead of failing them all. + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString(3) + ?? "0.0.0"; var metadataStart = version.IndexOf('+', StringComparison.Ordinal); return metadataStart < 0 ? version : version[..metadataStart]; } diff --git a/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs b/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs index 8a39277..e1444c8 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs @@ -2,6 +2,7 @@ using Spectre.Console.Cli; using Spectre.Console.Testing; using System.Reflection; +using System.Reflection.Emit; using System.Text.RegularExpressions; namespace ProjGraph.Tests.Integration.Cli; @@ -40,6 +41,17 @@ public void VersionOption_PrintsThePackageVersionAndSucceeds(string option) printed.Should().StartWith(assemblyVersion.ToString(3)); } + [Fact] + public void GetPackageVersion_WithoutInformationalVersionAttribute_FallsBackToTheAssemblyVersion() + { + // Configure runs for every command, so a build without the attribute must not break them all. + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("NoInformationalVersion") { Version = new Version(2, 3, 4, 5) }, + AssemblyBuilderAccess.Run); + + Program.GetPackageVersion(assembly).Should().Be("2.3.4"); + } + [GeneratedRegex(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$")] private static partial Regex PackageVersion(); }