From c41223b6d2241995f9c098c3091326b1b907dade Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:04:31 +0000 Subject: [PATCH 1/6] Initial plan From ccc0595f1451b649308a857e3164c8a0b20a26a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:37:20 +0000 Subject: [PATCH 2/6] Restore removed extensible enum members via back compat Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/LibraryVisitor.cs | 2 +- .../src/Providers/EnumProvider.cs | 2 +- .../src/Providers/ExtensibleEnumProvider.cs | 83 +++++++++++++++++++ .../src/Providers/NamedTypeSymbolProvider.cs | 12 +-- .../src/Providers/TypeProvider.cs | 39 +++++++-- .../test/OutputLibraryVisitorTests.cs | 2 +- .../EnumProviders/EnumProviderTests.cs | 78 +++++++++++++++++ ...ovedValueNotReaddedWhenBaselineAccepts.txt | 4 + .../MockInputEnum.cs | 27 ++++++ .../MockInputEnum.cs | 27 ++++++ .../generator/docs/backward-compatibility.md | 53 ++++++++++++ 11 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.txt create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts/MockInputEnum.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded/MockInputEnum.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/LibraryVisitor.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/LibraryVisitor.cs index 03df192714c..9528d3f308f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/LibraryVisitor.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/LibraryVisitor.cs @@ -297,7 +297,7 @@ protected internal virtual FinallyExpression VisitFinallyExpression(FinallyExpre /// /// The original . /// Null if it should be removed otherwise the modified version of the . - protected virtual PropertyProvider? VisitProperty(PropertyProvider property) + protected internal virtual PropertyProvider? VisitProperty(PropertyProvider property) { return property; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs index e8496d8987e..62453e8510b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs @@ -68,7 +68,7 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputType?.N protected static string RemoveUnderscores(string name) => name.Replace("_", string.Empty); private HashSet? _customMemberNames; - private HashSet CustomMemberNames => _customMemberNames ??= new HashSet( + private protected HashSet CustomMemberNames => _customMemberNames ??= new HashSet( GetCustomMemberNames(), StringComparer.OrdinalIgnoreCase); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs index c849dcf2a1b..c801192f5dd 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using Microsoft.TypeSpec.Generator.EmitterRpc; using Microsoft.TypeSpec.Generator.Expressions; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Input.Extensions; @@ -274,5 +276,86 @@ protected override TypeProvider[] BuildSerializationProviders() return CodeModelGenerator.Instance.TypeFactory.CreateSerializations(_inputType, this).ToArray(); } protected override bool GetIsEnum() => true; + + protected internal override IReadOnlyList? BuildEnumValuesForBackCompatibility(IReadOnlyList currentValues) + { + // Extensible enum members surface as public static properties whose wire values are stored in + // private const `Value` fields. Both the property names and the const values are + // recoverable from the last contract (including a compiled assembly's metadata), so a member + // dropped from the current spec can be restored to avoid a source-breaking removal. + var lastContractProperties = LastContractView?.Properties; + if (lastContractProperties == null || lastContractProperties.Count == 0) + { + return null; + } + + var currentNames = new HashSet(currentValues.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); + var lastContractValueFields = new Dictionary(StringComparer.Ordinal); + foreach (var field in LastContractView!.Fields) + { + lastContractValueFields[field.Name] = field; + } + + List? readdedMembers = null; + foreach (var property in lastContractProperties) + { + // Members that still exist in the current spec or are provided by custom code are left untouched. + if (currentNames.Contains(property.Name) || CustomMemberNames.Contains(property.Name)) + { + continue; + } + + // Honor an intentional removal recorded in the ApiCompat baseline. + if (CodeModelGenerator.Instance.SourceInputModel?.ApiCompatBaseline.IsMemberSuppressed(Type.FullyQualifiedName, property.Name, 0) == true) + { + CodeModelGenerator.Instance.Emitter.Debug( + $"Skipping re-add of enum member '{Name}.{property.Name}'; the removal is accepted in the ApiCompat baseline.", + BackCompatibilityChangeCategory.BaselineAcceptedRemovalSkipped); + continue; + } + + if (TryResurrectRemovedMember(property, lastContractValueFields, out var resurrectedMember)) + { + (readdedMembers ??= []).Add(resurrectedMember); + CodeModelGenerator.Instance.Emitter.Debug( + $"Re-added enum member '{property.Name}' to enum '{Name}' to preserve a member from the last contract.", + BackCompatibilityChangeCategory.EnumMemberAddedFromLastContract); + } + } + + if (readdedMembers == null) + { + return null; + } + + // Preserve the current spec order and append the restored members at the end. + return [.. currentValues, .. readdedMembers]; + } + + private bool TryResurrectRemovedMember( + PropertyProvider lastContractProperty, + IReadOnlyDictionary lastContractValueFields, + [NotNullWhen(true)] out EnumTypeMember? member) + { + member = null; + + // The wire value lives in the private const `Value` field. + var valueFieldName = $"{lastContractProperty.Name}Value"; + if (!lastContractValueFields.TryGetValue(valueFieldName, out var valueField) + || valueField.InitializationValue is not LiteralExpression { Literal: { } literalValue }) + { + return false; + } + + var field = new FieldProvider( + FieldModifiers.Private | FieldModifiers.Const, + EnumUnderlyingType, + valueFieldName, + this, + lastContractProperty.Description, + Literal(literalValue)); + member = new EnumTypeMember(lastContractProperty.Name, field, literalValue); + return true; + } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs index b3f7bb8dbd8..9dd63f9f61a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs @@ -249,13 +249,13 @@ protected internal override PropertyProvider[] BuildProperties() private static ValueExpression? GetFieldInitializer(IFieldSymbol fieldSymbol) { - if (fieldSymbol.ContainingType?.TypeKind == TypeKind.Enum) + // Constant fields expose their value through metadata, so we can recover it even from a + // compiled last-contract assembly without debug symbols. This covers both enum members and + // the private const backing fields of an extensible enum (e.g. `Value`), which lets + // back-compat restore an extensible enum member that was dropped from the current spec. + if (fieldSymbol.HasConstantValue && fieldSymbol.ConstantValue != null) { - if (fieldSymbol.HasConstantValue && fieldSymbol.ConstantValue != null) - { - return Literal(fieldSymbol.ConstantValue); - } - return null; + return Literal(fieldSymbol.ConstantValue); } return null; 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 5bf5cd615b6..7d028c7de62 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 @@ -825,7 +825,8 @@ internal void ProcessTypeForBackCompatibility() IReadOnlyList? updatedEnumValues = null; IEnumerable? newFields = null; - if (this is EnumProvider) + IEnumerable? newProperties = null; + if (this is EnumProvider enumProvider) { var hasFields = LastContractView?.Fields != null && LastContractView.Fields.Count > 0; if (hasFields) @@ -848,7 +849,28 @@ internal void ProcessTypeForBackCompatibility() updatedEnumValues = newEnumValues; } - newFields = filteredFields; + // Sync the enum values before rebuilding the member collections from them. + _enumValues = updatedEnumValues; + + if (enumProvider.IsExtensible) + { + // Extensible enums carry an extra backing `_value` field and surface members + // as properties, so rebuild both from the updated members. Reuse the + // already-visited property instances for members that still exist so only the + // restored members are (re)visited below. + newFields = ApplyCustomizationFilter(BuildFields()); + var existingProperties = new Dictionary(StringComparer.Ordinal); + foreach (var property in Properties) + { + existingProperties[property.Name] = property; + } + newProperties = ApplyCustomizationFilter( + BuildProperties().Select(p => existingProperties.TryGetValue(p.Name, out var existing) ? existing : p)); + } + else + { + newFields = filteredFields; + } } } } @@ -856,13 +878,8 @@ internal void ProcessTypeForBackCompatibility() var newMethods = hasMethods ? BuildMethodsForBackCompatibility(Methods) : null; var newConstructors = hasConstructors ? BuildConstructorsForBackCompatibility(Constructors) : null; - if (newFields != null || newMethods != null || newConstructors != null) + if (newFields != null || newProperties != null || newMethods != null || newConstructors != null) { - if (updatedEnumValues != null) - { - _enumValues = updatedEnumValues; - } - // Back-compatibility processing intentionally runs after the library visitor pass so // that the contract comparison uses the final, post-visitor member signatures (otherwise // we could incorrectly decide whether a back-compat member is needed). As a result, any @@ -877,12 +894,16 @@ internal void ProcessTypeForBackCompatibility() { newConstructors = VisitNewMembers(newConstructors, Constructors, static (member, visitor) => visitor.VisitConstructor(member)); } + if (newProperties != null) + { + newProperties = VisitNewMembers(newProperties, Properties, static (member, visitor) => visitor.VisitProperty(member)); + } if (newFields != null) { newFields = VisitNewMembers(newFields, Fields, static (member, visitor) => visitor.VisitField(member)); } - Update(fields: newFields, methods: newMethods, constructors: newConstructors); + Update(fields: newFields, properties: newProperties, methods: newMethods, constructors: newConstructors); } // Providers whose attributes depend on final generation decisions build their attributes at write diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs index 49b40624443..dd59b12dd58 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs @@ -494,7 +494,7 @@ private class TestFilterVisitor : LibraryVisitor return constructor; } - protected override PropertyProvider? VisitProperty(PropertyProvider property) + protected internal override PropertyProvider? VisitProperty(PropertyProvider property) { if (property.Name == "TestProperty") { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs index eb341de30b5..825bbe24df4 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs @@ -650,6 +650,84 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.IsNull(fields[1].InitializationValue); } + // Validates that a member removed from an extensible (string-backed) enum is re-added from the + // last contract. Unlike fixed string enums, an extensible enum stores its wire value in a private + // const `Value` field, so the value is recoverable (even from a compiled assembly's + // metadata) and the previously shipped member can be restored to avoid a source-breaking removal. + [Test] + public async Task BackCompat_ExtensibleEnumRemovedValueReadded() + { + await MockHelpers.LoadMockGeneratorAsync( + createCSharpTypeCore: (inputType) => typeof(string), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + // Last contract: Default, Recover, Third. Current input removes "Third". + var input = InputFactory.StringEnum("mockInputEnum", [ + ("Default", "default"), + ("Recover", "recover"), + ], isExtensible: true); + + var enumType = EnumProvider.Create(input); + Assert.IsFalse(enumType is ApiVersionEnumProvider); + + enumType.EnsureBuilt(); + enumType.ProcessTypeForBackCompatibility(); + + // "Third" is re-added (appended after the current members) as a public static property. + var properties = enumType.Properties; + Assert.AreEqual(3, properties.Count); + Assert.AreEqual("Default", properties[0].Name); + Assert.AreEqual("Recover", properties[1].Name); + Assert.AreEqual("Third", properties[2].Name); + + // The re-added member restores its wire value from the last contract's private const field. + var thirdValueField = enumType.Fields.SingleOrDefault(f => f.Name == "ThirdValue"); + Assert.IsNotNull(thirdValueField); + Assert.AreEqual("third", (thirdValueField!.InitializationValue as LiteralExpression)?.Literal); + + // The backing `_value` field is preserved when the fields are rebuilt. + Assert.IsTrue(enumType.Fields.Any(f => f.Name == "_value")); + + // The corresponding enum value carries the recovered wire value. + var thirdMember = enumType.EnumValues.SingleOrDefault(v => v.Name == "Third"); + Assert.IsNotNull(thirdMember); + Assert.AreEqual("third", thirdMember!.Value); + } + + // Validates that a removed extensible enum member is NOT re-added when its removal is accepted in + // the ApiCompat baseline (recorded as a MembersMustExist suppression), so the generator honors the + // intentional removal instead of resurrecting it. + [Test] + public async Task BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts() + { + var baseline = Helpers.GetApiCompatBaselineFromFile(); + + await MockHelpers.LoadMockGeneratorAsync( + createCSharpTypeCore: (inputType) => typeof(string), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + apiCompatBaseline: baseline); + + // Last contract: Default, Recover, Third. Current input removes "Third", but the baseline + // accepts that removal, so it must NOT be re-added. + var input = InputFactory.StringEnum("mockInputEnum", [ + ("Default", "default"), + ("Recover", "recover"), + ], isExtensible: true); + + var enumType = EnumProvider.Create(input); + Assert.IsFalse(enumType is ApiVersionEnumProvider); + + enumType.EnsureBuilt(); + enumType.ProcessTypeForBackCompatibility(); + + var properties = enumType.Properties; + Assert.AreEqual(2, properties.Count); + Assert.IsFalse(properties.Any(p => p.Name == "Third")); + Assert.AreEqual("Default", properties[0].Name); + Assert.AreEqual("Recover", properties[1].Name); + Assert.IsFalse(enumType.Fields.Any(f => f.Name == "ThirdValue")); + } + // Validates that a removed integer enum member is NOT re-added when its removal is accepted // in the ApiCompat baseline (here recorded as an EnumValuesMustMatch suppression), so the // generator honors the intentional removal instead of resurrecting it. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.txt b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.txt new file mode 100644 index 00000000000..5d107c84471 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.txt @@ -0,0 +1,4 @@ +# The Third extensible-enum member was intentionally removed during migration; suppress the difference +# so the back-compat system honors the removal instead of re-adding it. ApiCompat reports a removed +# extensible-enum member (a public static property) as a MembersMustExist difference on its getter. +MembersMustExist : Member 'public static Sample.Models.MockInputEnum Sample.Models.MockInputEnum.Third.get()' does not exist in the implementation but it does exist in the contract. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts/MockInputEnum.cs new file mode 100644 index 00000000000..aa76d3215f1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts/MockInputEnum.cs @@ -0,0 +1,27 @@ +#nullable disable + +using System; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : IEquatable + { + private readonly string _value; + private const string DefaultValue = "default"; + private const string RecoverValue = "recover"; + private const string ThirdValue = "third"; + + public MockInputEnum(string value) + { + _value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public static MockInputEnum Default { get; } = new MockInputEnum(DefaultValue); + + public static MockInputEnum Recover { get; } = new MockInputEnum(RecoverValue); + + public static MockInputEnum Third { get; } = new MockInputEnum(ThirdValue); + + public bool Equals(MockInputEnum other) => string.Equals(_value, other._value, StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded/MockInputEnum.cs new file mode 100644 index 00000000000..aa76d3215f1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded/MockInputEnum.cs @@ -0,0 +1,27 @@ +#nullable disable + +using System; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : IEquatable + { + private readonly string _value; + private const string DefaultValue = "default"; + private const string RecoverValue = "recover"; + private const string ThirdValue = "third"; + + public MockInputEnum(string value) + { + _value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public static MockInputEnum Default { get; } = new MockInputEnum(DefaultValue); + + public static MockInputEnum Recover { get; } = new MockInputEnum(RecoverValue); + + public static MockInputEnum Third { get; } = new MockInputEnum(ThirdValue); + + public bool Equals(MockInputEnum other) => string.Equals(_value, other._value, StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/packages/http-client-csharp/generator/docs/backward-compatibility.md b/packages/http-client-csharp/generator/docs/backward-compatibility.md index 6ca83258649..10bd96cf26f 100644 --- a/packages/http-client-csharp/generator/docs/backward-compatibility.md +++ b/packages/http-client-csharp/generator/docs/backward-compatibility.md @@ -16,6 +16,8 @@ - [Explicit (Non-contiguous) Values Preserved](#scenario-explicit-non-contiguous-values-preserved) - [Removed Integer Enum Member Re-added](#scenario-removed-integer-enum-member-re-added) - [Baseline-Accepted Removal Honored](#scenario-baseline-accepted-removal-honored) + - [Extensible Enum Members](#extensible-enum-members) + - [Removed Extensible Enum Member Re-added](#scenario-removed-extensible-enum-member-re-added) - [API Version Enum](#api-version-enum) - [Non-abstract Base Models](#non-abstract-base-models) - [Model Constructors](#model-constructors) @@ -447,6 +449,57 @@ public enum SampleEnum - Suppressed members are matched by the declaring enum's fully-qualified name and the member name - This lets a library intentionally drop a previously shipped enum member once the removal is reviewed and recorded in the baseline +### Extensible Enum Members + +Extensible enums are generated as `readonly partial struct` types whose members are exposed as public static properties, with each member's wire value stored in a private `const` backing field (`Value`). Because both the property name and its wire value are recoverable from the last contract — including from a compiled assembly's metadata — the generator can re-add an extensible enum member that was dropped from the current spec, avoiding a source-breaking removal. + +> [!NOTE] +> Unlike fixed **string** enums (where the wire value is not recoverable from the last contract), extensible enums keep the wire value in a `const` field, so removed members can be safely restored. + +#### Scenario: Removed Extensible Enum Member Re-added + +**Description:** When an extensible enum member present in the last contract is dropped from the current spec, the generator restores it — as a public static property backed by its recovered `const` wire value — to keep the previously shipped API. + +**Example:** + +Previous version: + +```csharp +public readonly partial struct OperationStatusType : IEquatable +{ + private const string CompletedValue = "Completed"; + private const string FailedValue = "Failed"; + private const string RunningValue = "Running"; + + public static OperationStatusType Completed { get; } = new OperationStatusType(CompletedValue); + public static OperationStatusType Failed { get; } = new OperationStatusType(FailedValue); + public static OperationStatusType Running { get; } = new OperationStatusType(RunningValue); + // ... +} +``` + +Current TypeSpec removes `Running`. **Generated Result:** `Running` is re-added (appended after the current members) with its original wire value: + +```csharp +public readonly partial struct OperationStatusType : IEquatable +{ + private const string CompletedValue = "Completed"; + private const string FailedValue = "Failed"; + private const string RunningValue = "Running"; + + public static OperationStatusType Completed { get; } = new OperationStatusType(CompletedValue); + public static OperationStatusType Failed { get; } = new OperationStatusType(FailedValue); + public static OperationStatusType Running { get; } = new OperationStatusType(RunningValue); + // ... +} +``` + +**Key Points:** + +- The member's property name and its wire value are read from the previously published assembly's metadata (no debug symbols required) +- Members that already exist in the current spec, are provided by custom code, or whose removal is accepted in the [ApiCompat baseline](#apicompat-baseline-awareness) are not re-added +- Restored members are appended after the current spec's members, preserving the current spec's order + ### API Version Enum Service version enums maintain backward compatibility by preserving version values from previous releases. From 1ab538b590bae625c122fa63900c91f9c28792fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:06:46 +0000 Subject: [PATCH 3/6] Address PR review feedback on extensible enum back-compat Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/ExtensibleEnumProvider.cs | 6 +-- .../src/Providers/NamedTypeSymbolProvider.cs | 4 -- .../src/Providers/TypeProvider.cs | 14 ++++-- .../EnumProviders/EnumProviderTests.cs | 15 +++--- ...ompat_ExtensibleEnumRemovedValueReadded.cs | 49 +++++++++++++++++++ .../generator/docs/backward-compatibility.md | 5 +- 6 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs index c801192f5dd..61b65a6a91b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs @@ -279,10 +279,6 @@ protected override TypeProvider[] BuildSerializationProviders() protected internal override IReadOnlyList? BuildEnumValuesForBackCompatibility(IReadOnlyList currentValues) { - // Extensible enum members surface as public static properties whose wire values are stored in - // private const `Value` fields. Both the property names and the const values are - // recoverable from the last contract (including a compiled assembly's metadata), so a member - // dropped from the current spec can be restored to avoid a source-breaking removal. var lastContractProperties = LastContractView?.Properties; if (lastContractProperties == null || lastContractProperties.Count == 0) { @@ -293,7 +289,7 @@ protected override TypeProvider[] BuildSerializationProviders() var lastContractValueFields = new Dictionary(StringComparer.Ordinal); foreach (var field in LastContractView!.Fields) { - lastContractValueFields[field.Name] = field; + lastContractValueFields.TryAdd(field.Name, field); } List? readdedMembers = null; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs index 9dd63f9f61a..d8be8a10706 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs @@ -249,10 +249,6 @@ protected internal override PropertyProvider[] BuildProperties() private static ValueExpression? GetFieldInitializer(IFieldSymbol fieldSymbol) { - // Constant fields expose their value through metadata, so we can recover it even from a - // compiled last-contract assembly without debug symbols. This covers both enum members and - // the private const backing fields of an extensible enum (e.g. `Value`), which lets - // back-compat restore an extensible enum member that was dropped from the current spec. if (fieldSymbol.HasConstantValue && fieldSymbol.ConstantValue != null) { return Literal(fieldSymbol.ConstantValue); 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 7d028c7de62..843176b5af5 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 @@ -856,9 +856,17 @@ internal void ProcessTypeForBackCompatibility() { // Extensible enums carry an extra backing `_value` field and surface members // as properties, so rebuild both from the updated members. Reuse the - // already-visited property instances for members that still exist so only the - // restored members are (re)visited below. - newFields = ApplyCustomizationFilter(BuildFields()); + // already-visited field and property instances for members that still exist so + // any visitor mutations are preserved and only the restored members are + // (re)visited below. + var existingFields = new Dictionary(StringComparer.Ordinal); + foreach (var field in Fields) + { + existingFields[field.Name] = field; + } + newFields = ApplyCustomizationFilter( + BuildFields().Select(f => existingFields.TryGetValue(f.Name, out var existing) ? existing : f)); + var existingProperties = new Dictionary(StringComparer.Ordinal); foreach (var property in Properties) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs index 825bbe24df4..99de806e2a1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs @@ -680,18 +680,15 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.AreEqual("Recover", properties[1].Name); Assert.AreEqual("Third", properties[2].Name); - // The re-added member restores its wire value from the last contract's private const field. - var thirdValueField = enumType.Fields.SingleOrDefault(f => f.Name == "ThirdValue"); - Assert.IsNotNull(thirdValueField); - Assert.AreEqual("third", (thirdValueField!.InitializationValue as LiteralExpression)?.Literal); - - // The backing `_value` field is preserved when the fields are rebuilt. - Assert.IsTrue(enumType.Fields.Any(f => f.Name == "_value")); - - // The corresponding enum value carries the recovered wire value. + // The corresponding enum value carries the wire value recovered from the last contract. var thirdMember = enumType.EnumValues.SingleOrDefault(v => v.Name == "Third"); Assert.IsNotNull(thirdMember); Assert.AreEqual("third", thirdMember!.Value); + + // Validate the full generated output, including the restored const `Value` field + // and the preserved backing `_value` field. + var content = new TypeProviderWriter(enumType).Write().Content; + Assert.AreEqual(Helpers.GetExpectedFromFile(), content); } // Validates that a removed extensible enum member is NOT re-added when its removal is accepted in diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded.cs new file mode 100644 index 00000000000..bee3d70db2e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueReadded.cs @@ -0,0 +1,49 @@ +// + +#nullable disable + +using System; +using System.ComponentModel; +using Sample; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : global::System.IEquatable + { + private readonly string _value; + private const string DefaultValue = "default"; + private const string RecoverValue = "recover"; + private const string ThirdValue = "third"; + + public MockInputEnum(string value) + { + global::Sample.Argument.AssertNotNull(value, nameof(value)); + + _value = value; + } + + public static global::Sample.Models.MockInputEnum Default { get; } = new global::Sample.Models.MockInputEnum(DefaultValue); + + public static global::Sample.Models.MockInputEnum Recover { get; } = new global::Sample.Models.MockInputEnum(RecoverValue); + + public static global::Sample.Models.MockInputEnum Third { get; } = new global::Sample.Models.MockInputEnum(ThirdValue); + + public static bool operator ==(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => left.Equals(right); + + public static bool operator !=(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => !left.Equals(right); + + public static implicit operator global::Sample.Models.MockInputEnum(string value) => new global::Sample.Models.MockInputEnum(value); + + public static implicit operator global::Sample.Models.MockInputEnum?(string value) => (value == null) ? null : new global::Sample.Models.MockInputEnum(value); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override bool Equals(object obj) => ((obj is global::Sample.Models.MockInputEnum other) && this.Equals(other)); + + public bool Equals(global::Sample.Models.MockInputEnum other) => string.Equals(_value, other._value, global::System.StringComparison.InvariantCultureIgnoreCase); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override int GetHashCode() => (_value != null) ? global::System.StringComparer.InvariantCultureIgnoreCase.GetHashCode(_value) : 0; + + public override string ToString() => _value; + } +} diff --git a/packages/http-client-csharp/generator/docs/backward-compatibility.md b/packages/http-client-csharp/generator/docs/backward-compatibility.md index 10bd96cf26f..bf71e1cd856 100644 --- a/packages/http-client-csharp/generator/docs/backward-compatibility.md +++ b/packages/http-client-csharp/generator/docs/backward-compatibility.md @@ -451,10 +451,7 @@ public enum SampleEnum ### Extensible Enum Members -Extensible enums are generated as `readonly partial struct` types whose members are exposed as public static properties, with each member's wire value stored in a private `const` backing field (`Value`). Because both the property name and its wire value are recoverable from the last contract — including from a compiled assembly's metadata — the generator can re-add an extensible enum member that was dropped from the current spec, avoiding a source-breaking removal. - -> [!NOTE] -> Unlike fixed **string** enums (where the wire value is not recoverable from the last contract), extensible enums keep the wire value in a `const` field, so removed members can be safely restored. +Extensible enums (C# `readonly partial struct` types) preserve their previously shipped members by comparing the current spec against the last contract. The generator re-adds an extensible enum member that was dropped from the current spec, restoring it with its original wire value to avoid removing a previously shipped member. #### Scenario: Removed Extensible Enum Member Re-added From 0ae951a12f82da4ad8be45206fed425d39de127d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:02:14 +0000 Subject: [PATCH 4/6] Address PR review: TryAdd, guard, spell fixes, custom-code & symbol tests Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/ExtensibleEnumProvider.cs | 8 +- .../src/Providers/TypeProvider.cs | 9 +- .../EnumProviders/EnumProviderTests.cs | 95 ++++++++++++++++++- .../MockInputEnum.cs | 9 ++ .../MockInputEnum.cs | 27 ++++++ ...movedValueNotReaddedWhenBaselineAccepts.cs | 46 +++++++++ ...ovedValueNotReaddedWhenBaselineAccepts.xml | 12 +++ .../MockInputEnum.cs | 9 ++ .../MockInputEnum.cs | 27 ++++++ ...ValueNotReaddedWhenProvidedByCustomCode.cs | 46 +++++++++ .../NamedTypeSymbolProviderTests.cs | 61 ++++++++++++ .../generator/docs/backward-compatibility.md | 1 - 12 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Custom)/MockInputEnum.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Last)/MockInputEnum.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.xml create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Custom)/MockInputEnum.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Last)/MockInputEnum.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs index 61b65a6a91b..bacc3912c58 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs @@ -292,7 +292,7 @@ protected override TypeProvider[] BuildSerializationProviders() lastContractValueFields.TryAdd(field.Name, field); } - List? readdedMembers = null; + List? restoredMembers = null; foreach (var property in lastContractProperties) { // Members that still exist in the current spec or are provided by custom code are left untouched. @@ -312,20 +312,20 @@ protected override TypeProvider[] BuildSerializationProviders() if (TryResurrectRemovedMember(property, lastContractValueFields, out var resurrectedMember)) { - (readdedMembers ??= []).Add(resurrectedMember); + (restoredMembers ??= []).Add(resurrectedMember); CodeModelGenerator.Instance.Emitter.Debug( $"Re-added enum member '{property.Name}' to enum '{Name}' to preserve a member from the last contract.", BackCompatibilityChangeCategory.EnumMemberAddedFromLastContract); } } - if (readdedMembers == null) + if (restoredMembers == null) { return null; } // Preserve the current spec order and append the restored members at the end. - return [.. currentValues, .. readdedMembers]; + return [.. currentValues, .. restoredMembers]; } private bool TryResurrectRemovedMember( 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 843176b5af5..5ecd0b10836 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 @@ -850,7 +850,10 @@ internal void ProcessTypeForBackCompatibility() } // Sync the enum values before rebuilding the member collections from them. - _enumValues = updatedEnumValues; + if (updatedEnumValues != null) + { + _enumValues = updatedEnumValues; + } if (enumProvider.IsExtensible) { @@ -862,7 +865,7 @@ internal void ProcessTypeForBackCompatibility() var existingFields = new Dictionary(StringComparer.Ordinal); foreach (var field in Fields) { - existingFields[field.Name] = field; + existingFields.TryAdd(field.Name, field); } newFields = ApplyCustomizationFilter( BuildFields().Select(f => existingFields.TryGetValue(f.Name, out var existing) ? existing : f)); @@ -870,7 +873,7 @@ internal void ProcessTypeForBackCompatibility() var existingProperties = new Dictionary(StringComparer.Ordinal); foreach (var property in Properties) { - existingProperties[property.Name] = property; + existingProperties.TryAdd(property.Name, property); } newProperties = ApplyCustomizationFilter( BuildProperties().Select(p => existingProperties.TryGetValue(p.Name, out var existing) ? existing : p)); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs index 99de806e2a1..34afc34392e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs @@ -693,11 +693,13 @@ await MockHelpers.LoadMockGeneratorAsync( // Validates that a removed extensible enum member is NOT re-added when its removal is accepted in // the ApiCompat baseline (recorded as a MembersMustExist suppression), so the generator honors the - // intentional removal instead of resurrecting it. - [Test] - public async Task BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts() + // intentional removal instead of resurrecting it. Runs against both the text and XML baseline + // formats to ensure either representation of the accepted removal is honored. + [TestCase(".txt")] + [TestCase(".xml")] + public async Task BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts(string baselineExtension) { - var baseline = Helpers.GetApiCompatBaselineFromFile(); + var baseline = Helpers.GetApiCompatBaselineFromFile(fileExtension: baselineExtension); await MockHelpers.LoadMockGeneratorAsync( createCSharpTypeCore: (inputType) => typeof(string), @@ -723,6 +725,91 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.AreEqual("Default", properties[0].Name); Assert.AreEqual("Recover", properties[1].Name); Assert.IsFalse(enumType.Fields.Any(f => f.Name == "ThirdValue")); + + // Validate the full generated output; "Third" (property and its const `ThirdValue` field) + // must be absent regardless of which baseline format recorded the accepted removal. + var content = new TypeProviderWriter(enumType).Write().Content; + Assert.AreEqual(Helpers.GetExpectedFromFile(), content); + } + + // Validates that when custom code already provides a member that the current spec removed (and + // that the last contract still declares), back-compat does NOT re-add it. The custom code is left + // as the single source of truth for that member so the generated member does not collide with it. + [Test] + public async Task BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode() + { + await MockHelpers.LoadMockGeneratorAsync( + createCSharpTypeCore: (inputType) => typeof(string), + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(parameters: "Custom"), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync(parameters: "Last")); + + // Last contract: Default, Recover, Third. Current spec removes "Third", but custom code + // provides it, so back-compat must NOT re-add "Third". + var input = InputFactory.StringEnum("mockInputEnum", [ + ("Default", "default"), + ("Recover", "recover"), + ], isExtensible: true); + + var enumType = EnumProvider.Create(input); + Assert.IsFalse(enumType is ApiVersionEnumProvider); + Assert.IsNotNull(enumType.CustomCodeView); + Assert.IsTrue(enumType.CustomCodeView!.Properties.Any(p => p.Name == "Third")); + + enumType.EnsureBuilt(); + enumType.ProcessTypeForBackCompatibility(); + + // The generated provider must not re-add the custom-owned "Third" member (property or const + // `ThirdValue` field); only the two current members remain in generated code. + var properties = enumType.Properties; + Assert.AreEqual(2, properties.Count); + Assert.AreEqual("Default", properties[0].Name); + Assert.AreEqual("Recover", properties[1].Name); + Assert.IsFalse(properties.Any(p => p.Name == "Third")); + Assert.IsFalse(enumType.Fields.Any(f => f.Name == "ThirdValue")); + + // Validate the full generated output as well. + var content = new TypeProviderWriter(enumType).Write().Content; + Assert.AreEqual(Helpers.GetExpectedFromFile(), content); + } + + // Validates that when custom code re-declares a member that both the current spec and the last + // contract still contain, back-compat leaves the member to the custom code (no duplicate generated + // member) while other removed last-contract members are still restored. + [Test] + public async Task BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored() + { + await MockHelpers.LoadMockGeneratorAsync( + createCSharpTypeCore: (inputType) => typeof(string), + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(parameters: "Custom"), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync(parameters: "Last")); + + // Last contract: Default, Recover, Third. Current spec keeps Default (customized) and Recover + // but removes "Third". Custom code owns "Default", so it must not be regenerated, while the + // removed "Third" is restored from the last contract. + var input = InputFactory.StringEnum("mockInputEnum", [ + ("Default", "default"), + ("Recover", "recover"), + ], isExtensible: true); + + var enumType = EnumProvider.Create(input); + Assert.IsFalse(enumType is ApiVersionEnumProvider); + Assert.IsNotNull(enumType.CustomCodeView); + Assert.IsTrue(enumType.CustomCodeView!.Properties.Any(p => p.Name == "Default")); + + enumType.EnsureBuilt(); + enumType.ProcessTypeForBackCompatibility(); + + var properties = enumType.Properties; + // "Default" is owned by custom code so it is filtered out of generated code; "Recover" stays + // and "Third" is restored from the last contract, appended after the current members. + Assert.IsFalse(properties.Any(p => p.Name == "Default")); + Assert.IsTrue(properties.Any(p => p.Name == "Recover")); + Assert.IsTrue(properties.Any(p => p.Name == "Third")); + + var thirdMember = enumType.EnumValues.SingleOrDefault(v => v.Name == "Third"); + Assert.IsNotNull(thirdMember); + Assert.AreEqual("third", thirdMember!.Value); + Assert.IsTrue(enumType.Fields.Any(f => f.Name == "ThirdValue")); } // Validates that a removed integer enum member is NOT re-added when its removal is accepted diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Custom)/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Custom)/MockInputEnum.cs new file mode 100644 index 00000000000..9044df45e5b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Custom)/MockInputEnum.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum + { + public static MockInputEnum Default { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Last)/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Last)/MockInputEnum.cs new file mode 100644 index 00000000000..aa76d3215f1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeMemberPreservedWhileOtherMemberRestored(Last)/MockInputEnum.cs @@ -0,0 +1,27 @@ +#nullable disable + +using System; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : IEquatable + { + private readonly string _value; + private const string DefaultValue = "default"; + private const string RecoverValue = "recover"; + private const string ThirdValue = "third"; + + public MockInputEnum(string value) + { + _value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public static MockInputEnum Default { get; } = new MockInputEnum(DefaultValue); + + public static MockInputEnum Recover { get; } = new MockInputEnum(RecoverValue); + + public static MockInputEnum Third { get; } = new MockInputEnum(ThirdValue); + + public bool Equals(MockInputEnum other) => string.Equals(_value, other._value, StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.cs new file mode 100644 index 00000000000..da774fa2930 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.cs @@ -0,0 +1,46 @@ +// + +#nullable disable + +using System; +using System.ComponentModel; +using Sample; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : global::System.IEquatable + { + private readonly string _value; + private const string DefaultValue = "default"; + private const string RecoverValue = "recover"; + + public MockInputEnum(string value) + { + global::Sample.Argument.AssertNotNull(value, nameof(value)); + + _value = value; + } + + public static global::Sample.Models.MockInputEnum Default { get; } = new global::Sample.Models.MockInputEnum(DefaultValue); + + public static global::Sample.Models.MockInputEnum Recover { get; } = new global::Sample.Models.MockInputEnum(RecoverValue); + + public static bool operator ==(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => left.Equals(right); + + public static bool operator !=(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => !left.Equals(right); + + public static implicit operator global::Sample.Models.MockInputEnum(string value) => new global::Sample.Models.MockInputEnum(value); + + public static implicit operator global::Sample.Models.MockInputEnum?(string value) => (value == null) ? null : new global::Sample.Models.MockInputEnum(value); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override bool Equals(object obj) => ((obj is global::Sample.Models.MockInputEnum other) && this.Equals(other)); + + public bool Equals(global::Sample.Models.MockInputEnum other) => string.Equals(_value, other._value, global::System.StringComparison.InvariantCultureIgnoreCase); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override int GetHashCode() => (_value != null) ? global::System.StringComparer.InvariantCultureIgnoreCase.GetHashCode(_value) : 0; + + public override string ToString() => _value; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.xml b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.xml new file mode 100644 index 00000000000..e1a45af9549 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenBaselineAccepts.xml @@ -0,0 +1,12 @@ + + + + + CP0002 + M:Sample.Models.MockInputEnum.get_Third + + diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Custom)/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Custom)/MockInputEnum.cs new file mode 100644 index 00000000000..affc8ad51a8 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Custom)/MockInputEnum.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum + { + public static MockInputEnum Third { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Last)/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Last)/MockInputEnum.cs new file mode 100644 index 00000000000..aa76d3215f1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode(Last)/MockInputEnum.cs @@ -0,0 +1,27 @@ +#nullable disable + +using System; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : IEquatable + { + private readonly string _value; + private const string DefaultValue = "default"; + private const string RecoverValue = "recover"; + private const string ThirdValue = "third"; + + public MockInputEnum(string value) + { + _value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public static MockInputEnum Default { get; } = new MockInputEnum(DefaultValue); + + public static MockInputEnum Recover { get; } = new MockInputEnum(RecoverValue); + + public static MockInputEnum Third { get; } = new MockInputEnum(ThirdValue); + + public bool Equals(MockInputEnum other) => string.Equals(_value, other._value, StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode.cs new file mode 100644 index 00000000000..da774fa2930 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumRemovedValueNotReaddedWhenProvidedByCustomCode.cs @@ -0,0 +1,46 @@ +// + +#nullable disable + +using System; +using System.ComponentModel; +using Sample; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : global::System.IEquatable + { + private readonly string _value; + private const string DefaultValue = "default"; + private const string RecoverValue = "recover"; + + public MockInputEnum(string value) + { + global::Sample.Argument.AssertNotNull(value, nameof(value)); + + _value = value; + } + + public static global::Sample.Models.MockInputEnum Default { get; } = new global::Sample.Models.MockInputEnum(DefaultValue); + + public static global::Sample.Models.MockInputEnum Recover { get; } = new global::Sample.Models.MockInputEnum(RecoverValue); + + public static bool operator ==(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => left.Equals(right); + + public static bool operator !=(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => !left.Equals(right); + + public static implicit operator global::Sample.Models.MockInputEnum(string value) => new global::Sample.Models.MockInputEnum(value); + + public static implicit operator global::Sample.Models.MockInputEnum?(string value) => (value == null) ? null : new global::Sample.Models.MockInputEnum(value); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override bool Equals(object obj) => ((obj is global::Sample.Models.MockInputEnum other) && this.Equals(other)); + + public bool Equals(global::Sample.Models.MockInputEnum other) => string.Equals(_value, other._value, global::System.StringComparison.InvariantCultureIgnoreCase); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override int GetHashCode() => (_value != null) ? global::System.StringComparer.InvariantCultureIgnoreCase.GetHashCode(_value) : 0; + + public override string ToString() => _value; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs index 4a4b4f24474..c2e0f20be3f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs @@ -645,6 +645,67 @@ public void ValidateEnumFieldsWithExplicitValues() Assert.AreEqual(3, v74Literal!.Literal); } + // Validates that the constant value of a const field on a non-enum type (here a struct, + // mirroring the private `Value` backing constants an extensible enum uses) is + // recovered as the field's initialization value. This is what lets back-compat processing + // read a previously shipped member's wire value from the last contract's metadata. + [Test] + public void ValidateConstFieldInitializerIsRecovered() + { + var compilation = CSharpCompilation.Create( + "Customization", + [CSharpSyntaxTree.ParseText(""" + namespace Sample.Models + { + public readonly partial struct MockInputEnum + { + private const string RecoverValue = "recover"; + private const int Answer = 42; + } + } + """)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); + var symbol = compilation.GetTypeByMetadataName("Sample.Models.MockInputEnum"); + Assert.IsNotNull(symbol); + + var provider = new NamedTypeSymbolProvider(symbol!, compilation); + var fields = provider.Fields.ToDictionary(f => f.Name); + + Assert.IsTrue(fields.ContainsKey("RecoverValue")); + var recoverValue = fields["RecoverValue"]; + Assert.IsInstanceOf(recoverValue.InitializationValue); + Assert.AreEqual("recover", (recoverValue.InitializationValue as LiteralExpression)!.Literal); + + Assert.IsTrue(fields.ContainsKey("Answer")); + var answer = fields["Answer"]; + Assert.IsInstanceOf(answer.InitializationValue); + Assert.AreEqual(42, (answer.InitializationValue as LiteralExpression)!.Literal); + } + + // Validates that a non-const field carries no recovered initialization value. + [Test] + public void ValidateNonConstFieldHasNoInitializer() + { + var compilation = CSharpCompilation.Create( + "Customization", + [CSharpSyntaxTree.ParseText(""" + namespace Sample.Models + { + public readonly partial struct MockInputEnum + { + private readonly string _value; + } + } + """)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); + var symbol = compilation.GetTypeByMetadataName("Sample.Models.MockInputEnum"); + Assert.IsNotNull(symbol); + + var provider = new NamedTypeSymbolProvider(symbol!, compilation); + var field = provider.Fields.Single(f => f.Name == "_value"); + Assert.IsNull(field.InitializationValue); + } + public enum SomeEnum { Foo, diff --git a/packages/http-client-csharp/generator/docs/backward-compatibility.md b/packages/http-client-csharp/generator/docs/backward-compatibility.md index bf71e1cd856..15d6dd0e768 100644 --- a/packages/http-client-csharp/generator/docs/backward-compatibility.md +++ b/packages/http-client-csharp/generator/docs/backward-compatibility.md @@ -493,7 +493,6 @@ public readonly partial struct OperationStatusType : IEquatable Date: Fri, 31 Jul 2026 19:49:11 +0000 Subject: [PATCH 5/6] fix(csharp-generator): recover null const field initializer values Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../src/Providers/NamedTypeSymbolProvider.cs | 2 +- .../NamedTypeSymbolProviderTests.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs index d8be8a10706..7f771583133 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs @@ -249,7 +249,7 @@ protected internal override PropertyProvider[] BuildProperties() private static ValueExpression? GetFieldInitializer(IFieldSymbol fieldSymbol) { - if (fieldSymbol.HasConstantValue && fieldSymbol.ConstantValue != null) + if (fieldSymbol.HasConstantValue) { return Literal(fieldSymbol.ConstantValue); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs index c2e0f20be3f..eac80bc74e0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs @@ -660,6 +660,7 @@ namespace Sample.Models public readonly partial struct MockInputEnum { private const string RecoverValue = "recover"; + private const string NullValue = null; private const int Answer = 42; } } @@ -680,6 +681,11 @@ public readonly partial struct MockInputEnum var answer = fields["Answer"]; Assert.IsInstanceOf(answer.InitializationValue); Assert.AreEqual(42, (answer.InitializationValue as LiteralExpression)!.Literal); + + Assert.IsTrue(fields.ContainsKey("NullValue")); + var nullValue = fields["NullValue"]; + Assert.IsInstanceOf(nullValue.InitializationValue); + Assert.IsNull((nullValue.InitializationValue as LiteralExpression)!.Literal); } // Validates that a non-const field carries no recovered initialization value. From c5df9d67ad575d614976c6bd0af5b5cef7817cc0 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Fri, 31 Jul 2026 16:50:56 -0500 Subject: [PATCH 6/6] fix metadata loading --- .../src/PostProcessing/GeneratedCodeWorkspace.cs | 3 ++- .../src/Providers/ExtensibleEnumProvider.cs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 81ee9fcd288..9d0c903ef53 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -233,7 +233,8 @@ .. _assemblyMetadataReferences.Value.Concat(CodeModelGenerator.Instance.Addition project = project .AddMetadataReferences(metadataReferences) .WithCompilationOptions(new CSharpCompilationOptions( - OutputKind.DynamicallyLinkedLibrary, metadataReferenceResolver: _metadataReferenceResolver.Value, nullableContextOptions: NullableContextOptions.Disable)); + OutputKind.DynamicallyLinkedLibrary, metadataReferenceResolver: _metadataReferenceResolver.Value, nullableContextOptions: NullableContextOptions.Disable) + .WithMetadataImportOptions(MetadataImportOptions.All)); return await project.GetCompilationAsync(); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs index bacc3912c58..4276c01550f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs @@ -348,8 +348,7 @@ private bool TryResurrectRemovedMember( EnumUnderlyingType, valueFieldName, this, - lastContractProperty.Description, - Literal(literalValue)); + initializationValue: Literal(literalValue)); member = new EnumTypeMember(lastContractProperty.Name, field, literalValue); return true; }