From a8f33c8219e2afe9d6d51f65ff546298cf05e3b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:54:11 +0000 Subject: [PATCH] Add SW003: EnumJsonStringEnumConverter analyzer, tests and docs Co-authored-by: cliedeman <3578740+cliedeman@users.noreply.github.com> Agent-Logs-Url: https://github.com/stackworx-dotnet/Stackworx.Analyzers/sessions/8cb492e0-2d54-4781-8a94-e44306b70123 --- ...numJsonStringEnumConverterAnalyzerTests.cs | 94 +++++++++++++ .../EnumJsonStringEnumConverterAnalyzer.cs | 126 ++++++++++++++++++ .../sw003-enum-json-string-enum-converter.md | 97 ++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 Stackworx.Analyzers.Tests/EnumJsonStringEnumConverterAnalyzerTests.cs create mode 100644 Stackworx.Analyzers/EnumJsonStringEnumConverterAnalyzer.cs create mode 100644 docs/docs/rules/sw003-enum-json-string-enum-converter.md diff --git a/Stackworx.Analyzers.Tests/EnumJsonStringEnumConverterAnalyzerTests.cs b/Stackworx.Analyzers.Tests/EnumJsonStringEnumConverterAnalyzerTests.cs new file mode 100644 index 0000000..0d939b7 --- /dev/null +++ b/Stackworx.Analyzers.Tests/EnumJsonStringEnumConverterAnalyzerTests.cs @@ -0,0 +1,94 @@ +namespace Stackworx.Analyzers.Tests; + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Xunit; +using Verifier = + Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier< + Stackworx.Analyzers.EnumJsonStringEnumConverterAnalyzer, + Microsoft.CodeAnalysis.Testing.DefaultVerifier>; + +public class EnumJsonStringEnumConverterAnalyzerTests +{ + [Fact] + public async Task ReportsWarning_EnumWithoutJsonConverterAttribute() + { + const string testCode = @" +using System.Text.Json.Serialization; + +public enum {|#0:Status|} +{ + Active, + Inactive +} +"; + + var expected = Verifier.Diagnostic(EnumJsonStringEnumConverterAnalyzer.Rule) + .WithArguments("Status") + .WithLocation(0) + .WithSeverity(DiagnosticSeverity.Warning); + + await Verifier.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task NoWarning_EnumWithJsonStringEnumConverterAttribute() + { + const string testCode = @" +using System.Text.Json.Serialization; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Status +{ + Active, + Inactive +} +"; + + await Verifier.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task ReportsWarning_EnumWithDifferentJsonConverterAttribute() + { + const string testCode = @" +using System.Text.Json.Serialization; + +public class CustomEnumConverter : JsonConverter +{ + public override int Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + => reader.GetInt32(); + + public override void Write(System.Text.Json.Utf8JsonWriter writer, int value, System.Text.Json.JsonSerializerOptions options) + => writer.WriteNumberValue(value); +} + +[JsonConverter(typeof(CustomEnumConverter))] +public enum {|#0:Status|} +{ + Active, + Inactive +} +"; + + var expected = Verifier.Diagnostic(EnumJsonStringEnumConverterAnalyzer.Rule) + .WithArguments("Status") + .WithLocation(0) + .WithSeverity(DiagnosticSeverity.Warning); + + await Verifier.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task NoWarning_ClassIsNotAnEnum() + { + const string testCode = @" +public class MyClass +{ + public string Name { get; set; } +} +"; + + await Verifier.VerifyAnalyzerAsync(testCode); + } +} diff --git a/Stackworx.Analyzers/EnumJsonStringEnumConverterAnalyzer.cs b/Stackworx.Analyzers/EnumJsonStringEnumConverterAnalyzer.cs new file mode 100644 index 0000000..dc74b75 --- /dev/null +++ b/Stackworx.Analyzers/EnumJsonStringEnumConverterAnalyzer.cs @@ -0,0 +1,126 @@ +namespace Stackworx.Analyzers; + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EnumJsonStringEnumConverterAnalyzer : DiagnosticAnalyzer +{ + private const string DiagnosticId = "SW003"; + private const string Title = "Enum must be annotated with [JsonConverter(typeof(JsonStringEnumConverter))]"; + private const string MessageFormat = "Enum '{0}' must be annotated with [JsonConverter(typeof(JsonStringEnumConverter))] to ensure string serialization"; + private const string Description = "Enums should be annotated with [JsonConverter(typeof(JsonStringEnumConverter))] to ensure they are serialized to their string value instead of their numeric value."; + private const string Category = "Serialization"; + + public static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticId, + title: Title, + messageFormat: MessageFormat, + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Description); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType); + } + + private static void AnalyzeSymbol(SymbolAnalysisContext context) + { + if (context.Symbol is not INamedTypeSymbol typeSymbol) + { + return; + } + + if (typeSymbol.TypeKind != TypeKind.Enum) + { + return; + } + + var jsonConverterAttrType = context.Compilation.GetTypeByMetadataName( + "System.Text.Json.Serialization.JsonConverterAttribute"); + var jsonStringEnumConverterType = context.Compilation.GetTypeByMetadataName( + "System.Text.Json.Serialization.JsonStringEnumConverter"); + // Generic JsonStringEnumConverter introduced in .NET 8 + var jsonStringEnumConverterGenericType = context.Compilation.GetTypeByMetadataName( + "System.Text.Json.Serialization.JsonStringEnumConverter`1"); + + // If System.Text.Json is not referenced, skip analysis + if (jsonConverterAttrType == null || (jsonStringEnumConverterType == null && jsonStringEnumConverterGenericType == null)) + { + return; + } + + if (!HasJsonStringEnumConverterAttribute(typeSymbol, jsonConverterAttrType, jsonStringEnumConverterType, jsonStringEnumConverterGenericType)) + { + var diagnostic = Diagnostic.Create( + Rule, + typeSymbol.Locations.FirstOrDefault(), + typeSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + } + + private static bool HasJsonStringEnumConverterAttribute( + INamedTypeSymbol enumSymbol, + INamedTypeSymbol jsonConverterAttrType, + INamedTypeSymbol? jsonStringEnumConverterType, + INamedTypeSymbol? jsonStringEnumConverterGenericType) + { + foreach (var attribute in enumSymbol.GetAttributes()) + { + if (attribute.AttributeClass == null) + { + continue; + } + + // Check if attribute is or inherits from JsonConverterAttribute + var attrClass = attribute.AttributeClass; + if (!InheritsFrom(attrClass, jsonConverterAttrType)) + { + continue; + } + + // Check if the constructor argument is typeof(JsonStringEnumConverter) + // or typeof(JsonStringEnumConverter). JsonStringEnumConverter is sealed, + // so we use original definition equality rather than an inheritance walk. + if (attribute.ConstructorArguments.Length == 1 && + attribute.ConstructorArguments[0].Value is ITypeSymbol converterType) + { + var originalDef = converterType.OriginalDefinition; + if ((jsonStringEnumConverterType != null && + SymbolEqualityComparer.Default.Equals(originalDef, jsonStringEnumConverterType)) || + (jsonStringEnumConverterGenericType != null && + SymbolEqualityComparer.Default.Equals(originalDef, jsonStringEnumConverterGenericType))) + { + return true; + } + } + } + + return false; + } + + private static bool InheritsFrom(ITypeSymbol type, ITypeSymbol baseType) + { + var current = type; + while (current != null) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, baseType.OriginalDefinition)) + { + return true; + } + + current = current.BaseType; + } + + return false; + } +} diff --git a/docs/docs/rules/sw003-enum-json-string-enum-converter.md b/docs/docs/rules/sw003-enum-json-string-enum-converter.md new file mode 100644 index 0000000..48c018e --- /dev/null +++ b/docs/docs/rules/sw003-enum-json-string-enum-converter.md @@ -0,0 +1,97 @@ +--- +sidebar_position: 4 +--- + +# SW003: Enum must have JsonStringEnumConverter + +## Overview + +**Rule ID:** SW003 +**Category:** Serialization +**Severity:** Warning +**Status:** Enabled by default + +## Description + +This rule ensures that all enums defined in user code are annotated with `[JsonConverter(typeof(JsonStringEnumConverter))]`. Without this attribute, `System.Text.Json` serializes enums as their numeric (integer) values by default, which can lead to brittle APIs and data that is difficult to read or debug. + +## When This Rule Triggers + +The analyzer reports a diagnostic when: +- An enum is declared without the `[JsonConverter(typeof(JsonStringEnumConverter))]` attribute +- `System.Text.Json` is referenced by the project + +## Why This Matters + +### The Problem + +By default, `System.Text.Json` serializes enums as integers: + +```csharp +// ⚠️ Missing attribute - SW003 +public enum Status +{ + Active, // serializes as 0 + Inactive // serializes as 1 +} +``` + +This results in JSON like `{"status": 0}`, which is hard to read, fragile (adding enum members can break consumers), and not self-documenting. + +### The Solution + +Annotating enums with `[JsonConverter(typeof(JsonStringEnumConverter))]` ensures they are serialized as their string names: + +```csharp +// ✅ Correct +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Status +{ + Active, // serializes as "Active" + Inactive // serializes as "Inactive" +} +``` + +This produces `{"status": "Active"}`, which is readable, stable, and self-documenting. + +## How to Fix + +Add the `[JsonConverter(typeof(JsonStringEnumConverter))]` attribute to your enum: + +```csharp +using System.Text.Json.Serialization; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum OrderStatus +{ + Pending, + Processing, + Shipped, + Delivered +} +``` + +## Configuration + +This rule is enabled by default. To disable it, add to your `.editorconfig`: + +```ini +[*.cs] +dotnet_diagnostic.SW003.severity = none +``` + +To change severity to error: + +```ini +[*.cs] +dotnet_diagnostic.SW003.severity = error +``` + +## Related Rules + +- None + +## See Also + +- [JsonStringEnumConverter](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonstringenumconverter) +- [How to serialize and deserialize (marshal and unmarshal) JSON in .NET](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview)