From 6bf07f5bf67ef7230b5e54e640ca1d8a702f6fd5 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Sun, 8 Feb 2026 06:21:40 +0100 Subject: [PATCH 1/8] feat: Restore target framework compilation for .NET Framework (#591) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sensslen <3428860+sensslen@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +-- build.ps1 | 16 ++++++++-- ...ensions.CommandLineUtils.Generators.csproj | 1 - .../CommandLineApplication.cs | 6 ++-- .../Conventions/OptionAttributeConvention.cs | 31 ++++++++++--------- .../OptionAttributeConventionBase.cs | 5 +-- .../SubcommandAttributeConvention.cs | 8 +++-- .../Extensions/DictionaryExtensions.cs | 22 +++++++++++++ .../Extensions/StringExtensions.cs | 21 +++++++++++++ .../HelpText/HangingIndentWriter.cs | 3 +- .../Internal/CommandLineProcessor.cs | 3 +- .../Internal/ReflectionHelper.cs | 27 +++++++++++++++- .../Internal/SuggestionCreator.cs | 7 +++-- ...cMaster.Extensions.CommandLineUtils.csproj | 28 ++++++++--------- .../Properties/NullabilityHelpers.cs | 2 +- src/CommandLineUtils/Properties/Strings.cs | 2 +- .../SourceGeneration/ActivatorModelFactory.cs | 2 ++ .../DefaultMetadataResolver.cs | 6 ++++ .../ReflectionExecuteHandler.cs | 2 ++ .../ReflectionMetadataProvider.cs | 5 ++- .../ReflectionValidateHandler.cs | 2 ++ .../ReflectionValidationErrorHandler.cs | 2 ++ src/CommandLineUtils/Utilities/DotNetExe.cs | 3 +- ...ster.Extensions.Hosting.CommandLine.csproj | 10 +++++- ...AppNameFromEntryAssemblyConventionTests.cs | 2 +- .../ArgumentAttributeTests.cs | 6 ++-- .../AttributeValidatorTests.cs | 16 +++++----- .../CommandLineApplicationExecutorTests.cs | 6 ++-- .../CommandLineApplicationTests.cs | 10 +++--- .../CustomValidationAttributeTest.cs | 7 +++-- .../DefaultHelpTextGeneratorTests.cs | 12 +++---- .../FilePathExistsAttributeTests.cs | 4 +-- .../FilePathNotExistsAttributeTests.cs | 4 +-- .../HelpOptionAttributeTests.cs | 3 +- .../LegalFilePathAttributeTests.cs | 2 +- ...r.Extensions.CommandLineUtils.Tests.csproj | 15 +++++++-- .../OptionAttributeTests.cs | 22 ++++++------- .../ResponseFileTests.cs | 2 +- .../StringExtensionsTests.cs | 4 +-- .../ValidateMethodConventionTests.cs | 8 ++--- .../CommandLineUtils.Tests/ValidationTests.cs | 2 +- .../ValueParserProviderCustomTests.cs | 2 +- .../VersionOptionAttributeTests.cs | 6 ++-- ...xtensions.Hosting.CommandLine.Tests.csproj | 13 +++++++- 44 files changed, 251 insertions(+), 114 deletions(-) create mode 100644 src/CommandLineUtils/Extensions/DictionaryExtensions.cs create mode 100644 src/CommandLineUtils/Extensions/StringExtensions.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5b7c967..5151e3e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: 10.x - name: Run build script id: build_script - run: ./build.ps1 -ci + run: ./build.ps1 -ci ${{ matrix.os == 'windows-latest' && '-skipCoverage' || '' }} - uses: actions/upload-artifact@v6 if: ${{ matrix.os == 'windows-latest' }} with: @@ -51,6 +51,7 @@ jobs: path: artifacts/ if-no-files-found: error - uses: codecov/codecov-action@v5 + if: ${{ matrix.os != 'windows-latest' }} with: name: unittests-${{ matrix.os }} fail_ci_if_error: true @@ -58,7 +59,7 @@ jobs: release: if: ${{ github.event.inputs.release }} needs: build - runs-on: ubuntu-latest + runs-on: windows-latest permissions: contents: write # for creating GitHub releases id-token: write # for NuGet trusted publishing (OIDC) diff --git a/build.ps1 b/build.ps1 index 989f4708..2343b28a 100755 --- a/build.ps1 +++ b/build.ps1 @@ -4,7 +4,9 @@ param( [ValidateSet('Debug', 'Release')] $Configuration = $null, [switch] - $ci + $ci, + [switch] + $skipCoverage ) Set-StrictMode -Version 1 @@ -27,11 +29,21 @@ if ($ci) { $formatArgs += '--check' } +[string[]] $testArgs = @('--no-build', '--configuration', $Configuration) +if (!$skipCoverage) { + $testArgs += "--collect:`"XPlat Code Coverage`"" +} + exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/CommandLineUtils.sln" exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/docs/samples/samples.sln" exec dotnet build --configuration $Configuration '-warnaserror:CS1591' exec dotnet pack --no-build --configuration $Configuration -o $artifacts exec dotnet build --configuration $Configuration "$PSScriptRoot/docs/samples/samples.sln" -exec dotnet test --no-build --configuration $Configuration --collect:"XPlat Code Coverage" + +if ($skipCoverage) { + exec dotnet test --no-build --configuration $Configuration +} else { + exec dotnet test --no-build --configuration $Configuration --collect:"XPlat Code Coverage" +} write-host -f green 'BUILD SUCCEEDED' diff --git a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj index cfd7d030..cc21eccc 100644 --- a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj +++ b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj @@ -2,7 +2,6 @@ netstandard2.0 - latest enable true true diff --git a/src/CommandLineUtils/CommandLineApplication.cs b/src/CommandLineUtils/CommandLineApplication.cs index 9a9e5bd7..a0468cf3 100644 --- a/src/CommandLineUtils/CommandLineApplication.cs +++ b/src/CommandLineUtils/CommandLineApplication.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Reflection; @@ -13,6 +12,7 @@ using System.Threading.Tasks; using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Conventions; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.HelpText; using McMaster.Extensions.CommandLineUtils.Internal; @@ -221,7 +221,7 @@ public IEnumerable Names { get { - if (!string.IsNullOrEmpty(Name)) + if (!Name.IsNullOrEmpty()) { yield return Name; } @@ -499,7 +499,7 @@ internal CommandLineApplication AddSubcommand(string name, Type modelType, Sourc private void AssertCommandNameIsUnique(string? name, CommandLineApplication? commandToIgnore) { - if (string.IsNullOrEmpty(name)) + if (name.IsNullOrEmpty()) { return; } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs index 5ac89455..e1e6b8e7 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -32,33 +33,33 @@ public virtual void Apply(ConventionContext context) var (template, shortName, longName) = GetOptionNames(optMeta); // Check for same-class conflicts (options in the same provider with conflicting names) - if (!string.IsNullOrEmpty(shortName) && addedShortOptions.TryGetValue(shortName, out var existingShort)) + if (!shortName.IsNullOrEmpty() && addedShortOptions.TryGetValue(shortName, out var existingShort)) { throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(shortName, optMeta.PropertyName, optMeta.DeclaringType, existingShort.PropertyName, existingShort.DeclaringType)); } - if (!string.IsNullOrEmpty(longName) && addedLongOptions.TryGetValue(longName, out var existingLong)) + if (!longName.IsNullOrEmpty() && addedLongOptions.TryGetValue(longName, out var existingLong)) { throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(longName, optMeta.PropertyName, optMeta.DeclaringType, existingLong.PropertyName, existingLong.DeclaringType)); } // Check if option already exists from parent command (inherited options) - if (!string.IsNullOrEmpty(shortName) && context.Application._shortOptions.ContainsKey(shortName)) + if (!shortName.IsNullOrEmpty() && context.Application._shortOptions.ContainsKey(shortName)) { continue; // Skip - option already registered by parent } - if (!string.IsNullOrEmpty(longName) && context.Application._longOptions.ContainsKey(longName)) + if (!longName.IsNullOrEmpty() && context.Application._longOptions.ContainsKey(longName)) { continue; // Skip - option already registered by parent } // Track this option - if (!string.IsNullOrEmpty(shortName)) + if (!shortName.IsNullOrEmpty()) { addedShortOptions[shortName] = optMeta; } - if (!string.IsNullOrEmpty(longName)) + if (!longName.IsNullOrEmpty()) { addedLongOptions[longName] = optMeta; } @@ -74,18 +75,18 @@ private static (string template, string? shortName, string? longName) GetOptionN string? shortName = meta.ShortName; string? longName = meta.LongName; - if (string.IsNullOrEmpty(template)) + if (template.IsNullOrEmpty()) { // Build template from ShortName/LongName - if (!string.IsNullOrEmpty(shortName) && !string.IsNullOrEmpty(longName)) + if (!shortName.IsNullOrEmpty() && !longName.IsNullOrEmpty()) { template = $"-{shortName}|--{longName}"; } - else if (!string.IsNullOrEmpty(longName)) + else if (!longName.IsNullOrEmpty()) { template = $"--{longName}"; } - else if (!string.IsNullOrEmpty(shortName)) + else if (!shortName.IsNullOrEmpty()) { template = $"-{shortName}"; } @@ -99,17 +100,17 @@ private static (string template, string? shortName, string? longName) GetOptionN else { // Parse short/long names from template if not already set - if (string.IsNullOrEmpty(shortName) || string.IsNullOrEmpty(longName)) + if (shortName.IsNullOrEmpty() || longName.IsNullOrEmpty()) { var parts = template.Split('|'); foreach (var part in parts) { var trimmed = part.Trim(); - if (trimmed.StartsWith("--") && string.IsNullOrEmpty(longName)) + if (trimmed.StartsWith("--") && longName.IsNullOrEmpty()) { longName = trimmed.Substring(2).Split(' ', '<', ':', '=')[0]; } - else if (trimmed.StartsWith("-") && string.IsNullOrEmpty(shortName)) + else if (trimmed.StartsWith("-") && shortName.IsNullOrEmpty()) { shortName = trimmed.Substring(1).Split(' ', '<', ':', '=')[0]; } @@ -175,12 +176,12 @@ private void AddOptionFromMetadata(ConventionContext context, CommandOption opti } // Register names for duplicate checking - if (!string.IsNullOrEmpty(option.ShortName)) + if (!option.ShortName.IsNullOrEmpty()) { context.Application._shortOptions.TryAdd(option.ShortName, null!); } - if (!string.IsNullOrEmpty(option.LongName)) + if (!option.LongName.IsNullOrEmpty()) { context.Application._longOptions.TryAdd(option.LongName, null!); } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs index d9c484a5..2e383e4f 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs @@ -6,6 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.Validation; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -38,7 +39,7 @@ private protected void AddOption(ConventionContext context, CommandOption option throw new InvalidOperationException(Strings.NoValueTypesMustBeBoolean); } - if (!string.IsNullOrEmpty(option.ShortName)) + if (!option.ShortName.IsNullOrEmpty()) { if (context.Application._shortOptions.TryGetValue(option.ShortName, out var otherProp)) { @@ -53,7 +54,7 @@ private protected void AddOption(ConventionContext context, CommandOption option context.Application._shortOptions.Add(option.ShortName, prop); } - if (!string.IsNullOrEmpty(option.LongName)) + if (!option.LongName.IsNullOrEmpty()) { if (context.Application._longOptions.TryGetValue(option.LongName, out var otherProp)) { diff --git a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs index f615fc1a..d005eb45 100644 --- a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs @@ -6,6 +6,7 @@ using System.Reflection; using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Errors; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -46,7 +47,7 @@ public virtual void Apply(ConventionContext context) private static string GetSubcommandName(Type subcommandType, ICommandMetadataProvider provider) { var commandInfo = provider.CommandInfo; - if (!string.IsNullOrEmpty(commandInfo?.Name)) + if (!(commandInfo?.Name).IsNullOrEmpty()) { // Use the explicit name as-is return commandInfo.Name; @@ -58,7 +59,10 @@ private static string GetSubcommandName(Type subcommandType, ICommandMetadataPro private void AddSubcommandFromMetadata( ConventionContext context, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type subcommandType, +#if NET6_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] +#endif + Type subcommandType, ICommandMetadataProvider provider, string name) { diff --git a/src/CommandLineUtils/Extensions/DictionaryExtensions.cs b/src/CommandLineUtils/Extensions/DictionaryExtensions.cs new file mode 100644 index 00000000..86a64785 --- /dev/null +++ b/src/CommandLineUtils/Extensions/DictionaryExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.Extensions +{ + internal static class DictionaryExtensions + { +#if !NET6_0_OR_GREATER + public static bool TryAdd(this IDictionary dictionary, TKey key, TValue value) + { + if (dictionary.ContainsKey(key)) + { + return false; + } + dictionary.Add(key, value); + return true; + } +#endif + } +} diff --git a/src/CommandLineUtils/Extensions/StringExtensions.cs b/src/CommandLineUtils/Extensions/StringExtensions.cs new file mode 100644 index 00000000..fb128474 --- /dev/null +++ b/src/CommandLineUtils/Extensions/StringExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics.CodeAnalysis; + +namespace McMaster.Extensions.CommandLineUtils.Extensions +{ + internal static class StringExtensions + { + /// + /// A wrapper around that allows proper nullability annotation. + /// This is a workaround because .NET Framework assemblies are not nullability annotated. + /// + public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value) => string.IsNullOrEmpty(value); + /// + /// A wrapper around that allows proper nullability annotation. + /// This is a workaround because .NET Framework assemblies are not nullability annotated. + /// + public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value) => string.IsNullOrWhiteSpace(value); + } +} diff --git a/src/CommandLineUtils/HelpText/HangingIndentWriter.cs b/src/CommandLineUtils/HelpText/HangingIndentWriter.cs index 7ad7b818..79c4ad84 100644 --- a/src/CommandLineUtils/HelpText/HangingIndentWriter.cs +++ b/src/CommandLineUtils/HelpText/HangingIndentWriter.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Text; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils.HelpText { @@ -46,7 +47,7 @@ public HangingIndentWriter(int indentSize, int? maxLineLength = null, bool inden /// Dynamically wrapped description with explicit newlines preserved. public string Write(string? input) { - if (string.IsNullOrWhiteSpace(input)) + if (input.IsNullOrWhiteSpace()) { return string.Empty; } diff --git a/src/CommandLineUtils/Internal/CommandLineProcessor.cs b/src/CommandLineUtils/Internal/CommandLineProcessor.cs index 4efa9701..3d33fbf8 100644 --- a/src/CommandLineUtils/Internal/CommandLineProcessor.cs +++ b/src/CommandLineUtils/Internal/CommandLineProcessor.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using McMaster.Extensions.CommandLineUtils.Abstractions; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -325,7 +326,7 @@ private bool ProcessUnexpectedArg(string argTypeName, string? argValue = null) var suggestions = Enumerable.Empty(); - if (_currentCommand.MakeSuggestionsInErrorMessage && !string.IsNullOrEmpty(value)) + if (_currentCommand.MakeSuggestionsInErrorMessage && !value.IsNullOrEmpty()) { suggestions = SuggestionCreator.GetTopSuggestions(_currentCommand, value); } diff --git a/src/CommandLineUtils/Internal/ReflectionHelper.cs b/src/CommandLineUtils/Internal/ReflectionHelper.cs index 3567560a..9f5a25f1 100644 --- a/src/CommandLineUtils/Internal/ReflectionHelper.cs +++ b/src/CommandLineUtils/Internal/ReflectionHelper.cs @@ -188,12 +188,37 @@ public bool Equals(MethodInfo? x, MethodInfo? y) return true; } - return x != null && y != null && x.HasSameMetadataDefinitionAs(y); + if (x == null || y == null) + { + return false; + } + +#if NET_6_0_OR_GREATER + return x.HasSameMetadataDefinitionAs(y); +#else + return x.MetadataToken == y.MetadataToken && x.Module.Equals(y.Module); +#endif } public int GetHashCode(MethodInfo obj) { +#if NET_6_0_OR_GREATER return obj.HasMetadataToken() ? obj.GetMetadataToken().GetHashCode() : 0; +#else + // see https://github.com/dotnet/dotnet/blob/b0f34d51fccc69fd334253924abd8d6853fad7aa/src/runtime/src/libraries/System.Reflection.TypeExtensions/src/System/Reflection/TypeExtensions.cs#L496 + int token = obj.MetadataToken; + + // Tokens have MSB = table index, 3 LSBs = row index + // row index of 0 is a nil token + const int rowMask = 0x00FFFFFF; + if ((token & rowMask) == 0) + { + // Nil token is returned for edge cases like typeof(byte[]).MetadataToken. + return 0; + } + + return token; +#endif } } diff --git a/src/CommandLineUtils/Internal/SuggestionCreator.cs b/src/CommandLineUtils/Internal/SuggestionCreator.cs index 95e78b48..1344035e 100644 --- a/src/CommandLineUtils/Internal/SuggestionCreator.cs +++ b/src/CommandLineUtils/Internal/SuggestionCreator.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -45,17 +46,17 @@ private static IEnumerable GetCandidates(CommandLineApplication command) foreach (var option in command.GetOptions().Where(o => o.ShowInHelpText)) { - if (!string.IsNullOrEmpty(option.LongName)) + if (!option.LongName.IsNullOrEmpty()) { yield return option.LongName; } - if (!string.IsNullOrEmpty(option.ShortName)) + if (!option.ShortName.IsNullOrEmpty()) { yield return option.ShortName; } - if (!string.IsNullOrEmpty(option.SymbolName)) + if (!option.SymbolName.IsNullOrEmpty()) { yield return option.SymbolName; } diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index ac9b2874..486edf2c 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -1,7 +1,7 @@ - + - net8.0 + netstandard2.0;net8.0 true true Command-line parsing API. @@ -30,27 +30,27 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + - + - + - + - + \ No newline at end of file diff --git a/src/CommandLineUtils/Properties/NullabilityHelpers.cs b/src/CommandLineUtils/Properties/NullabilityHelpers.cs index 2ba73d2a..aa27683e 100644 --- a/src/CommandLineUtils/Properties/NullabilityHelpers.cs +++ b/src/CommandLineUtils/Properties/NullabilityHelpers.cs @@ -15,7 +15,7 @@ public NotNullWhenAttribute(bool returnValue) } // https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.allownullattribute - [System.AttributeUsage(System.AttributeTargets.Field | System.AttributeTargets.Parameter | System.AttributeTargets.Property, Inherited=false)] + [System.AttributeUsage(System.AttributeTargets.Field | System.AttributeTargets.Parameter | System.AttributeTargets.Property, Inherited = false)] internal sealed class AllowNullAttribute : Attribute { } } #endif diff --git a/src/CommandLineUtils/Properties/Strings.cs b/src/CommandLineUtils/Properties/Strings.cs index 34fd28d9..d1611363 100644 --- a/src/CommandLineUtils/Properties/Strings.cs +++ b/src/CommandLineUtils/Properties/Strings.cs @@ -64,7 +64,7 @@ public static string BothOptionAndHelpOptionAttributesCannotBeSpecified(Property public static string BothOptionAndVersionOptionAttributesCannotBeSpecified(PropertyInfo prop) => $"Cannot specify both {nameof(OptionAttribute)} and {nameof(VersionOptionAttribute)} on property {prop.DeclaringType?.Name}.{prop.Name}."; - internal static string UnsupportedParameterTypeOnMethod(string methodName, ParameterInfo methodParam) + internal static string UnsupportedParameterTypeOnMethod(string? methodName, ParameterInfo methodParam) => $"Unsupported type on {methodName} '{methodParam.ParameterType.FullName}' on parameter {methodParam.Name}."; public static string BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(PropertyInfo prop) diff --git a/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs index 2ec3c8d9..d9953417 100644 --- a/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs +++ b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs @@ -11,7 +11,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Model factory that uses Activator.CreateInstance or DI with constructor injection. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses Activator.CreateInstance or DI with constructor injection")] +#endif internal sealed class ActivatorModelFactory : IModelFactory { private readonly Type _modelType; diff --git a/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs index 302a4a4d..a2ca3943 100644 --- a/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs +++ b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs @@ -30,7 +30,9 @@ private DefaultMetadataResolver() /// For full AOT compatibility, ensure the CommandLineUtils.Generators package is referenced /// and the source generator runs during compilation. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] +#endif public ICommandMetadataProvider GetProvider(Type modelType) { // Check for generated metadata first (AOT-safe path) @@ -50,7 +52,9 @@ public ICommandMetadataProvider GetProvider(Type modelType) /// For full AOT compatibility, ensure the CommandLineUtils.Generators package is referenced /// and the source generator runs during compilation. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] +#endif public ICommandMetadataProvider GetProvider() where TModel : class { // Check for generated metadata first (AOT-safe path) @@ -78,7 +82,9 @@ public bool HasGeneratedMetadata(Type modelType) return CommandMetadataRegistry.HasMetadata(modelType); } +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to analyze the model type")] +#endif private static ICommandMetadataProvider CreateReflectionProvider(Type modelType) { // This creates a reflection-based implementation of ICommandMetadataProvider diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs index 7ed1237c..8e7ae1fd 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs @@ -12,7 +12,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Execute handler that uses reflection to invoke OnExecute/OnExecuteAsync. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#endif internal sealed class ReflectionExecuteHandler : IExecuteHandler { private readonly MethodInfo _method; diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs index e5b26f7d..7dfbc8ff 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils.SourceGeneration { @@ -15,7 +16,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// Provides command metadata by analyzing a type using reflection. /// This is the fallback when generated metadata is not available. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to analyze the model type")] +#endif internal sealed class ReflectionMetadataProvider : ICommandMetadataProvider { private const BindingFlags MethodLookup = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; @@ -442,7 +445,7 @@ private IReadOnlyList ExtractSubcommands() { Func? versionGetter = null; - if (!string.IsNullOrEmpty(fromMemberAttr.MemberName)) + if (!fromMemberAttr.MemberName.IsNullOrEmpty()) { var members = ReflectionHelper.GetPropertyOrMethod(_modelType, fromMemberAttr.MemberName); if (members.Length > 0) diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs index bb473e9f..359711d0 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs @@ -12,7 +12,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Validate handler that uses reflection to invoke OnValidate. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#endif internal sealed class ReflectionValidateHandler : IValidateHandler { private readonly MethodInfo _method; diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs index 0619433f..2791d181 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs @@ -11,7 +11,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Validation error handler that uses reflection to invoke OnValidationError. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#endif internal sealed class ReflectionValidationErrorHandler : IValidationErrorHandler { private readonly MethodInfo _method; diff --git a/src/CommandLineUtils/Utilities/DotNetExe.cs b/src/CommandLineUtils/Utilities/DotNetExe.cs index e7424021..72395afb 100644 --- a/src/CommandLineUtils/Utilities/DotNetExe.cs +++ b/src/CommandLineUtils/Utilities/DotNetExe.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -49,7 +50,7 @@ public static string FullPathOrDefault() } var mainModule = Process.GetCurrentProcess().MainModule; - if (!string.IsNullOrEmpty(mainModule?.FileName) + if (!(mainModule?.FileName).IsNullOrEmpty() && Path.GetFileName(mainModule.FileName).Equals(fileName, StringComparison.OrdinalIgnoreCase)) { return mainModule.FileName; diff --git a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj index 42eafd8a..fff468e3 100644 --- a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj +++ b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj @@ -1,7 +1,7 @@  - net8.0 + netstandard2.0;net8.0 true true Provides command-line parsing API integration with the generic host API (Microsoft.Extensions.Hosting). @@ -15,6 +15,14 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs b/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs index a3e49d34..77b7d19d 100644 --- a/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs +++ b/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs @@ -16,7 +16,7 @@ public void ItSetsAppNameToEntryAssemblyIfNotSpecified() return; } - var expected = Assembly.GetEntryAssembly().GetName().Name; + var expected = Assembly.GetEntryAssembly()?.GetName().Name; var app = new CommandLineApplication(); app.Conventions.SetAppNameFromEntryAssembly(); Assert.Equal(expected, app.Name); diff --git a/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs b/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs index 7bf5aa87..71926578 100644 --- a/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs +++ b/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Linq; +using System.Reflection; using Xunit; using Xunit.Abstractions; @@ -43,8 +43,8 @@ public void ThrowsWhenDuplicateArgumentPositionsAreSpecified() Assert.Equal( Strings.DuplicateArgumentPosition( 0, - typeof(DuplicateArguments).GetProperty("AlsoFirst"), - typeof(DuplicateArguments).GetProperty("First")), + Assert.IsAssignableFrom(typeof(DuplicateArguments).GetProperty("AlsoFirst")), + Assert.IsAssignableFrom(typeof(DuplicateArguments).GetProperty("First"))), ex.Message); } diff --git a/test/CommandLineUtils.Tests/AttributeValidatorTests.cs b/test/CommandLineUtils.Tests/AttributeValidatorTests.cs index 2cfad77f..99febd92 100644 --- a/test/CommandLineUtils.Tests/AttributeValidatorTests.cs +++ b/test/CommandLineUtils.Tests/AttributeValidatorTests.cs @@ -39,7 +39,7 @@ public void ItOnlyInvokesAttributeIfValueExists() [InlineData(typeof(PhoneAttribute), "(800) 555-5555", "xyz")] public void ItExecutesValidationAttribute(Type attributeType, string validValue, string invalidValue) { - var attr = (ValidationAttribute)Activator.CreateInstance(attributeType); + var attr = Assert.IsAssignableFrom(Activator.CreateInstance(attributeType)); var app = new CommandLineApplication(); var arg = app.Argument("arg", "arg"); var validator = new AttributeValidator(attr); @@ -53,7 +53,7 @@ public void ItExecutesValidationAttribute(Type attributeType, string validValue, arg.Reset(); arg.TryParse(invalidValue); var result = validator.GetValidationResult(arg, context); - Assert.NotNull(result); + Assert.NotNull(result?.ErrorMessage); Assert.NotEmpty(result.ErrorMessage); } @@ -61,7 +61,7 @@ public void ItExecutesValidationAttribute(Type attributeType, string validValue, [InlineData(typeof(ClassLevelValidationAttribute), "good", "also good", "bad", "also bad")] public void ItExecutesClassLevelValidationAttribute(Type attributeType, string validProp1Value, string validProp2Value, string invalidProp1Value, string invalidProp2Value) { - var attr = (ValidationAttribute)Activator.CreateInstance(attributeType); + var attr = Assert.IsAssignableFrom(Activator.CreateInstance(attributeType)); var app = new CommandLineApplication(); var validator = new AttributeValidator(attr); var factory = new CommandLineValidationContextFactory(app); @@ -76,7 +76,7 @@ public void ItExecutesClassLevelValidationAttribute(Type attributeType, string v app.Model.Arg2 = invalidProp2Value; var result = validator.GetValidationResult(app, context); - Assert.NotNull(result); + Assert.NotNull(result?.ErrorMessage); Assert.NotEmpty(result.ErrorMessage); } @@ -96,7 +96,7 @@ private void OnExecute() { } [InlineData("email@example.com", 0)] public void ValidatesEmailArgument(string? email, int exitCode) { - Assert.Equal(exitCode, CommandLineApplication.Execute(new TestConsole(_output), email)); + Assert.Equal(exitCode, CommandLineApplication.Execute(new TestConsole(_output), email!)); } private class OptionBuilderApp : CommandLineApplication @@ -165,7 +165,7 @@ public void ValidatesAttributesOnOption(string[] args, int exitCode) private sealed class ThrowingValidationAttribute : ValidationAttribute { - public override bool IsValid(object value) + public override bool IsValid(object? value) { throw new InvalidOperationException(); } @@ -182,7 +182,7 @@ private sealed class ClassLevelValidationApp [AttributeUsage(AttributeTargets.Class)] private sealed class ClassLevelValidationAttribute : ValidationAttribute { - public override bool IsValid(object value) + public override bool IsValid(object? value) => value is ClassLevelValidationApp app && app.Arg1 != null && app.Arg1.Contains("good") && app.Arg2 != null && app.Arg2.Contains("good"); @@ -191,7 +191,7 @@ public override bool IsValid(object value) [AttributeUsage(AttributeTargets.Property)] private sealed class ModeValidationAttribute : ValidationAttribute { - public override bool IsValid(object value) + public override bool IsValid(object? value) { return value is string text && text.Contains("mode"); } diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs index 99b6cbfd..02f30d1b 100755 --- a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs @@ -158,8 +158,8 @@ public void ThrowsForUnknownOnExecuteTypes() var ex = Assert.Throws( () => CommandLineApplication.Execute()); var method = typeof(ExecuteWithUnknownTypes).GetMethod("OnExecute", BindingFlags.Instance | BindingFlags.NonPublic); - var param = Assert.Single(method.GetParameters()); - Assert.Equal(Strings.UnsupportedParameterTypeOnMethod(method.Name, param), ex.Message); + var param = Assert.Single(method?.GetParameters() ?? []); + Assert.Equal(Strings.UnsupportedParameterTypeOnMethod(method?.Name, param), ex.Message); } private class ExecuteAsyncWithInt @@ -312,7 +312,7 @@ public void Dispose() [Command("sub")] private class Subcommand { - public DisposableParentCommand Parent { get; } + public DisposableParentCommand? Parent { get; } public void OnExecute() { diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs index c7d3d5c8..717ab5ed 100644 --- a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs @@ -492,7 +492,7 @@ public void AllowNoThrowBehaviorOnUnexpectedOptionAfterSubcommand() // (does not throw) app.Execute("k", "run", unexpectedOption); Assert.Empty(testCmd.RemainingArguments); - var arg = Assert.Single(subCmd?.RemainingArguments); + var arg = Assert.Single(subCmd?.RemainingArguments ?? []); Assert.Equal(unexpectedOption, arg); } @@ -697,9 +697,9 @@ public void NestedInheritedOptions() Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "nest1"); Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "global"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest2"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest1"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "global"); + Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "nest2"); + Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "nest1"); + Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "global"); Assert.ThrowsAny(() => app.Execute("--nest2", "N2", "--nest1", "N1", "-g", "G")); Assert.ThrowsAny(() => app.Execute("lvl1", "--nest2", "N2", "--nest1", "N1", "-g", "G")); @@ -1051,7 +1051,7 @@ public void ThrowsExceptionOnInvalidArgument(string? inputOption) { var app = new CommandLineApplication(); - var exception = Assert.ThrowsAny(() => app.Execute(inputOption)); + var exception = Assert.ThrowsAny(() => app.Execute(inputOption!)); Assert.Equal($"Unrecognized command or argument '{inputOption}'", exception.Message); } diff --git a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs index 0cd119f2..2f6692cd 100644 --- a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs +++ b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.ComponentModel.DataAnnotations; +using System.Linq; using Xunit; namespace McMaster.Extensions.CommandLineUtils.Tests @@ -12,7 +13,7 @@ public class CustomValidationAttributeTest [InlineData(null)] [InlineData("-c", "red")] [InlineData("-c", "blue")] - public void CustomValidationAttributePasses(params string?[] args) + public void CustomValidationAttributePasses(params string[]? args) { var app = new CommandLineApplication(); app.Conventions.UseDefaultConventions(); @@ -34,7 +35,7 @@ public void CustomValidationAttributeFails(params string?[] args) { var app = new CommandLineApplication(); app.Conventions.UseAttributes(); - var result = app.Parse(args); + var result = app.Parse(args.Select(a => a!).ToArray()); var validationResult = result.SelectedCommand.GetValidationResult(); Assert.NotEqual(ValidationResult.Success, validationResult); var program = Assert.IsType>(result.SelectedCommand); @@ -43,7 +44,7 @@ public void CustomValidationAttributeFails(params string?[] args) { Assert.Equal(args[1], app.Model.Color); } - Assert.Equal("The value for --color must be 'red' or 'blue'", validationResult.ErrorMessage); + Assert.Equal("The value for --color must be 'red' or 'blue'", validationResult?.ErrorMessage); } private class RedBlueProgram diff --git a/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs b/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs index 7f46adf4..e2084052 100644 --- a/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs +++ b/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs @@ -79,7 +79,7 @@ public void DoesNotOrderCommandsByName() Assert.True(indexOfA > indexOfB); } - private string GetHelpText(CommandLineApplication app, DefaultHelpTextGenerator generator, string helpOption = null) + private string GetHelpText(CommandLineApplication app, DefaultHelpTextGenerator generator, string? helpOption = null) { var sb = new StringBuilder(); app.Out = new StringWriter(sb); @@ -90,7 +90,7 @@ private string GetHelpText(CommandLineApplication app, DefaultHelpTextGenerator return helpText; } - private string GetHelpText(CommandLineApplication app, string helpOption = null) + private string GetHelpText(CommandLineApplication app, string? helpOption = null) { var generator = new DefaultHelpTextGenerator { @@ -232,12 +232,12 @@ SomeNullableEnumArgument nullable enum arg desc. public class MyApp { [Option(ShortName = "strOpt", ValueName = "STR_OPT", Description = "str option desc.")] - public string strOpt { get; set; } + public string? strOpt { get; set; } [Option(ShortName = "rStrOpt", ValueName = "STR_OPT", Description = "restricted str option desc.")] [Required] [AllowedValues("Foo", "Bar")] - public string rStrOpt { get; set; } + public string? rStrOpt { get; set; } [Option(ShortName = "dStrOpt", ValueName = "STR_OPT", Description = "str option with default value desc.")] public string dStrOpt { get; set; } = "Foo"; @@ -265,12 +265,12 @@ public class MyApp public SomeEnum Verb5 { get; set; } [Argument(0, Description = "string arg desc.")] - public string SomeStringArgument { get; set; } + public string? SomeStringArgument { get; set; } [Argument(1, Description = "restricted string arg desc.")] [Required] [AllowedValues("Foo", "Bar")] - public string RestrictedStringArgument { get; set; } + public string? RestrictedStringArgument { get; set; } [Argument(2, Description = "string arg with default value desc.")] public string DefaultValStringArgument { get; set; } = "Foo"; diff --git a/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs b/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs index 63fd189c..22f06da0 100644 --- a/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs +++ b/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs @@ -45,7 +45,7 @@ public void ValidatesFilesMustExist(string? filePath) .GetValidationResult(); Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal($"The file path '{filePath}' does not exist.", result.ErrorMessage); + Assert.Equal($"The file path '{filePath}' does not exist.", result?.ErrorMessage); var console = new TestConsole(_output); Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath!)); @@ -95,7 +95,7 @@ public void ValidatesFilesRelativeToAppContext() Assert.Equal(ValidationResult.Success, success); Assert.NotEqual(ValidationResult.Success, fails); - Assert.Equal("The file path 'exists.txt' does not exist.", fails.ErrorMessage); + Assert.Equal("The file path 'exists.txt' does not exist.", fails?.ErrorMessage); var console = new TestConsole(_output); var context = new DefaultCommandLineContext(console, appNotInBaseDir.WorkingDirectory, new[] { "exists.txt" }); diff --git a/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs b/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs index 79fff038..9a0374c4 100644 --- a/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs +++ b/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs @@ -51,7 +51,7 @@ public void ValidatesFilesMustNotExist(string filePath) .GetValidationResult(); Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal($"The file path '{filePath}' already exists.", result.ErrorMessage); + Assert.Equal($"The file path '{filePath}' already exists.", result?.ErrorMessage); var console = new TestConsole(_output); Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath)); @@ -90,7 +90,7 @@ public void ValidatesFilesRelativeToAppContext() .GetValidationResult(); Assert.NotEqual(ValidationResult.Success, fails); - Assert.Equal("The file path 'exists.txt' already exists.", fails.ErrorMessage); + Assert.Equal("The file path 'exists.txt' already exists.", fails?.ErrorMessage); Assert.Equal(ValidationResult.Success, success); diff --git a/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs b/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs index 7e40b5f5..b3fab333 100644 --- a/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Reflection; using System.Text; using Xunit; using Xunit.Abstractions; @@ -90,7 +91,7 @@ public void ThrowsIfMultipleAttributesApplied() { var ex = Assert.Throws(() => new CommandLineApplication().Conventions.UseHelpOptionAttribute()); - var prop = typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsHelpOption)); + var prop = Assert.IsAssignableFrom(typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsHelpOption))); Assert.Equal(Strings.BothOptionAndHelpOptionAttributesCannotBeSpecified(prop), ex.Message); } diff --git a/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs b/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs index 3f2bf4ea..2bd7b02c 100644 --- a/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs +++ b/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs @@ -49,7 +49,7 @@ public void ValidatesLegalFilePaths(string filePath) public void FailsInvalidLegalFilePaths(string? filePath) { var console = new TestConsole(_output); - Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath)); + Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath!)); } } } diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index 317c17cd..b0eab390 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -1,9 +1,11 @@  - net8.0;net10.0 - - annotations + <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + + net8.0;net10.0;net472 + net8.0;net10.0 + enable @@ -27,6 +29,13 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/test/CommandLineUtils.Tests/OptionAttributeTests.cs b/test/CommandLineUtils.Tests/OptionAttributeTests.cs index feb8f475..91416c43 100644 --- a/test/CommandLineUtils.Tests/OptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/OptionAttributeTests.cs @@ -31,7 +31,7 @@ public void ThrowsWhenOptionTypeCannotBeDetermined() var ex = Assert.Throws( () => Create()); Assert.Equal( - Strings.CannotDetermineOptionType(typeof(AppWithUnknownOptionType).GetProperty("Option")), + Strings.CannotDetermineOptionType(Assert.IsAssignableFrom(typeof(AppWithUnknownOptionType).GetProperty("Option"))), ex.Message); } @@ -71,7 +71,7 @@ private class EmptyShortName public void CanSetShortNameToEmptyString() { var app = Create(); - Assert.All(app.Options, o => Assert.Empty(o.ShortName)); + Assert.All(app.Options, o => Assert.Empty(o.ShortName ?? "test")); } private class AmbiguousShortOptionName @@ -91,8 +91,8 @@ public void ThrowsWhenShortOptionNamesAreAmbiguous() Assert.Equal( Strings.OptionNameIsAmbiguous("m", - typeof(AmbiguousShortOptionName).GetProperty("Mode"), - typeof(AmbiguousShortOptionName).GetProperty("Message")), + Assert.IsAssignableFrom(typeof(AmbiguousShortOptionName).GetProperty("Mode")), + Assert.IsAssignableFrom(typeof(AmbiguousShortOptionName).GetProperty("Message"))), ex.Message); } @@ -113,8 +113,8 @@ public void ThrowsWhenLongOptionNamesAreAmbiguous() Assert.Equal( Strings.OptionNameIsAmbiguous("no-edit", - typeof(AmbiguousLongOptionName).GetProperty("NoEdit"), - typeof(AmbiguousLongOptionName).GetProperty("ManuallySetToNoEdit")), + Assert.IsAssignableFrom(typeof(AmbiguousLongOptionName).GetProperty("NoEdit")), + Assert.IsAssignableFrom(typeof(AmbiguousLongOptionName).GetProperty("ManuallySetToNoEdit"))), ex.Message); } @@ -133,7 +133,7 @@ public void ThrowsWhenOptionAndArgumentAreSpecified() Assert.Equal( Strings.BothOptionAndArgumentAttributesCannotBeSpecified( - typeof(BothOptionAndArgument).GetProperty("NotPossible")), + Assert.IsAssignableFrom(typeof(BothOptionAndArgument).GetProperty("NotPossible"))), ex.Message); } @@ -203,7 +203,7 @@ public void KeepsDefaultValues() private class AppWithMultiValueStringOption { [Option("-o1")] - string[] Opt1 { get; } + string[]? Opt1 { get; } [Option("-o2")] string[] Opt2 { get; } = Array.Empty(); @@ -341,12 +341,12 @@ private CommandOption CreateOption(Type propType, string propName) var tb = mb.DefineType("Program"); var pb = tb.DefineProperty(propName, PropertyAttributes.None, propType, Array.Empty()); tb.DefineField($"<{propName}>k__BackingField", propType, FieldAttributes.Private); - var ctor = typeof(OptionAttribute).GetConstructor(Array.Empty()); + var ctor = Assert.IsAssignableFrom(typeof(OptionAttribute).GetConstructor(Array.Empty())); var ab = new CustomAttributeBuilder(ctor, Array.Empty()); pb.SetCustomAttribute(ab); var program = tb.CreateType(); var appBuilder = typeof(CommandLineApplication<>).MakeGenericType(program); - var app = (CommandLineApplication)Activator.CreateInstance(appBuilder, Array.Empty()); + var app = Assert.IsAssignableFrom(Activator.CreateInstance(appBuilder, Array.Empty())); app.Conventions.UseOptionAttributes(); return app.Options.First(); } @@ -465,7 +465,7 @@ public void ApplyingOptionConventionTwice_WithLongOnlyOptions_DoesNotThrow() Assert.Single(app.Options, o => o.LongName == "count"); // Verify short names are empty - Assert.All(app.Options, o => Assert.Empty(o.ShortName)); + Assert.All(app.Options, o => Assert.Empty(o.ShortName ?? "test")); } #endregion diff --git a/test/CommandLineUtils.Tests/ResponseFileTests.cs b/test/CommandLineUtils.Tests/ResponseFileTests.cs index 90621e12..bb5867c9 100644 --- a/test/CommandLineUtils.Tests/ResponseFileTests.cs +++ b/test/CommandLineUtils.Tests/ResponseFileTests.cs @@ -269,7 +269,7 @@ public void SubcommandsCanResponseFileOptions() }); var rspFile = CreateResponseFile(" 'lorem ipsum' ", "dolor sit amet"); app.Execute("save", "@" + rspFile); - Assert.Collection(wordArgs?.Values, + Assert.Collection(wordArgs?.Values ?? [], a => Assert.Equal("lorem ipsum", a), a => Assert.Equal("dolor", a), a => Assert.Equal("sit", a), diff --git a/test/CommandLineUtils.Tests/StringExtensionsTests.cs b/test/CommandLineUtils.Tests/StringExtensionsTests.cs index ce107778..b435ae47 100644 --- a/test/CommandLineUtils.Tests/StringExtensionsTests.cs +++ b/test/CommandLineUtils.Tests/StringExtensionsTests.cs @@ -29,7 +29,7 @@ public class StringExtensionsTests [InlineData("m_Field", "m-field")] public void ToKebabCase(string? input, string? expected) { - Assert.Equal(expected, input.ToKebabCase()); + Assert.Equal(expected, input!.ToKebabCase()); } [Theory] @@ -41,7 +41,7 @@ public void ToKebabCase(string? input, string? expected) [InlineData("MSBuildTask", "MSBUILD_TASK")] public void ToConstantCase(string? input, string? expected) { - Assert.Equal(expected, input.ToConstantCase()); + Assert.Equal(expected, input!.ToConstantCase()); } } } diff --git a/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs b/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs index a37097eb..09609a2f 100644 --- a/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs +++ b/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs @@ -25,7 +25,7 @@ public void ValidatorAddedViaConvention() app.Conventions.UseOnValidateMethodFromModel(); var result = app.GetValidationResult(); Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal("Failed", result.ErrorMessage); + Assert.Equal("Failed", result?.ErrorMessage); } private class ProgramWithBadOnValidate @@ -49,7 +49,7 @@ private class MainValidate [Option] public int? Middle { get; } - private ValidationResult OnValidate(ValidationContext context, CommandLineContext appContext) + private ValidationResult? OnValidate(ValidationContext context, CommandLineContext appContext) { if (this.Middle.HasValue && this.Middle < 0) { @@ -71,7 +71,7 @@ private class SubcommandValidate [Option] public int End { get; private set; } = Int32.MaxValue; - private ValidationResult OnValidate(ValidationContext context, CommandLineContext appContext) + private ValidationResult? OnValidate(ValidationContext context, CommandLineContext appContext) { if (this.Start >= this.End) { @@ -119,7 +119,7 @@ public void ValidatorShouldGetDeserializedModelInSubcommands(string args, string else { Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal(error, result.ErrorMessage); + Assert.Equal(error, result?.ErrorMessage); } } } diff --git a/test/CommandLineUtils.Tests/ValidationTests.cs b/test/CommandLineUtils.Tests/ValidationTests.cs index b4cfee74..1797c971 100644 --- a/test/CommandLineUtils.Tests/ValidationTests.cs +++ b/test/CommandLineUtils.Tests/ValidationTests.cs @@ -50,7 +50,7 @@ public void ValidatorInvoked() app.OnValidate(_ => { called = true; - return ValidationResult.Success; + return ValidationResult.Success!; }); Assert.Equal(0, app.Execute()); Assert.True(called); diff --git a/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs b/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs index 77edfb54..a5295fae 100644 --- a/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs +++ b/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs @@ -144,7 +144,7 @@ public void DefaultCultureCanBeChanged(string property, string test, string cult app.Conventions.UseAttributes(); app.Parse(test); - var actual = (DateTimeOffset)typeof(DateParserProgram).GetProperty(property).GetMethod.Invoke(app.Model, null); + var actual = Assert.IsAssignableFrom(typeof(DateParserProgram).GetProperty(property)?.GetMethod?.Invoke(app.Model, null)); Assert.Equal(expected, actual); } diff --git a/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs b/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs index 2afe5da4..b5b7b54d 100644 --- a/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Reflection; using Xunit; using Xunit.Abstractions; @@ -88,7 +89,7 @@ public void ThrowsIfMultipleAttributesApplied() { var ex = Assert.Throws(() => new CommandLineApplication().Conventions.UseVersionOptionAttribute()); - var prop = typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsVersionOption)); + var prop = Assert.IsAssignableFrom(typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsVersionOption))); Assert.Equal(Strings.BothOptionAndVersionOptionAttributesCannotBeSpecified(prop), ex.Message); } @@ -104,8 +105,7 @@ public void ThrowsIfHelpAndVersionAttributesApplied() { var ex = Assert.Throws(() => new CommandLineApplication().Conventions.UseVersionOptionAttribute()); - var prop = typeof(DuplicateOptionAttributes2).GetProperty(nameof(DuplicateOptionAttributes - .IsVersionOption)); + var prop = Assert.IsAssignableFrom(typeof(DuplicateOptionAttributes2).GetProperty(nameof(DuplicateOptionAttributes.IsVersionOption))); Assert.Equal(Strings.BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(prop), ex.Message); } diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index 5989a3de..5c925c68 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -1,7 +1,11 @@  - net8.0;net10.0 + <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + + net8.0;net10.0;net472 + net8.0;net10.0 + enable @@ -24,6 +28,13 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + From 01f946ac3fecb522da561801aed562a554b71060 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Sat, 7 Feb 2026 21:24:34 -0800 Subject: [PATCH 2/8] build: Restore test coverage on Windows builds --- .github/workflows/ci.yml | 5 ++-- Directory.Build.props | 7 +++++ build.ps1 | 20 ++++++-------- ...cMaster.Extensions.CommandLineUtils.csproj | 26 +++++++++++-------- ...ster.Extensions.Hosting.CommandLine.csproj | 10 +------ test/.runsettings | 11 ++++++++ ...r.Extensions.CommandLineUtils.Tests.csproj | 14 ++++------ test/Directory.Build.props | 1 + ...xtensions.Hosting.CommandLine.Tests.csproj | 14 ++-------- 9 files changed, 52 insertions(+), 56 deletions(-) create mode 100644 test/.runsettings diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5151e3e2..c5b7c967 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: 10.x - name: Run build script id: build_script - run: ./build.ps1 -ci ${{ matrix.os == 'windows-latest' && '-skipCoverage' || '' }} + run: ./build.ps1 -ci - uses: actions/upload-artifact@v6 if: ${{ matrix.os == 'windows-latest' }} with: @@ -51,7 +51,6 @@ jobs: path: artifacts/ if-no-files-found: error - uses: codecov/codecov-action@v5 - if: ${{ matrix.os != 'windows-latest' }} with: name: unittests-${{ matrix.os }} fail_ci_if_error: true @@ -59,7 +58,7 @@ jobs: release: if: ${{ github.event.inputs.release }} needs: build - runs-on: windows-latest + runs-on: ubuntu-latest permissions: contents: write # for creating GitHub releases id-token: write # for NuGet trusted publishing (OIDC) diff --git a/Directory.Build.props b/Directory.Build.props index 2e972097..f50405be 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -31,6 +31,8 @@ $(WarningsNotAsErrors);1591 true enable + + annotations $(MSBuildThisFileDirectory)src\StrongName.snk true @@ -53,6 +55,11 @@ $(PackageVersion)+$(RepositoryCommit) + + + + + diff --git a/build.ps1 b/build.ps1 index 2343b28a..a0b26fc9 100755 --- a/build.ps1 +++ b/build.ps1 @@ -4,9 +4,7 @@ param( [ValidateSet('Debug', 'Release')] $Configuration = $null, [switch] - $ci, - [switch] - $skipCoverage + $ci ) Set-StrictMode -Version 1 @@ -29,21 +27,19 @@ if ($ci) { $formatArgs += '--check' } -[string[]] $testArgs = @('--no-build', '--configuration', $Configuration) -if (!$skipCoverage) { - $testArgs += "--collect:`"XPlat Code Coverage`"" -} - exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/CommandLineUtils.sln" exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/docs/samples/samples.sln" exec dotnet build --configuration $Configuration '-warnaserror:CS1591' exec dotnet pack --no-build --configuration $Configuration -o $artifacts exec dotnet build --configuration $Configuration "$PSScriptRoot/docs/samples/samples.sln" -if ($skipCoverage) { - exec dotnet test --no-build --configuration $Configuration -} else { - exec dotnet test --no-build --configuration $Configuration --collect:"XPlat Code Coverage" +[string[]] $testArgs = @() +if (-not $IsWindows) { + $testArgs += '-p:TestFullFramework=false' } +exec dotnet test --no-build --configuration $Configuration ` + --collect:"XPlat Code Coverage" ` + @testArgs + write-host -f green 'BUILD SUCCEEDED' diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index 486edf2c..236eb6f2 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net8.0 + net8.0;net472 true true Command-line parsing API. @@ -30,27 +30,31 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + - + - + - + - \ No newline at end of file + diff --git a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj index fff468e3..ec78fa0d 100644 --- a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj +++ b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net8.0 + net8.0;net472 true true Provides command-line parsing API integration with the generic host API (Microsoft.Extensions.Hosting). @@ -15,14 +15,6 @@ - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - diff --git a/test/.runsettings b/test/.runsettings new file mode 100644 index 00000000..29e4ea44 --- /dev/null +++ b/test/.runsettings @@ -0,0 +1,11 @@ + + + + + + True + + diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index b0eab390..bac6e9fc 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -1,11 +1,10 @@  - <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true - - net8.0;net10.0;net472 - net8.0;net10.0 - enable + net8.0;net10.0 + $(TargetFrameworks);net472 + + annotations @@ -30,10 +29,7 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 358cb1a6..ab31da95 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -2,5 +2,6 @@ $(DefaultItemExcludes);TestResults\** + $(MSBuildThisFileDirectory)\.runsettings diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index 5c925c68..4f29a0a9 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -1,11 +1,8 @@  - <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true - - net8.0;net10.0;net472 - net8.0;net10.0 - enable + net8.0;net10.0 + $(TargetFrameworks);net472 @@ -28,13 +25,6 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - From 82906334d84c8fe8bd4335973e40caba9e14b448 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Sat, 7 Feb 2026 21:33:29 -0800 Subject: [PATCH 3/8] fix: Adjust conditional compilation to ensure preprocessor directives on TFM are accurate --- .../Conventions/SubcommandAttributeConvention.cs | 3 +++ src/CommandLineUtils/Internal/ReflectionHelper.cs | 12 ++++++++---- .../SourceGeneration/ActivatorModelFactory.cs | 3 +++ .../SourceGeneration/DefaultMetadataResolver.cs | 9 +++++++++ .../SourceGeneration/ReflectionExecuteHandler.cs | 5 ++++- .../SourceGeneration/ReflectionMetadataProvider.cs | 3 +++ .../SourceGeneration/ReflectionValidateHandler.cs | 3 +++ .../ReflectionValidationErrorHandler.cs | 3 +++ test/CommandLineUtils.Tests/DotNetExeTests.cs | 2 +- 9 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs index d005eb45..a207748e 100644 --- a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs @@ -61,6 +61,9 @@ private void AddSubcommandFromMetadata( ConventionContext context, #if NET6_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif Type subcommandType, ICommandMetadataProvider provider, diff --git a/src/CommandLineUtils/Internal/ReflectionHelper.cs b/src/CommandLineUtils/Internal/ReflectionHelper.cs index 9f5a25f1..2f9cdef5 100644 --- a/src/CommandLineUtils/Internal/ReflectionHelper.cs +++ b/src/CommandLineUtils/Internal/ReflectionHelper.cs @@ -193,18 +193,20 @@ public bool Equals(MethodInfo? x, MethodInfo? y) return false; } -#if NET_6_0_OR_GREATER +#if NET6_0_OR_GREATER return x.HasSameMetadataDefinitionAs(y); -#else +#elif NET472_OR_GREATER return x.MetadataToken == y.MetadataToken && x.Module.Equals(y.Module); +#else +#error Target framework misconfiguration #endif } public int GetHashCode(MethodInfo obj) { -#if NET_6_0_OR_GREATER +#if NET6_0_OR_GREATER return obj.HasMetadataToken() ? obj.GetMetadataToken().GetHashCode() : 0; -#else +#elif NET472_OR_GREATER // see https://github.com/dotnet/dotnet/blob/b0f34d51fccc69fd334253924abd8d6853fad7aa/src/runtime/src/libraries/System.Reflection.TypeExtensions/src/System/Reflection/TypeExtensions.cs#L496 int token = obj.MetadataToken; @@ -218,6 +220,8 @@ public int GetHashCode(MethodInfo obj) } return token; +#else +#error Target framework misconfiguration #endif } } diff --git a/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs index d9953417..b9967031 100644 --- a/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs +++ b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs @@ -13,6 +13,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses Activator.CreateInstance or DI with constructor injection")] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif internal sealed class ActivatorModelFactory : IModelFactory { diff --git a/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs index a2ca3943..1e6f95d9 100644 --- a/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs +++ b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs @@ -32,6 +32,9 @@ private DefaultMetadataResolver() /// #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif public ICommandMetadataProvider GetProvider(Type modelType) { @@ -54,6 +57,9 @@ public ICommandMetadataProvider GetProvider(Type modelType) /// #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif public ICommandMetadataProvider GetProvider() where TModel : class { @@ -84,6 +90,9 @@ public bool HasGeneratedMetadata(Type modelType) #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to analyze the model type")] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif private static ICommandMetadataProvider CreateReflectionProvider(Type modelType) { diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs index 8e7ae1fd..f3e26c38 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs @@ -14,7 +14,10 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] -#endif +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration +#endif internal sealed class ReflectionExecuteHandler : IExecuteHandler { private readonly MethodInfo _method; diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs index 7dfbc8ff..de49150d 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs @@ -18,6 +18,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to analyze the model type")] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif internal sealed class ReflectionMetadataProvider : ICommandMetadataProvider { diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs index 359711d0..3ead1679 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs @@ -14,6 +14,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif internal sealed class ReflectionValidateHandler : IValidateHandler { diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs index 2791d181..68dbf57f 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs @@ -13,6 +13,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#elif NET472_OR_GREATER +#else +#error Target framework misconfiguration #endif internal sealed class ReflectionValidationErrorHandler : IValidationErrorHandler { diff --git a/test/CommandLineUtils.Tests/DotNetExeTests.cs b/test/CommandLineUtils.Tests/DotNetExeTests.cs index 4e3797b3..855122bf 100644 --- a/test/CommandLineUtils.Tests/DotNetExeTests.cs +++ b/test/CommandLineUtils.Tests/DotNetExeTests.cs @@ -22,7 +22,7 @@ public void FindsTheDotNetPath() } } } -#elif NET472 +#elif NET472_OR_GREATER #else #error Update target frameworks #endif From 448ca0605b48ec3be1e2e723dc0d8c05a5f8c85d Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Sat, 7 Feb 2026 21:37:08 -0800 Subject: [PATCH 4/8] cleanup: Revert unneeded changes to string.IsNullOrEmpty checks --- .../CommandLineApplication.cs | 6 ++-- .../Conventions/OptionAttributeConvention.cs | 31 +++++++++---------- .../OptionAttributeConventionBase.cs | 5 ++- .../SubcommandAttributeConvention.cs | 3 +- .../Extensions/StringExtensions.cs | 21 ------------- .../HelpText/HangingIndentWriter.cs | 3 +- .../Internal/CommandLineProcessor.cs | 3 +- .../DictionaryExtensions.cs | 7 +++-- .../Internal/SuggestionCreator.cs | 7 ++--- src/CommandLineUtils/Properties/Strings.cs | 2 +- .../ReflectionMetadataProvider.cs | 3 +- src/CommandLineUtils/Utilities/DotNetExe.cs | 3 +- 12 files changed, 34 insertions(+), 60 deletions(-) delete mode 100644 src/CommandLineUtils/Extensions/StringExtensions.cs rename src/CommandLineUtils/{Extensions => Internal}/DictionaryExtensions.cs (79%) diff --git a/src/CommandLineUtils/CommandLineApplication.cs b/src/CommandLineUtils/CommandLineApplication.cs index a0468cf3..9a9e5bd7 100644 --- a/src/CommandLineUtils/CommandLineApplication.cs +++ b/src/CommandLineUtils/CommandLineApplication.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Reflection; @@ -12,7 +13,6 @@ using System.Threading.Tasks; using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Conventions; -using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.HelpText; using McMaster.Extensions.CommandLineUtils.Internal; @@ -221,7 +221,7 @@ public IEnumerable Names { get { - if (!Name.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(Name)) { yield return Name; } @@ -499,7 +499,7 @@ internal CommandLineApplication AddSubcommand(string name, Type modelType, Sourc private void AssertCommandNameIsUnique(string? name, CommandLineApplication? commandToIgnore) { - if (name.IsNullOrEmpty()) + if (string.IsNullOrEmpty(name)) { return; } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs index e1e6b8e7..5ac89455 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -33,33 +32,33 @@ public virtual void Apply(ConventionContext context) var (template, shortName, longName) = GetOptionNames(optMeta); // Check for same-class conflicts (options in the same provider with conflicting names) - if (!shortName.IsNullOrEmpty() && addedShortOptions.TryGetValue(shortName, out var existingShort)) + if (!string.IsNullOrEmpty(shortName) && addedShortOptions.TryGetValue(shortName, out var existingShort)) { throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(shortName, optMeta.PropertyName, optMeta.DeclaringType, existingShort.PropertyName, existingShort.DeclaringType)); } - if (!longName.IsNullOrEmpty() && addedLongOptions.TryGetValue(longName, out var existingLong)) + if (!string.IsNullOrEmpty(longName) && addedLongOptions.TryGetValue(longName, out var existingLong)) { throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(longName, optMeta.PropertyName, optMeta.DeclaringType, existingLong.PropertyName, existingLong.DeclaringType)); } // Check if option already exists from parent command (inherited options) - if (!shortName.IsNullOrEmpty() && context.Application._shortOptions.ContainsKey(shortName)) + if (!string.IsNullOrEmpty(shortName) && context.Application._shortOptions.ContainsKey(shortName)) { continue; // Skip - option already registered by parent } - if (!longName.IsNullOrEmpty() && context.Application._longOptions.ContainsKey(longName)) + if (!string.IsNullOrEmpty(longName) && context.Application._longOptions.ContainsKey(longName)) { continue; // Skip - option already registered by parent } // Track this option - if (!shortName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(shortName)) { addedShortOptions[shortName] = optMeta; } - if (!longName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(longName)) { addedLongOptions[longName] = optMeta; } @@ -75,18 +74,18 @@ private static (string template, string? shortName, string? longName) GetOptionN string? shortName = meta.ShortName; string? longName = meta.LongName; - if (template.IsNullOrEmpty()) + if (string.IsNullOrEmpty(template)) { // Build template from ShortName/LongName - if (!shortName.IsNullOrEmpty() && !longName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(shortName) && !string.IsNullOrEmpty(longName)) { template = $"-{shortName}|--{longName}"; } - else if (!longName.IsNullOrEmpty()) + else if (!string.IsNullOrEmpty(longName)) { template = $"--{longName}"; } - else if (!shortName.IsNullOrEmpty()) + else if (!string.IsNullOrEmpty(shortName)) { template = $"-{shortName}"; } @@ -100,17 +99,17 @@ private static (string template, string? shortName, string? longName) GetOptionN else { // Parse short/long names from template if not already set - if (shortName.IsNullOrEmpty() || longName.IsNullOrEmpty()) + if (string.IsNullOrEmpty(shortName) || string.IsNullOrEmpty(longName)) { var parts = template.Split('|'); foreach (var part in parts) { var trimmed = part.Trim(); - if (trimmed.StartsWith("--") && longName.IsNullOrEmpty()) + if (trimmed.StartsWith("--") && string.IsNullOrEmpty(longName)) { longName = trimmed.Substring(2).Split(' ', '<', ':', '=')[0]; } - else if (trimmed.StartsWith("-") && shortName.IsNullOrEmpty()) + else if (trimmed.StartsWith("-") && string.IsNullOrEmpty(shortName)) { shortName = trimmed.Substring(1).Split(' ', '<', ':', '=')[0]; } @@ -176,12 +175,12 @@ private void AddOptionFromMetadata(ConventionContext context, CommandOption opti } // Register names for duplicate checking - if (!option.ShortName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(option.ShortName)) { context.Application._shortOptions.TryAdd(option.ShortName, null!); } - if (!option.LongName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(option.LongName)) { context.Application._longOptions.TryAdd(option.LongName, null!); } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs index 2e383e4f..d9c484a5 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs @@ -6,7 +6,6 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; -using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.Validation; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -39,7 +38,7 @@ private protected void AddOption(ConventionContext context, CommandOption option throw new InvalidOperationException(Strings.NoValueTypesMustBeBoolean); } - if (!option.ShortName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(option.ShortName)) { if (context.Application._shortOptions.TryGetValue(option.ShortName, out var otherProp)) { @@ -54,7 +53,7 @@ private protected void AddOption(ConventionContext context, CommandOption option context.Application._shortOptions.Add(option.ShortName, prop); } - if (!option.LongName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(option.LongName)) { if (context.Application._longOptions.TryGetValue(option.LongName, out var otherProp)) { diff --git a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs index a207748e..4c2e5839 100644 --- a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs @@ -6,7 +6,6 @@ using System.Reflection; using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Errors; -using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -47,7 +46,7 @@ public virtual void Apply(ConventionContext context) private static string GetSubcommandName(Type subcommandType, ICommandMetadataProvider provider) { var commandInfo = provider.CommandInfo; - if (!(commandInfo?.Name).IsNullOrEmpty()) + if (!string.IsNullOrEmpty(commandInfo?.Name)) { // Use the explicit name as-is return commandInfo.Name; diff --git a/src/CommandLineUtils/Extensions/StringExtensions.cs b/src/CommandLineUtils/Extensions/StringExtensions.cs deleted file mode 100644 index fb128474..00000000 --- a/src/CommandLineUtils/Extensions/StringExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Nate McMaster. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.Diagnostics.CodeAnalysis; - -namespace McMaster.Extensions.CommandLineUtils.Extensions -{ - internal static class StringExtensions - { - /// - /// A wrapper around that allows proper nullability annotation. - /// This is a workaround because .NET Framework assemblies are not nullability annotated. - /// - public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value) => string.IsNullOrEmpty(value); - /// - /// A wrapper around that allows proper nullability annotation. - /// This is a workaround because .NET Framework assemblies are not nullability annotated. - /// - public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value) => string.IsNullOrWhiteSpace(value); - } -} diff --git a/src/CommandLineUtils/HelpText/HangingIndentWriter.cs b/src/CommandLineUtils/HelpText/HangingIndentWriter.cs index 79c4ad84..7ad7b818 100644 --- a/src/CommandLineUtils/HelpText/HangingIndentWriter.cs +++ b/src/CommandLineUtils/HelpText/HangingIndentWriter.cs @@ -4,7 +4,6 @@ using System; using System.Linq; using System.Text; -using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils.HelpText { @@ -47,7 +46,7 @@ public HangingIndentWriter(int indentSize, int? maxLineLength = null, bool inden /// Dynamically wrapped description with explicit newlines preserved. public string Write(string? input) { - if (input.IsNullOrWhiteSpace()) + if (string.IsNullOrWhiteSpace(input)) { return string.Empty; } diff --git a/src/CommandLineUtils/Internal/CommandLineProcessor.cs b/src/CommandLineUtils/Internal/CommandLineProcessor.cs index 3d33fbf8..4efa9701 100644 --- a/src/CommandLineUtils/Internal/CommandLineProcessor.cs +++ b/src/CommandLineUtils/Internal/CommandLineProcessor.cs @@ -8,7 +8,6 @@ using System.IO; using System.Linq; using McMaster.Extensions.CommandLineUtils.Abstractions; -using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -326,7 +325,7 @@ private bool ProcessUnexpectedArg(string argTypeName, string? argValue = null) var suggestions = Enumerable.Empty(); - if (_currentCommand.MakeSuggestionsInErrorMessage && !value.IsNullOrEmpty()) + if (_currentCommand.MakeSuggestionsInErrorMessage && !string.IsNullOrEmpty(value)) { suggestions = SuggestionCreator.GetTopSuggestions(_currentCommand, value); } diff --git a/src/CommandLineUtils/Extensions/DictionaryExtensions.cs b/src/CommandLineUtils/Internal/DictionaryExtensions.cs similarity index 79% rename from src/CommandLineUtils/Extensions/DictionaryExtensions.cs rename to src/CommandLineUtils/Internal/DictionaryExtensions.cs index 86a64785..5918f826 100644 --- a/src/CommandLineUtils/Extensions/DictionaryExtensions.cs +++ b/src/CommandLineUtils/Internal/DictionaryExtensions.cs @@ -3,11 +3,12 @@ using System.Collections.Generic; -namespace McMaster.Extensions.CommandLineUtils.Extensions +namespace McMaster.Extensions.CommandLineUtils { internal static class DictionaryExtensions { -#if !NET6_0_OR_GREATER +#if NET6_0_OR_GREATER +#elif NET472_OR_GREATER public static bool TryAdd(this IDictionary dictionary, TKey key, TValue value) { if (dictionary.ContainsKey(key)) @@ -17,6 +18,8 @@ public static bool TryAdd(this IDictionary dictionar dictionary.Add(key, value); return true; } +#else +#error Target framework misconfiguration #endif } } diff --git a/src/CommandLineUtils/Internal/SuggestionCreator.cs b/src/CommandLineUtils/Internal/SuggestionCreator.cs index 1344035e..95e78b48 100644 --- a/src/CommandLineUtils/Internal/SuggestionCreator.cs +++ b/src/CommandLineUtils/Internal/SuggestionCreator.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -46,17 +45,17 @@ private static IEnumerable GetCandidates(CommandLineApplication command) foreach (var option in command.GetOptions().Where(o => o.ShowInHelpText)) { - if (!option.LongName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(option.LongName)) { yield return option.LongName; } - if (!option.ShortName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(option.ShortName)) { yield return option.ShortName; } - if (!option.SymbolName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(option.SymbolName)) { yield return option.SymbolName; } diff --git a/src/CommandLineUtils/Properties/Strings.cs b/src/CommandLineUtils/Properties/Strings.cs index d1611363..34fd28d9 100644 --- a/src/CommandLineUtils/Properties/Strings.cs +++ b/src/CommandLineUtils/Properties/Strings.cs @@ -64,7 +64,7 @@ public static string BothOptionAndHelpOptionAttributesCannotBeSpecified(Property public static string BothOptionAndVersionOptionAttributesCannotBeSpecified(PropertyInfo prop) => $"Cannot specify both {nameof(OptionAttribute)} and {nameof(VersionOptionAttribute)} on property {prop.DeclaringType?.Name}.{prop.Name}."; - internal static string UnsupportedParameterTypeOnMethod(string? methodName, ParameterInfo methodParam) + internal static string UnsupportedParameterTypeOnMethod(string methodName, ParameterInfo methodParam) => $"Unsupported type on {methodName} '{methodParam.ParameterType.FullName}' on parameter {methodParam.Name}."; public static string BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(PropertyInfo prop) diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs index de49150d..3659f241 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; -using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils.SourceGeneration { @@ -448,7 +447,7 @@ private IReadOnlyList ExtractSubcommands() { Func? versionGetter = null; - if (!fromMemberAttr.MemberName.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(fromMemberAttr.MemberName)) { var members = ReflectionHelper.GetPropertyOrMethod(_modelType, fromMemberAttr.MemberName); if (members.Length > 0) diff --git a/src/CommandLineUtils/Utilities/DotNetExe.cs b/src/CommandLineUtils/Utilities/DotNetExe.cs index 72395afb..e7424021 100644 --- a/src/CommandLineUtils/Utilities/DotNetExe.cs +++ b/src/CommandLineUtils/Utilities/DotNetExe.cs @@ -7,7 +7,6 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; -using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -50,7 +49,7 @@ public static string FullPathOrDefault() } var mainModule = Process.GetCurrentProcess().MainModule; - if (!(mainModule?.FileName).IsNullOrEmpty() + if (!string.IsNullOrEmpty(mainModule?.FileName) && Path.GetFileName(mainModule.FileName).Equals(fileName, StringComparison.OrdinalIgnoreCase)) { return mainModule.FileName; From daf70d94be3adbfc13684dc207b5346dc10ccc01 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Sat, 7 Feb 2026 21:45:05 -0800 Subject: [PATCH 5/8] revert: Undo changes to test cases which weaken assertions to make nullable compliation pass --- .../CommandLineApplicationExecutorTests.cs | 4 ++-- .../CommandLineUtils.Tests/CommandLineApplicationTests.cs | 8 ++++---- .../CustomValidationAttributeTest.cs | 3 +-- test/CommandLineUtils.Tests/OptionAttributeTests.cs | 4 ++-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs index 02f30d1b..2c12f545 100755 --- a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs @@ -158,8 +158,8 @@ public void ThrowsForUnknownOnExecuteTypes() var ex = Assert.Throws( () => CommandLineApplication.Execute()); var method = typeof(ExecuteWithUnknownTypes).GetMethod("OnExecute", BindingFlags.Instance | BindingFlags.NonPublic); - var param = Assert.Single(method?.GetParameters() ?? []); - Assert.Equal(Strings.UnsupportedParameterTypeOnMethod(method?.Name, param), ex.Message); + var param = Assert.Single(method.GetParameters()); + Assert.Equal(Strings.UnsupportedParameterTypeOnMethod(method.Name, param), ex.Message); } private class ExecuteAsyncWithInt diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs index 717ab5ed..8d9a94ec 100644 --- a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs @@ -492,7 +492,7 @@ public void AllowNoThrowBehaviorOnUnexpectedOptionAfterSubcommand() // (does not throw) app.Execute("k", "run", unexpectedOption); Assert.Empty(testCmd.RemainingArguments); - var arg = Assert.Single(subCmd?.RemainingArguments ?? []); + var arg = Assert.Single(subCmd?.RemainingArguments); Assert.Equal(unexpectedOption, arg); } @@ -697,9 +697,9 @@ public void NestedInheritedOptions() Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "nest1"); Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "global"); - Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "nest2"); - Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "nest1"); - Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "global"); + Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest2"); + Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest1"); + Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "global"); Assert.ThrowsAny(() => app.Execute("--nest2", "N2", "--nest1", "N1", "-g", "G")); Assert.ThrowsAny(() => app.Execute("lvl1", "--nest2", "N2", "--nest1", "N1", "-g", "G")); diff --git a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs index 2f6692cd..35c63b45 100644 --- a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs +++ b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.ComponentModel.DataAnnotations; -using System.Linq; using Xunit; namespace McMaster.Extensions.CommandLineUtils.Tests @@ -35,7 +34,7 @@ public void CustomValidationAttributeFails(params string?[] args) { var app = new CommandLineApplication(); app.Conventions.UseAttributes(); - var result = app.Parse(args.Select(a => a!).ToArray()); + var result = app.Parse(args); var validationResult = result.SelectedCommand.GetValidationResult(); Assert.NotEqual(ValidationResult.Success, validationResult); var program = Assert.IsType>(result.SelectedCommand); diff --git a/test/CommandLineUtils.Tests/OptionAttributeTests.cs b/test/CommandLineUtils.Tests/OptionAttributeTests.cs index 91416c43..2949095b 100644 --- a/test/CommandLineUtils.Tests/OptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/OptionAttributeTests.cs @@ -71,7 +71,7 @@ private class EmptyShortName public void CanSetShortNameToEmptyString() { var app = Create(); - Assert.All(app.Options, o => Assert.Empty(o.ShortName ?? "test")); + Assert.All(app.Options, o => Assert.Empty(o.ShortName)); } private class AmbiguousShortOptionName @@ -465,7 +465,7 @@ public void ApplyingOptionConventionTwice_WithLongOnlyOptions_DoesNotThrow() Assert.Single(app.Options, o => o.LongName == "count"); // Verify short names are empty - Assert.All(app.Options, o => Assert.Empty(o.ShortName ?? "test")); + Assert.All(app.Options, o => Assert.Empty(o.ShortName)); } #endregion From 0431dff7e30b40f783b4be8e13fc094f788c8778 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Sat, 7 Feb 2026 21:50:03 -0800 Subject: [PATCH 6/8] build: Bump package versioning to 5.0.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index f50405be..aa9c9601 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -43,7 +43,7 @@ - 5.0.0 + 5.0.1 beta true $(GITHUB_RUN_NUMBER) From 6bb00eabb098aac343409f7dffd383e4241932dd Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Sat, 7 Feb 2026 21:50:12 -0800 Subject: [PATCH 7/8] docs: Update release notes for 5.0.1 --- .claude/skills/prepare-release/SKILL.md | 37 ++++++++++++++++++------- CHANGELOG.md | 7 +++++ README.md | 12 ++++++++ src/CommandLineUtils/releasenotes.props | 3 ++ 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/.claude/skills/prepare-release/SKILL.md b/.claude/skills/prepare-release/SKILL.md index d22fa5f2..cd24b4fc 100644 --- a/.claude/skills/prepare-release/SKILL.md +++ b/.claude/skills/prepare-release/SKILL.md @@ -23,16 +23,20 @@ Run git commands to analyze commits since last release: - `git log --grep` for PR merges - Look for conventional commit patterns: `fix:`, `feat:`, `break:`, `docs:`, etc. -### 3. Categorize Changes +### 3. Filter and Categorize Changes -Group into categories (in this order): +**Exclude from release notes:** +- Dependabot / automated dependency bump commits (e.g., `chore(deps): Bump ...`). These are routine maintenance and not user-facing. +- CI/tooling-only changes (e.g., updating GitHub Actions workflows, claude workflows) unless they affect the shipped package. + +Group remaining changes into categories (in this order): 1. **Breaking changes** (major versions only) - API removals, behavior changes 2. **Features** - New functionality or APIs 3. **Fixes** - Bug fixes and corrections 4. **Improvements** - Performance or usability enhancements 5. **Docs** - Documentation-only changes -6. **Other** - Infrastructure, tooling, CI/CD +6. **Other** - Infrastructure, tooling, CI/CD (only if user-facing or noteworthy) ### 4. Generate releasenotes.props Entry @@ -57,15 +61,31 @@ Fixes: ``` **Patch versions:** + +Append the patch notes directly at the end of the existing parent version's `StartsWith('X.Y.')` block. Do NOT create a separate conditional block. Add the patch section just before the closing `` tag of the parent version: + ```xml - -$(PackageReleaseNotes) + +...existing X.Y.0 release notes... -X.Y.Z patch: +Updates in X.Y.Z patch: * @user: fix description (#123) ``` +For multiple patches, append each one in order at the end of the same block: + +```xml +...existing X.Y.0 release notes... + +Updates in X.Y.1 patch: +* @user: fix description (#123) + +Updates in X.Y.2 patch: +* @user: another fix (#456) + +``` + ### 5. Generate CHANGELOG.md Entry **Format:** `* [@contributor]: description ([#PR])` @@ -196,10 +216,7 @@ See https://natemcmaster.github.io/CommandLineUtils/vX.0/upgrade-guide.html ### Patch Versions -Use `$(PackageReleaseNotes)` to inherit parent version's notes. - -For first patch (X.Y.1), create new conditional entry after parent. -For subsequent patches, add BEFORE existing patches but AFTER minor version. +Append patch notes directly into the existing parent version's `StartsWith('X.Y.')` block in `releasenotes.props`. Do NOT create a separate conditional block or use `$(PackageReleaseNotes)` inheritance. Each patch gets an "Updates in X.Y.Z patch:" section appended at the end of the parent block. ## Quality Checklist diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ca9a14..5c428df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [v5.0.1](https://github.com/natemcmaster/CommandLineUtils/compare/v5.0.0...v5.0.1) + +### Features +* [@sensslen]: Restore target framework compilation for .NET Framework ([#591]) + +[#591]: https://github.com/natemcmaster/CommandLineUtils/pull/591 + ## [v5.0.0](https://github.com/natemcmaster/CommandLineUtils/compare/v4.1.1...v5.0.0) ### Breaking changes diff --git a/README.md b/README.md index 0e5061a8..bab675ad 100644 --- a/README.md +++ b/README.md @@ -138,3 +138,15 @@ If you need help with this project, please ... This is a fork of [Microsoft.Extensions.CommandLineUtils](https://github.com/aspnet/Common), which was [completely abandoned by Microsoft](https://github.com/aspnet/Common/issues/257). This project [forked in 2017](https://github.com/natemcmaster/CommandLineUtils/commit/f039360e4e51bbf8b8eb6236894b626ec7944cec) and continued to make improvements. From 2017 to 2021, over 30 contributors added new features and fixed bugs. As of 2022, the project has entered maintenance mode, so no major changes are planned. [See this issue for details on latest project status.](https://github.com/natemcmaster/CommandLineUtils/issues/485) This project is not abandoned -- I believe this library provides a stable API and rich feature set good enough for most developers to create command line apps in .NET -- but only the most critical of bugs will be fixed (such as security issues). +## Supported .NET Versions + +Framework | Version | Reason +---------------|---------|-------------------- +`dotnet` | 8.0 | Lowest Microsoft LTS version at time of release. See +.NET Framework | 4.7.2 | Lowest .NET Framework version fully compatible with [.NET Standard 2.0][netstandard-guidance] + +_Why not directly compile for .NET Standard?_ + +Microsoft guidance says ".NET 5 and later versions adopt a different approach to establishing uniformity that eliminates the need for .NET Standard in most scenarios." Compiling for 2 frameworks appears to be sufficient, so we avoid added complexity. + +[netstandard-guidance]: https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-0 diff --git a/src/CommandLineUtils/releasenotes.props b/src/CommandLineUtils/releasenotes.props index 0e5f7489..b857d9fc 100644 --- a/src/CommandLineUtils/releasenotes.props +++ b/src/CommandLineUtils/releasenotes.props @@ -26,6 +26,9 @@ Other: * @natemcmaster: Use NuGet trusted publishing with OIDC * @dependabot: Update GitHub Actions (#568) * @natemcmaster: Upgrade docfx to 2.78.4 + +Updates in 5.0.1 patch: +* @sensslen: Restore target framework compilation for .NET Framework (#591) Changes since 4.0: From cb876639660b01d79c55bbec6d2ba73ce7434454 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Sat, 7 Feb 2026 22:04:47 -0800 Subject: [PATCH 8/8] cleanup: Restore nullable annotations checks in tests and cleanup dead code --- src/CommandLineUtils/IO/Pager.cs | 5 ++++- .../Properties/NullabilityHelpers.cs | 21 ------------------- .../CommandLineApplicationExecutorTests.cs | 3 ++- .../CommandLineApplicationTests.cs | 10 +++++---- .../CustomValidationAttributeTest.cs | 2 +- test/CommandLineUtils.Tests/DotNetExeTests.cs | 2 +- ...r.Extensions.CommandLineUtils.Tests.csproj | 2 -- .../OptionAttributeTests.cs | 4 ++-- 8 files changed, 16 insertions(+), 33 deletions(-) delete mode 100644 src/CommandLineUtils/Properties/NullabilityHelpers.cs diff --git a/src/CommandLineUtils/IO/Pager.cs b/src/CommandLineUtils/IO/Pager.cs index 31559ec4..a1a0307a 100644 --- a/src/CommandLineUtils/IO/Pager.cs +++ b/src/CommandLineUtils/IO/Pager.cs @@ -122,8 +122,11 @@ public void Kill() FileName = "less", Arguments = ArgumentEscaper.EscapeAndConcatenate(args), RedirectStandardInput = true, -#if NET46_OR_GREATER +#if NET472_OR_GREATER UseShellExecute = false, +#elif NET6_0_OR_GREATER +#else +#error Target framework misconfiguration #endif } }; diff --git a/src/CommandLineUtils/Properties/NullabilityHelpers.cs b/src/CommandLineUtils/Properties/NullabilityHelpers.cs deleted file mode 100644 index aa27683e..00000000 --- a/src/CommandLineUtils/Properties/NullabilityHelpers.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Nate McMaster. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -// Files here are for simplify annotations of nullable code and are not functional in .NET Standard 2.0 -#if NETSTANDARD2_0 || NET46_OR_GREATER -namespace System.Diagnostics.CodeAnalysis -{ - // https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.notnullwhenattribute? - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class NotNullWhenAttribute : Attribute - { - public NotNullWhenAttribute(bool returnValue) - { - } - } - - // https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.allownullattribute - [System.AttributeUsage(System.AttributeTargets.Field | System.AttributeTargets.Parameter | System.AttributeTargets.Property, Inherited = false)] - internal sealed class AllowNullAttribute : Attribute { } -} -#endif diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs index 2c12f545..d94d0773 100755 --- a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Nate McMaster. +// Copyright (c) Nate McMaster. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -158,6 +158,7 @@ public void ThrowsForUnknownOnExecuteTypes() var ex = Assert.Throws( () => CommandLineApplication.Execute()); var method = typeof(ExecuteWithUnknownTypes).GetMethod("OnExecute", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); var param = Assert.Single(method.GetParameters()); Assert.Equal(Strings.UnsupportedParameterTypeOnMethod(method.Name, param), ex.Message); } diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs index 8d9a94ec..e8b0013f 100644 --- a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs @@ -492,7 +492,8 @@ public void AllowNoThrowBehaviorOnUnexpectedOptionAfterSubcommand() // (does not throw) app.Execute("k", "run", unexpectedOption); Assert.Empty(testCmd.RemainingArguments); - var arg = Assert.Single(subCmd?.RemainingArguments); + Assert.NotNull(subCmd); + var arg = Assert.Single(subCmd.RemainingArguments); Assert.Equal(unexpectedOption, arg); } @@ -697,9 +698,10 @@ public void NestedInheritedOptions() Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "nest1"); Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "global"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest2"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest1"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "global"); + Assert.NotNull(subcmd2); + Assert.Contains(subcmd2.GetOptions(), o => o.LongName == "nest2"); + Assert.Contains(subcmd2.GetOptions(), o => o.LongName == "nest1"); + Assert.Contains(subcmd2.GetOptions(), o => o.LongName == "global"); Assert.ThrowsAny(() => app.Execute("--nest2", "N2", "--nest1", "N1", "-g", "G")); Assert.ThrowsAny(() => app.Execute("lvl1", "--nest2", "N2", "--nest1", "N1", "-g", "G")); diff --git a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs index 35c63b45..15226d80 100644 --- a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs +++ b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs @@ -34,7 +34,7 @@ public void CustomValidationAttributeFails(params string?[] args) { var app = new CommandLineApplication(); app.Conventions.UseAttributes(); - var result = app.Parse(args); + var result = app.Parse(args!); var validationResult = result.SelectedCommand.GetValidationResult(); Assert.NotEqual(ValidationResult.Success, validationResult); var program = Assert.IsType>(result.SelectedCommand); diff --git a/test/CommandLineUtils.Tests/DotNetExeTests.cs b/test/CommandLineUtils.Tests/DotNetExeTests.cs index 855122bf..14c7cdb0 100644 --- a/test/CommandLineUtils.Tests/DotNetExeTests.cs +++ b/test/CommandLineUtils.Tests/DotNetExeTests.cs @@ -3,7 +3,7 @@ // This file has been modified from the original form. See Notice.txt in the project root for more information. -#if NETCOREAPP3_1_OR_GREATER +#if NET6_0_OR_GREATER using System.IO; using Xunit; diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index bac6e9fc..d40eec30 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -3,8 +3,6 @@ net8.0;net10.0 $(TargetFrameworks);net472 - - annotations diff --git a/test/CommandLineUtils.Tests/OptionAttributeTests.cs b/test/CommandLineUtils.Tests/OptionAttributeTests.cs index 2949095b..beeca0f9 100644 --- a/test/CommandLineUtils.Tests/OptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/OptionAttributeTests.cs @@ -71,7 +71,7 @@ private class EmptyShortName public void CanSetShortNameToEmptyString() { var app = Create(); - Assert.All(app.Options, o => Assert.Empty(o.ShortName)); + Assert.All(app.Options, o => Assert.True(o.ShortName is null or "")); } private class AmbiguousShortOptionName @@ -465,7 +465,7 @@ public void ApplyingOptionConventionTwice_WithLongOnlyOptions_DoesNotThrow() Assert.Single(app.Options, o => o.LongName == "count"); // Verify short names are empty - Assert.All(app.Options, o => Assert.Empty(o.ShortName)); + Assert.All(app.Options, o => Assert.True(o.ShortName is null or "")); } #endregion