From 1c1553606163b30382032840f508d679c7d7e605 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 27 Aug 2026 06:04:45 +0000 Subject: [PATCH 01/24] Add regression test for legacy date-time property name --- .../test/Providers/PropertyProviderTests.cs | 25 +++++++++++++++++++ .../TestModel.cs | 7 ++++++ 2 files changed, 32 insertions(+) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract/TestModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index 0e8d0c8c03f..87a67f31d00 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -199,6 +199,31 @@ public async Task TestPropertyNamePreservesLastContractDateTimeSuffix() Assert.AreEqual(Helpers.GetExpectedFromFile("Expected"), actual); } + [Test] + public async Task TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property( + "StartTime", + new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String), + isRequired: true) + ]); + + var modelProvider = new ModelProvider(inputModel); + + Assert.AreEqual("StartOn", modelProvider.Properties.Single().Name); + } + [TestCaseSource(nameof(CollectionPropertyTestCases))] public void CollectionProperty(CSharpType coreType, InputModelProperty collectionProperty, CSharpType expectedType) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract/TestModel.cs new file mode 100644 index 00000000000..16bb8a8f091 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract/TestModel.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class TestModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} From 5a782a75c7f694dabb46f7660c2394eb02ef577f Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 27 Aug 2026 06:11:42 +0000 Subject: [PATCH 02/24] Preserve legacy date-time property names from GA contracts --- .../src/Providers/PropertyProvider.cs | 31 ++++++++----- .../src/Shared/CSharpNameExtensions.cs | 46 +++++++++++++++++++ .../test/Providers/PropertyProviderTests.cs | 31 +++++++++++++ .../TestModels.cs | 15 ++++++ 4 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 248f522c13a..1a2972b53f9 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -103,20 +103,24 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T var hasOutputUsage = inputProperty.EnclosingType?.Usage.HasFlag(InputModelTypeUsage.Output) ?? false; Modifiers = IsDiscriminator || (!hasOutputUsage && _isRequiredNonNullableConstant) ? MethodSignatureModifiers.Internal : MethodSignatureModifiers.Public; var identifierName = inputProperty.IsExactName ? inputProperty.Name : inputProperty.Name.ToIdentifierName(); - var lastContractProperties = enclosingType.LastContractView?.Properties; - var legacyName = identifierName == enclosingType.Name - ? $"{identifierName}Property" - : identifierName; - if (!inputProperty.IsExactName && - (lastContractProperties is null || - !lastContractProperties.Any(p => p.Name == legacyName))) + if (!inputProperty.IsExactName) { - identifierName = identifierName - .NormalizeCSharpAcronyms(inputProperty.Type.IsDateTimeInputType()); + var isDateTime = inputProperty.Type.IsDateTimeInputType(); + var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime); + var previousGeneratedName = isDateTime + ? identifierName.NormalizeCSharpAcronyms().NormalizeLegacyDateTimeSuffix() + : canonicalName; + var inputName = AvoidPropertyNameCollision(identifierName, enclosingType.Name); + canonicalName = AvoidPropertyNameCollision(canonicalName, enclosingType.Name); + previousGeneratedName = AvoidPropertyNameCollision(previousGeneratedName, enclosingType.Name); + + var previousProperty = enclosingType.LastContractView?.Properties.FirstOrDefault(p => + p.Name == inputName || + p.Name == canonicalName || + p.Name == previousGeneratedName); + identifierName = previousProperty?.Name ?? canonicalName; } - Name = identifierName == enclosingType.Name - ? $"{identifierName}Property" - : identifierName; + Name = AvoidPropertyNameCollision(identifierName, enclosingType.Name); Body = new AutoPropertyBody(propHasSetter, setterModifier, GetPropertyInitializationValue(propertyType, inputProperty)); WireInfo = new PropertyWireInformation(inputProperty); @@ -180,6 +184,9 @@ private void BuildDocs() } } + private static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) => + propertyName == enclosingTypeName ? $"{propertyName}Property" : propertyName; + private static bool IsPropertyPrivate(MethodSignatureModifiers modifiers, TypeSignatureModifiers enclosingTypeModifiers) { return (modifiers.HasFlag(MethodSignatureModifiers.Private) && !modifiers.HasFlag(MethodSignatureModifiers.Protected)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index 62d1a2aa806..ef2896e1a5f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -77,6 +77,52 @@ public static string NormalizeDateTimeSuffix(this string name) return prefix + onSuffix; } + /// + /// Applies the date-time property naming convention used by the previous Azure management-plane + /// generator. This is only used to identify names that may exist in a last contract. + /// + public static string NormalizeLegacyDateTimeSuffix(this string name) + { + if (name.StartsWith("From", StringComparison.Ordinal) || + name.StartsWith("To", StringComparison.Ordinal) || + name.EndsWith("PointInTime", StringComparison.Ordinal)) + { + return name; + } + + int suffixLength = 0; + if (name.Length > 8 && name.EndsWith("DateTime", StringComparison.Ordinal)) + { + suffixLength = 8; + } + else if (name.Length > 4 && + (name.EndsWith("Time", StringComparison.Ordinal) || + name.EndsWith("Date", StringComparison.Ordinal))) + { + suffixLength = 4; + } + else if (name.Length > 2 && name.EndsWith("At", StringComparison.Ordinal)) + { + suffixLength = 2; + } + + if (suffixLength == 0) + { + return name; + } + + var prefix = name[..^suffixLength]; + var legacyPrefix = prefix switch + { + "Creation" => "Created", + "Deletion" => "Deleted", + "Expiration" => "Expire", + "Modification" => "Modified", + _ => prefix + }; + return legacyPrefix + "On"; + } + private static class DateTimeNameRules { private const string AtSuffix = "At"; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index 87a67f31d00..b9d3d1454c1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -224,6 +224,37 @@ public async Task TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFrom Assert.AreEqual("StartOn", modelProvider.Properties.Single().Name); } + [Test] + public async Task TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var historicalModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property("EndTime", dateTime, isRequired: true), + InputFactory.Property("ExpirationTime", dateTime, isRequired: true), + InputFactory.Property("AccessTierChangeTime", dateTime, isRequired: true), + InputFactory.Property("LastSyncTimestamp", dateTime, isRequired: true) + ]); + var canonicalModel = InputFactory.Model( + "CanonicalModel", + @namespace: "Test", + properties: [InputFactory.Property("StartTime", dateTime, isRequired: true)]); + + Assert.That( + new ModelProvider(historicalModel).Properties.Select(p => p.Name), + Is.EqualTo(new[] { "EndOn", "ExpireOn", "AccessTierChangeOn", "LastSyncTimestamp" })); + Assert.That(new ModelProvider(canonicalModel).Properties.Single().Name, Is.EqualTo("StartsOn")); + } + [TestCaseSource(nameof(CollectionPropertyTestCases))] public void CollectionProperty(CSharpType coreType, InputModelProperty collectionProperty, CSharpType expectedType) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs new file mode 100644 index 00000000000..d8eb7c7a89e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs @@ -0,0 +1,15 @@ +namespace Test +{ + public class TestModel + { + public System.DateTimeOffset EndOn { get; set; } + public System.DateTimeOffset ExpireOn { get; set; } + public System.DateTimeOffset AccessTierChangeOn { get; set; } + public System.DateTimeOffset LastSyncTimestamp { get; set; } + } + + public class CanonicalModel + { + public System.DateTimeOffset StartsOn { get; set; } + } +} From 3644022999edbfdda399ea08417228c80b8358d5 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 27 Aug 2026 06:16:03 +0000 Subject: [PATCH 03/24] Match GA properties by canonical date-time name --- .../src/Providers/PropertyProvider.cs | 22 +++++---- .../src/Shared/CSharpNameExtensions.cs | 47 +------------------ .../Utilities/CSharpNameExtensionsTests.cs | 2 + 3 files changed, 15 insertions(+), 56 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 1a2972b53f9..e961f5e3c88 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -106,18 +106,20 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T if (!inputProperty.IsExactName) { var isDateTime = inputProperty.Type.IsDateTimeInputType(); - var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime); - var previousGeneratedName = isDateTime - ? identifierName.NormalizeCSharpAcronyms().NormalizeLegacyDateTimeSuffix() - : canonicalName; + var canonicalName = AvoidPropertyNameCollision( + identifierName.NormalizeCSharpAcronyms(isDateTime), + enclosingType.Name); var inputName = AvoidPropertyNameCollision(identifierName, enclosingType.Name); - canonicalName = AvoidPropertyNameCollision(canonicalName, enclosingType.Name); - previousGeneratedName = AvoidPropertyNameCollision(previousGeneratedName, enclosingType.Name); + var lastContractProperties = enclosingType.LastContractView?.Properties; - var previousProperty = enclosingType.LastContractView?.Properties.FirstOrDefault(p => - p.Name == inputName || - p.Name == canonicalName || - p.Name == previousGeneratedName); + var previousProperty = lastContractProperties?.FirstOrDefault(p => p.Name == inputName); + if (previousProperty is null && isDateTime) + { + previousProperty = lastContractProperties?.FirstOrDefault(p => + AvoidPropertyNameCollision( + p.Name.NormalizeCSharpAcronyms(normalizeDateTimeSuffix: true), + enclosingType.Name) == canonicalName); + } identifierName = previousProperty?.Name ?? canonicalName; } Name = AvoidPropertyNameCollision(identifierName, enclosingType.Name); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index ef2896e1a5f..c60a903027c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -77,52 +77,6 @@ public static string NormalizeDateTimeSuffix(this string name) return prefix + onSuffix; } - /// - /// Applies the date-time property naming convention used by the previous Azure management-plane - /// generator. This is only used to identify names that may exist in a last contract. - /// - public static string NormalizeLegacyDateTimeSuffix(this string name) - { - if (name.StartsWith("From", StringComparison.Ordinal) || - name.StartsWith("To", StringComparison.Ordinal) || - name.EndsWith("PointInTime", StringComparison.Ordinal)) - { - return name; - } - - int suffixLength = 0; - if (name.Length > 8 && name.EndsWith("DateTime", StringComparison.Ordinal)) - { - suffixLength = 8; - } - else if (name.Length > 4 && - (name.EndsWith("Time", StringComparison.Ordinal) || - name.EndsWith("Date", StringComparison.Ordinal))) - { - suffixLength = 4; - } - else if (name.Length > 2 && name.EndsWith("At", StringComparison.Ordinal)) - { - suffixLength = 2; - } - - if (suffixLength == 0) - { - return name; - } - - var prefix = name[..^suffixLength]; - var legacyPrefix = prefix switch - { - "Creation" => "Created", - "Deletion" => "Deleted", - "Expiration" => "Expire", - "Modification" => "Modified", - _ => prefix - }; - return legacyPrefix + "On"; - } - private static class DateTimeNameRules { private const string AtSuffix = "At"; @@ -149,6 +103,7 @@ private static class DateTimeNameRules ["Deletion"] = "Deleted", ["End"] = "Ends", ["Expiration"] = "Expires", + ["Expire"] = "Expires", ["Modification"] = "Modified", ["Start"] = "Starts" }; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Utilities/CSharpNameExtensionsTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Utilities/CSharpNameExtensionsTests.cs index b321248a4e6..0f8676baaf1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Utilities/CSharpNameExtensionsTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Utilities/CSharpNameExtensionsTests.cs @@ -78,6 +78,8 @@ private static IEnumerable DateTimeNameTestCases() yield return new TestCaseData("creationTime", dateTime, "createdOn"); yield return new TestCaseData("ExpirationDate", dateTime, "ExpiresOn"); yield return new TestCaseData("expirationDate", dateTime, "expiresOn"); + yield return new TestCaseData("ExpireOn", dateTime, "ExpiresOn"); + yield return new TestCaseData("expireOn", dateTime, "expiresOn"); yield return new TestCaseData("ExpirationDateTime", dateTime, "ExpiresOn"); yield return new TestCaseData("expirationDateTime", dateTime, "expiresOn"); yield return new TestCaseData("modelExpirationDate", dateTime, "modelExpiresOn"); From 98ebb26fa969caa2c0d042d6682cd2ea47f24397 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 27 Aug 2026 06:30:20 +0000 Subject: [PATCH 04/24] Only preserve public GA property names --- .../src/Providers/PropertyProvider.cs | 3 ++- .../test/Providers/PropertyProviderTests.cs | 5 +++++ .../TestModels.cs | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index e961f5e3c88..4beaac461b6 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -110,7 +110,8 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T identifierName.NormalizeCSharpAcronyms(isDateTime), enclosingType.Name); var inputName = AvoidPropertyNameCollision(identifierName, enclosingType.Name); - var lastContractProperties = enclosingType.LastContractView?.Properties; + var lastContractProperties = enclosingType.LastContractView?.Properties + .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers)); var previousProperty = lastContractProperties?.FirstOrDefault(p => p.Name == inputName); if (previousProperty is null && isDateTime) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index b9d3d1454c1..5018482de42 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -248,11 +248,16 @@ public async Task TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContra "CanonicalModel", @namespace: "Test", properties: [InputFactory.Property("StartTime", dateTime, isRequired: true)]); + var internalLegacyModel = InputFactory.Model( + "InternalLegacyModel", + @namespace: "Test", + properties: [InputFactory.Property("StartTime", dateTime, isRequired: true)]); Assert.That( new ModelProvider(historicalModel).Properties.Select(p => p.Name), Is.EqualTo(new[] { "EndOn", "ExpireOn", "AccessTierChangeOn", "LastSyncTimestamp" })); Assert.That(new ModelProvider(canonicalModel).Properties.Single().Name, Is.EqualTo("StartsOn")); + Assert.That(new ModelProvider(internalLegacyModel).Properties.Single().Name, Is.EqualTo("StartsOn")); } [TestCaseSource(nameof(CollectionPropertyTestCases))] diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs index d8eb7c7a89e..e3593ac0652 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract/TestModels.cs @@ -12,4 +12,9 @@ public class CanonicalModel { public System.DateTimeOffset StartsOn { get; set; } } + + public class InternalLegacyModel + { + internal System.DateTimeOffset StartOn { get; set; } + } } From f3325b4db0078205a523a1264ef330ca989c6647 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 27 Aug 2026 07:18:46 +0000 Subject: [PATCH 05/24] Match raw custom properties after date normalization --- .../src/Providers/TypeProvider.cs | 18 ++++++++++----- .../ModelProviders/ModelCustomizationTests.cs | 23 +++++++++++++++++++ .../MockInputModel.cs | 9 ++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty/MockInputModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index b78ed2f5757..eebc75bc510 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -466,17 +466,23 @@ private static void AddCustomName( string? originalName, IReadOnlyDictionary specPropertiesByName) { - customNames.Add(name); - if (originalName is null) + AddCustomName(customNames, name, specPropertiesByName); + if (originalName is not null) { - return; + AddCustomName(customNames, originalName, specPropertiesByName); } + } - customNames.Add(originalName); - if (specPropertiesByName.TryGetValue(originalName, out var inputProperty) && !inputProperty.IsExactName) + private static void AddCustomName( + HashSet customNames, + string name, + IReadOnlyDictionary specPropertiesByName) + { + customNames.Add(name); + if (specPropertiesByName.TryGetValue(name, out var inputProperty) && !inputProperty.IsExactName) { customNames.Add( - originalName + name .ToIdentifierName() .NormalizeCSharpAcronyms(inputProperty.Type.IsDateTimeInputType())); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index 5dfa310e73a..4c93818c008 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -205,6 +205,29 @@ public async Task CustomCodeReplacesDateNormalizedPropertyUsingRawSpecName() Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); } + [Test] + public async Task CustomCodeWithRawDateTimeNameReplacesNormalizedProperty() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: [InputFactory.Property("valueDate", dateTime)]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + Assert.AreEqual("ValueDate", modelTypeProvider.CustomCodeView!.Properties.Single().Name); + Assert.AreEqual(0, modelTypeProvider.Properties.Count); + Assert.AreEqual("ValueDate", modelTypeProvider.CanonicalView!.Properties.Single().Name); + } + [Test] public async Task CustomCodeReplacesAcronymNormalizedProperty() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty/MockInputModel.cs new file mode 100644 index 00000000000..9f499b4c322 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public partial class MockInputModel + { + internal System.DateTimeOffset? ValueDate { get; } + } +} From 59ef218a35a3b01281a08ab38ce412f7bcf8e2ef Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Thu, 27 Aug 2026 12:11:32 -0500 Subject: [PATCH 06/24] fix: resolve customizations against preserved date-time names Address review feedback on preserving shipped date-time property names. Customization matching recomputed the canonical name (StartsOn) to decide which generated property a customization replaces. Now that a property can be emitted under a name preserved from the last contract (StartOn), that recomputation no longer matched, so: - A raw-name customization failed to suppress the generated property and the property was emitted twice. Suppression now derives the name from the constructed PropertyProvider instead of recomputing it. - A customization written against the preserved name failed to resolve back to its spec property in the canonical view, silently losing WireInfo and the serialized name. The canonical view now also indexes spec properties under the names the generator actually produced. Add the projected-provider regression case called for by the linked issue, where the public GA member lives on a different generated provider than the wire-model property, and validate all new tests against TestData so the preserved name is checked through constructors and not just property names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a5e8332-8ebe-44b1-97c5-44cc31caf25e --- .../src/Providers/CanonicalTypeProvider.cs | 9 +++ .../src/Providers/TypeProvider.cs | 58 +++++++++-------- .../ModelProviders/ModelCustomizationTests.cs | 63 +++++++++++++++++++ ...ContractNamePreservesWireInfo(Expected).cs | 25 ++++++++ .../MockInputModel.cs | 7 +++ .../MockInputModel.cs | 9 +++ ...ameReplacesNormalizedProperty(Expected).cs | 24 +++++++ ...LastContractPreservedProperty(Expected).cs | 25 ++++++++ .../MockInputModel.cs | 7 +++ .../MockInputModel.cs | 9 +++ .../test/Providers/PropertyProviderTests.cs | 54 +++++++++++++++- ...ProjectedProviderLastContract(Expected).cs | 15 +++++ .../DiskEncryptionSetData.cs | 12 ++++ ...DateTimeNamesFromLastContract(Expected).cs | 39 ++++++++++++ ...ateTimeSuffixFromLastContract(Expected).cs | 27 ++++++++ 15 files changed, 352 insertions(+), 31 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract/DiskEncryptionSetData.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract(Expected).cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs index 4020a979bb3..bb87a7a4400 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs @@ -95,6 +95,15 @@ protected internal override PropertyProvider[] BuildProperties() var generatedProperties = _generatedTypeProvider.Properties; var customProperties = _generatedTypeProvider.CustomCodeView?.Properties ?? []; + // Index the spec properties under the names the generator actually produced for them. + foreach (var generatedProperty in _generatedTypeProvider.GeneratedPropertiesBySpecName.Values) + { + if (generatedProperty.InputProperty is InputModelProperty preservedSpecProperty) + { + _specPropertiesMap.TryAdd(generatedProperty.Name, preservedSpecProperty); + } + } + // Update the serializedName of generated properties if necessary foreach (var generatedProperty in generatedProperties) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index eebc75bc510..b26db5d00f6 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -435,18 +435,30 @@ protected static bool IsResolvableBuildableType(CSharpType type) => internal PropertyProvider[] FilterCustomizedProperties(IEnumerable specProperties) { - var specPropertiesByName = BuildSpecPropertiesByName(specProperties); + _generatedPropertiesBySpecName.Clear(); + foreach (var specProperty in specProperties) + { + var inputProperty = specProperty.InputProperty; + if (inputProperty is null || inputProperty.IsExactName) + { + continue; + } + + _generatedPropertiesBySpecName.TryAdd(inputProperty.Name, specProperty); + _generatedPropertiesBySpecName.TryAdd(inputProperty.Name.ToIdentifierName(), specProperty); + } + var properties = new List(); var customProperties = new HashSet(); foreach (var customProperty in BuildAllCustomProperties()) { - AddCustomName(customProperties, customProperty.Name, customProperty.OriginalName, specPropertiesByName); + AddCustomName(customProperties, customProperty.Name, customProperty.OriginalName, _generatedPropertiesBySpecName); } foreach (var customField in BuildAllCustomFields()) { - AddCustomName(customProperties, customField.Name, customField.OriginalName, specPropertiesByName); + AddCustomName(customProperties, customField.Name, customField.OriginalName, _generatedPropertiesBySpecName); } foreach (var property in specProperties) @@ -464,49 +476,35 @@ private static void AddCustomName( HashSet customNames, string name, string? originalName, - IReadOnlyDictionary specPropertiesByName) + IReadOnlyDictionary generatedPropertiesBySpecName) { - AddCustomName(customNames, name, specPropertiesByName); + AddCustomName(customNames, name, generatedPropertiesBySpecName); if (originalName is not null) { - AddCustomName(customNames, originalName, specPropertiesByName); + AddCustomName(customNames, originalName, generatedPropertiesBySpecName); } } private static void AddCustomName( HashSet customNames, string name, - IReadOnlyDictionary specPropertiesByName) + IReadOnlyDictionary generatedPropertiesBySpecName) { customNames.Add(name); - if (specPropertiesByName.TryGetValue(name, out var inputProperty) && !inputProperty.IsExactName) + if (generatedPropertiesBySpecName.TryGetValue(name, out var generatedProperty)) { - customNames.Add( - name - .ToIdentifierName() - .NormalizeCSharpAcronyms(inputProperty.Type.IsDateTimeInputType())); + customNames.Add(generatedProperty.Name); } } - private static IReadOnlyDictionary BuildSpecPropertiesByName(IEnumerable specProperties) - { - var specPropertiesByName = new Dictionary(StringComparer.Ordinal); - - foreach (var specProperty in specProperties) - { - var inputProperty = specProperty.InputProperty; - if (inputProperty is null) - { - continue; - } + private readonly Dictionary _generatedPropertiesBySpecName = new(StringComparer.Ordinal); - var identifierName = inputProperty.Name.ToIdentifierName(); - specPropertiesByName.TryAdd(inputProperty.Name, inputProperty); - specPropertiesByName.TryAdd(identifierName, inputProperty); - } - - return specPropertiesByName; - } + /// + /// Maps the spec names a customization can reference to the property the generator built from that spec + /// property, as captured by before the properties replaced by + /// custom code were filtered out. Only populated once the properties have been built. + /// + internal IReadOnlyDictionary GeneratedPropertiesBySpecName => _generatedPropertiesBySpecName; internal FieldProvider[] FilterCustomizedFields(IEnumerable specFields) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index 4c93818c008..c927551a5dc 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -226,6 +226,69 @@ public async Task CustomCodeWithRawDateTimeNameReplacesNormalizedProperty() Assert.AreEqual("ValueDate", modelTypeProvider.CustomCodeView!.Properties.Single().Name); Assert.AreEqual(0, modelTypeProvider.Properties.Count); Assert.AreEqual("ValueDate", modelTypeProvider.CanonicalView!.Properties.Single().Name); + Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); + } + + [Test] + public async Task CustomCodeWithSpecNameReplacesLastContractPreservedProperty() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: [InputFactory.Property("startTime", dateTime, isRequired: true)]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // The spec property is generated as the shipped "StartOn" rather than the canonical "StartsOn", + // so the raw-name customization must suppress "StartOn". + Assert.AreEqual("StartTime", modelTypeProvider.CustomCodeView!.Properties.Single().Name); + Assert.AreEqual(0, modelTypeProvider.Properties.Count); + + var canonicalProperty = modelTypeProvider.CanonicalView!.Properties.Single(); + Assert.AreEqual("StartTime", canonicalProperty.Name); + Assert.AreEqual("startTime", canonicalProperty.WireInfo!.SerializedName); + Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); + } + + [Test] + public async Task CustomCodeWithLastContractNamePreservesWireInfo() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: [InputFactory.Property("startTime", dateTime, isRequired: true)]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // The customization uses the name preserved from the last contract, which is neither the raw spec + // name nor the canonical name. Suppression succeeds trivially because the custom name matches the + // generated one; what this covers is the reverse lookup, which must still resolve "StartOn" back to + // the "startTime" spec property so the canonical property keeps its wire information. + Assert.AreEqual("StartOn", modelTypeProvider.CustomCodeView!.Properties.Single().Name); + Assert.AreEqual(0, modelTypeProvider.Properties.Count); + + var canonicalProperty = modelTypeProvider.CanonicalView!.Properties.Single(); + Assert.AreEqual("StartOn", canonicalProperty.Name); + Assert.AreEqual("startTime", canonicalProperty.WireInfo!.SerializedName); + Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); } [Test] diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Expected).cs new file mode 100644 index 00000000000..5727c4114a3 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Expected).cs @@ -0,0 +1,25 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Sample.Models +{ + public partial class MockInputModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public MockInputModel(global::System.DateTimeOffset startOn) + { + StartOn = startOn; + } + + internal MockInputModel(global::System.DateTimeOffset startOn, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + StartOn = startOn; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Last)/MockInputModel.cs new file mode 100644 index 00000000000..9b42ee1a608 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo(Last)/MockInputModel.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo/MockInputModel.cs new file mode 100644 index 00000000000..74791e874c7 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithLastContractNamePreservesWireInfo/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty(Expected).cs new file mode 100644 index 00000000000..afbb84eeef4 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithRawDateTimeNameReplacesNormalizedProperty(Expected).cs @@ -0,0 +1,24 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Sample.Models +{ + public partial class MockInputModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public MockInputModel() + { + } + + internal MockInputModel(global::System.DateTimeOffset? valueDate, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + ValueDate = valueDate; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Expected).cs new file mode 100644 index 00000000000..88487bdaefa --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Expected).cs @@ -0,0 +1,25 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Sample.Models +{ + public partial class MockInputModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public MockInputModel(global::System.DateTimeOffset startTime) + { + StartTime = startTime; + } + + internal MockInputModel(global::System.DateTimeOffset startTime, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + StartTime = startTime; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Last)/MockInputModel.cs new file mode 100644 index 00000000000..9b42ee1a608 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty(Last)/MockInputModel.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty/MockInputModel.cs new file mode 100644 index 00000000000..c0f346fe924 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithSpecNameReplacesLastContractPreservedProperty/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartTime { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index 5018482de42..ef715a5d3d1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -222,6 +222,7 @@ public async Task TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFrom var modelProvider = new ModelProvider(inputModel); Assert.AreEqual("StartOn", modelProvider.Properties.Single().Name); + Assert.AreEqual(Helpers.GetExpectedFromFile("Expected"), new TypeProviderWriter(modelProvider).Write().Content); } [Test] @@ -253,13 +254,64 @@ public async Task TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContra @namespace: "Test", properties: [InputFactory.Property("StartTime", dateTime, isRequired: true)]); + var historicalProvider = new ModelProvider(historicalModel); + Assert.That( - new ModelProvider(historicalModel).Properties.Select(p => p.Name), + historicalProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "EndOn", "ExpireOn", "AccessTierChangeOn", "LastSyncTimestamp" })); + Assert.AreEqual( + Helpers.GetExpectedFromFile("Expected"), + new TypeProviderWriter(historicalProvider).Write().Content); + + // A property absent from the GA contract, and one whose only legacy-looking match is non-public, + // both take the new canonical name. Assert.That(new ModelProvider(canonicalModel).Properties.Single().Name, Is.EqualTo("StartsOn")); Assert.That(new ModelProvider(internalLegacyModel).Properties.Single().Name, Is.EqualTo("StartsOn")); } + [Test] + public async Task TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var timestampProperty = InputFactory.Property("lastKeyRotationTimestamp", dateTime, isRequired: true); + var renamedProperty = InputFactory.Property("startTime", dateTime, isRequired: true); + InputFactory.Model( + "DiskEncryptionSetProperties", + @namespace: "Test", + properties: [timestampProperty, renamedProperty]); + + // The wire model that declares the properties is not part of the shipped surface. The properties are + // projected onto the resource data provider, so the last contract must be consulted through the + // provider that actually declares the public members. + var projectedProvider = new TestTypeProvider(name: "DiskEncryptionSetData", ns: "Test"); + var wireProvider = new TestTypeProvider(name: "DiskEncryptionSetProperties", ns: "Test"); + + projectedProvider.Update(properties: + [ + new PropertyProvider(timestampProperty, projectedProvider), + new PropertyProvider(renamedProperty, projectedProvider) + ]); + + Assert.That( + projectedProvider.Properties.Select(p => p.Name), + Is.EqualTo(new[] { "LastKeyRotationTimestamp", "StartOn" })); + Assert.AreEqual( + Helpers.GetExpectedFromFile("Expected"), + new TypeProviderWriter(projectedProvider).Write().Content); + + // The same spec properties on the wire provider, which has no shipped contract of its own, take the + // new canonical names. + Assert.That( + new[] { timestampProperty, renamedProperty }.Select(p => new PropertyProvider(p, wireProvider).Name), + Is.EqualTo(new[] { "LastKeyRotationOn", "StartsOn" })); + } + [TestCaseSource(nameof(CollectionPropertyTestCases))] public void CollectionProperty(CSharpType coreType, InputModelProperty collectionProperty, CSharpType expectedType) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract(Expected).cs new file mode 100644 index 00000000000..a2f6727a9fc --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract(Expected).cs @@ -0,0 +1,15 @@ +// + +#nullable disable + +using System; + +namespace Test +{ + public partial class DiskEncryptionSetData + { + public global::System.DateTimeOffset LastKeyRotationTimestamp { get; set; } + + public global::System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract/DiskEncryptionSetData.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract/DiskEncryptionSetData.cs new file mode 100644 index 00000000000..a0953deaa1e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesDateTimeNameFromProjectedProviderLastContract/DiskEncryptionSetData.cs @@ -0,0 +1,12 @@ +namespace Test +{ + public class DiskEncryptionSetData + { + public System.DateTimeOffset LastKeyRotationTimestamp { get; set; } + public System.DateTimeOffset StartOn { get; set; } + } + + public class DiskEncryptionSetProperties + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract(Expected).cs new file mode 100644 index 00000000000..6ef57a5ade9 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalDateTimeNamesFromLastContract(Expected).cs @@ -0,0 +1,39 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Test +{ + public partial class TestModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public TestModel(global::System.DateTimeOffset endOn, global::System.DateTimeOffset expireOn, global::System.DateTimeOffset accessTierChangeOn, global::System.DateTimeOffset lastSyncTimestamp) + { + EndOn = endOn; + ExpireOn = expireOn; + AccessTierChangeOn = accessTierChangeOn; + LastSyncTimestamp = lastSyncTimestamp; + } + + internal TestModel(global::System.DateTimeOffset endOn, global::System.DateTimeOffset expireOn, global::System.DateTimeOffset accessTierChangeOn, global::System.DateTimeOffset lastSyncTimestamp, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + EndOn = endOn; + ExpireOn = expireOn; + AccessTierChangeOn = accessTierChangeOn; + LastSyncTimestamp = lastSyncTimestamp; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + public global::System.DateTimeOffset EndOn { get; set; } + + public global::System.DateTimeOffset ExpireOn { get; set; } + + public global::System.DateTimeOffset AccessTierChangeOn { get; set; } + + public global::System.DateTimeOffset LastSyncTimestamp { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract(Expected).cs new file mode 100644 index 00000000000..fcfbe26e8bf --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesPreviouslyGeneratedDateTimeSuffixFromLastContract(Expected).cs @@ -0,0 +1,27 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Test +{ + public partial class TestModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public TestModel(global::System.DateTimeOffset startOn) + { + StartOn = startOn; + } + + internal TestModel(global::System.DateTimeOffset startOn, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + StartOn = startOn; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + public global::System.DateTimeOffset StartOn { get; set; } + } +} From a657a9831730980a1dcbe20fe878c1e61c7d0809 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Thu, 27 Aug 2026 13:27:07 -0500 Subject: [PATCH 07/24] fix: scope customization name matching to the targeted spec property Address review feedback on the customization-matching changes. An explicit rename resolved its public name as a spec name in addition to its CodeGenMember target, so `[CodeGenMember("startTime")] ValueDate` alongside a separate `valueDate` spec property suppressed the generated property for both. The public name is now only added for direct collision suppression; only OriginalName is resolved as a spec name. Raw-name customizations, which declare no target, still resolve their public name. The spec-name snapshot was also rebuilt on every filtering pass. CSharpGen re-applies filtering by passing the already-filtered members back through Update, and the properties missing from that list are exactly the ones the snapshot needs to retain, so the rebuild dropped them. Entries are now merged across passes and cleared only when Reset rebuilds the members. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a5e8332-8ebe-44b1-97c5-44cc31caf25e --- .../src/Providers/TypeProvider.cs | 11 ++-- .../ModelProviders/ModelCustomizationTests.cs | 66 +++++++++++++++++++ ...sNotSuppressUnrelatedProperty(Expected).cs | 27 ++++++++ .../MockInputModel.cs | 12 ++++ .../MockInputModel.cs | 7 ++ .../MockInputModel.cs | 9 +++ 6 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation/MockInputModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index b26db5d00f6..0c4225b4ed4 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -435,7 +435,6 @@ protected static bool IsResolvableBuildableType(CSharpType type) => internal PropertyProvider[] FilterCustomizedProperties(IEnumerable specProperties) { - _generatedPropertiesBySpecName.Clear(); foreach (var specProperty in specProperties) { var inputProperty = specProperty.InputProperty; @@ -478,11 +477,14 @@ private static void AddCustomName( string? originalName, IReadOnlyDictionary generatedPropertiesBySpecName) { - AddCustomName(customNames, name, generatedPropertiesBySpecName); - if (originalName is not null) + if (originalName is null) { - AddCustomName(customNames, originalName, generatedPropertiesBySpecName); + AddCustomName(customNames, name, generatedPropertiesBySpecName); + return; } + + customNames.Add(name); + AddCustomName(customNames, originalName, generatedPropertiesBySpecName); } private static void AddCustomName( @@ -728,6 +730,7 @@ public virtual void Reset() { _methods = null; _properties = null; + _generatedPropertiesBySpecName.Clear(); _fields = null; ResetConstructors(); _implements = null; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index c927551a5dc..0c332466e4c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -291,6 +291,72 @@ public async Task CustomCodeWithLastContractNamePreservesWireInfo() Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); } + [Test] + public async Task CustomCodeRenameDoesNotSuppressUnrelatedProperty() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("startTime", dateTime, isRequired: true), + InputFactory.Property("valueDate", dateTime, isRequired: true) + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // The customization renames "startTime" to "ValueDate". Its public name must not additionally be + // resolved as a spec name, or it would also suppress the generated property for "valueDate". + Assert.AreEqual("ValueDate", modelTypeProvider.CustomCodeView!.Properties.Single().Name); + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "ValueOn" })); + Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); + } + + [Test] + public async Task RefilteringPreservesCustomCodeSpecAssociation() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: [InputFactory.Property("startTime", dateTime, isRequired: true)]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // Mirrors CSharpGen.FilterCustomizedMembers, which re-applies filtering by passing the already + // filtered members back through Update. The generated property suppressed by the customization is + // absent from that list, so the spec-name snapshot must survive the second pass. + modelTypeProvider.Update( + modelTypeProvider.Methods, + modelTypeProvider.Constructors, + modelTypeProvider.Properties, + modelTypeProvider.Fields); + + Assert.That( + modelTypeProvider.GeneratedPropertiesBySpecName.Keys, + Is.EquivalentTo(new[] { "startTime", "StartTime" })); + + var canonicalProperty = modelTypeProvider.CanonicalView!.Properties.Single(); + Assert.AreEqual("StartOn", canonicalProperty.Name); + Assert.AreEqual("startTime", canonicalProperty.WireInfo!.SerializedName); + } + [Test] public async Task CustomCodeReplacesAcronymNormalizedProperty() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs new file mode 100644 index 00000000000..ed76a9c77ee --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs @@ -0,0 +1,27 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Sample.Models +{ + public partial class MockInputModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public MockInputModel(global::System.DateTimeOffset valueOn) + { + ValueOn = valueOn; + } + + internal MockInputModel(global::System.DateTimeOffset valueOn, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + ValueOn = valueOn; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + public global::System.DateTimeOffset ValueOn { get; set; } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty/MockInputModel.cs new file mode 100644 index 00000000000..32a60aae939 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty/MockInputModel.cs @@ -0,0 +1,12 @@ +#nullable disable + +using Microsoft.TypeSpec.Generator.Customizations; + +namespace Sample.Models +{ + public partial class MockInputModel + { + [CodeGenMember("startTime")] + public System.DateTimeOffset ValueDate { get; set; } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation(Last)/MockInputModel.cs new file mode 100644 index 00000000000..9b42ee1a608 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation(Last)/MockInputModel.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation/MockInputModel.cs new file mode 100644 index 00000000000..74791e874c7 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringPreservesCustomCodeSpecAssociation/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} From 5cc81bd446d4ca3f7cec58a0f2cc990274a430e2 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Thu, 27 Aug 2026 13:51:57 -0500 Subject: [PATCH 08/24] fix: resolve renamed custom members by their CodeGenMember target The canonical view categorized a custom member by its public name before its OriginalName, while the property provider map a few lines above is keyed the other way around. An explicit rename whose public name matched an unrelated spec property was therefore filed under that property, and the custom member was dropped from the canonical view that model construction and serialization consume. Resolve OriginalName first so both stay consistent. The plain spec map lookup is kept for the public name rather than reusing TryGetSpecProperty, whose renamed-member guard would stop a redefined original name from being absorbed into its spec property. Also name the rename regression test after what it covers and record why the colliding name is deliberate: the custom member's public name has to match another spec property for the scenario to exist at all, so a non-colliding rename cannot exercise it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a5e8332-8ebe-44b1-97c5-44cc31caf25e --- .../src/Providers/CanonicalTypeProvider.cs | 6 +++--- .../ModelProviders/ModelCustomizationTests.cs | 18 +++++++++++++++--- ...SpecPropertyDoesNotSuppressIt(Expected).cs} | 6 ++++-- .../MockInputModel.cs | 0 4 files changed, 22 insertions(+), 8 deletions(-) rename packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/{CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs => CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt(Expected).cs} (71%) rename packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/{CustomCodeRenameDoesNotSuppressUnrelatedProperty => CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt}/MockInputModel.cs (100%) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs index bb87a7a4400..f08874a6cda 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs @@ -195,9 +195,9 @@ protected internal override PropertyProvider[] BuildProperties() foreach (var prop in customProperties) { - // Check if custom property is in spec - if (_specPropertiesMap.TryGetValue(prop.Name, out var specProp) || - (prop.OriginalName != null && TryGetSpecProperty(prop.OriginalName, out specProp))) + // Check if custom property is in spec. + if ((prop.OriginalName != null && TryGetSpecProperty(prop.OriginalName, out var specProp)) || + _specPropertiesMap.TryGetValue(prop.Name, out specProp)) { inputProperties.Add(specProp); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index 0c332466e4c..aa1e72e1f2d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -292,13 +292,20 @@ public async Task CustomCodeWithLastContractNamePreservesWireInfo() } [Test] - public async Task CustomCodeRenameDoesNotSuppressUnrelatedProperty() + public async Task CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt() { var dateTime = new InputDateTimeType( DateTimeKnownEncoding.Rfc3339, "utcDateTime", "TypeSpec.utcDateTime", InputPrimitiveType.String); + + // Two unrelated spec properties. The customization renames "startTime", and the name it renames to + // deliberately collides with the identifier form of the *other* spec property, "valueDate". + // That collision is the point of this test: a custom member's public name must be matched against + // the spec only when it is a raw-name customization with no explicit CodeGenMember target. Without + // that distinction the rename also resolves as a customization of "valueDate" and wrongly suppresses + // its generated property. A non-colliding rename cannot exercise this at all. var inputModel = InputFactory.Model( "mockInputModel", properties: @@ -313,10 +320,15 @@ public async Task CustomCodeRenameDoesNotSuppressUnrelatedProperty() var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); - // The customization renames "startTime" to "ValueDate". Its public name must not additionally be - // resolved as a spec name, or it would also suppress the generated property for "valueDate". + // The customization targets only "startTime", so "valueDate" keeps its generated property. Assert.AreEqual("ValueDate", modelTypeProvider.CustomCodeView!.Properties.Single().Name); Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "ValueOn" })); + + // Downstream construction and serialization consume the canonical view, which must contain both + // the renamed custom member and the untouched generated one. + Assert.That( + modelTypeProvider.CanonicalView!.Properties.Select(p => p.Name), + Is.EquivalentTo(new[] { "ValueDate", "ValueOn" })); Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt(Expected).cs similarity index 71% rename from packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs rename to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt(Expected).cs index ed76a9c77ee..8d284a3be9d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty(Expected).cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt(Expected).cs @@ -11,13 +11,15 @@ public partial class MockInputModel { private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; - public MockInputModel(global::System.DateTimeOffset valueOn) + public MockInputModel(global::System.DateTimeOffset valueDate, global::System.DateTimeOffset valueOn) { + ValueDate = valueDate; ValueOn = valueOn; } - internal MockInputModel(global::System.DateTimeOffset valueOn, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + internal MockInputModel(global::System.DateTimeOffset valueDate, global::System.DateTimeOffset valueOn, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) { + ValueDate = valueDate; ValueOn = valueOn; _additionalBinaryDataProperties = additionalBinaryDataProperties; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt/MockInputModel.cs similarity index 100% rename from packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameDoesNotSuppressUnrelatedProperty/MockInputModel.cs rename to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSuppressIt/MockInputModel.cs From 47550d823a9f0b400bacf2fe3380876a43c8048b Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Thu, 27 Aug 2026 14:01:59 -0500 Subject: [PATCH 09/24] fix: do not reuse a historical date-time name another property claims The date-time normalization fallback matched any shipped name that normalized onto the canonical name, without checking whether the current spec still maps a property directly onto that shipped name. With a GA string `StartOn`, a retained `startOn` and a new date-time `startTime` were both emitted as `StartOn`, producing a type that does not compile. A historical name is now only reused when no sibling property of the same input model maps directly onto it, so the retained property keeps the shipped name and the new property takes the canonical one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a5e8332-8ebe-44b1-97c5-44cc31caf25e --- .../src/Providers/PropertyProvider.cs | 37 +++++++++++++++++++ .../test/Providers/PropertyProviderTests.cs | 31 ++++++++++++++++ ...lNameClaimedByAnotherProperty(Expected).cs | 34 +++++++++++++++++ .../TestModel.cs | 7 ++++ 4 files changed, 109 insertions(+) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty/TestModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 4beaac461b6..e1bcbc2c898 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -116,7 +116,11 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T var previousProperty = lastContractProperties?.FirstOrDefault(p => p.Name == inputName); if (previousProperty is null && isDateTime) { + // A historical name is only available if no other property in the current spec claims it + // directly. Otherwise a retained property matching the shipped name and a new date-time + // property normalizing onto it would both be emitted under that name. previousProperty = lastContractProperties?.FirstOrDefault(p => + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType.Name) && AvoidPropertyNameCollision( p.Name.NormalizeCSharpAcronyms(normalizeDateTimeSuffix: true), enclosingType.Name) == canonicalName); @@ -190,6 +194,39 @@ private void BuildDocs() private static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) => propertyName == enclosingTypeName ? $"{propertyName}Property" : propertyName; + /// + /// Determines whether another property of the same input model maps directly onto + /// , meaning that name is already spoken for and cannot be reused to + /// preserve a historical name for . + /// + private static bool IsClaimedBySiblingProperty( + string contractName, + InputProperty inputProperty, + string enclosingTypeName) + { + var siblings = inputProperty.EnclosingType?.Properties; + if (siblings is null) + { + return false; + } + + foreach (var sibling in siblings) + { + if (ReferenceEquals(sibling, inputProperty)) + { + continue; + } + + var siblingName = sibling.IsExactName ? sibling.Name : sibling.Name.ToIdentifierName(); + if (AvoidPropertyNameCollision(siblingName, enclosingTypeName) == contractName) + { + return true; + } + } + + return false; + } + private static bool IsPropertyPrivate(MethodSignatureModifiers modifiers, TypeSignatureModifiers enclosingTypeModifiers) { return (modifiers.HasFlag(MethodSignatureModifiers.Private) && !modifiers.HasFlag(MethodSignatureModifiers.Protected)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index ef715a5d3d1..b7afc141c96 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -312,6 +312,37 @@ public async Task TestPropertyNamePreservesDateTimeNameFromProjectedProviderLast Is.EqualTo(new[] { "LastKeyRotationOn", "StartsOn" })); } + [Test] + public async Task TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + + // The GA contract shipped a string "StartOn". The current spec keeps "startOn" and adds a new + // date-time "startTime", whose canonical name normalizes onto the same historical "StartOn". + // The historical name is already spoken for, so only the retained property may claim it. + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property("startOn", InputPrimitiveType.String, isRequired: true), + InputFactory.Property("startTime", dateTime, isRequired: true) + ]); + + var modelProvider = new ModelProvider(inputModel); + + Assert.That(modelProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartOn", "StartsOn" })); + Assert.AreEqual( + Helpers.GetExpectedFromFile("Expected"), + new TypeProviderWriter(modelProvider).Write().Content); + } + [TestCaseSource(nameof(CollectionPropertyTestCases))] public void CollectionProperty(CSharpType coreType, InputModelProperty collectionProperty, CSharpType expectedType) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty(Expected).cs new file mode 100644 index 00000000000..17c032a605a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty(Expected).cs @@ -0,0 +1,34 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Sample; + +namespace Test +{ + public partial class TestModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public TestModel(string startOn, global::System.DateTimeOffset startsOn) + { + global::Sample.Argument.AssertNotNull(startOn, nameof(startOn)); + + StartOn = startOn; + StartsOn = startsOn; + } + + internal TestModel(string startOn, global::System.DateTimeOffset startsOn, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + StartOn = startOn; + StartsOn = startsOn; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + public string StartOn { get; set; } + + public global::System.DateTimeOffset StartsOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty/TestModel.cs new file mode 100644 index 00000000000..ca157485ed3 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty/TestModel.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class TestModel + { + public string StartOn { get; set; } + } +} \ No newline at end of file From 6b37a8f96ce7d406f276dabd330fa5b2b1d9569b Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Thu, 27 Aug 2026 15:23:38 -0500 Subject: [PATCH 10/24] fix: do not reuse a historical name claimed by a renamed custom member The sibling check only considered spec properties, so a customization that renamed an unrelated property onto the shipped name did not block reuse. With a GA `StartOn`, a spec `startTime` and `foo`, and custom code renaming `foo` to `StartOn`, the preserved name collided with the custom member and the generated property was dropped, losing `startTime` from the output entirely. Custom properties that explicitly target a different spec property are now treated as claiming their public name. Raw-name customizations are excluded because they declare no target and are meant to replace whichever generated property takes that name, including a preserved one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a5e8332-8ebe-44b1-97c5-44cc31caf25e --- .../src/Providers/PropertyProvider.cs | 32 +++++++++------- .../ModelProviders/ModelCustomizationTests.cs | 38 +++++++++++++++++++ ...imedByCustomRenameIsNotReused(Expected).cs | 32 ++++++++++++++++ .../MockInputModel.cs | 7 ++++ .../MockInputModel.cs | 12 ++++++ 5 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Expected).cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused/MockInputModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index e1bcbc2c898..b99d5f12ce1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -120,7 +120,7 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T // directly. Otherwise a retained property matching the shipped name and a new date-time // property normalizing onto it would both be emitted under that name. previousProperty = lastContractProperties?.FirstOrDefault(p => - !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType.Name) && + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType) && AvoidPropertyNameCollision( p.Name.NormalizeCSharpAcronyms(normalizeDateTimeSuffix: true), enclosingType.Name) == canonicalName); @@ -194,23 +194,14 @@ private void BuildDocs() private static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) => propertyName == enclosingTypeName ? $"{propertyName}Property" : propertyName; - /// - /// Determines whether another property of the same input model maps directly onto - /// , meaning that name is already spoken for and cannot be reused to - /// preserve a historical name for . - /// private static bool IsClaimedBySiblingProperty( string contractName, InputProperty inputProperty, - string enclosingTypeName) + TypeProvider enclosingType) { - var siblings = inputProperty.EnclosingType?.Properties; - if (siblings is null) - { - return false; - } + var enclosingTypeName = enclosingType.Name; - foreach (var sibling in siblings) + foreach (var sibling in inputProperty.EnclosingType?.Properties ?? []) { if (ReferenceEquals(sibling, inputProperty)) { @@ -224,6 +215,21 @@ private static bool IsClaimedBySiblingProperty( } } + foreach (var customProperty in enclosingType.CustomCodeView?.Properties ?? []) + { + if (customProperty.OriginalName is not { } originalName || + originalName == inputProperty.Name || + originalName == inputProperty.Name.ToIdentifierName()) + { + continue; + } + + if (customProperty.Name == contractName) + { + return true; + } + } + return false; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index aa1e72e1f2d..c9388c25f2f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -369,6 +369,44 @@ public async Task RefilteringPreservesCustomCodeSpecAssociation() Assert.AreEqual("startTime", canonicalProperty.WireInfo!.SerializedName); } + [Test] + public async Task HistoricalNameClaimedByCustomRenameIsNotReused() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + + // The GA contract shipped "StartOn", which "startTime" would normally preserve. Custom code renames + // the unrelated "foo" onto that same name, so it is no longer available. Preserving it anyway would + // emit two "StartOn" members, and the collision is resolved by dropping the generated property, which + // silently loses "startTime" from the output entirely. + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("startTime", dateTime, isRequired: true), + InputFactory.Property("foo", InputPrimitiveType.String, isRequired: true) + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + Assert.AreEqual("StartOn", modelTypeProvider.CustomCodeView!.Properties.Single().Name); + + // "startTime" falls back to the canonical name instead of being dropped. + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartsOn" })); + Assert.That( + modelTypeProvider.CanonicalView!.Properties.Select(p => p.Name), + Is.EquivalentTo(new[] { "StartsOn", "StartOn" })); + Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); + } + [Test] public async Task CustomCodeReplacesAcronymNormalizedProperty() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Expected).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Expected).cs new file mode 100644 index 00000000000..970d7ec980d --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Expected).cs @@ -0,0 +1,32 @@ +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Sample; + +namespace Sample.Models +{ + public partial class MockInputModel + { + private protected readonly global::System.Collections.Generic.IDictionary _additionalBinaryDataProperties; + + public MockInputModel(global::System.DateTimeOffset startsOn, string startOn) + { + global::Sample.Argument.AssertNotNull(startOn, nameof(startOn)); + + StartsOn = startsOn; + StartOn = startOn; + } + + internal MockInputModel(global::System.DateTimeOffset startsOn, string startOn, global::System.Collections.Generic.IDictionary additionalBinaryDataProperties) + { + StartsOn = startsOn; + StartOn = startOn; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + public global::System.DateTimeOffset StartsOn { get; set; } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Last)/MockInputModel.cs new file mode 100644 index 00000000000..108d6cd5741 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused(Last)/MockInputModel.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused/MockInputModel.cs new file mode 100644 index 00000000000..9fe20434f47 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByCustomRenameIsNotReused/MockInputModel.cs @@ -0,0 +1,12 @@ +#nullable disable + +using Microsoft.TypeSpec.Generator.Customizations; + +namespace Sample.Models +{ + public partial class MockInputModel + { + [CodeGenMember("foo")] + public string StartOn { get; set; } + } +} \ No newline at end of file From c289d460a9be3b0b0d8c9913a37c278840cc6c26 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Thu, 27 Aug 2026 16:05:50 -0500 Subject: [PATCH 11/24] fix: constrain historical date-time name matches The date-time fallback took the first shipped name that normalized onto the canonical name, which let it claim members it should not have: - An exact canonical match now wins over a historical one, so the result no longer depends on the order the contract declares its members. - Candidates must have a matching type. Claiming an unrelated shipped member also let ModelProvider restore that member's type by name, so a date-time property could be emitted as string. - Candidates carrying the enclosing-type Property collision suffix are stripped before matching, so a shipped member on a type whose name matches the property is still found. The sibling claim check missed two cases. It compared only a sibling's raw identifier, so a sibling reaching the shipped name through acronym normalization went unnoticed and both properties were emitted under that name. It also ignored custom fields even though customization filtering treats them as claims on a property name, so the generated property was dropped instead of falling back to the canonical name. Finally, the spec-name snapshot now also indexes the canonical name, which stays a supported customization target even when the generated property kept a name preserved from the last contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a5e8332-8ebe-44b1-97c5-44cc31caf25e --- .../src/Providers/PropertyProvider.cs | 75 +++++++++--- .../src/Providers/TypeProvider.cs | 6 +- .../ModelProviders/ModelCustomizationTests.cs | 56 ++++++++- .../MockInputModel.cs | 7 ++ .../MockInputModel.cs | 12 ++ .../MockInputModel.cs | 7 ++ .../MockInputModel.cs | 12 ++ .../test/Providers/PropertyProviderTests.cs | 108 ++++++++++++++++++ .../TestModel.cs | 7 ++ .../TestModel.cs | 7 ++ .../TestModel.cs | 8 ++ .../TestModel.cs | 7 ++ 12 files changed, 292 insertions(+), 20 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByNormalizedSibling/TestModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameIgnoresIncompatiblyTypedHistoricalName/TestModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePrefersExactCanonicalNameFromLastContract/TestModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesCollisionSuffixedHistoricalName/TestModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index b99d5f12ce1..1a9c56f7649 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -106,24 +106,27 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T if (!inputProperty.IsExactName) { var isDateTime = inputProperty.Type.IsDateTimeInputType(); - var canonicalName = AvoidPropertyNameCollision( - identifierName.NormalizeCSharpAcronyms(isDateTime), - enclosingType.Name); - var inputName = AvoidPropertyNameCollision(identifierName, enclosingType.Name); + var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime); + var enclosingTypeName = enclosingType.Name; var lastContractProperties = enclosingType.LastContractView?.Properties .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers)); - var previousProperty = lastContractProperties?.FirstOrDefault(p => p.Name == inputName); + // The shipped member may carry the enclosing-type collision suffix, so candidates are compared + // against the collision-adjusted form of each name we are looking for. + var previousProperty = + lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)) + ?? lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName)); + if (previousProperty is null && isDateTime) { - // A historical name is only available if no other property in the current spec claims it - // directly. Otherwise a retained property matching the shipped name and a new date-time - // property normalizing onto it would both be emitted under that name. + // Fall back to a name produced by an earlier date-time naming convention. The candidate has + // to be the same property in all but name, so it must have a compatible type and must not + // already be claimed by another member of this type. previousProperty = lastContractProperties?.FirstOrDefault(p => + p.Type.WithNullable(false).Equals(Type.WithNullable(false)) && !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType) && - AvoidPropertyNameCollision( - p.Name.NormalizeCSharpAcronyms(normalizeDateTimeSuffix: true), - enclosingType.Name) == canonicalName); + (p.Name == $"{enclosingTypeName}Property" ? enclosingTypeName : p.Name) + .NormalizeCSharpAcronyms(normalizeDateTimeSuffix: true) == canonicalName); } identifierName = previousProperty?.Name ?? canonicalName; } @@ -208,23 +211,40 @@ private static bool IsClaimedBySiblingProperty( continue; } - var siblingName = sibling.IsExactName ? sibling.Name : sibling.Name.ToIdentifierName(); - if (AvoidPropertyNameCollision(siblingName, enclosingTypeName) == contractName) + if (sibling.IsExactName) + { + if (AvoidPropertyNameCollision(sibling.Name, enclosingTypeName) == contractName) + { + return true; + } + + continue; + } + + // Compare both the raw identifier and the name the sibling actually emits, since acronym + // normalization can move it onto the shipped name even when the identifier does not match. + var siblingIdentifier = sibling.Name.ToIdentifierName(); + var siblingGenerated = siblingIdentifier.NormalizeCSharpAcronyms(sibling.Type.IsDateTimeInputType()); + if (AvoidPropertyNameCollision(siblingIdentifier, enclosingTypeName) == contractName || + AvoidPropertyNameCollision(siblingGenerated, enclosingTypeName) == contractName) { return true; } } - foreach (var customProperty in enclosingType.CustomCodeView?.Properties ?? []) + // A customization that explicitly targets a different spec property owns its public name outright. + // A raw-name customization is excluded: it has no declared target, so it is intended to replace + // whichever generated property ends up with that name, including a preserved one. Fields are + // included because customization filtering treats a custom field as a claim on a property name. + foreach (var customName in GetExplicitlyRenamedCustomNames(enclosingType)) { - if (customProperty.OriginalName is not { } originalName || - originalName == inputProperty.Name || - originalName == inputProperty.Name.ToIdentifierName()) + if (customName.OriginalName == inputProperty.Name || + customName.OriginalName == inputProperty.Name.ToIdentifierName()) { continue; } - if (customProperty.Name == contractName) + if (customName.Name == contractName) { return true; } @@ -233,6 +253,25 @@ private static bool IsClaimedBySiblingProperty( return false; } + private static IEnumerable<(string Name, string OriginalName)> GetExplicitlyRenamedCustomNames(TypeProvider enclosingType) + { + foreach (var customProperty in enclosingType.CustomCodeView?.Properties ?? []) + { + if (customProperty.OriginalName is { } propertyOriginalName) + { + yield return (customProperty.Name, propertyOriginalName); + } + } + + foreach (var customField in enclosingType.CustomCodeView?.Fields ?? []) + { + if (customField.OriginalName is { } fieldOriginalName) + { + yield return (customField.Name, fieldOriginalName); + } + } + } + private static bool IsPropertyPrivate(MethodSignatureModifiers modifiers, TypeSignatureModifiers enclosingTypeModifiers) { return (modifiers.HasFlag(MethodSignatureModifiers.Private) && !modifiers.HasFlag(MethodSignatureModifiers.Protected)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 0c4225b4ed4..cf0f6835f10 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -443,8 +443,12 @@ internal PropertyProvider[] FilterCustomizedProperties(IEnumerable(); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index c9388c25f2f..4b819b607d4 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -362,7 +362,7 @@ public async Task RefilteringPreservesCustomCodeSpecAssociation() Assert.That( modelTypeProvider.GeneratedPropertiesBySpecName.Keys, - Is.EquivalentTo(new[] { "startTime", "StartTime" })); + Is.EquivalentTo(new[] { "startTime", "StartTime", "StartsOn" })); var canonicalProperty = modelTypeProvider.CanonicalView!.Properties.Single(); Assert.AreEqual("StartOn", canonicalProperty.Name); @@ -407,6 +407,60 @@ public async Task HistoricalNameClaimedByCustomRenameIsNotReused() Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); } + [Test] + public async Task CustomCodeWithCanonicalNameReplacesPreservedProperty() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: [InputFactory.Property("startTime", dateTime, isRequired: true)]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // The canonical name stays a supported customization target even though the generated property kept + // the shipped "StartOn", so the generated property must still be replaced. + Assert.AreEqual("MyStart", modelTypeProvider.CustomCodeView!.Properties.Single().Name); + Assert.AreEqual(0, modelTypeProvider.Properties.Count); + } + + [Test] + public async Task HistoricalNameClaimedByRenamedCustomFieldIsNotReused() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("startTime", dateTime, isRequired: true), + InputFactory.Property("foo", InputPrimitiveType.String, isRequired: true) + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // A custom field renamed onto the shipped name claims it just as a custom property would, because + // customization filtering treats custom fields as claims on property names. + Assert.AreEqual("StartOn", modelTypeProvider.CustomCodeView!.Fields.Single().Name); + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartsOn" })); + } + [Test] public async Task CustomCodeReplacesAcronymNormalizedProperty() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty(Last)/MockInputModel.cs new file mode 100644 index 00000000000..9b42ee1a608 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty(Last)/MockInputModel.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty/MockInputModel.cs new file mode 100644 index 00000000000..2c636a40756 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CustomCodeWithCanonicalNameReplacesPreservedProperty/MockInputModel.cs @@ -0,0 +1,12 @@ +#nullable disable + +using Microsoft.TypeSpec.Generator.Customizations; + +namespace Sample.Models +{ + public partial class MockInputModel + { + [CodeGenMember("StartsOn")] + public System.DateTimeOffset MyStart { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused(Last)/MockInputModel.cs new file mode 100644 index 00000000000..9b42ee1a608 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused(Last)/MockInputModel.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused/MockInputModel.cs new file mode 100644 index 00000000000..3066ed50f2c --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByRenamedCustomFieldIsNotReused/MockInputModel.cs @@ -0,0 +1,12 @@ +#nullable disable + +using Microsoft.TypeSpec.Generator.Customizations; + +namespace Sample.Models +{ + public partial class MockInputModel + { + [CodeGenMember("foo")] + internal string StartOn; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index b7afc141c96..2e938c02355 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -343,6 +343,114 @@ public async Task TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProp new TypeProviderWriter(modelProvider).Write().Content); } + [Test] + public async Task TestPropertyNamePrefersExactCanonicalNameFromLastContract() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + // The contract shipped both the historical "StartOn" and the canonical "StartsOn". The exact + // canonical match wins, so the result does not depend on their declaration order. + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property( + "startTime", + new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String), + isRequired: true) + ]); + + Assert.AreEqual("StartsOn", new ModelProvider(inputModel).Properties.Single().Name); + } + + [Test] + public async Task TestPropertyNameIgnoresIncompatiblyTypedHistoricalName() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + // The contract's "StartOn" is a string, so it is a different property that merely normalizes onto + // the same canonical name. Claiming it would also let ModelProvider restore the shipped string type + // onto this date-time property. + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property( + "startTime", + new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String), + isRequired: true) + ]); + + var property = new ModelProvider(inputModel).Properties.Single(); + + Assert.AreEqual("StartsOn", property.Name); + Assert.AreEqual(new CSharpType(typeof(DateTimeOffset)), property.Type); + } + + [Test] + public async Task TestPropertyNameDoesNotReuseHistoricalNameClaimedByNormalizedSibling() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + // The retained "ipStartOn" only claims the shipped "IPStartOn" after acronym normalization, so the + // claim has to be detected on the generated name rather than the raw identifier. + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property("ipStartOn", InputPrimitiveType.String, isRequired: true), + InputFactory.Property( + "ipStartTime", + new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String), + isRequired: true) + ]); + + Assert.That( + new ModelProvider(inputModel).Properties.Select(p => p.Name), + Is.EqualTo(new[] { "IPStartOn", "IPStartsOn" })); + } + + [Test] + public async Task TestPropertyNamePreservesCollisionSuffixedHistoricalName() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + // The model is named "StartOn", so the shipped member carries the enclosing-type collision suffix + // and is named "StartOnProperty". That suffix has to be stripped before the historical name is + // matched against the canonical one. + var inputModel = InputFactory.Model( + "StartOn", + @namespace: "Test", + properties: + [ + InputFactory.Property( + "startTime", + new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String), + isRequired: true) + ]); + + Assert.AreEqual("StartOnProperty", new ModelProvider(inputModel).Properties.Single().Name); + } + [TestCaseSource(nameof(CollectionPropertyTestCases))] public void CollectionProperty(CSharpType coreType, InputModelProperty collectionProperty, CSharpType expectedType) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByNormalizedSibling/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByNormalizedSibling/TestModel.cs new file mode 100644 index 00000000000..5a891a93556 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseHistoricalNameClaimedByNormalizedSibling/TestModel.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class TestModel + { + public System.DateTimeOffset IPStartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameIgnoresIncompatiblyTypedHistoricalName/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameIgnoresIncompatiblyTypedHistoricalName/TestModel.cs new file mode 100644 index 00000000000..a98d045aef9 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameIgnoresIncompatiblyTypedHistoricalName/TestModel.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class TestModel + { + public string StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePrefersExactCanonicalNameFromLastContract/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePrefersExactCanonicalNameFromLastContract/TestModel.cs new file mode 100644 index 00000000000..7ef464e2788 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePrefersExactCanonicalNameFromLastContract/TestModel.cs @@ -0,0 +1,8 @@ +namespace Test +{ + public class TestModel + { + public System.DateTimeOffset StartOn { get; set; } + public System.DateTimeOffset StartsOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesCollisionSuffixedHistoricalName/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesCollisionSuffixedHistoricalName/TestModel.cs new file mode 100644 index 00000000000..717cf8ea3d7 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesCollisionSuffixedHistoricalName/TestModel.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class StartOn + { + public System.DateTimeOffset StartOnProperty { get; set; } + } +} From 76ba071babffac25a989b6d12f901237ed64bfeb Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 01:22:52 +0000 Subject: [PATCH 12/24] fix: derive historical date-time compatibility candidates --- .../src/Providers/CanonicalTypeProvider.cs | 20 ++++---- .../src/Providers/PropertyProvider.cs | 14 +++--- .../src/Providers/TypeProvider.cs | 30 ++++++++++-- .../src/Shared/CSharpNameExtensions.cs | 46 +++++++++++++++++++ .../ModelProviders/ModelCustomizationTests.cs | 33 +++++++++++++ .../MockInputModel.cs | 9 ++++ .../MockInputModel.cs | 9 ++++ .../test/Providers/PropertyProviderTests.cs | 25 ++++++++++ .../TestModel.cs | 7 +++ 9 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseUnrelatedHistoricalDateTimeName/TestModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs index f08874a6cda..3b16aa6371e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs @@ -33,11 +33,12 @@ public CanonicalTypeProvider(TypeProvider generatedTypeProvider, InputType? inpu foreach (var property in _specProperties) { var name = property.IsExactName ? property.Name : property.Name.ToIdentifierName(); - _specPropertiesMap.TryAdd(name, property); - if (!property.IsExactName) - { - _specPropertiesMap.TryAdd(name.NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()), property); - } + _specPropertiesMap[name] = property; + } + foreach (var property in _specProperties.Where(p => !p.IsExactName)) + { + var name = property.Name.ToIdentifierName(); + _specPropertiesMap.TryAdd(name.NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()), property); } _serializedNameMap = BuildSerializationNameMap(); _renamedProperties = (_generatedTypeProvider.CustomCodeView?.Properties ?? []) @@ -95,12 +96,13 @@ protected internal override PropertyProvider[] BuildProperties() var generatedProperties = _generatedTypeProvider.Properties; var customProperties = _generatedTypeProvider.CustomCodeView?.Properties ?? []; - // Index the spec properties under the names the generator actually produced for them. + // Exact emitted names take precedence over canonical aliases. A canonical alias from an earlier + // property must not shadow a later property that actually emits that name. foreach (var generatedProperty in _generatedTypeProvider.GeneratedPropertiesBySpecName.Values) { if (generatedProperty.InputProperty is InputModelProperty preservedSpecProperty) { - _specPropertiesMap.TryAdd(generatedProperty.Name, preservedSpecProperty); + _specPropertiesMap[generatedProperty.Name] = preservedSpecProperty; } } @@ -172,7 +174,9 @@ protected internal override PropertyProvider[] BuildProperties() // Filter out generated properties that have been customized to avoid duplicates. // This is needed because EnsureBuilt caches members without applying customization // filtering, so _generatedTypeProvider.Properties may contain unfiltered results. - var filteredGeneratedProperties = FilterCustomizedProperties(generatedProperties); + var filteredGeneratedProperties = FilterCustomizedProperties( + generatedProperties, + _generatedTypeProvider.GeneratedPropertiesBySpecName); if (_specProperties.Count > 0) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 1a9c56f7649..e6a8ece038b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -119,14 +119,16 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T if (previousProperty is null && isDateTime) { - // Fall back to a name produced by an earlier date-time naming convention. The candidate has - // to be the same property in all but name, so it must have a compatible type and must not - // already be claimed by another member of this type. + // The previous name is derived from this input property rather than inferred by normalizing + // arbitrary contract members. Date-time normalization is many-to-one, so reverse matching + // could otherwise claim an unrelated removed property such as StartDate for StartTime. + var previousGeneratedName = AvoidPropertyNameCollision( + identifierName.NormalizeCSharpAcronyms().GetPreviousDateTimePropertyName(), + enclosingTypeName); previousProperty = lastContractProperties?.FirstOrDefault(p => + p.Name == previousGeneratedName && p.Type.WithNullable(false).Equals(Type.WithNullable(false)) && - !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType) && - (p.Name == $"{enclosingTypeName}Property" ? enclosingTypeName : p.Name) - .NormalizeCSharpAcronyms(normalizeDateTimeSuffix: true) == canonicalName); + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); } identifierName = previousProperty?.Name ?? canonicalName; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index cf0f6835f10..74b2b5e9d7a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -433,8 +433,19 @@ protected static bool IsResolvableBuildableType(CSharpType type) => protected virtual CSharpType[] GetTypeArguments() => []; - internal PropertyProvider[] FilterCustomizedProperties(IEnumerable specProperties) + internal PropertyProvider[] FilterCustomizedProperties( + IEnumerable specProperties, + IReadOnlyDictionary? knownSpecProperties = null) { + if (knownSpecProperties is not null) + { + foreach (var (name, property) in knownSpecProperties) + { + _generatedPropertiesBySpecName[name] = property; + } + } + + var normalizedNames = new List<(string Name, PropertyProvider Property)>(); foreach (var specProperty in specProperties) { var inputProperty = specProperty.InputProperty; @@ -444,11 +455,20 @@ internal PropertyProvider[] FilterCustomizedProperties(IEnumerable StartsOn cannot shadow a separate startsOn property. + foreach (var (name, property) in normalizedNames) + { + _generatedPropertiesBySpecName.TryAdd(name, property); } var properties = new List(); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index c60a903027c..8dcae0b8848 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -77,6 +77,52 @@ public static string NormalizeDateTimeSuffix(this string name) return prefix + onSuffix; } + /// + /// Gets the property name that the previous Azure management-plane date-time naming convention + /// would have produced. This is a compatibility candidate only; new properties use + /// . + /// + public static string GetPreviousDateTimePropertyName(this string name) + { + if (name.StartsWith("From", StringComparison.Ordinal) || + name.StartsWith("To", StringComparison.Ordinal) || + name.EndsWith("PointInTime", StringComparison.Ordinal)) + { + return name; + } + + int suffixLength; + if (name.Length > 8 && name.EndsWith("DateTime", StringComparison.Ordinal)) + { + suffixLength = 8; + } + else if (name.Length > 4 && + (name.EndsWith("Time", StringComparison.Ordinal) || + name.EndsWith("Date", StringComparison.Ordinal))) + { + suffixLength = 4; + } + else if (name.Length > 2 && name.EndsWith("At", StringComparison.Ordinal)) + { + suffixLength = 2; + } + else + { + return name; + } + + var prefix = name[..^suffixLength]; + prefix = prefix switch + { + "Creation" => "Created", + "Deletion" => "Deleted", + "Expiration" => "Expire", + "Modification" => "Modified", + _ => prefix + }; + return prefix + "On"; + } + private static class DateTimeNameRules { private const string AtSuffix = "At"; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index 4b819b607d4..48373f95387 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -432,6 +432,39 @@ public async Task CustomCodeWithCanonicalNameReplacesPreservedProperty() Assert.AreEqual(0, modelTypeProvider.Properties.Count); } + [Test] + public async Task ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("startTime", dateTime, wireName: "startTime", isRequired: true), + InputFactory.Property("startsOn", dateTime, wireName: "startsOn", isRequired: true) + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // The raw StartsOn customization targets the exact "startsOn" spec property. It must not resolve + // through the earlier startTime property's canonical StartsOn alias and suppress the preserved StartOn. + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartOn" })); + + var canonicalProperties = modelTypeProvider.CanonicalView!.Properties.ToDictionary(p => p.Name); + Assert.That(canonicalProperties.Keys, Is.EquivalentTo(new[] { "StartOn", "StartsOn" })); + Assert.AreEqual("startTime", canonicalProperties["StartOn"].WireInfo!.SerializedName); + Assert.AreEqual("startsOn", canonicalProperties["StartsOn"].WireInfo!.SerializedName); + } + [Test] public async Task HistoricalNameClaimedByRenamedCustomFieldIsNotReused() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization(Last)/MockInputModel.cs new file mode 100644 index 00000000000..2f6c707eae4 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization(Last)/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public class MockInputModel + { + public System.DateTimeOffset StartOn { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization/MockInputModel.cs new file mode 100644 index 00000000000..7c75b590f3b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset StartsOn { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index 2e938c02355..dfc50f8b339 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -343,6 +343,31 @@ public async Task TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProp new TypeProviderWriter(modelProvider).Write().Content); } + [Test] + public async Task TestPropertyNameDoesNotReuseUnrelatedHistoricalDateTimeName() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + // StartDate is a removed GA property, not the previous generated name of the new StartTime property. + // Although both normalize to StartsOn, the new property must not take the unrelated GA name. + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property( + "startTime", + new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String), + isRequired: true) + ]); + + Assert.AreEqual("StartsOn", new ModelProvider(inputModel).Properties.Single().Name); + } + [Test] public async Task TestPropertyNamePrefersExactCanonicalNameFromLastContract() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseUnrelatedHistoricalDateTimeName/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseUnrelatedHistoricalDateTimeName/TestModel.cs new file mode 100644 index 00000000000..b17cea051a3 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseUnrelatedHistoricalDateTimeName/TestModel.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class TestModel + { + public System.DateTimeOffset StartDate { get; set; } + } +} From 74028563d95a6046b02ee0ee190486b75b008f77 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 01:40:36 +0000 Subject: [PATCH 13/24] fix: prioritize exact-name customization targets --- .../src/Providers/TypeProvider.cs | 8 ++++- .../src/Shared/CSharpNameExtensions.cs | 4 +-- .../ModelProviders/ModelCustomizationTests.cs | 34 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 74b2b5e9d7a..bd4f91d25d7 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -449,11 +449,17 @@ internal PropertyProvider[] FilterCustomizedProperties( foreach (var specProperty in specProperties) { var inputProperty = specProperty.InputProperty; - if (inputProperty is null || inputProperty.IsExactName) + if (inputProperty is null) { continue; } + if (inputProperty.IsExactName) + { + _generatedPropertiesBySpecName[inputProperty.Name] = specProperty; + continue; + } + var identifierName = inputProperty.Name.ToIdentifierName(); // Raw and identifier names are exact spec identities and take precedence over aliases left by // an earlier property or filtering pass. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index 8dcae0b8848..fde5dc3b778 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -78,8 +78,8 @@ public static string NormalizeDateTimeSuffix(this string name) } /// - /// Gets the property name that the previous Azure management-plane date-time naming convention - /// would have produced. This is a compatibility candidate only; new properties use + /// Gets the property name that the previous date-time naming convention would have produced. + /// This is a compatibility candidate only; new properties use /// . /// public static string GetPreviousDateTimePropertyName(this string name) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index 48373f95387..ee878f986e6 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -465,6 +465,40 @@ public async Task ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization Assert.AreEqual("startsOn", canonicalProperties["StartsOn"].WireInfo!.SerializedName); } + [Test] + public async Task ExactNameSpecPropertyTakesPrecedenceOverCanonicalAliasForCustomization() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("startTime", dateTime, wireName: "startTime", isRequired: true), + InputFactory.Property("StartsOn", dateTime, wireName: "startsOn", isRequired: true, isExactName: true) + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync( + method: nameof(ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization)), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync( + "Last", + method: nameof(ExactSpecNameTakesPrecedenceOverCanonicalAliasForCustomization))); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartOn" })); + + var canonicalProperties = modelTypeProvider.CanonicalView!.Properties.ToDictionary(p => p.Name); + Assert.That(canonicalProperties.Keys, Is.EquivalentTo(new[] { "StartOn", "StartsOn" })); + Assert.AreEqual("startTime", canonicalProperties["StartOn"].WireInfo!.SerializedName); + Assert.AreEqual("startsOn", canonicalProperties["StartsOn"].WireInfo!.SerializedName); + } + [Test] public async Task HistoricalNameClaimedByRenamedCustomFieldIsNotReused() { From a11e60f76bf7c8beaffc5d753bdecaf5598f6b9b Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 01:55:22 +0000 Subject: [PATCH 14/24] fix: respect claims on canonical contract names --- .../src/Providers/PropertyProvider.cs | 4 ++- .../test/Providers/PropertyProviderTests.cs | 26 +++++++++++++++++++ .../TestModel.cs | 8 ++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index e6a8ece038b..1750d8bc299 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -115,7 +115,9 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T // against the collision-adjusted form of each name we are looking for. var previousProperty = lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)) - ?? lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName)); + ?? lastContractProperties?.FirstOrDefault(p => + p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName) && + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); if (previousProperty is null && isDateTime) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index dfc50f8b339..2e65e0c3e71 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -393,6 +393,32 @@ public async Task TestPropertyNamePrefersExactCanonicalNameFromLastContract() Assert.AreEqual("StartsOn", new ModelProvider(inputModel).Properties.Single().Name); } + [Test] + public async Task TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property("startTime", dateTime, isRequired: true), + InputFactory.Property("startsOn", dateTime, isRequired: true) + ]); + + // The sibling owns the exact canonical StartsOn name, so startTime preserves the still-available + // historical StartOn name rather than producing a duplicate StartsOn declaration. + Assert.That( + new ModelProvider(inputModel).Properties.Select(p => p.Name), + Is.EqualTo(new[] { "StartOn", "StartsOn" })); + } + [Test] public async Task TestPropertyNameIgnoresIncompatiblyTypedHistoricalName() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs new file mode 100644 index 00000000000..7ef464e2788 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs @@ -0,0 +1,8 @@ +namespace Test +{ + public class TestModel + { + public System.DateTimeOffset StartOn { get; set; } + public System.DateTimeOffset StartsOn { get; set; } + } +} From 75f2f44b66aa849bba3d28b532f366b2744a957d Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 03:21:43 +0000 Subject: [PATCH 15/24] refactor: match historical date-time names by stem --- .../src/Providers/PropertyProvider.cs | 35 +++++++++----- .../src/Shared/CSharpNameExtensions.cs | 47 +++---------------- .../test/Providers/PropertyProviderTests.cs | 25 ++++++++++ .../TestModels.cs | 12 +++++ 4 files changed, 67 insertions(+), 52 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalFirstAndLastDateTimeNames/TestModels.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 1750d8bc299..e627163f80c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -119,18 +119,20 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName) && !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); - if (previousProperty is null && isDateTime) + if (previousProperty is null && + isDateTime && + !identifierName.EndsWith("On", StringComparison.Ordinal)) { - // The previous name is derived from this input property rather than inferred by normalizing - // arbitrary contract members. Date-time normalization is many-to-one, so reverse matching - // could otherwise claim an unrelated removed property such as StartDate for StartTime. - var previousGeneratedName = AvoidPropertyNameCollision( - identifierName.NormalizeCSharpAcronyms().GetPreviousDateTimePropertyName(), - enclosingTypeName); - previousProperty = lastContractProperties?.FirstOrDefault(p => - p.Name == previousGeneratedName && - p.Type.WithNullable(false).Equals(Type.WithNullable(false)) && - !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); + // Both the current and previous conventions render date-time names as On. Requiring + // that suffix on the contract name prevents a removed property such as StartDate from being + // mistaken for the historical name of StartTime even though both normalize to StartsOn. + var specStem = identifierName.NormalizeCSharpAcronyms().GetDateTimeStem(); + previousProperty = specStem is null + ? null + : lastContractProperties?.FirstOrDefault(p => + HasDateTimeStem(p.Name, specStem, enclosingTypeName) && + p.Type.WithNullable(false).Equals(Type.WithNullable(false)) && + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); } identifierName = previousProperty?.Name ?? canonicalName; } @@ -201,6 +203,17 @@ private void BuildDocs() private static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) => propertyName == enclosingTypeName ? $"{propertyName}Property" : propertyName; + private static bool HasDateTimeStem(string contractName, string specStem, string enclosingTypeName) + { + if (contractName == AvoidPropertyNameCollision(enclosingTypeName, enclosingTypeName)) + { + contractName = enclosingTypeName; + } + + return contractName.EndsWith("On", StringComparison.Ordinal) && + contractName.GetDateTimeStem() == specStem; + } + private static bool IsClaimedBySiblingProperty( string contractName, InputProperty inputProperty, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index fde5dc3b778..752673f830d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -78,49 +78,14 @@ public static string NormalizeDateTimeSuffix(this string name) } /// - /// Gets the property name that the previous date-time naming convention would have produced. - /// This is a compatibility candidate only; new properties use - /// . + /// Gets the normalized semantic stem of a date-time name by removing its recognized suffix. /// - public static string GetPreviousDateTimePropertyName(this string name) + public static string? GetDateTimeStem(this string name) { - if (name.StartsWith("From", StringComparison.Ordinal) || - name.StartsWith("To", StringComparison.Ordinal) || - name.EndsWith("PointInTime", StringComparison.Ordinal)) - { - return name; - } - - int suffixLength; - if (name.Length > 8 && name.EndsWith("DateTime", StringComparison.Ordinal)) - { - suffixLength = 8; - } - else if (name.Length > 4 && - (name.EndsWith("Time", StringComparison.Ordinal) || - name.EndsWith("Date", StringComparison.Ordinal))) - { - suffixLength = 4; - } - else if (name.Length > 2 && name.EndsWith("At", StringComparison.Ordinal)) - { - suffixLength = 2; - } - else - { - return name; - } - - var prefix = name[..^suffixLength]; - prefix = prefix switch - { - "Creation" => "Created", - "Deletion" => "Deleted", - "Expiration" => "Expire", - "Modification" => "Modified", - _ => prefix - }; - return prefix + "On"; + var suffixLength = DateTimeNameRules.GetSuffixLength(name); + return suffixLength == 0 || suffixLength == name.Length + ? null + : DateTimeNameRules.ToVerbForm(name[..^suffixLength]); } private static class DateTimeNameRules diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index 2e65e0c3e71..f1adb74e7f4 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -343,6 +343,31 @@ public async Task TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProp new TypeProviderWriter(modelProvider).Write().Content); } + [Test] + public async Task TestPropertyNamePreservesHistoricalFirstAndLastDateTimeNames() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var firstModel = InputFactory.Model( + "FirstModel", + @namespace: "Test", + properties: [InputFactory.Property("firstTime", dateTime, isRequired: true)]); + var lastModel = InputFactory.Model( + "LastModel", + @namespace: "Test", + properties: [InputFactory.Property("lastDate", dateTime, isRequired: true)]); + + // First and Last are excluded from canonical normalization, but their GA On names still + // represent the same properties and must remain preserved. + Assert.AreEqual("FirstOn", new ModelProvider(firstModel).Properties.Single().Name); + Assert.AreEqual("LastOn", new ModelProvider(lastModel).Properties.Single().Name); + } + [Test] public async Task TestPropertyNameDoesNotReuseUnrelatedHistoricalDateTimeName() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalFirstAndLastDateTimeNames/TestModels.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalFirstAndLastDateTimeNames/TestModels.cs new file mode 100644 index 00000000000..4efb8499186 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNamePreservesHistoricalFirstAndLastDateTimeNames/TestModels.cs @@ -0,0 +1,12 @@ +namespace Test +{ + public class FirstModel + { + public System.DateTimeOffset FirstOn { get; set; } + } + + public class LastModel + { + public System.DateTimeOffset LastOn { get; set; } + } +} From ff031d887e29120763df37e598d20bd22866dbbe Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 03:28:46 +0000 Subject: [PATCH 16/24] fix: preserve first exact customization match --- .../src/Providers/CanonicalTypeProvider.cs | 8 +++++-- .../ModelProviders/ModelCustomizationTests.cs | 23 +++++++++++++++++++ .../MockInputModel.cs | 9 ++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/DuplicateExactIdentifierCustomizationKeepsFirstSpecMatch/MockInputModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs index 3b16aa6371e..366cee7788d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs @@ -18,6 +18,7 @@ internal class CanonicalTypeProvider : TypeProvider { private readonly TypeProvider _generatedTypeProvider; private readonly Dictionary _specPropertiesMap; + private readonly HashSet _exactSpecPropertyNames; private readonly Dictionary _serializedNameMap; private readonly Dictionary _propertyProviderMap = new(); private readonly HashSet _renamedProperties; @@ -30,10 +31,12 @@ public CanonicalTypeProvider(TypeProvider generatedTypeProvider, InputType? inpu var inputModel = inputType as InputModelType; _specProperties = inputModel?.Properties ?? []; _specPropertiesMap = []; + _exactSpecPropertyNames = []; foreach (var property in _specProperties) { var name = property.IsExactName ? property.Name : property.Name.ToIdentifierName(); - _specPropertiesMap[name] = property; + _specPropertiesMap.TryAdd(name, property); + _exactSpecPropertyNames.Add(name); } foreach (var property in _specProperties.Where(p => !p.IsExactName)) { @@ -100,7 +103,8 @@ protected internal override PropertyProvider[] BuildProperties() // property must not shadow a later property that actually emits that name. foreach (var generatedProperty in _generatedTypeProvider.GeneratedPropertiesBySpecName.Values) { - if (generatedProperty.InputProperty is InputModelProperty preservedSpecProperty) + if (generatedProperty.InputProperty is InputModelProperty preservedSpecProperty && + _exactSpecPropertyNames.Add(generatedProperty.Name)) { _specPropertiesMap[generatedProperty.Name] = preservedSpecProperty; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index ee878f986e6..b35ea25f0eb 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -332,6 +332,29 @@ public async Task CustomCodeRenameWithNameMatchingAnotherSpecPropertyDoesNotSupp Assert.AreEqual($"{Helpers.GetExpectedFromFile("Expected")}\n", new TypeProviderWriter(modelTypeProvider).Write().Content); } + [Test] + public async Task DuplicateExactIdentifierCustomizationKeepsFirstSpecMatch() + { + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("start_time", InputPrimitiveType.String, wireName: "firstWireName"), + InputFactory.Property("startTime", InputPrimitiveType.String, wireName: "secondWireName") + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + var canonicalProperty = modelTypeProvider.CanonicalView!.Properties.Single(); + + // Both spec names collapse to StartTime. Preserve the existing first-wins behavior when resolving + // that exact identifier rather than incidentally changing it while moving aliases to a later pass. + Assert.AreEqual("firstWireName", canonicalProperty.WireInfo!.SerializedName); + } + [Test] public async Task RefilteringPreservesCustomCodeSpecAssociation() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/DuplicateExactIdentifierCustomizationKeepsFirstSpecMatch/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/DuplicateExactIdentifierCustomizationKeepsFirstSpecMatch/MockInputModel.cs new file mode 100644 index 00000000000..e335f6ffad2 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/DuplicateExactIdentifierCustomizationKeepsFirstSpecMatch/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public partial class MockInputModel + { + public string StartTime { get; } + } +} From a3572f4668a1c8d843ae9976765b797b2e32452d Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 03:48:59 +0000 Subject: [PATCH 17/24] fix: harden date-time customization matching --- .../src/Providers/PropertyProvider.cs | 5 ++- .../src/Providers/TypeProvider.cs | 39 ++++++++++++------- .../src/Shared/CSharpNameExtensions.cs | 38 ++++++++++-------- .../ModelProviders/ModelCustomizationTests.cs | 32 +++++++++++++++ .../MockInputModel.cs | 9 +++++ 5 files changed, 92 insertions(+), 31 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringDoesNotUseStaleAliasToSuppressActiveProperty/MockInputModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index e627163f80c..758c7dbe09f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -109,8 +109,11 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime); var enclosingTypeName = enclosingType.Name; var lastContractProperties = enclosingType.LastContractView?.Properties - .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers)); + .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers)) + .ToList(); + // An exact input-identifier match is authoritative: it is the name this property would have had + // before normalization, so it does not need the disambiguation required by normalized candidates. // The shipped member may carry the enclosing-type collision suffix, so candidates are compared // against the collision-adjusted form of each name we are looking for. var previousProperty = diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index bd4f91d25d7..80fff37da73 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -25,6 +25,7 @@ public abstract class TypeProvider private Lazy _specView; private Lazy _declaringTypeName; private readonly InputType? _inputType; + private readonly Dictionary _generatedPropertiesBySpecName = new(StringComparer.Ordinal); protected TypeProvider(InputType? inputType = default) { @@ -437,6 +438,9 @@ internal PropertyProvider[] FilterCustomizedProperties( IEnumerable specProperties, IReadOnlyDictionary? knownSpecProperties = null) { + var propertiesToFilter = specProperties as IReadOnlyList ?? [.. specProperties]; + var activeProperties = new HashSet(propertiesToFilter); + if (knownSpecProperties is not null) { foreach (var (name, property) in knownSpecProperties) @@ -446,7 +450,7 @@ internal PropertyProvider[] FilterCustomizedProperties( } var normalizedNames = new List<(string Name, PropertyProvider Property)>(); - foreach (var specProperty in specProperties) + foreach (var specProperty in propertiesToFilter) { var inputProperty = specProperty.InputProperty; if (inputProperty is null) @@ -482,15 +486,15 @@ internal PropertyProvider[] FilterCustomizedProperties( foreach (var customProperty in BuildAllCustomProperties()) { - AddCustomName(customProperties, customProperty.Name, customProperty.OriginalName, _generatedPropertiesBySpecName); + AddCustomName(customProperties, customProperty.Name, customProperty.OriginalName, _generatedPropertiesBySpecName, activeProperties); } foreach (var customField in BuildAllCustomFields()) { - AddCustomName(customProperties, customField.Name, customField.OriginalName, _generatedPropertiesBySpecName); + AddCustomName(customProperties, customField.Name, customField.OriginalName, _generatedPropertiesBySpecName, activeProperties); } - foreach (var property in specProperties) + foreach (var property in propertiesToFilter) { if (ShouldGenerate(property, customProperties)) { @@ -505,38 +509,47 @@ private static void AddCustomName( HashSet customNames, string name, string? originalName, - IReadOnlyDictionary generatedPropertiesBySpecName) + IReadOnlyDictionary generatedPropertiesBySpecName, + IReadOnlySet activeProperties) { if (originalName is null) { - AddCustomName(customNames, name, generatedPropertiesBySpecName); + AddCustomName(customNames, name, generatedPropertiesBySpecName, activeProperties); return; } customNames.Add(name); - AddCustomName(customNames, originalName, generatedPropertiesBySpecName); + AddCustomName(customNames, originalName, generatedPropertiesBySpecName, activeProperties); } private static void AddCustomName( HashSet customNames, string name, - IReadOnlyDictionary generatedPropertiesBySpecName) + IReadOnlyDictionary generatedPropertiesBySpecName, + IReadOnlySet activeProperties) { customNames.Add(name); - if (generatedPropertiesBySpecName.TryGetValue(name, out var generatedProperty)) + if (generatedPropertiesBySpecName.TryGetValue(name, out var generatedProperty) && + activeProperties.Contains(generatedProperty)) { customNames.Add(generatedProperty.Name); } } - private readonly Dictionary _generatedPropertiesBySpecName = new(StringComparer.Ordinal); - /// /// Maps the spec names a customization can reference to the property the generator built from that spec /// property, as captured by before the properties replaced by - /// custom code were filtered out. Only populated once the properties have been built. + /// custom code were filtered out. Accessing this map ensures the properties have been built so callers + /// do not depend on ordering a separate access before this one. /// - internal IReadOnlyDictionary GeneratedPropertiesBySpecName => _generatedPropertiesBySpecName; + internal IReadOnlyDictionary GeneratedPropertiesBySpecName + { + get + { + _ = Properties; + return _generatedPropertiesBySpecName; + } + } internal FieldProvider[] FilterCustomizedFields(IEnumerable specFields) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index 752673f830d..d78ae06395b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; using Microsoft.TypeSpec.Generator.Input; @@ -106,26 +105,31 @@ private static class DateTimeNameRules private const string TimestampSuffix = "Timestamp"; private const string ToName = "To"; - // Complete prefixes that read better as verbs when combined with the "On" suffix. - private static readonly Dictionary _nounToVerbMap = new(StringComparer.OrdinalIgnoreCase) - { - ["Change"] = "Changed", - ["Creation"] = "Created", - ["Deletion"] = "Deleted", - ["End"] = "Ends", - ["Expiration"] = "Expires", - ["Expire"] = "Expires", - ["Modification"] = "Modified", - ["Start"] = "Starts" - }; + // Complete prefixes that read better as verbs when combined with the "On" suffix. Keep the + // collection ordered so adding overlapping suffixes in the future cannot make compound matching + // depend on dictionary enumeration order. + private static readonly (string Noun, string Verb)[] _nounToVerbMap = + [ + ("Change", "Changed"), + ("Creation", "Created"), + ("Deletion", "Deleted"), + ("End", "Ends"), + ("Expiration", "Expires"), + ("Expire", "Expires"), + ("Modification", "Modified"), + ("Start", "Starts") + ]; internal static string ToVerbForm(string prefix) { - if (_nounToVerbMap.TryGetValue(prefix, out var verb)) + foreach (var (noun, verb) in _nounToVerbMap) { - return char.IsLower(prefix[0]) - ? char.ToLowerInvariant(verb[0]) + verb[1..] - : verb; + if (prefix.Equals(noun, StringComparison.OrdinalIgnoreCase)) + { + return char.IsLower(prefix[0]) + ? char.ToLowerInvariant(verb[0]) + verb[1..] + : verb; + } } foreach (var (noun, compoundVerb) in _nounToVerbMap) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index b35ea25f0eb..2e6eeb65e71 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -392,6 +392,38 @@ public async Task RefilteringPreservesCustomCodeSpecAssociation() Assert.AreEqual("startTime", canonicalProperty.WireInfo!.SerializedName); } + [Test] + public async Task RefilteringDoesNotUseStaleAliasToSuppressActiveProperty() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("startTime", dateTime), + InputFactory.Property("other", InputPrimitiveType.String) + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + var activeProperty = modelTypeProvider.GeneratedPropertiesBySpecName["other"]; + activeProperty.Update(name: "StartsOn"); + + // Simulate a visitor removing the customized startTime property and renaming the remaining property + // before CSharpGen re-applies filtering. The stale startTime -> StartsOn alias must not suppress the + // active property because its mapped provider is no longer in this filtering pass. + modelTypeProvider.Update(properties: [activeProperty]); + + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartsOn" })); + } + [Test] public async Task HistoricalNameClaimedByCustomRenameIsNotReused() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringDoesNotUseStaleAliasToSuppressActiveProperty/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringDoesNotUseStaleAliasToSuppressActiveProperty/MockInputModel.cs new file mode 100644 index 00000000000..96792e2a466 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/RefilteringDoesNotUseStaleAliasToSuppressActiveProperty/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public partial class MockInputModel + { + public System.DateTimeOffset? StartTime { get; } + } +} From 3b5aaa62af22705aaa657f5df7ce8a34df6074ae Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 03:57:16 +0000 Subject: [PATCH 18/24] fix: reconcile date-time names on emitted providers --- .../src/Providers/PropertyProvider.cs | 97 +++++++++++-------- .../src/Providers/TypeProvider.cs | 21 +++- .../test/Providers/PropertyProviderTests.cs | 50 ++++++++++ .../ProjectedData.cs | 7 ++ .../ProjectedData.cs | 7 ++ 5 files changed, 139 insertions(+), 43 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 758c7dbe09f..68381a6855e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -23,6 +23,10 @@ public class PropertyProvider private VariableExpression? _variable; private Lazy _parameter; internal InputProperty? InputProperty { get; set; } + private string? _canonicalDateTimeName; + private string? _canonicalContractDateTimeName; + private string? _historicalContractDateTimeName; + private bool _hasExactInputContractName; private readonly SerializationFormat _serializationFormat; private FormattableString? _customDescription; @@ -116,13 +120,13 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T // before normalization, so it does not need the disambiguation required by normalized candidates. // The shipped member may carry the enclosing-type collision suffix, so candidates are compared // against the collision-adjusted form of each name we are looking for. - var previousProperty = - lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)) - ?? lastContractProperties?.FirstOrDefault(p => - p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName) && - !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); + var inputContractProperty = lastContractProperties?.FirstOrDefault(p => + p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)); + var canonicalContractProperty = lastContractProperties?.FirstOrDefault(p => + p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName)); + PropertyProvider? historicalContractProperty = null; - if (previousProperty is null && + if (inputContractProperty is null && isDateTime && !identifierName.EndsWith("On", StringComparison.Ordinal)) { @@ -130,14 +134,25 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T // that suffix on the contract name prevents a removed property such as StartDate from being // mistaken for the historical name of StartTime even though both normalize to StartsOn. var specStem = identifierName.NormalizeCSharpAcronyms().GetDateTimeStem(); - previousProperty = specStem is null + historicalContractProperty = specStem is null ? null : lastContractProperties?.FirstOrDefault(p => HasDateTimeStem(p.Name, specStem, enclosingTypeName) && - p.Type.WithNullable(false).Equals(Type.WithNullable(false)) && - !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); + p.Type.WithNullable(false).Equals(Type.WithNullable(false))); } - identifierName = previousProperty?.Name ?? canonicalName; + + if (isDateTime) + { + _canonicalDateTimeName = AvoidPropertyNameCollision(canonicalName, enclosingTypeName); + _canonicalContractDateTimeName = canonicalContractProperty?.Name; + _historicalContractDateTimeName = historicalContractProperty?.Name; + _hasExactInputContractName = inputContractProperty is not null; + } + + identifierName = inputContractProperty?.Name + ?? canonicalContractProperty?.Name + ?? historicalContractProperty?.Name + ?? canonicalName; } Name = AvoidPropertyNameCollision(identifierName, enclosingType.Name); Body = new AutoPropertyBody(propHasSetter, setterModifier, GetPropertyInitializationValue(propertyType, inputProperty)); @@ -217,36 +232,40 @@ private static bool HasDateTimeStem(string contractName, string specStem, string contractName.GetDateTimeStem() == specStem; } - private static bool IsClaimedBySiblingProperty( - string contractName, - InputProperty inputProperty, - TypeProvider enclosingType) + /// + /// Reconciles a provisional last-contract name after the enclosing provider has built its complete + /// property collection. This must run before constructors and serialization materialize property names. + /// + internal void ReconcileDateTimeName(IReadOnlyList emittedProperties) { - var enclosingTypeName = enclosingType.Name; - - foreach (var sibling in inputProperty.EnclosingType?.Properties ?? []) + if (_canonicalDateTimeName is null || _hasExactInputContractName || InputProperty is null) { - if (ReferenceEquals(sibling, inputProperty)) - { - continue; - } - - if (sibling.IsExactName) - { - if (AvoidPropertyNameCollision(sibling.Name, enclosingTypeName) == contractName) - { - return true; - } + return; + } - continue; - } + var canonicalIsAvailable = _canonicalContractDateTimeName is not null && + !IsClaimedByOtherMember(_canonicalContractDateTimeName, emittedProperties); + var historicalIsAvailable = _historicalContractDateTimeName is not null && + !IsClaimedByOtherMember(_historicalContractDateTimeName, emittedProperties); + var reconciledName = canonicalIsAvailable + ? _canonicalContractDateTimeName! + : historicalIsAvailable + ? _historicalContractDateTimeName! + : _canonicalDateTimeName; + + if (Name != reconciledName) + { + Update(name: reconciledName); + } + } - // Compare both the raw identifier and the name the sibling actually emits, since acronym - // normalization can move it onto the shipped name even when the identifier does not match. - var siblingIdentifier = sibling.Name.ToIdentifierName(); - var siblingGenerated = siblingIdentifier.NormalizeCSharpAcronyms(sibling.Type.IsDateTimeInputType()); - if (AvoidPropertyNameCollision(siblingIdentifier, enclosingTypeName) == contractName || - AvoidPropertyNameCollision(siblingGenerated, enclosingTypeName) == contractName) + private bool IsClaimedByOtherMember( + string contractName, + IReadOnlyList emittedProperties) + { + foreach (var sibling in emittedProperties) + { + if (!ReferenceEquals(sibling, this) && sibling.Name == contractName) { return true; } @@ -256,10 +275,10 @@ private static bool IsClaimedBySiblingProperty( // A raw-name customization is excluded: it has no declared target, so it is intended to replace // whichever generated property ends up with that name, including a preserved one. Fields are // included because customization filtering treats a custom field as a claim on a property name. - foreach (var customName in GetExplicitlyRenamedCustomNames(enclosingType)) + foreach (var customName in GetExplicitlyRenamedCustomNames(EnclosingType)) { - if (customName.OriginalName == inputProperty.Name || - customName.OriginalName == inputProperty.Name.ToIdentifierName()) + if (customName.OriginalName == InputProperty!.Name || + customName.OriginalName == InputProperty.Name.ToIdentifierName()) { continue; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 80fff37da73..cb0e4d9721d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -323,9 +323,22 @@ LastContractView is { } lastContractView && // Centralizes the FilterCustomizedMembers gate so the property getters and // ProcessTypeForBackCompatibility share identical filtering semantics. private IReadOnlyList ApplyCustomizationFilter(IEnumerable properties) - => FilterCustomizedMembers - ? FilterCustomizedProperties(properties) - : (properties as IReadOnlyList) ?? [.. properties]; + { + var propertiesToFilter = ReconcilePropertyNames(properties); + return FilterCustomizedMembers + ? FilterCustomizedProperties(propertiesToFilter) + : propertiesToFilter; + } + + private static IReadOnlyList ReconcilePropertyNames(IEnumerable properties) + { + var propertyList = properties as IReadOnlyList ?? [.. properties]; + foreach (var property in propertyList) + { + property.ReconcileDateTimeName(propertyList); + } + return propertyList; + } private IReadOnlyList ApplyCustomizationFilter(IEnumerable methods) => FilterCustomizedMembers @@ -938,7 +951,7 @@ internal void EnsureBuilt() // full set of members before customization filtering is applied. _methods ??= BuildMethods(); _constructors ??= BuildConstructors(); - _properties ??= BuildProperties(); + _properties ??= ReconcilePropertyNames(BuildProperties()); _fields ??= BuildFields(); _ = Implements; if (IsEnum) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index f1adb74e7f4..fa0060b2cb5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -312,6 +312,56 @@ public async Task TestPropertyNamePreservesDateTimeNameFromProjectedProviderLast Is.EqualTo(new[] { "LastKeyRotationOn", "StartsOn" })); } + [Test] + public async Task TestProjectedPropertyIgnoresUnprojectedInputSibling() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var unprojectedProperty = InputFactory.Property("startOn", InputPrimitiveType.String, isRequired: true); + var projectedProperty = InputFactory.Property("startTime", dateTime, isRequired: true); + InputFactory.Model( + "WireModel", + @namespace: "Test", + properties: [unprojectedProperty, projectedProperty]); + + var projectedProvider = new TestTypeProvider(name: "ProjectedData", ns: "Test"); + projectedProvider.Update(properties: [new PropertyProvider(projectedProperty, projectedProvider)]); + + Assert.That(projectedProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartOn" })); + } + + [Test] + public async Task TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var normalizedProperty = InputFactory.Property("startTime", dateTime, isRequired: true); + var retainedProperty = InputFactory.Property("startOn", InputPrimitiveType.String, isRequired: true); + InputFactory.Model("FirstWireModel", @namespace: "Test", properties: [normalizedProperty]); + InputFactory.Model("SecondWireModel", @namespace: "Test", properties: [retainedProperty]); + + var projectedProvider = new TestTypeProvider(name: "ProjectedData", ns: "Test"); + projectedProvider.Update(properties: + [ + new PropertyProvider(normalizedProperty, projectedProvider), + new PropertyProvider(retainedProperty, projectedProvider) + ]); + + Assert.That( + projectedProvider.Properties.Select(p => p.Name), + Is.EqualTo(new[] { "StartsOn", "StartOn" })); + } + [Test] public async Task TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs new file mode 100644 index 00000000000..7bf2df793c5 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class ProjectedData + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs new file mode 100644 index 00000000000..7bf2df793c5 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class ProjectedData + { + public System.DateTimeOffset StartOn { get; set; } + } +} From b7b952eb869df035adcc1aae2bd19bc643625bb5 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 04:00:23 +0000 Subject: [PATCH 19/24] fix: preserve first exact spec mapping --- .../src/Providers/TypeProvider.cs | 61 +++++++++++-------- .../ModelProviders/ModelCustomizationTests.cs | 31 ++++++++++ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index cb0e4d9721d..48760b6d111 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -456,42 +456,55 @@ internal PropertyProvider[] FilterCustomizedProperties( if (knownSpecProperties is not null) { + // The supplied map came from the complete generated property set and already resolved exact-name + // precedence. Do not rebuild it from this potentially filtered subset. foreach (var (name, property) in knownSpecProperties) { _generatedPropertiesBySpecName[name] = property; } } - - var normalizedNames = new List<(string Name, PropertyProvider Property)>(); - foreach (var specProperty in propertiesToFilter) + else { - var inputProperty = specProperty.InputProperty; - if (inputProperty is null) + var exactNames = new HashSet(StringComparer.Ordinal); + var normalizedNames = new List<(string Name, PropertyProvider Property)>(); + foreach (var specProperty in propertiesToFilter) { - continue; + var inputProperty = specProperty.InputProperty; + if (inputProperty is null) + { + continue; + } + + if (inputProperty.IsExactName) + { + AddExactName(inputProperty.Name, specProperty); + continue; + } + + var identifierName = inputProperty.Name.ToIdentifierName(); + // The first exact identity in this pass replaces any stale alias, while a later exact + // collision keeps the established first-wins behavior. + AddExactName(inputProperty.Name, specProperty); + AddExactName(identifierName, specProperty); + normalizedNames.Add(( + identifierName.NormalizeCSharpAcronyms(inputProperty.Type.IsDateTimeInputType()), + specProperty)); } - if (inputProperty.IsExactName) + // Canonical names are aliases. Add them only after all exact names have been registered so an + // alias such as startTime -> StartsOn cannot shadow a separate startsOn property. + foreach (var (name, property) in normalizedNames) { - _generatedPropertiesBySpecName[inputProperty.Name] = specProperty; - continue; + _generatedPropertiesBySpecName.TryAdd(name, property); } - var identifierName = inputProperty.Name.ToIdentifierName(); - // Raw and identifier names are exact spec identities and take precedence over aliases left by - // an earlier property or filtering pass. - _generatedPropertiesBySpecName[inputProperty.Name] = specProperty; - _generatedPropertiesBySpecName[identifierName] = specProperty; - normalizedNames.Add(( - identifierName.NormalizeCSharpAcronyms(inputProperty.Type.IsDateTimeInputType()), - specProperty)); - } - - // Canonical names are aliases. Add them only after all exact names have been registered so an alias - // such as startTime -> StartsOn cannot shadow a separate startsOn property. - foreach (var (name, property) in normalizedNames) - { - _generatedPropertiesBySpecName.TryAdd(name, property); + void AddExactName(string name, PropertyProvider property) + { + if (exactNames.Add(name)) + { + _generatedPropertiesBySpecName[name] = property; + } + } } var properties = new List(); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index 2e6eeb65e71..cefa2e2685f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -355,6 +355,37 @@ public async Task DuplicateExactIdentifierCustomizationKeepsFirstSpecMatch() Assert.AreEqual("firstWireName", canonicalProperty.WireInfo!.SerializedName); } + [Test] + public async Task DuplicateExactIdentifierCustomizationSuppressesOnlyFirstSpecMatch() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var inputModel = InputFactory.Model( + "mockInputModel", + properties: + [ + InputFactory.Property("start_time", InputPrimitiveType.String, wireName: "firstWireName"), + InputFactory.Property("startTime", dateTime, wireName: "secondWireName") + ]); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync( + method: nameof(DuplicateExactIdentifierCustomizationKeepsFirstSpecMatch))); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // StartTime targets the first exact identifier. It must not resolve to the second property's alias + // and suppress that property's distinct StartsOn output as well. + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartsOn" })); + Assert.That( + modelTypeProvider.CanonicalView!.Properties.Select(p => p.WireInfo!.SerializedName), + Is.EquivalentTo(new[] { "firstWireName", "secondWireName" })); + } + [Test] public async Task RefilteringPreservesCustomCodeSpecAssociation() { From 83d94f97f87a4a7df88868a34dfee000d5a3d983 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 04:03:38 +0000 Subject: [PATCH 20/24] Revert "fix: reconcile date-time names on emitted providers" This reverts commit 3b5aaa62af22705aaa657f5df7ce8a34df6074ae. --- .../src/Providers/PropertyProvider.cs | 97 ++++++++----------- .../src/Providers/TypeProvider.cs | 21 +--- .../test/Providers/PropertyProviderTests.cs | 50 ---------- .../ProjectedData.cs | 7 -- .../ProjectedData.cs | 7 -- 5 files changed, 43 insertions(+), 139 deletions(-) delete mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs delete mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 68381a6855e..758c7dbe09f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -23,10 +23,6 @@ public class PropertyProvider private VariableExpression? _variable; private Lazy _parameter; internal InputProperty? InputProperty { get; set; } - private string? _canonicalDateTimeName; - private string? _canonicalContractDateTimeName; - private string? _historicalContractDateTimeName; - private bool _hasExactInputContractName; private readonly SerializationFormat _serializationFormat; private FormattableString? _customDescription; @@ -120,13 +116,13 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T // before normalization, so it does not need the disambiguation required by normalized candidates. // The shipped member may carry the enclosing-type collision suffix, so candidates are compared // against the collision-adjusted form of each name we are looking for. - var inputContractProperty = lastContractProperties?.FirstOrDefault(p => - p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)); - var canonicalContractProperty = lastContractProperties?.FirstOrDefault(p => - p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName)); - PropertyProvider? historicalContractProperty = null; + var previousProperty = + lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)) + ?? lastContractProperties?.FirstOrDefault(p => + p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName) && + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); - if (inputContractProperty is null && + if (previousProperty is null && isDateTime && !identifierName.EndsWith("On", StringComparison.Ordinal)) { @@ -134,25 +130,14 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T // that suffix on the contract name prevents a removed property such as StartDate from being // mistaken for the historical name of StartTime even though both normalize to StartsOn. var specStem = identifierName.NormalizeCSharpAcronyms().GetDateTimeStem(); - historicalContractProperty = specStem is null + previousProperty = specStem is null ? null : lastContractProperties?.FirstOrDefault(p => HasDateTimeStem(p.Name, specStem, enclosingTypeName) && - p.Type.WithNullable(false).Equals(Type.WithNullable(false))); + p.Type.WithNullable(false).Equals(Type.WithNullable(false)) && + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); } - - if (isDateTime) - { - _canonicalDateTimeName = AvoidPropertyNameCollision(canonicalName, enclosingTypeName); - _canonicalContractDateTimeName = canonicalContractProperty?.Name; - _historicalContractDateTimeName = historicalContractProperty?.Name; - _hasExactInputContractName = inputContractProperty is not null; - } - - identifierName = inputContractProperty?.Name - ?? canonicalContractProperty?.Name - ?? historicalContractProperty?.Name - ?? canonicalName; + identifierName = previousProperty?.Name ?? canonicalName; } Name = AvoidPropertyNameCollision(identifierName, enclosingType.Name); Body = new AutoPropertyBody(propHasSetter, setterModifier, GetPropertyInitializationValue(propertyType, inputProperty)); @@ -232,40 +217,36 @@ private static bool HasDateTimeStem(string contractName, string specStem, string contractName.GetDateTimeStem() == specStem; } - /// - /// Reconciles a provisional last-contract name after the enclosing provider has built its complete - /// property collection. This must run before constructors and serialization materialize property names. - /// - internal void ReconcileDateTimeName(IReadOnlyList emittedProperties) + private static bool IsClaimedBySiblingProperty( + string contractName, + InputProperty inputProperty, + TypeProvider enclosingType) { - if (_canonicalDateTimeName is null || _hasExactInputContractName || InputProperty is null) - { - return; - } + var enclosingTypeName = enclosingType.Name; - var canonicalIsAvailable = _canonicalContractDateTimeName is not null && - !IsClaimedByOtherMember(_canonicalContractDateTimeName, emittedProperties); - var historicalIsAvailable = _historicalContractDateTimeName is not null && - !IsClaimedByOtherMember(_historicalContractDateTimeName, emittedProperties); - var reconciledName = canonicalIsAvailable - ? _canonicalContractDateTimeName! - : historicalIsAvailable - ? _historicalContractDateTimeName! - : _canonicalDateTimeName; - - if (Name != reconciledName) + foreach (var sibling in inputProperty.EnclosingType?.Properties ?? []) { - Update(name: reconciledName); - } - } + if (ReferenceEquals(sibling, inputProperty)) + { + continue; + } - private bool IsClaimedByOtherMember( - string contractName, - IReadOnlyList emittedProperties) - { - foreach (var sibling in emittedProperties) - { - if (!ReferenceEquals(sibling, this) && sibling.Name == contractName) + if (sibling.IsExactName) + { + if (AvoidPropertyNameCollision(sibling.Name, enclosingTypeName) == contractName) + { + return true; + } + + continue; + } + + // Compare both the raw identifier and the name the sibling actually emits, since acronym + // normalization can move it onto the shipped name even when the identifier does not match. + var siblingIdentifier = sibling.Name.ToIdentifierName(); + var siblingGenerated = siblingIdentifier.NormalizeCSharpAcronyms(sibling.Type.IsDateTimeInputType()); + if (AvoidPropertyNameCollision(siblingIdentifier, enclosingTypeName) == contractName || + AvoidPropertyNameCollision(siblingGenerated, enclosingTypeName) == contractName) { return true; } @@ -275,10 +256,10 @@ private bool IsClaimedByOtherMember( // A raw-name customization is excluded: it has no declared target, so it is intended to replace // whichever generated property ends up with that name, including a preserved one. Fields are // included because customization filtering treats a custom field as a claim on a property name. - foreach (var customName in GetExplicitlyRenamedCustomNames(EnclosingType)) + foreach (var customName in GetExplicitlyRenamedCustomNames(enclosingType)) { - if (customName.OriginalName == InputProperty!.Name || - customName.OriginalName == InputProperty.Name.ToIdentifierName()) + if (customName.OriginalName == inputProperty.Name || + customName.OriginalName == inputProperty.Name.ToIdentifierName()) { continue; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 48760b6d111..112462f6cd2 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -323,22 +323,9 @@ LastContractView is { } lastContractView && // Centralizes the FilterCustomizedMembers gate so the property getters and // ProcessTypeForBackCompatibility share identical filtering semantics. private IReadOnlyList ApplyCustomizationFilter(IEnumerable properties) - { - var propertiesToFilter = ReconcilePropertyNames(properties); - return FilterCustomizedMembers - ? FilterCustomizedProperties(propertiesToFilter) - : propertiesToFilter; - } - - private static IReadOnlyList ReconcilePropertyNames(IEnumerable properties) - { - var propertyList = properties as IReadOnlyList ?? [.. properties]; - foreach (var property in propertyList) - { - property.ReconcileDateTimeName(propertyList); - } - return propertyList; - } + => FilterCustomizedMembers + ? FilterCustomizedProperties(properties) + : (properties as IReadOnlyList) ?? [.. properties]; private IReadOnlyList ApplyCustomizationFilter(IEnumerable methods) => FilterCustomizedMembers @@ -964,7 +951,7 @@ internal void EnsureBuilt() // full set of members before customization filtering is applied. _methods ??= BuildMethods(); _constructors ??= BuildConstructors(); - _properties ??= ReconcilePropertyNames(BuildProperties()); + _properties ??= BuildProperties(); _fields ??= BuildFields(); _ = Implements; if (IsEnum) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index fa0060b2cb5..f1adb74e7f4 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -312,56 +312,6 @@ public async Task TestPropertyNamePreservesDateTimeNameFromProjectedProviderLast Is.EqualTo(new[] { "LastKeyRotationOn", "StartsOn" })); } - [Test] - public async Task TestProjectedPropertyIgnoresUnprojectedInputSibling() - { - await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); - - var dateTime = new InputDateTimeType( - DateTimeKnownEncoding.Rfc3339, - "utcDateTime", - "TypeSpec.utcDateTime", - InputPrimitiveType.String); - var unprojectedProperty = InputFactory.Property("startOn", InputPrimitiveType.String, isRequired: true); - var projectedProperty = InputFactory.Property("startTime", dateTime, isRequired: true); - InputFactory.Model( - "WireModel", - @namespace: "Test", - properties: [unprojectedProperty, projectedProperty]); - - var projectedProvider = new TestTypeProvider(name: "ProjectedData", ns: "Test"); - projectedProvider.Update(properties: [new PropertyProvider(projectedProperty, projectedProvider)]); - - Assert.That(projectedProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartOn" })); - } - - [Test] - public async Task TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName() - { - await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); - - var dateTime = new InputDateTimeType( - DateTimeKnownEncoding.Rfc3339, - "utcDateTime", - "TypeSpec.utcDateTime", - InputPrimitiveType.String); - var normalizedProperty = InputFactory.Property("startTime", dateTime, isRequired: true); - var retainedProperty = InputFactory.Property("startOn", InputPrimitiveType.String, isRequired: true); - InputFactory.Model("FirstWireModel", @namespace: "Test", properties: [normalizedProperty]); - InputFactory.Model("SecondWireModel", @namespace: "Test", properties: [retainedProperty]); - - var projectedProvider = new TestTypeProvider(name: "ProjectedData", ns: "Test"); - projectedProvider.Update(properties: - [ - new PropertyProvider(normalizedProperty, projectedProvider), - new PropertyProvider(retainedProperty, projectedProvider) - ]); - - Assert.That( - projectedProvider.Properties.Select(p => p.Name), - Is.EqualTo(new[] { "StartsOn", "StartOn" })); - } - [Test] public async Task TestPropertyNameDoesNotReuseHistoricalNameClaimedByAnotherProperty() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs deleted file mode 100644 index 7bf2df793c5..00000000000 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertiesFromDifferentModelsDoNotReuseClaimedName/ProjectedData.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Test -{ - public class ProjectedData - { - public System.DateTimeOffset StartOn { get; set; } - } -} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs deleted file mode 100644 index 7bf2df793c5..00000000000 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestProjectedPropertyIgnoresUnprojectedInputSibling/ProjectedData.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Test -{ - public class ProjectedData - { - public System.DateTimeOffset StartOn { get; set; } - } -} From 788eaab74b02c8c0803607becc150f19b1caf51b Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 04:10:58 +0000 Subject: [PATCH 21/24] test: make date-time ownership invariants explicit --- .../src/Providers/TypeProvider.cs | 4 ++- .../src/Shared/CSharpNameExtensions.cs | 6 ++--- .../test/Providers/PropertyProviderTests.cs | 25 +++++++++++++++++++ .../TestModel.cs | 7 ++++++ .../TestModel.cs | 2 +- 5 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestDateTimeNameAlreadyEndingOnDoesNotUseHistoricalStemMatch/TestModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 112462f6cd2..05bd45f50a1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -439,7 +439,9 @@ internal PropertyProvider[] FilterCustomizedProperties( IReadOnlyDictionary? knownSpecProperties = null) { var propertiesToFilter = specProperties as IReadOnlyList ?? [.. specProperties]; - var activeProperties = new HashSet(propertiesToFilter); + var activeProperties = new HashSet( + propertiesToFilter, + ReferenceEqualityComparer.Instance); if (knownSpecProperties is not null) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index d78ae06395b..cbbea0510b1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -108,7 +108,7 @@ private static class DateTimeNameRules // Complete prefixes that read better as verbs when combined with the "On" suffix. Keep the // collection ordered so adding overlapping suffixes in the future cannot make compound matching // depend on dictionary enumeration order. - private static readonly (string Noun, string Verb)[] _nounToVerbMap = + private static readonly (string Noun, string Verb)[] _nounToVerbRules = [ ("Change", "Changed"), ("Creation", "Created"), @@ -122,7 +122,7 @@ private static readonly (string Noun, string Verb)[] _nounToVerbMap = internal static string ToVerbForm(string prefix) { - foreach (var (noun, verb) in _nounToVerbMap) + foreach (var (noun, verb) in _nounToVerbRules) { if (prefix.Equals(noun, StringComparison.OrdinalIgnoreCase)) { @@ -132,7 +132,7 @@ internal static string ToVerbForm(string prefix) } } - foreach (var (noun, compoundVerb) in _nounToVerbMap) + foreach (var (noun, compoundVerb) in _nounToVerbRules) { if (prefix.Length > noun.Length && prefix.EndsWith(noun, StringComparison.OrdinalIgnoreCase) && diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs index f1adb74e7f4..b07e9428ede 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/PropertyProviderTests.cs @@ -444,6 +444,31 @@ public async Task TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling() Is.EqualTo(new[] { "StartOn", "StartsOn" })); } + [Test] + public async Task TestDateTimeNameAlreadyEndingOnDoesNotUseHistoricalStemMatch() + { + await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var inputModel = InputFactory.Model( + "TestModel", + @namespace: "Test", + properties: + [ + InputFactory.Property( + "startsOn", + new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String), + isRequired: true) + ]); + + // A spec name already in On form has no earlier spelling to recover. The GA StartOn member + // belongs to a different property and must not be selected merely because its stem also normalizes. + Assert.AreEqual("StartsOn", new ModelProvider(inputModel).Properties.Single().Name); + } + [Test] public async Task TestPropertyNameIgnoresIncompatiblyTypedHistoricalName() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestDateTimeNameAlreadyEndingOnDoesNotUseHistoricalStemMatch/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestDateTimeNameAlreadyEndingOnDoesNotUseHistoricalStemMatch/TestModel.cs new file mode 100644 index 00000000000..16bb8a8f091 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestDateTimeNameAlreadyEndingOnDoesNotUseHistoricalStemMatch/TestModel.cs @@ -0,0 +1,7 @@ +namespace Test +{ + public class TestModel + { + public System.DateTimeOffset StartOn { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs index 7ef464e2788..54273576a50 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/PropertyProviderTests/TestPropertyNameDoesNotReuseCanonicalNameClaimedBySibling/TestModel.cs @@ -2,7 +2,7 @@ namespace Test { public class TestModel { - public System.DateTimeOffset StartOn { get; set; } public System.DateTimeOffset StartsOn { get; set; } + public System.DateTimeOffset StartOn { get; set; } } } From 95cb3cc6b9de9545577e578bd31e1c09c70ed5b8 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 04:18:46 +0000 Subject: [PATCH 22/24] refactor: make customization map initialization explicit --- .../src/Providers/CanonicalTypeProvider.cs | 7 +++++-- .../src/Providers/TypeProvider.cs | 12 ++---------- .../src/Shared/CSharpNameExtensions.cs | 2 ++ 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs index 366cee7788d..0446fcef30d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs @@ -96,12 +96,15 @@ protected internal override MethodProvider[] BuildMethods() protected internal override PropertyProvider[] BuildProperties() { + // Building Properties populates GeneratedPropertiesBySpecName as part of customization filtering. + // Keep these reads together and in this order so the map is never observed mid-build. var generatedProperties = _generatedTypeProvider.Properties; + var generatedPropertiesBySpecName = _generatedTypeProvider.GeneratedPropertiesBySpecName; var customProperties = _generatedTypeProvider.CustomCodeView?.Properties ?? []; // Exact emitted names take precedence over canonical aliases. A canonical alias from an earlier // property must not shadow a later property that actually emits that name. - foreach (var generatedProperty in _generatedTypeProvider.GeneratedPropertiesBySpecName.Values) + foreach (var generatedProperty in generatedPropertiesBySpecName.Values) { if (generatedProperty.InputProperty is InputModelProperty preservedSpecProperty && _exactSpecPropertyNames.Add(generatedProperty.Name)) @@ -180,7 +183,7 @@ protected internal override PropertyProvider[] BuildProperties() // filtering, so _generatedTypeProvider.Properties may contain unfiltered results. var filteredGeneratedProperties = FilterCustomizedProperties( generatedProperties, - _generatedTypeProvider.GeneratedPropertiesBySpecName); + generatedPropertiesBySpecName); if (_specProperties.Count > 0) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 05bd45f50a1..564e3bbd43f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -554,17 +554,9 @@ private static void AddCustomName( /// /// Maps the spec names a customization can reference to the property the generator built from that spec /// property, as captured by before the properties replaced by - /// custom code were filtered out. Accessing this map ensures the properties have been built so callers - /// do not depend on ordering a separate access before this one. + /// custom code were filtered out. Callers must build before reading this map. /// - internal IReadOnlyDictionary GeneratedPropertiesBySpecName - { - get - { - _ = Properties; - return _generatedPropertiesBySpecName; - } - } + internal IReadOnlyDictionary GeneratedPropertiesBySpecName => _generatedPropertiesBySpecName; internal FieldProvider[] FilterCustomizedFields(IEnumerable specFields) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs index cbbea0510b1..fe73f4bf19c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/CSharpNameExtensions.cs @@ -122,6 +122,8 @@ private static readonly (string Noun, string Verb)[] _nounToVerbRules = internal static string ToVerbForm(string prefix) { + // Resolve all exact rules before considering compound suffixes so an overlapping rule added + // later cannot be preempted by an earlier compound match. foreach (var (noun, verb) in _nounToVerbRules) { if (prefix.Equals(noun, StringComparison.OrdinalIgnoreCase)) From 001c070ce2a1d899b89389988fb57e4ad32be8cc Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 04:29:29 +0000 Subject: [PATCH 23/24] fix: include inherited custom name claims --- .../src/Providers/PropertyProvider.cs | 21 +------------- .../src/Providers/TypeProvider.cs | 19 +++++++++++++ .../ModelProviders/ModelCustomizationTests.cs | 28 +++++++++++++++++++ .../MockInputModel.cs | 9 ++++++ .../BaseModel.cs | 12 ++++++++ 5 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 758c7dbe09f..05d21fde9a3 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -256,7 +256,7 @@ private static bool IsClaimedBySiblingProperty( // A raw-name customization is excluded: it has no declared target, so it is intended to replace // whichever generated property ends up with that name, including a preserved one. Fields are // included because customization filtering treats a custom field as a claim on a property name. - foreach (var customName in GetExplicitlyRenamedCustomNames(enclosingType)) + foreach (var customName in enclosingType.GetExplicitlyRenamedCustomMemberNames()) { if (customName.OriginalName == inputProperty.Name || customName.OriginalName == inputProperty.Name.ToIdentifierName()) @@ -273,25 +273,6 @@ private static bool IsClaimedBySiblingProperty( return false; } - private static IEnumerable<(string Name, string OriginalName)> GetExplicitlyRenamedCustomNames(TypeProvider enclosingType) - { - foreach (var customProperty in enclosingType.CustomCodeView?.Properties ?? []) - { - if (customProperty.OriginalName is { } propertyOriginalName) - { - yield return (customProperty.Name, propertyOriginalName); - } - } - - foreach (var customField in enclosingType.CustomCodeView?.Fields ?? []) - { - if (customField.OriginalName is { } fieldOriginalName) - { - yield return (customField.Name, fieldOriginalName); - } - } - } - private static bool IsPropertyPrivate(MethodSignatureModifiers modifiers, TypeSignatureModifiers enclosingTypeModifiers) { return (modifiers.HasFlag(MethodSignatureModifiers.Private) && !modifiers.HasFlag(MethodSignatureModifiers.Protected)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 564e3bbd43f..96fe3aaf0d0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -149,6 +149,25 @@ private IReadOnlyList BuildAllCustomFields() return allCustomFields; } + internal IEnumerable<(string Name, string OriginalName)> GetExplicitlyRenamedCustomMemberNames() + { + foreach (var customProperty in BuildAllCustomProperties()) + { + if (customProperty.OriginalName is { } originalName) + { + yield return (customProperty.Name, originalName); + } + } + + foreach (var customField in BuildAllCustomFields()) + { + if (customField.OriginalName is { } originalName) + { + yield return (customField.Name, originalName); + } + } + } + private protected virtual CanonicalTypeProvider BuildCanonicalView() => new CanonicalTypeProvider(this, _inputType); public TypeProvider CanonicalView => _canonicalView.Value; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index cefa2e2685f..69be876eb80 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -585,6 +585,34 @@ public async Task ExactNameSpecPropertyTakesPrecedenceOverCanonicalAliasForCusto Assert.AreEqual("startsOn", canonicalProperties["StartsOn"].WireInfo!.SerializedName); } + [Test] + public async Task HistoricalNameClaimedByInheritedCustomRenameIsNotReused() + { + var dateTime = new InputDateTimeType( + DateTimeKnownEncoding.Rfc3339, + "utcDateTime", + "TypeSpec.utcDateTime", + InputPrimitiveType.String); + var baseModel = InputFactory.Model( + "baseModel", + properties: [InputFactory.Property("foo", InputPrimitiveType.String, isRequired: true)]); + var derivedModel = InputFactory.Model( + "mockInputModel", + properties: [InputFactory.Property("startTime", dateTime, isRequired: true)], + baseModel: baseModel); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [derivedModel, baseModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + + // The base customization explicitly renames another spec property to StartOn. Since inherited custom + // members participate in filtering, they must also claim names during historical-name selection. + Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartsOn" })); + } + [Test] public async Task HistoricalNameClaimedByRenamedCustomFieldIsNotReused() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs new file mode 100644 index 00000000000..2f6c707eae4 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public class MockInputModel + { + public System.DateTimeOffset StartOn { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs new file mode 100644 index 00000000000..815bffddac6 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs @@ -0,0 +1,12 @@ +#nullable disable + +using Microsoft.TypeSpec.Generator.Customizations; + +namespace Sample.Models +{ + public partial class BaseModel + { + [CodeGenMember("foo")] + public string StartOn { get; } + } +} From 5b0a73145e8da191ca97fb7024755e4f251b89d4 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 28 Aug 2026 04:32:23 +0000 Subject: [PATCH 24/24] Revert "fix: include inherited custom name claims" This reverts commit 001c070ce2a1d899b89389988fb57e4ad32be8cc. --- .../src/Providers/PropertyProvider.cs | 21 +++++++++++++- .../src/Providers/TypeProvider.cs | 19 ------------- .../ModelProviders/ModelCustomizationTests.cs | 28 ------------------- .../MockInputModel.cs | 9 ------ .../BaseModel.cs | 12 -------- 5 files changed, 20 insertions(+), 69 deletions(-) delete mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs delete mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs index 05d21fde9a3..758c7dbe09f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs @@ -256,7 +256,7 @@ private static bool IsClaimedBySiblingProperty( // A raw-name customization is excluded: it has no declared target, so it is intended to replace // whichever generated property ends up with that name, including a preserved one. Fields are // included because customization filtering treats a custom field as a claim on a property name. - foreach (var customName in enclosingType.GetExplicitlyRenamedCustomMemberNames()) + foreach (var customName in GetExplicitlyRenamedCustomNames(enclosingType)) { if (customName.OriginalName == inputProperty.Name || customName.OriginalName == inputProperty.Name.ToIdentifierName()) @@ -273,6 +273,25 @@ private static bool IsClaimedBySiblingProperty( return false; } + private static IEnumerable<(string Name, string OriginalName)> GetExplicitlyRenamedCustomNames(TypeProvider enclosingType) + { + foreach (var customProperty in enclosingType.CustomCodeView?.Properties ?? []) + { + if (customProperty.OriginalName is { } propertyOriginalName) + { + yield return (customProperty.Name, propertyOriginalName); + } + } + + foreach (var customField in enclosingType.CustomCodeView?.Fields ?? []) + { + if (customField.OriginalName is { } fieldOriginalName) + { + yield return (customField.Name, fieldOriginalName); + } + } + } + private static bool IsPropertyPrivate(MethodSignatureModifiers modifiers, TypeSignatureModifiers enclosingTypeModifiers) { return (modifiers.HasFlag(MethodSignatureModifiers.Private) && !modifiers.HasFlag(MethodSignatureModifiers.Protected)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 96fe3aaf0d0..564e3bbd43f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -149,25 +149,6 @@ private IReadOnlyList BuildAllCustomFields() return allCustomFields; } - internal IEnumerable<(string Name, string OriginalName)> GetExplicitlyRenamedCustomMemberNames() - { - foreach (var customProperty in BuildAllCustomProperties()) - { - if (customProperty.OriginalName is { } originalName) - { - yield return (customProperty.Name, originalName); - } - } - - foreach (var customField in BuildAllCustomFields()) - { - if (customField.OriginalName is { } originalName) - { - yield return (customField.Name, originalName); - } - } - } - private protected virtual CanonicalTypeProvider BuildCanonicalView() => new CanonicalTypeProvider(this, _inputType); public TypeProvider CanonicalView => _canonicalView.Value; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index 69be876eb80..cefa2e2685f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -585,34 +585,6 @@ public async Task ExactNameSpecPropertyTakesPrecedenceOverCanonicalAliasForCusto Assert.AreEqual("startsOn", canonicalProperties["StartsOn"].WireInfo!.SerializedName); } - [Test] - public async Task HistoricalNameClaimedByInheritedCustomRenameIsNotReused() - { - var dateTime = new InputDateTimeType( - DateTimeKnownEncoding.Rfc3339, - "utcDateTime", - "TypeSpec.utcDateTime", - InputPrimitiveType.String); - var baseModel = InputFactory.Model( - "baseModel", - properties: [InputFactory.Property("foo", InputPrimitiveType.String, isRequired: true)]); - var derivedModel = InputFactory.Model( - "mockInputModel", - properties: [InputFactory.Property("startTime", dateTime, isRequired: true)], - baseModel: baseModel); - - var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( - inputModelTypes: [derivedModel, baseModel], - compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), - lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Last")); - - var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); - - // The base customization explicitly renames another spec property to StartOn. Since inherited custom - // members participate in filtering, they must also claim names during historical-name selection. - Assert.That(modelTypeProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "StartsOn" })); - } - [Test] public async Task HistoricalNameClaimedByRenamedCustomFieldIsNotReused() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs deleted file mode 100644 index 2f6c707eae4..00000000000 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused(Last)/MockInputModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -#nullable disable - -namespace Sample.Models -{ - public class MockInputModel - { - public System.DateTimeOffset StartOn { get; } - } -} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs deleted file mode 100644 index 815bffddac6..00000000000 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/HistoricalNameClaimedByInheritedCustomRenameIsNotReused/BaseModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -#nullable disable - -using Microsoft.TypeSpec.Generator.Customizations; - -namespace Sample.Models -{ - public partial class BaseModel - { - [CodeGenMember("foo")] - public string StartOn { get; } - } -}