diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9a6693de..6f627ece 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,9 @@ updates: target-branch: "develop" schedule: interval: "weekly" - day: "monday" + day: "friday" + time: "17:00" + timezone: "America/New_York" open-pull-requests-limit: 10 assignees: - "philcarbone" @@ -24,7 +26,9 @@ updates: target-branch: "develop" schedule: interval: "weekly" - day: "monday" + day: "friday" + time: "17:00" + timezone: "America/New_York" open-pull-requests-limit: 5 assignees: - "philcarbone" diff --git a/src/Whizbang.Generators/JsonMessageTypeInfo.cs b/src/Whizbang.Generators/JsonMessageTypeInfo.cs index ffcfca0b..a830171b 100644 --- a/src/Whizbang.Generators/JsonMessageTypeInfo.cs +++ b/src/Whizbang.Generators/JsonMessageTypeInfo.cs @@ -20,7 +20,16 @@ internal sealed record JsonMessageTypeInfo( bool IsSerializable, PropertyInfo[] Properties, bool HasParameterizedConstructor -); +) { + /// + /// Unique identifier derived from fully qualified name, suitable for C# identifiers. + /// Strips "global::" prefix and replaces "." with "_". + /// E.g., "global::MyApp.Commands.StartCommand" becomes "MyApp_Commands_StartCommand". + /// This prevents duplicate field/method names when types have the same SimpleName. + /// + /// tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs:Generator_WithSameSimpleNameInDifferentNamespaces_GeneratesUniqueIdentifiersAsync + public string UniqueIdentifier => FullyQualifiedName.Replace("global::", "").Replace(".", "_"); +} /// /// Information about a property for JSON serialization. diff --git a/src/Whizbang.Generators/ListTypeInfo.cs b/src/Whizbang.Generators/ListTypeInfo.cs index 5345f64c..f41c5ad1 100644 --- a/src/Whizbang.Generators/ListTypeInfo.cs +++ b/src/Whizbang.Generators/ListTypeInfo.cs @@ -15,4 +15,13 @@ public sealed record ListTypeInfo( string ListTypeName, string ElementTypeName, string ElementSimpleName -); +) { + /// + /// Unique identifier derived from element type name, suitable for C# identifiers. + /// Strips "global::" prefix and replaces "." with "_". + /// E.g., "global::MyApp.Models.OrderLineItem" becomes "MyApp_Models_OrderLineItem". + /// This prevents duplicate field/method names when element types have the same SimpleName. + /// + /// tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs:Generator_WithSameSimpleNameInDifferentNamespaces_GeneratesUniqueIdentifiersAsync + public string ElementUniqueIdentifier => ElementTypeName.Replace("global::", "").Replace(".", "_"); +} diff --git a/src/Whizbang.Generators/MessageJsonContextGenerator.cs b/src/Whizbang.Generators/MessageJsonContextGenerator.cs index ac8729de..f23e91a2 100644 --- a/src/Whizbang.Generators/MessageJsonContextGenerator.cs +++ b/src/Whizbang.Generators/MessageJsonContextGenerator.cs @@ -50,7 +50,7 @@ public class MessageJsonContextGenerator : IIncrementalGenerator { private const string PLACEHOLDER_MESSAGE_ID = "MessageId"; private const string PLACEHOLDER_FULLY_QUALIFIED_NAME = "__FULLY_QUALIFIED_NAME__"; private const string PLACEHOLDER_SIMPLE_NAME = "__SIMPLE_NAME__"; - private const string PLACEHOLDER_SAFE_NAME = "__SAFE_NAME__"; + private const string PLACEHOLDER_UNIQUE_IDENTIFIER = "__UNIQUE_IDENTIFIER__"; private const string PLACEHOLDER_GLOBAL = "global::"; private const string PLACEHOLDER_INDEX = "__INDEX__"; private const string PLACEHOLDER_PROPERTY_TYPE = "__PROPERTY_TYPE__"; @@ -395,7 +395,7 @@ private static string _generateLazyFields(Assembly assembly, ImmutableArray t.IsCommand || t.IsEvent)) { var field = envelopeFieldSnippet .Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName) - .Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName)); + .Replace(PLACEHOLDER_UNIQUE_IDENTIFIER, type.UniqueIdentifier); sb.AppendLine(field); } @@ -466,7 +466,7 @@ private static string _generateGetTypeInfo(Assembly assembly, ImmutableArray t.IsCommand || t.IsEvent)) { var check = envelopeCheckSnippet .Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName) - .Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName)); + .Replace(PLACEHOLDER_UNIQUE_IDENTIFIER, type.UniqueIdentifier); sb.AppendLine(check); sb.AppendLine(); } @@ -487,7 +487,7 @@ private static string _generateGetTypeInfo(Assembly assembly, ImmutableArray Create_{safeName}(JsonSerializerOptions options) {{"); + sb.AppendLine($"private JsonTypeInfo<{message.FullyQualifiedName}> Create_{message.UniqueIdentifier}(JsonSerializerOptions options) {{"); // Generate properties array sb.AppendLine($" var properties = new JsonPropertyInfo[{message.Properties.Length}];"); @@ -724,8 +723,7 @@ private static string _generateMessageEnvelopeFactories(Assembly assembly, Immut "PARAMETER_INFO_VALUES"); foreach (var message in messages) { - var safeName = _toSafeMethodName(message.FullyQualifiedName); - sb.AppendLine($"private JsonTypeInfo> CreateMessageEnvelope_{safeName}(JsonSerializerOptions options) {{"); + sb.AppendLine($"private JsonTypeInfo> CreateMessageEnvelope_{message.UniqueIdentifier}(JsonSerializerOptions options) {{"); // Generate properties array for MessageEnvelope (MessageId, Payload, Hops) sb.AppendLine(" var properties = new JsonPropertyInfo[3];"); @@ -1037,7 +1035,7 @@ private static string _generateListLazyFields(Assembly assembly, ImmutableArray< foreach (var listType in listTypes) { var field = snippet .Replace("__ELEMENT_TYPE__", listType.ElementTypeName) - .Replace("__ELEMENT_SAFE_NAME__", _toSafeMethodName(listType.ElementTypeName)); + .Replace("__ELEMENT_UNIQUE_IDENTIFIER__", listType.ElementUniqueIdentifier); sb.AppendLine(field); } @@ -1063,7 +1061,7 @@ private static string _generateListFactories(Assembly assembly, ImmutableArray? ___SAFE_NAME__; + private JsonTypeInfo<__FULLY_QUALIFIED_NAME__>? ___UNIQUE_IDENTIFIER__; #endregion #region LAZY_FIELD_MESSAGE_ENVELOPE - private JsonTypeInfo>? _MessageEnvelope___SAFE_NAME__; + private JsonTypeInfo>? _MessageEnvelope___UNIQUE_IDENTIFIER__; #endregion #region GET_TYPE_INFO_VALUE_OBJECT @@ -32,13 +32,13 @@ internal class JsonContextSnippets { #region GET_TYPE_INFO_MESSAGE if (type == typeof(__FULLY_QUALIFIED_NAME__)) { - return Create___SAFE_NAME__(options); + return Create___UNIQUE_IDENTIFIER__(options); } #endregion #region GET_TYPE_INFO_MESSAGE_ENVELOPE if (type == typeof(MessageEnvelope<__FULLY_QUALIFIED_NAME__>)) { - return CreateMessageEnvelope___SAFE_NAME__(options); + return CreateMessageEnvelope___UNIQUE_IDENTIFIER__(options); } #endregion @@ -52,17 +52,17 @@ internal class JsonContextSnippets { #endregion #region LAZY_FIELD_LIST -private JsonTypeInfo>? _List___ELEMENT_SAFE_NAME__; +private JsonTypeInfo>? _List___ELEMENT_UNIQUE_IDENTIFIER__; #endregion #region GET_TYPE_INFO_LIST if (type == typeof(global::System.Collections.Generic.List<__ELEMENT_TYPE__>)) { - return CreateList___ELEMENT_SAFE_NAME__(options); + return CreateList___ELEMENT_UNIQUE_IDENTIFIER__(options); } #endregion #region LIST_TYPE_FACTORY -private JsonTypeInfo> CreateList___ELEMENT_SAFE_NAME__(JsonSerializerOptions options) { +private JsonTypeInfo> CreateList___ELEMENT_UNIQUE_IDENTIFIER__(JsonSerializerOptions options) { var elementInfo = GetOrCreateTypeInfo<__ELEMENT_TYPE__>(options); var collectionInfo = new JsonCollectionInfoValues> { ObjectCreator = static () => new global::System.Collections.Generic.List<__ELEMENT_TYPE__>(), diff --git a/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs b/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs index 472e32bb..117637de 100644 --- a/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs +++ b/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs @@ -155,7 +155,7 @@ public record CreateOrder(string OrderId) : ICommand; await Assert.That(code).IsNotNull(); // Should generate specific factory method for MessageEnvelope - // Safe names use fully qualified path to avoid collisions (e.g., MyApp_Commands_CreateOrder) + // Uses unique identifier from fully qualified name: MyApp.Commands.CreateOrder -> MyApp_Commands_CreateOrder await Assert.That(code!).Contains("CreateMessageEnvelope_MyApp_Commands_CreateOrder"); await Assert.That(code).Contains("MessageEnvelope"); } @@ -613,7 +613,51 @@ public record CreateOrder(string OrderId, List PublicItems) : ICom await Assert.That(code).IsNotNull(); await Assert.That(code!).Contains("CreateOrder"); await Assert.That(code).Contains("PublicDetail"); - // PublicDetail should have factory method with safe name (namespace-qualified) + // PublicDetail should have factory method (uses unique identifier from FQN) await Assert.That(code).Contains("Create_MyApp_PublicDetail"); } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithSameSimpleNameInDifferentNamespaces_GeneratesUniqueIdentifiersAsync() { + // Arrange - Two types with same SimpleName but different namespaces + // This would previously cause duplicate field names (_StartCommand) and factory methods + var source = """ +using Whizbang.Core; + +namespace MyApp.Commands { + public record StartCommand(string Data) : ICommand; +} + +namespace MyApp.Events { + public record StartCommand(string Data) : IEvent; +} +"""; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should not have compilation errors (duplicate identifiers would cause errors) + await Assert.That(result.Diagnostics).DoesNotContain(d => d.Severity == DiagnosticSeverity.Error); + + var code = GeneratorTestHelper.GetGeneratedSource(result, "MessageJsonContext.g.cs"); + await Assert.That(code).IsNotNull(); + + // Both types should be present with fully qualified names + await Assert.That(code!).Contains("global::MyApp.Commands.StartCommand"); + await Assert.That(code).Contains("global::MyApp.Events.StartCommand"); + + // Should have unique field names (not duplicate _StartCommand) + // Uses namespace-qualified identifiers like _MyApp_Commands_StartCommand + await Assert.That(code).Contains("_MyApp_Commands_StartCommand"); + await Assert.That(code).Contains("_MyApp_Events_StartCommand"); + + // Should have unique factory method names + await Assert.That(code).Contains("Create_MyApp_Commands_StartCommand"); + await Assert.That(code).Contains("Create_MyApp_Events_StartCommand"); + + // Should have unique MessageEnvelope factory method names + await Assert.That(code).Contains("CreateMessageEnvelope_MyApp_Commands_StartCommand"); + await Assert.That(code).Contains("CreateMessageEnvelope_MyApp_Events_StartCommand"); + } }