From 93faf2b52fd9fd1bafdb3a3d0404a478866616f0 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Wed, 2 Sep 2026 03:45:48 +0000 Subject: [PATCH 01/18] Add regression tests for last-contract model bases --- .../ModelProviders/ModelProviderTests.cs | 98 +++++++++++++++++++ .../Models.cs | 10 ++ .../DerivedModel.cs | 6 ++ .../Models.cs | 12 +++ 4 files changed, 126 insertions(+) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesLastContractBaseType/Models.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties/Models.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index f0c040b1432..e8b848f0ad7 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -502,6 +502,104 @@ public void BuildBaseType() Assert.AreEqual(baseModel!.Type, derivedModel!.Type.BaseType); } + [Test] + public async Task BackCompat_BaseTypeChangePreservesLastContractBaseType() + { + var previousBase = InputFactory.Model("PreviousBase", properties: []); + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [previousBase, currentBase, derivedModel], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(t => t.Name == "DerivedModel"); + + Assert.AreEqual("PreviousBase", modelProvider.LastContractView?.BaseType?.Name, + "The regression requires the previously shipped base type to be available from the last contract"); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(previousBase.Name, modelProvider.BaseType?.Name, + "A model must remain assignable to its previously shipped CLR base type"); + } + + [Test] + public async Task BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType() + { + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [currentBase, derivedModel], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(t => t.Name == "DerivedModel"); + + Assert.Multiple(() => + { + Assert.AreEqual(nameof(Exception), modelProvider.LastContractView?.BaseType?.Name); + Assert.AreEqual(nameof(System), modelProvider.LastContractView?.BaseType?.Namespace, + "The regression requires the previously shipped framework base type to be available from the last contract"); + }); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.Multiple(() => + { + Assert.AreEqual(nameof(Exception), modelProvider.BaseType?.Name, + "A model must remain assignable to its previously shipped non-generated CLR base type"); + Assert.AreEqual(nameof(System), modelProvider.BaseType?.Namespace); + Assert.IsNotInstanceOf(modelProvider.BaseTypeProvider, + "The preserved base must not require a generated model provider"); + }); + } + + [Test] + public async Task BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties() + { + var previousBase = InputFactory.Model( + "PreviousBase", + properties: [InputFactory.Property("id", InputPrimitiveType.String)]); + var currentBase = InputFactory.Model( + "CurrentBase", + properties: + [ + InputFactory.Property("id", InputPrimitiveType.String), + InputFactory.Property("location", InputPrimitiveType.String), + InputFactory.Property("tags", InputFactory.Dictionary(InputPrimitiveType.String)), + ]); + var derivedModel = InputFactory.Model( + "DerivedModel", + properties: [InputFactory.Property("childProp", InputPrimitiveType.String)], + baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [previousBase, currentBase, derivedModel], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(t => t.Name == "DerivedModel"); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.Multiple(() => + { + Assert.AreEqual(previousBase.Name, modelProvider.BaseType?.Name, + "The narrower previously shipped base type must be preserved"); + Assert.That(modelProvider.BaseTypeProvider?.Properties.Select(p => p.Name), Does.Contain("Id"), + "Properties supplied by the preserved base must remain inherited"); + Assert.That(modelProvider.Properties.Select(p => p.Name), + Is.EquivalentTo(new[] { "Location", "Tags", "ChildProp" }), + "Properties supplied only by the broader current spec base must be materialized on the derived model"); + }); + } + [Test] public void OverridingBuildBaseType_AutoResolvesBaseModelProviderForGeneratedModel() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesLastContractBaseType/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesLastContractBaseType/Models.cs new file mode 100644 index 00000000000..d84c8dea3a1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesLastContractBaseType/Models.cs @@ -0,0 +1,10 @@ +namespace Sample.Models +{ + public partial class PreviousBase + { + } + + public partial class DerivedModel : PreviousBase + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs new file mode 100644 index 00000000000..b996bb27ce4 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs @@ -0,0 +1,6 @@ +namespace Sample.Models +{ + public partial class DerivedModel : System.Exception + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties/Models.cs new file mode 100644 index 00000000000..8a4fc9ad8d2 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties/Models.cs @@ -0,0 +1,12 @@ +namespace Sample.Models +{ + public partial class PreviousBase + { + public string Id { get; set; } + } + + public partial class DerivedModel : PreviousBase + { + public string ChildProp { get; set; } + } +} From 4d0a14ad55d6dd645f970dfc75d4552b927e4bf4 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Wed, 2 Sep 2026 05:20:27 +0000 Subject: [PATCH 02/18] Focus base regression on shipped inherited properties --- .../ModelProviders/ModelProviderTests.cs | 21 ++++++++++++------- .../Models.cs | 4 ++++ 2 files changed, 17 insertions(+), 8 deletions(-) rename packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/{BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties => BackCompat_LastContractBaseRestoresInheritedProperties}/Models.cs (61%) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index e8b848f0ad7..cee8a557568 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -560,11 +560,16 @@ await MockHelpers.LoadMockGeneratorAsync( } [Test] - public async Task BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties() + public async Task BackCompat_LastContractBaseRestoresInheritedProperties() { var previousBase = InputFactory.Model( "PreviousBase", - properties: [InputFactory.Property("id", InputPrimitiveType.String)]); + properties: + [ + InputFactory.Property("id", InputPrimitiveType.String), + InputFactory.Property("location", InputPrimitiveType.String), + InputFactory.Property("tags", InputFactory.Dictionary(InputPrimitiveType.String)), + ]); var currentBase = InputFactory.Model( "CurrentBase", properties: @@ -591,12 +596,12 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.Multiple(() => { Assert.AreEqual(previousBase.Name, modelProvider.BaseType?.Name, - "The narrower previously shipped base type must be preserved"); - Assert.That(modelProvider.BaseTypeProvider?.Properties.Select(p => p.Name), Does.Contain("Id"), - "Properties supplied by the preserved base must remain inherited"); - Assert.That(modelProvider.Properties.Select(p => p.Name), - Is.EquivalentTo(new[] { "Location", "Tags", "ChildProp" }), - "Properties supplied only by the broader current spec base must be materialized on the derived model"); + "The previously shipped base type must be preserved"); + Assert.That(modelProvider.BaseTypeProvider?.Properties.Select(p => p.Name), + Is.EquivalentTo(new[] { "Id", "Location", "Tags" }), + "Properties shipped on the previous GA base must remain inherited"); + Assert.That(modelProvider.Properties.Select(p => p.Name), Is.EqualTo(new[] { "ChildProp" }), + "Properties supplied by the preserved base must not be duplicated on the derived model"); }); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_LastContractBaseRestoresInheritedProperties/Models.cs similarity index 61% rename from packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties/Models.cs rename to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_LastContractBaseRestoresInheritedProperties/Models.cs index 8a4fc9ad8d2..b2508ddfa0d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NarrowerLastContractBaseMaterializesCurrentBaseProperties/Models.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_LastContractBaseRestoresInheritedProperties/Models.cs @@ -1,8 +1,12 @@ +using System.Collections.Generic; + namespace Sample.Models { public partial class PreviousBase { public string Id { get; set; } + public string Location { get; set; } + public IDictionary Tags { get; set; } } public partial class DerivedModel : PreviousBase From 5f630716a2c790350c893fb3451274098e8b3a2e Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Wed, 2 Sep 2026 05:40:24 +0000 Subject: [PATCH 03/18] Preserve model base types from last contract --- .../BackCompatibilityChangeCategory.cs | 3 + .../src/EmitterRpc/Emitter.cs | 1 + .../src/Providers/ModelProvider.cs | 130 ++++++++++++++---- .../ModelProviders/ModelProviderTests.cs | 27 +++- 4 files changed, 131 insertions(+), 30 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/BackCompatibilityChangeCategory.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/BackCompatibilityChangeCategory.cs index 6a2aad4f357..ec737b2f3fe 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/BackCompatibilityChangeCategory.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/BackCompatibilityChangeCategory.cs @@ -22,6 +22,9 @@ public enum BackCompatibilityChangeCategory /// A property type was preserved from the last contract. PropertyTypePreserved, + /// A model base type was preserved from the last contract. + ModelBaseTypePreserved, + /// A constructor modifier (e.g. private protected -> public) was preserved from the last contract. ConstructorModifierPreserved, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/Emitter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/Emitter.cs index 7c073c93c99..1552b25c58e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/Emitter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/Emitter.cs @@ -174,6 +174,7 @@ public void WriteBufferedMessages() BackCompatibilityChangeCategory.ParameterNamePreserved => "Parameter Name Preserved", BackCompatibilityChangeCategory.AdditionalPropertiesShapePreserved => "AdditionalProperties Shape Preserved", BackCompatibilityChangeCategory.PropertyTypePreserved => "Property Type Preserved", + BackCompatibilityChangeCategory.ModelBaseTypePreserved => "Model Base Type Preserved", BackCompatibilityChangeCategory.ConstructorModifierPreserved => "Constructor Modifier Preserved", BackCompatibilityChangeCategory.EnumMemberReordering => "Enum Member Reordering", BackCompatibilityChangeCategory.ApiVersionEnumMemberAdded => "Api Version Enum Member Added From Last Contract", diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index c48a3611d7d..40cc589c61b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -134,43 +134,43 @@ private IReadOnlyList BuildDerivedModels() private TypeProvider? BuildBaseTypeProvider() { - // First check if there's a generated base model + // First check if there's a generated base model. if (BaseModelProvider != null) { return BaseModelProvider; } - // If there's a custom base type that's not a generated model, create a provider for it - if (CustomCodeView?.BaseType != null && !string.IsNullOrEmpty(CustomCodeView.BaseType.Namespace)) + var baseType = BaseType; + if (baseType is null || string.IsNullOrEmpty(baseType.Namespace)) { - var baseType = CustomCodeView.BaseType; + return null; + } - // Try to find it in the CSharpTypeMap first - if (CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.TryGetValue(baseType, out var existingProvider)) - { - return existingProvider; - } + // A base preserved from the last contract can be a framework or external type, just like + // a custom base. Resolve it from the current compilation rather than retaining a symbol + // that exists only in the baseline assembly. + if (CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.TryGetValue(baseType, out var existingProvider)) + { + return existingProvider; + } - // Try to find the type in the customization compilation. Referenced assemblies are - // included so custom bases from framework or external packages are represented by - // normal symbol-backed providers. - var baseTypeProvider = CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCurrentCompilation( - baseType.Namespace, - baseType.Name, - baseType.DeclaringType?.Name, - includeReferencedAssemblies: true); + var baseTypeProvider = CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCurrentCompilation( + baseType.Namespace, + baseType.Name, + baseType.DeclaringType?.Name, + includeReferencedAssemblies: true); - if (baseTypeProvider != null) - { - // Cache it in CSharpTypeMap for future lookups - CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[baseType] = baseTypeProvider; - return baseTypeProvider; - } + if (baseTypeProvider != null) + { + CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[baseType] = baseTypeProvider; + return baseTypeProvider; + } - // If we couldn't find the type symbol, create a SystemObjectTypeProvider that - // represents the external type without member metadata. + // Preserve the existing fallback for unresolved custom base types. Last-contract bases + // are selected only after they have been resolved against the current build. + if (CustomCodeView?.BaseType != null) + { var systemObjectTypeProvider = new SystemObjectTypeProvider(baseType); - // Cache it in CSharpTypeMap for future lookups CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[baseType] = systemObjectTypeProvider; return systemObjectTypeProvider; } @@ -246,6 +246,30 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N CodeModelGenerator.Instance.TypeFactory.GetCleanNameSpace(_inputModel.Namespace); protected override CSharpType? BuildBaseType() + { + var currentBase = BuildCurrentBaseType(); + var previousBase = LastContractView?.BaseType; + if (previousBase is null || IsInBaseTypeHierarchy(currentBase, previousBase)) + { + return currentBase; + } + + var resolvedPreviousBase = ResolveTypeInCurrentBuild(previousBase); + if (resolvedPreviousBase is null) + { + CodeModelGenerator.Instance.Emitter.ReportDiagnostic( + DiagnosticCodes.UnavailableBackcompatType, + $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because the previous base is unavailable in the current build."); + return currentBase; + } + + CodeModelGenerator.Instance.Emitter.Info( + $"Changed base type of model '{BuildName()}' from '{currentBase?.FullyQualifiedName ?? "object"}' to '{resolvedPreviousBase.FullyQualifiedName}' to match the last contract.", + BackCompatibilityChangeCategory.ModelBaseTypePreserved); + return resolvedPreviousBase; + } + + private CSharpType? BuildCurrentBaseType() { if (CustomCodeView?.BaseType != null) { @@ -288,12 +312,62 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N return customBase; } - if (_inputModel.BaseModel == null) + return _inputModel.BaseModel is null + ? null + : CodeModelGenerator.Instance.TypeFactory.CreateModel(_inputModel.BaseModel)?.Type; + } + + private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType previousBase) + { + var visited = new HashSet(StringComparer.Ordinal); + for (var type = currentBase; type is not null && visited.Add(type.FullyQualifiedName); type = type.BaseType) + { + if (type.AreNamesEqual(previousBase)) + { + return true; + } + } + + return false; + } + + private CSharpType? ResolveTypeInCurrentBuild(CSharpType type) + { + foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) + { + if (provider is not null && provider.Type.AreNamesEqual(type)) + { + return provider.Type; + } + } + + // The previous base may occur later in input order. Force-create all input models before + // deciding that no generated provider is available. + foreach (var model in CodeModelGenerator.Instance.InputLibrary.InputNamespace.Models) + { + CodeModelGenerator.Instance.TypeFactory.CreateModel(model); + } + + foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) + { + if (provider is not null && provider.Type.AreNamesEqual(type)) + { + return provider.Type; + } + } + + var currentProvider = CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCurrentCompilation( + type.Namespace, + type.Name, + type.DeclaringType?.Name, + includeReferencedAssemblies: true); + if (currentProvider is null) { return null; } - return CodeModelGenerator.Instance.TypeFactory.CreateModel(_inputModel.BaseModel)?.Type; + CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[currentProvider.Type] = currentProvider; + return currentProvider.Type; } protected override TypeProvider[] BuildSerializationProviders() diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index cee8a557568..34929c72c46 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -526,6 +526,28 @@ await MockHelpers.LoadMockGeneratorAsync( "A model must remain assignable to its previously shipped CLR base type"); } + [Test] + public async Task BackCompat_CurrentBaseDerivedFromLastContractBaseIsPreserved() + { + var previousBase = InputFactory.Model("PreviousBase", properties: []); + var currentBase = InputFactory.Model("CurrentBase", properties: [], baseModel: previousBase); + var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [previousBase, currentBase, derivedModel], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync( + method: nameof(BackCompat_BaseTypeChangePreservesLastContractBaseType))); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(t => t.Name == "DerivedModel"); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(currentBase.Name, modelProvider.BaseType?.Name, + "The current base should remain when it already derives from the previously shipped base"); + } + [Test] public async Task BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType() { @@ -534,6 +556,7 @@ public async Task BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBase await MockHelpers.LoadMockGeneratorAsync( inputModelTypes: [currentBase, derivedModel], + compilation: async () => await Helpers.GetCompilationFromSourceFilesAsync([]), lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders @@ -554,8 +577,8 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.AreEqual(nameof(Exception), modelProvider.BaseType?.Name, "A model must remain assignable to its previously shipped non-generated CLR base type"); Assert.AreEqual(nameof(System), modelProvider.BaseType?.Namespace); - Assert.IsNotInstanceOf(modelProvider.BaseTypeProvider, - "The preserved base must not require a generated model provider"); + Assert.IsInstanceOf(modelProvider.BaseTypeProvider, + "The preserved base should resolve from the current referenced assemblies without a generated model provider"); }); } From d87c7af9d4a091f15199ebd4c0f567dcb0e9ecb8 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Wed, 2 Sep 2026 06:08:08 +0000 Subject: [PATCH 04/18] Use try pattern for base type resolution --- .../src/Providers/ModelProvider.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 40cc589c61b..a3f792822f3 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -254,8 +254,7 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N return currentBase; } - var resolvedPreviousBase = ResolveTypeInCurrentBuild(previousBase); - if (resolvedPreviousBase is null) + if (!TryResolveTypeInCurrentBuild(previousBase, out var resolvedPreviousBase)) { CodeModelGenerator.Instance.Emitter.ReportDiagnostic( DiagnosticCodes.UnavailableBackcompatType, @@ -331,13 +330,14 @@ private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType pr return false; } - private CSharpType? ResolveTypeInCurrentBuild(CSharpType type) + private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] out CSharpType? resolvedType) { foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) { if (provider is not null && provider.Type.AreNamesEqual(type)) { - return provider.Type; + resolvedType = provider.Type; + return true; } } @@ -352,7 +352,8 @@ private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType pr { if (provider is not null && provider.Type.AreNamesEqual(type)) { - return provider.Type; + resolvedType = provider.Type; + return true; } } @@ -363,11 +364,13 @@ private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType pr includeReferencedAssemblies: true); if (currentProvider is null) { - return null; + resolvedType = null; + return false; } CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[currentProvider.Type] = currentProvider; - return currentProvider.Type; + resolvedType = currentProvider.Type; + return true; } protected override TypeProvider[] BuildSerializationProviders() From 97385741351f990b6a0f43bd52b0118732e4cfe9 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Wed, 2 Sep 2026 06:31:17 +0000 Subject: [PATCH 05/18] Handle unrestorable last-contract model bases --- .../src/Providers/ModelProvider.cs | 46 ++++++++++++++++--- .../src/Utilities/DiagnosticCodes.cs | 1 + .../ModelProviders/ModelProviderTests.cs | 46 +++++++++++++++++++ .../Models.cs | 10 ++++ .../Models.cs | 10 ++++ .../ExternalBase.cs | 9 ++++ .../Models.cs | 16 +++++++ 7 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(Current)/Models.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(LastContract)/Models.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(Current)/ExternalBase.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(LastContract)/Models.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index a3f792822f3..462d244aa4a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -254,11 +254,22 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N return currentBase; } + // A generated partial cannot replace a different base declared by custom code: all partial + // declarations must specify the same base class. Keep the custom base authoritative and + // report that the previous inheritance relationship could not be restored. + if (CustomCodeView?.BaseType is not null) + { + CodeModelGenerator.Instance.Emitter.ReportDiagnostic( + DiagnosticCodes.UnrestorableBackcompatBaseType, + $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because custom code declares base type '{currentBase?.FullyQualifiedName}'."); + return currentBase; + } + if (!TryResolveTypeInCurrentBuild(previousBase, out var resolvedPreviousBase)) { CodeModelGenerator.Instance.Emitter.ReportDiagnostic( DiagnosticCodes.UnavailableBackcompatType, - $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because the previous base is unavailable in the current build."); + $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because the previous base is unavailable or does not expose an accessible parameterless constructor in the current build."); return currentBase; } @@ -336,8 +347,7 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o { if (provider is not null && provider.Type.AreNamesEqual(type)) { - resolvedType = provider.Type; - return true; + return TryUseProviderAsBase(provider, out resolvedType); } } @@ -352,8 +362,7 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o { if (provider is not null && provider.Type.AreNamesEqual(type)) { - resolvedType = provider.Type; - return true; + return TryUseProviderAsBase(provider, out resolvedType); } } @@ -369,10 +378,33 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o } CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[currentProvider.Type] = currentProvider; - resolvedType = currentProvider.Type; - return true; + return TryUseProviderAsBase(currentProvider, out resolvedType); } + private static bool TryUseProviderAsBase(TypeProvider provider, [NotNullWhen(true)] out CSharpType? resolvedType) + { + // Generated model bases already participate in ModelProvider's constructor chaining. A + // symbol-backed base does not, so generated constructors can only rely on an accessible + // parameterless constructor (explicit or implicit). + if (provider is ModelProvider || + provider.Constructors.Count == 0 || + provider.Constructors.Any(c => + c.Signature.Parameters.Count == 0 && + IsConstructorAccessibleFromDerivedType(c.Signature.Modifiers))) + { + resolvedType = provider.Type; + return true; + } + + resolvedType = null; + return false; + } + + private static bool IsConstructorAccessibleFromDerivedType(MethodSignatureModifiers modifiers) + => MethodSignatureHelper.IsPublicApi(modifiers) || + (modifiers.HasFlag(MethodSignatureModifiers.Internal) && + !modifiers.HasFlag(MethodSignatureModifiers.Private)); + protected override TypeProvider[] BuildSerializationProviders() { return [.. CodeModelGenerator.Instance.TypeFactory.CreateSerializations(_inputModel, this)]; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs index 35a67676649..6fe343f7c8e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs @@ -9,5 +9,6 @@ internal static class DiagnosticCodes public const string InvalidAccessModifier = "invalid-access-modifier"; public const string PluginBuildFailed = "plugin-build-failed"; public const string UnavailableBackcompatType = "unavailable-backcompat-type"; + public const string UnrestorableBackcompatBaseType = "unrestorable-backcompat-base-type"; } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 34929c72c46..0505758b1e3 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -526,6 +526,28 @@ await MockHelpers.LoadMockGeneratorAsync( "A model must remain assignable to its previously shipped CLR base type"); } + [Test] + public async Task BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase() + { + var previousBase = InputFactory.Model("PreviousBase", properties: []); + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [previousBase, currentBase, derivedModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Current"), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("LastContract")); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(t => t.Name == "DerivedModel"); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual("CustomBase", modelProvider.BaseType?.Name, + "An explicit custom base must remain authoritative when the previous base cannot be restored without conflicting partial declarations"); + } + [Test] public async Task BackCompat_CurrentBaseDerivedFromLastContractBaseIsPreserved() { @@ -582,6 +604,30 @@ await MockHelpers.LoadMockGeneratorAsync( }); } + [Test] + public async Task BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored() + { + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [currentBase, derivedModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Current"), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("LastContract")); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(t => t.Name == "DerivedModel"); + + Assert.AreEqual("ExternalBase", modelProvider.LastContractView?.BaseType?.Name, + "The regression requires a resolvable previous base without a parameterless constructor"); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(currentBase.Name, modelProvider.BaseType?.Name, + "The previous base must not be restored when generated constructors cannot chain to it"); + } + [Test] public async Task BackCompat_LastContractBaseRestoresInheritedProperties() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(Current)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(Current)/Models.cs new file mode 100644 index 00000000000..7211d5622b1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(Current)/Models.cs @@ -0,0 +1,10 @@ +namespace Sample.Models +{ + public class CustomBase + { + } + + public partial class DerivedModel : CustomBase + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(LastContract)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(LastContract)/Models.cs new file mode 100644 index 00000000000..c1fc50ee8a4 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase(LastContract)/Models.cs @@ -0,0 +1,10 @@ +namespace Sample.Models +{ + public class PreviousBase + { + } + + public class DerivedModel : PreviousBase + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(Current)/ExternalBase.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(Current)/ExternalBase.cs new file mode 100644 index 00000000000..f851a660c28 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(Current)/ExternalBase.cs @@ -0,0 +1,9 @@ +namespace Sample.Models +{ + public class ExternalBase + { + public ExternalBase(string value) + { + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(LastContract)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(LastContract)/Models.cs new file mode 100644 index 00000000000..50a4ead7471 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_NonGeneratedLastContractBaseWithoutParameterlessConstructorIsNotRestored(LastContract)/Models.cs @@ -0,0 +1,16 @@ +namespace Sample.Models +{ + public class ExternalBase + { + public ExternalBase(string value) + { + } + } + + public class DerivedModel : ExternalBase + { + public DerivedModel(string value) : base(value) + { + } + } +} From 29ff3b82f6b9da6a1639bafb26980bf7d3fc2d58 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Wed, 2 Sep 2026 06:47:48 +0000 Subject: [PATCH 06/18] Validate referenced base constructor accessibility --- .../src/Providers/ModelProvider.cs | 13 ++-- .../src/Providers/NamedTypeSymbolProvider.cs | 12 ++++ .../src/Utilities/DiagnosticCodes.cs | 2 +- .../ModelProviders/ModelProviderTests.cs | 60 +++++++++++++++++++ .../Models.cs | 20 +++++++ 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_ReferencedLastContractBaseWithInternalParameterlessConstructorIsNotRestored(LastContract)/Models.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 462d244aa4a..af51631c3bc 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -260,7 +260,7 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N if (CustomCodeView?.BaseType is not null) { CodeModelGenerator.Instance.Emitter.ReportDiagnostic( - DiagnosticCodes.UnrestorableBackcompatBaseType, + DiagnosticCodes.IncompatibleBackcompatBaseType, $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because custom code declares base type '{currentBase?.FullyQualifiedName}'."); return currentBase; } @@ -387,10 +387,10 @@ private static bool TryUseProviderAsBase(TypeProvider provider, [NotNullWhen(tru // symbol-backed base does not, so generated constructors can only rely on an accessible // parameterless constructor (explicit or implicit). if (provider is ModelProvider || - provider.Constructors.Count == 0 || - provider.Constructors.Any(c => + provider is NamedTypeSymbolProvider { HasAccessibleParameterlessConstructor: true } || + provider is not NamedTypeSymbolProvider && provider.Constructors.Any(c => c.Signature.Parameters.Count == 0 && - IsConstructorAccessibleFromDerivedType(c.Signature.Modifiers))) + MethodSignatureHelper.IsPublicApi(c.Signature.Modifiers))) { resolvedType = provider.Type; return true; @@ -400,11 +400,6 @@ private static bool TryUseProviderAsBase(TypeProvider provider, [NotNullWhen(tru return false; } - private static bool IsConstructorAccessibleFromDerivedType(MethodSignatureModifiers modifiers) - => MethodSignatureHelper.IsPublicApi(modifiers) || - (modifiers.HasFlag(MethodSignatureModifiers.Internal) && - !modifiers.HasFlag(MethodSignatureModifiers.Private)); - protected override TypeProvider[] BuildSerializationProviders() { return [.. CodeModelGenerator.Instance.TypeFactory.CreateSerializations(_inputModel, this)]; 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 1a822a16db8..79ae3157077 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 @@ -50,6 +50,18 @@ internal string MetadataName internal string MetadataSimpleName => _metadataSimpleName ??= _namedTypeSymbol.Name; + internal bool HasAccessibleParameterlessConstructor => _namedTypeSymbol.InstanceConstructors.Any(constructor => + constructor.Parameters.Length == 0 && IsConstructorAccessibleFromGeneratedType(constructor)); + + private bool IsConstructorAccessibleFromGeneratedType(IMethodSymbol constructor) + => constructor.DeclaredAccessibility switch + { + Accessibility.Public or Accessibility.Protected or Accessibility.ProtectedOrInternal => true, + Accessibility.Internal or Accessibility.ProtectedAndInternal => + SymbolEqualityComparer.Default.Equals(constructor.ContainingAssembly, _compilation.Assembly), + _ => false, + }; + private protected sealed override NamedTypeSymbolProvider? BuildCustomCodeView(string? generatedTypeName = default, string? generatedTypeNamespace = default) => null; private protected sealed override TypeProvider? BuildLastContractView(string? generatedTypeName = default, string? generatedTypeNamespace = default) => null; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs index 6fe343f7c8e..ec18200d1f9 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/DiagnosticCodes.cs @@ -9,6 +9,6 @@ internal static class DiagnosticCodes public const string InvalidAccessModifier = "invalid-access-modifier"; public const string PluginBuildFailed = "plugin-build-failed"; public const string UnavailableBackcompatType = "unavailable-backcompat-type"; - public const string UnrestorableBackcompatBaseType = "unrestorable-backcompat-base-type"; + public const string IncompatibleBackcompatBaseType = "incompatible-backcompat-base-type"; } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 0505758b1e3..449fa09173c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -8,6 +8,8 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Primitives; using Microsoft.TypeSpec.Generator.Providers; @@ -628,6 +630,64 @@ await MockHelpers.LoadMockGeneratorAsync( "The previous base must not be restored when generated constructors cannot chain to it"); } + [Test] + public async Task BackCompat_ReferencedLastContractBaseWithInternalParameterlessConstructorIsNotRestored() + { + const string externalBaseSource = """ + namespace Sample.Models + { + public class ExternalBase + { + internal ExternalBase() { } + public ExternalBase(string value) { } + } + } + """; + var externalCompilation = CSharpCompilation.Create( + "ExternalAssembly", + [CSharpSyntaxTree.ParseText(externalBaseSource)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var externalAssembly = new MemoryStream(); + var emitResult = externalCompilation.Emit(externalAssembly); + Assert.That(emitResult.Success, Is.True, string.Join(Environment.NewLine, emitResult.Diagnostics)); + var externalReference = MetadataReference.CreateFromImage(externalAssembly.ToArray()); + + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [currentBase, derivedModel], + additionalMetadataReferences: [externalReference], + compilation: async () => + { + var compilation = await Helpers.GetCompilationFromSourceFilesAsync([]); + return compilation.WithOptions( + ((CSharpCompilationOptions)compilation.Options).WithMetadataImportOptions(MetadataImportOptions.All)); + }, + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("LastContract")); + + var referencedBase = mockGenerator.Object.SourceInputModel.FindForTypeInCurrentCompilation( + "Sample.Models", "ExternalBase", includeReferencedAssemblies: true); + Assert.IsNotNull(referencedBase, "The previous base must resolve from the referenced assembly"); + Assert.That(referencedBase!.Constructors, Has.Some.Matches(c => + c.Signature.Parameters.Count == 0 && c.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Internal))); + Assert.That(referencedBase.Constructors, Has.Some.Matches(c => + c.Signature.Parameters.Count == 1 && c.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Public))); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(t => t.Name == "DerivedModel"); + + Assert.AreEqual("ExternalBase", modelProvider.LastContractView?.BaseType?.Name, + "The regression requires a previous base that resolves from a referenced assembly"); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(currentBase.Name, modelProvider.BaseType?.Name, + "An internal constructor from a referenced assembly is not accessible to the generated derived model"); + } + [Test] public async Task BackCompat_LastContractBaseRestoresInheritedProperties() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_ReferencedLastContractBaseWithInternalParameterlessConstructorIsNotRestored(LastContract)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_ReferencedLastContractBaseWithInternalParameterlessConstructorIsNotRestored(LastContract)/Models.cs new file mode 100644 index 00000000000..02b8353795d --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_ReferencedLastContractBaseWithInternalParameterlessConstructorIsNotRestored(LastContract)/Models.cs @@ -0,0 +1,20 @@ +namespace Sample.Models +{ + public class ExternalBase + { + internal ExternalBase() + { + } + + public ExternalBase(string value) + { + } + } + + public class DerivedModel : ExternalBase + { + public DerivedModel() + { + } + } +} From 2cc9dcbd14c3955857dd2bdb083b75540ee040c4 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 03:14:23 +0000 Subject: [PATCH 07/18] Add model base back-compat hook --- .../src/Providers/ModelProvider.cs | 12 +++++ .../ModelProviders/ModelProviderTests.cs | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index af51631c3bc..bb374556dfa 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -248,6 +248,18 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N protected override CSharpType? BuildBaseType() { var currentBase = BuildCurrentBaseType(); + return BuildBaseTypeForBackCompatibility(currentBase); + } + + /// + /// Returns the model base type after applying backward compatibility against . + /// The default implementation conservatively restores a resolvable previously-published base type. + /// Override and call base to extend this behavior, or override without calling base to replace it. + /// This hook runs while the base type is being built, before model members and serialization are materialized. + /// + /// The base type selected from custom code or the current input model. + protected internal virtual CSharpType? BuildBaseTypeForBackCompatibility(CSharpType? currentBase) + { var previousBase = LastContractView?.BaseType; if (previousBase is null || IsInBaseTypeHierarchy(currentBase, previousBase)) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 449fa09173c..359584b3316 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -528,6 +528,36 @@ await MockHelpers.LoadMockGeneratorAsync( "A model must remain assignable to its previously shipped CLR base type"); } + [Test] + public async Task BackCompat_BaseTypeHookCanKeepCurrentBaseType() + { + var previousBase = InputFactory.Model("PreviousBase", properties: []); + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + createModelCore: input => input == derivedModel + ? new BaseTypeBackCompatibilityOverridingModelProvider(input) + : new ModelProvider(input), + inputModelTypes: [previousBase, currentBase, derivedModel], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync( + method: nameof(BackCompat_BaseTypeChangePreservesLastContractBaseType))); + + var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .Single(); + + Assert.Multiple(() => + { + Assert.AreEqual(previousBase.Name, modelProvider.LastContractView?.BaseType?.Name, + "The default back-compat behavior would restore the previous base"); + Assert.AreEqual(currentBase.Name, modelProvider.CapturedCurrentBase?.Name, + "The hook should receive the base selected from the current input model"); + Assert.AreEqual(currentBase.Name, modelProvider.BaseType?.Name, + "A downstream provider can override the hook to retain the current base"); + }); + } + [Test] public async Task BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase() { @@ -835,6 +865,21 @@ public BuildBaseTypeOverridingModelProvider(InputModelType inputModel, CSharpTyp protected override CSharpType? BuildBaseType() => _redirectedBaseType; } + private sealed class BaseTypeBackCompatibilityOverridingModelProvider : ModelProvider + { + public BaseTypeBackCompatibilityOverridingModelProvider(InputModelType inputModel) : base(inputModel) + { + } + + public CSharpType? CapturedCurrentBase { get; private set; } + + protected internal override CSharpType? BuildBaseTypeForBackCompatibility(CSharpType? currentBase) + { + CapturedCurrentBase = currentBase; + return currentBase; + } + } + // Regression: custom code (such as an inheritable system base model) can produce a base // ModelProvider chain that cycles back on itself. Base-model traversal during constructor, // field, and raw-data discovery must terminate instead of recursing infinitely. From fb39c2c00319ac7c989ea090823162f4136acd1a Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 07:29:42 +0000 Subject: [PATCH 08/18] Guard incompatible model base restoration --- .../src/Providers/ScmModelProvider.cs | 30 ++++++++ .../ScmModelProvider/ScmModelProviderTests.cs | 71 +++++++++++++++++++ .../Models.cs | 10 +++ .../src/Providers/ModelProvider.cs | 67 +++++++++++++---- .../ModelProviders/ModelProviderTests.cs | 48 +++++++++++++ .../Models.cs | 11 +++ 6 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/TestData/ScmModelProviderTests/BackCompat_DifferentLastContractBaseIsNotRestoredAcrossDiscriminatorHierarchy/Models.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs index 660f0cdd3cf..ff9d692f5f8 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs @@ -73,6 +73,36 @@ public ScmModelProvider(InputModelType inputModel) : base(inputModel) BaseJsonPatchProperty = new(GetBaseJsonPatchProperty()); } + protected override CSharpType? BuildBaseTypeForBackCompatibility(CSharpType? currentBase) + { + var previousBase = LastContractView?.BaseType; + if (_inputModel.DiscriminatorValue is not null && + previousBase is not null && + !IsInBaseTypeHierarchy(currentBase, previousBase)) + { + ReportIncompatibleBackcompatBaseType( + previousBase, + "the model participates in the current discriminator hierarchy"); + return currentBase; + } + + return base.BuildBaseTypeForBackCompatibility(currentBase); + } + + private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType expectedBase) + { + var visited = new HashSet(StringComparer.Ordinal); + for (var type = currentBase; type is not null && visited.Add(type.FullyQualifiedName); type = type.BaseType) + { + if (type.AreNamesEqual(expectedBase)) + { + return true; + } + } + + return false; + } + protected override FieldProvider[] BuildFields() { if (JsonPatchField is null) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs index d5b58f5a7ca..78d51259732 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json.Serialization; using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.TypeSpec.Generator.Expressions; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Primitives; @@ -262,6 +265,74 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.AreEqual(Helpers.GetExpectedFromFile("Serialization"), serializationContent); } + [Test] + public async Task BackCompat_DifferentLastContractBaseIsNotRestoredAcrossDiscriminatorHierarchy() + { + var previousBase = InputFactory.Model("previousBase", properties: []); + var derivedModel = InputFactory.Model( + "derivedModel", + discriminatedKind: "derived", + usage: InputModelTypeUsage.Json, + properties: []); + var currentBase = InputFactory.Model( + "currentBase", + usage: InputModelTypeUsage.Json, + properties: + [ + InputFactory.Property("kind", InputPrimitiveType.String, isRequired: true, isDiscriminator: true) + ], + discriminatedModels: new Dictionary { ["derived"] = derivedModel }); + + await MockHelpers.LoadMockGeneratorAsync( + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync(), + inputModels: () => [previousBase, currentBase, derivedModel]); + + var models = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .ToArray(); + foreach (var model in models) + { + model.ProcessTypeForBackCompatibility(); + } + + var derivedProvider = models.Single(t => t.Name == "DerivedModel"); + Assert.Multiple(() => + { + Assert.AreEqual("PreviousBase", derivedProvider.LastContractView?.BaseType?.Name, + "The regression requires a different last-contract base"); + Assert.AreEqual("CurrentBase", derivedProvider.BaseType?.Name, + "The current discriminator hierarchy must remain assignable"); + Assert.That(models, Has.Some.Matches(m => m.IsUnknownDiscriminatorModel), + "The current discriminator hierarchy should include its unknown subtype"); + }); + + string[] supportingProviderNames = ["ModelSerializationExtensions", "ChangeTrackingDictionary", "SampleContext", "TypeFormatters", "SerializationFormat"]; + var generatedProviders = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders + .Where(provider => provider is ScmModel || supportingProviderNames.Contains(provider.Name)) + .Concat(models.SelectMany(model => model.SerializationProviders)) + .Distinct() + .ToArray(); + var syntaxTrees = generatedProviders.Select(provider => + CSharpSyntaxTree.ParseText( + new TypeProviderWriter(provider).Write().Content, + path: $"{provider.Name}.cs")) + .Append(CSharpSyntaxTree.ParseText( + "namespace Sample { public partial class SampleContext { public static SampleContext Default => null; } }", + path: "SampleContext.Default.cs")); + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => MetadataReference.CreateFromFile(a.Location)); + var compilation = CSharpCompilation.Create( + "DiscriminatorModels", + syntaxTrees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That( + compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "The discriminator deserializer should compile after incompatible base restoration is skipped"); + } + [Test] public async Task BackCompat_AccessibleParameterlessSerializationConstructorIsPreserved() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/TestData/ScmModelProviderTests/BackCompat_DifferentLastContractBaseIsNotRestoredAcrossDiscriminatorHierarchy/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/TestData/ScmModelProviderTests/BackCompat_DifferentLastContractBaseIsNotRestoredAcrossDiscriminatorHierarchy/Models.cs new file mode 100644 index 00000000000..d84c8dea3a1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/TestData/ScmModelProviderTests/BackCompat_DifferentLastContractBaseIsNotRestoredAcrossDiscriminatorHierarchy/Models.cs @@ -0,0 +1,10 @@ +namespace Sample.Models +{ + public partial class PreviousBase + { + } + + public partial class DerivedModel : PreviousBase + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index bb374556dfa..7ef244d2f92 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -271,13 +271,13 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N // report that the previous inheritance relationship could not be restored. if (CustomCodeView?.BaseType is not null) { - CodeModelGenerator.Instance.Emitter.ReportDiagnostic( - DiagnosticCodes.IncompatibleBackcompatBaseType, - $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because custom code declares base type '{currentBase?.FullyQualifiedName}'."); + ReportIncompatibleBackcompatBaseType( + previousBase, + $"custom code declares base type '{currentBase?.FullyQualifiedName}'"); return currentBase; } - if (!TryResolveTypeInCurrentBuild(previousBase, out var resolvedPreviousBase)) + if (!TryResolveTypeInCurrentBuild(previousBase, out var resolvedPreviousBaseProvider)) { CodeModelGenerator.Instance.Emitter.ReportDiagnostic( DiagnosticCodes.UnavailableBackcompatType, @@ -285,12 +285,35 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N return currentBase; } + if (resolvedPreviousBaseProvider is ModelProvider previousBaseModel && + resolvedPreviousBaseProvider is not SystemObjectModelProvider && + HasDirectPropertyNameCollision(previousBaseModel)) + { + ReportIncompatibleBackcompatBaseType( + previousBase, + "the current model directly declares a property from the previous base hierarchy"); + return currentBase; + } + + var resolvedPreviousBase = resolvedPreviousBaseProvider.Type; CodeModelGenerator.Instance.Emitter.Info( $"Changed base type of model '{BuildName()}' from '{currentBase?.FullyQualifiedName ?? "object"}' to '{resolvedPreviousBase.FullyQualifiedName}' to match the last contract.", BackCompatibilityChangeCategory.ModelBaseTypePreserved); return resolvedPreviousBase; } + /// + /// Reports that a last-contract base type cannot be restored without making the current model invalid. + /// + /// The base type from the last contract. + /// The reason the base type cannot be restored. + protected void ReportIncompatibleBackcompatBaseType(CSharpType previousBase, string reason) + { + CodeModelGenerator.Instance.Emitter.ReportDiagnostic( + DiagnosticCodes.IncompatibleBackcompatBaseType, + $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because {reason}."); + } + private CSharpType? BuildCurrentBaseType() { if (CustomCodeView?.BaseType != null) @@ -353,13 +376,33 @@ private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType pr return false; } - private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] out CSharpType? resolvedType) + private bool HasDirectPropertyNameCollision(ModelProvider previousBase) + { + var directPropertyNames = _inputModel.Properties.Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + if (directPropertyNames.Count == 0) + { + return false; + } + + var visited = new HashSet(); + for (InputModelType? model = previousBase._inputModel; model is not null && visited.Add(model); model = model.BaseModel) + { + if (model.Properties.Any(p => directPropertyNames.Contains(p.Name))) + { + return true; + } + } + + return false; + } + + private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] out TypeProvider? resolvedProvider) { foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) { if (provider is not null && provider.Type.AreNamesEqual(type)) { - return TryUseProviderAsBase(provider, out resolvedType); + return TryUseProviderAsBase(provider, out resolvedProvider); } } @@ -374,7 +417,7 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o { if (provider is not null && provider.Type.AreNamesEqual(type)) { - return TryUseProviderAsBase(provider, out resolvedType); + return TryUseProviderAsBase(provider, out resolvedProvider); } } @@ -385,15 +428,15 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o includeReferencedAssemblies: true); if (currentProvider is null) { - resolvedType = null; + resolvedProvider = null; return false; } CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[currentProvider.Type] = currentProvider; - return TryUseProviderAsBase(currentProvider, out resolvedType); + return TryUseProviderAsBase(currentProvider, out resolvedProvider); } - private static bool TryUseProviderAsBase(TypeProvider provider, [NotNullWhen(true)] out CSharpType? resolvedType) + private static bool TryUseProviderAsBase(TypeProvider provider, [NotNullWhen(true)] out TypeProvider? resolvedProvider) { // Generated model bases already participate in ModelProvider's constructor chaining. A // symbol-backed base does not, so generated constructors can only rely on an accessible @@ -404,11 +447,11 @@ private static bool TryUseProviderAsBase(TypeProvider provider, [NotNullWhen(tru c.Signature.Parameters.Count == 0 && MethodSignatureHelper.IsPublicApi(c.Signature.Modifiers))) { - resolvedType = provider.Type; + resolvedProvider = provider; return true; } - resolvedType = null; + resolvedProvider = null; return false; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 359584b3316..d02e8c5a890 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -558,6 +558,54 @@ await MockHelpers.LoadMockGeneratorAsync( }); } + [TestCase(false)] + [TestCase(true)] + public async Task BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored(bool hasMismatchedType) + { + var previousBase = InputFactory.Model( + "PreviousBase", + properties: [InputFactory.Property("sharedProperty", InputPrimitiveType.String)]); + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model( + "DerivedModel", + properties: + [ + InputFactory.Property( + "sharedProperty", + hasMismatchedType ? InputPrimitiveType.Int32 : InputPrimitiveType.String) + ], + baseModel: currentBase); + + await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [previousBase, currentBase, derivedModel], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelProviders = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .ToArray(); + var derivedProvider = modelProviders.Single(t => t.Name == "DerivedModel"); + + derivedProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(currentBase.Name, derivedProvider.BaseType?.Name, + "The previous base must not be restored when it would collide with a directly declared current property"); + + var syntaxTrees = modelProviders.Select(provider => + CSharpSyntaxTree.ParseText(new TypeProviderWriter(provider).Write().Content)); + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => MetadataReference.CreateFromFile(a.Location)); + var compilation = CSharpCompilation.Create( + "PropertyCollisionModels", + syntaxTrees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That( + compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "The generated model hierarchy should compile after the incompatible base restoration is skipped"); + } + [Test] public async Task BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs new file mode 100644 index 00000000000..26cafaa7adf --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs @@ -0,0 +1,11 @@ +namespace Sample.Models +{ + public partial class PreviousBase + { + public string SharedProperty { get; set; } + } + + public partial class DerivedModel : PreviousBase + { + } +} From fa01391087863121774586cebdcf1858b6f5ba59 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 07:45:29 +0000 Subject: [PATCH 09/18] Normalize model property collision names --- .../src/Providers/ModelProvider.cs | 18 ++++++++++++++++-- .../ModelProviders/ModelProviderTests.cs | 14 +++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 7ef244d2f92..5e96a438158 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -378,7 +378,9 @@ private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType pr private bool HasDirectPropertyNameCollision(ModelProvider previousBase) { - var directPropertyNames = _inputModel.Properties.Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + var directPropertyNames = _inputModel.Properties + .Select(GetGeneratedPropertyName) + .ToHashSet(StringComparer.Ordinal); if (directPropertyNames.Count == 0) { return false; @@ -387,7 +389,7 @@ private bool HasDirectPropertyNameCollision(ModelProvider previousBase) var visited = new HashSet(); for (InputModelType? model = previousBase._inputModel; model is not null && visited.Add(model); model = model.BaseModel) { - if (model.Properties.Any(p => directPropertyNames.Contains(p.Name))) + if (model.Properties.Any(p => directPropertyNames.Contains(GetGeneratedPropertyName(p)))) { return true; } @@ -396,6 +398,18 @@ private bool HasDirectPropertyNameCollision(ModelProvider previousBase) return false; } + private static string GetGeneratedPropertyName(InputModelProperty property) + { + if (property.IsExactName) + { + return property.Name; + } + + return property.Name + .ToIdentifierName() + .NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()); + } + private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] out TypeProvider? resolvedProvider) { foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index d02e8c5a890..282891cd8cf 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -558,20 +558,24 @@ await MockHelpers.LoadMockGeneratorAsync( }); } - [TestCase(false)] - [TestCase(true)] - public async Task BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored(bool hasMismatchedType) + [TestCase("sharedProperty", "sharedProperty", false)] + [TestCase("sharedProperty", "sharedProperty", true)] + [TestCase("shared-property", "shared_property", false)] + public async Task BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored( + string previousPropertyName, + string currentPropertyName, + bool hasMismatchedType) { var previousBase = InputFactory.Model( "PreviousBase", - properties: [InputFactory.Property("sharedProperty", InputPrimitiveType.String)]); + properties: [InputFactory.Property(previousPropertyName, InputPrimitiveType.String)]); var currentBase = InputFactory.Model("CurrentBase", properties: []); var derivedModel = InputFactory.Model( "DerivedModel", properties: [ InputFactory.Property( - "sharedProperty", + currentPropertyName, hasMismatchedType ? InputPrimitiveType.Int32 : InputPrimitiveType.String) ], baseModel: currentBase); From 2769b760b05b0523c57f6cd29094572a0c44d7f8 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 08:08:44 +0000 Subject: [PATCH 10/18] Guard symbol-backed base property collisions --- .../src/Providers/ModelProvider.cs | 31 ++++++------- .../src/Providers/PropertyProvider.cs | 2 +- .../ModelProviders/ModelProviderTests.cs | 43 +++++++++++++++++++ .../ExternalBase.cs | 7 +++ .../Models.cs | 11 +++++ 5 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 5e96a438158..5bc8e716e1a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -285,9 +285,7 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N return currentBase; } - if (resolvedPreviousBaseProvider is ModelProvider previousBaseModel && - resolvedPreviousBaseProvider is not SystemObjectModelProvider && - HasDirectPropertyNameCollision(previousBaseModel)) + if (HasDirectPropertyNameCollision(resolvedPreviousBaseProvider)) { ReportIncompatibleBackcompatBaseType( previousBase, @@ -376,20 +374,21 @@ private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType pr return false; } - private bool HasDirectPropertyNameCollision(ModelProvider previousBase) + private bool HasDirectPropertyNameCollision(TypeProvider previousBase) { + var enclosingTypeName = BuildName(); var directPropertyNames = _inputModel.Properties - .Select(GetGeneratedPropertyName) + .Select(property => GetGeneratedPropertyName(property, enclosingTypeName)) .ToHashSet(StringComparer.Ordinal); if (directPropertyNames.Count == 0) { return false; } - var visited = new HashSet(); - for (InputModelType? model = previousBase._inputModel; model is not null && visited.Add(model); model = model.BaseModel) + var visited = new HashSet(); + for (TypeProvider? provider = previousBase; provider is not null && visited.Add(provider); provider = provider.BaseTypeProvider) { - if (model.Properties.Any(p => directPropertyNames.Contains(GetGeneratedPropertyName(p)))) + if (provider.Properties.Any(property => directPropertyNames.Contains(property.Name))) { return true; } @@ -398,16 +397,14 @@ private bool HasDirectPropertyNameCollision(ModelProvider previousBase) return false; } - private static string GetGeneratedPropertyName(InputModelProperty property) + private static string GetGeneratedPropertyName(InputModelProperty property, string enclosingTypeName) { - if (property.IsExactName) - { - return property.Name; - } - - return property.Name - .ToIdentifierName() - .NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()); + var propertyName = property.IsExactName + ? property.Name + : property.Name + .ToIdentifierName() + .NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()); + return PropertyProvider.AvoidPropertyNameCollision(propertyName, enclosingTypeName); } private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] out TypeProvider? resolvedProvider) 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..80f905313ab 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 @@ -203,7 +203,7 @@ private void BuildDocs() } } - private static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) => + internal static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) => propertyName == enclosingTypeName ? $"{propertyName}Property" : propertyName; private static bool HasDateTimeStem(string contractName, string specStem, string enclosingTypeName) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 282891cd8cf..bd009b4aaab 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -561,6 +561,7 @@ await MockHelpers.LoadMockGeneratorAsync( [TestCase("sharedProperty", "sharedProperty", false)] [TestCase("sharedProperty", "sharedProperty", true)] [TestCase("shared-property", "shared_property", false)] + [TestCase("previousBase", "previousBaseProperty", false)] public async Task BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored( string previousPropertyName, string currentPropertyName, @@ -610,6 +611,48 @@ await MockHelpers.LoadMockGeneratorAsync( "The generated model hierarchy should compile after the incompatible base restoration is skipped"); } + [Test] + public async Task BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored() + { + var currentBase = InputFactory.Model("CurrentBase", properties: []); + var derivedModel = InputFactory.Model( + "DerivedModel", + properties: [InputFactory.Property("sharedProperty", InputPrimitiveType.String)], + baseModel: currentBase); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [currentBase, derivedModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Current"), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("LastContract")); + + var modelProviders = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + .OfType() + .ToArray(); + var modelProvider = modelProviders.Single(t => t.Name == "DerivedModel"); + + modelProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(currentBase.Name, modelProvider.BaseType?.Name, + "The previous symbol-backed base must not be restored when one of its properties collides with a directly declared current property"); + + var syntaxTrees = modelProviders + .Select(provider => CSharpSyntaxTree.ParseText(new TypeProviderWriter(provider).Write().Content)) + .Concat(mockGenerator.Object.SourceInputModel.Customization!.SyntaxTrees.Where(tree => + Path.GetFileName(tree.FilePath) == "ExternalBase.cs")); + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => MetadataReference.CreateFromFile(a.Location)); + var compilation = CSharpCompilation.Create( + "SymbolBackedPropertyCollisionModels", + syntaxTrees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That( + compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "The generated model hierarchy should compile after the incompatible symbol-backed base restoration is skipped"); + } + [Test] public async Task BackCompat_CustomBaseTakesPrecedenceOverDifferentLastContractBase() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs new file mode 100644 index 00000000000..3425dfe2c4e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs @@ -0,0 +1,7 @@ +namespace Sample.Models +{ + public class ExternalBase + { + public string SharedProperty { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs new file mode 100644 index 00000000000..cb63177c20f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs @@ -0,0 +1,11 @@ +namespace Sample.Models +{ + public class ExternalBase + { + public string SharedProperty { get; set; } + } + + public class DerivedModel : ExternalBase + { + } +} From 18993c438f809e16d07ff4145dcb6c40946d593e Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 08:23:08 +0000 Subject: [PATCH 11/18] Share final model property naming for base guards --- .../src/Providers/ModelProvider.cs | 16 ++-- .../src/Providers/PropertyProvider.cs | 89 ++++++++++--------- .../ModelProviders/ModelProviderTests.cs | 14 +-- .../Models.cs | 1 + 4 files changed, 66 insertions(+), 54 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 5bc8e716e1a..96018b37047 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -397,14 +397,16 @@ private bool HasDirectPropertyNameCollision(TypeProvider previousBase) return false; } - private static string GetGeneratedPropertyName(InputModelProperty property, string enclosingTypeName) + private string GetGeneratedPropertyName(InputModelProperty property, string enclosingTypeName) { - var propertyName = property.IsExactName - ? property.Name - : property.Name - .ToIdentifierName() - .NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()); - return PropertyProvider.AvoidPropertyNameCollision(propertyName, enclosingTypeName); + var propertyType = CodeModelGenerator.Instance.TypeFactory.CreateCSharpType(property.Type); + return propertyType is null + ? PropertyProvider.AvoidPropertyNameCollision( + property.IsExactName + ? property.Name + : property.Name.ToIdentifierName().NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()), + enclosingTypeName) + : PropertyProvider.GetPropertyName(property, propertyType, this, enclosingTypeName); } private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] out TypeProvider? resolvedProvider) 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 80f905313ab..d642ab2b081 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 @@ -102,44 +102,7 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T IsDiscriminator = IsDiscriminatorProperty(inputProperty); 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(); - if (!inputProperty.IsExactName) - { - var isDateTime = inputProperty.Type.IsDateTimeInputType(); - var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime); - var enclosingTypeName = enclosingType.Name; - var lastContractProperties = enclosingType.LastContractView?.Properties - .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 = - lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)) - ?? lastContractProperties?.FirstOrDefault(p => - p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName) && - !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType)); - - if (previousProperty is null && - isDateTime && - !identifierName.EndsWith("On", StringComparison.Ordinal)) - { - // 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; - } - Name = AvoidPropertyNameCollision(identifierName, enclosingType.Name); + Name = GetPropertyName(inputProperty, Type, enclosingType, enclosingType.Name); Body = new AutoPropertyBody(propHasSetter, setterModifier, GetPropertyInitializationValue(propertyType, inputProperty)); WireInfo = new PropertyWireInformation(inputProperty); @@ -203,6 +166,51 @@ private void BuildDocs() } } + internal static string GetPropertyName( + InputProperty inputProperty, + CSharpType propertyType, + TypeProvider enclosingType, + string enclosingTypeName) + { + var identifierName = inputProperty.IsExactName ? inputProperty.Name : inputProperty.Name.ToIdentifierName(); + if (!inputProperty.IsExactName) + { + var isDateTime = inputProperty.Type.IsDateTimeInputType(); + var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime); + var lastContractProperties = enclosingType.LastContractView?.Properties + .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 = + lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName)) + ?? lastContractProperties?.FirstOrDefault(p => + p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName) && + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType, enclosingTypeName)); + + if (previousProperty is null && + isDateTime && + !identifierName.EndsWith("On", StringComparison.Ordinal)) + { + // 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(propertyType.WithNullable(false)) && + !IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType, enclosingTypeName)); + } + identifierName = previousProperty?.Name ?? canonicalName; + } + return AvoidPropertyNameCollision(identifierName, enclosingTypeName); + } + internal static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) => propertyName == enclosingTypeName ? $"{propertyName}Property" : propertyName; @@ -220,10 +228,9 @@ private static bool HasDateTimeStem(string contractName, string specStem, string private static bool IsClaimedBySiblingProperty( string contractName, InputProperty inputProperty, - TypeProvider enclosingType) + TypeProvider enclosingType, + string enclosingTypeName) { - var enclosingTypeName = enclosingType.Name; - foreach (var sibling in inputProperty.EnclosingType?.Properties ?? []) { if (ReferenceEquals(sibling, inputProperty)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index bd009b4aaab..090153891e1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -558,18 +558,20 @@ await MockHelpers.LoadMockGeneratorAsync( }); } - [TestCase("sharedProperty", "sharedProperty", false)] - [TestCase("sharedProperty", "sharedProperty", true)] - [TestCase("shared-property", "shared_property", false)] - [TestCase("previousBase", "previousBaseProperty", false)] + [TestCase("sharedProperty", "sharedProperty", false, false)] + [TestCase("sharedProperty", "sharedProperty", true, false)] + [TestCase("shared-property", "shared_property", false, false)] + [TestCase("previousBase", "previousBaseProperty", false, false)] + [TestCase("IpAddress", "IpAddress", false, true)] public async Task BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored( string previousPropertyName, string currentPropertyName, - bool hasMismatchedType) + bool hasMismatchedType, + bool previousPropertyIsExact) { var previousBase = InputFactory.Model( "PreviousBase", - properties: [InputFactory.Property(previousPropertyName, InputPrimitiveType.String)]); + properties: [InputFactory.Property(previousPropertyName, InputPrimitiveType.String, isExactName: previousPropertyIsExact)]); var currentBase = InputFactory.Model("CurrentBase", properties: []); var derivedModel = InputFactory.Model( "DerivedModel", diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs index 26cafaa7adf..5b89f09f7bb 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_GeneratedLastContractBaseWithPropertyCollisionIsNotRestored/Models.cs @@ -7,5 +7,6 @@ public partial class PreviousBase public partial class DerivedModel : PreviousBase { + public string IpAddress { get; set; } } } From 30a06fcc9c9ffb22ef4ebc93443fa40443a11b5f Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 08:35:27 +0000 Subject: [PATCH 12/18] Resolve nested last-contract base types --- .../src/Providers/ModelProvider.cs | 18 ++++++++--- .../src/Providers/NamedTypeSymbolProvider.cs | 4 +++ .../ModelProviders/ModelProviderTests.cs | 32 ++++++++++++++++--- .../DerivedModel.cs | 14 ++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 96018b37047..f6d58eddb45 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -155,9 +155,9 @@ private IReadOnlyList BuildDerivedModels() } var baseTypeProvider = CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCurrentCompilation( - baseType.Namespace, + GetMetadataNamespace(baseType), baseType.Name, - baseType.DeclaringType?.Name, + baseType.DeclaringType?.ClrMetadataName, includeReferencedAssemblies: true); if (baseTypeProvider != null) @@ -435,9 +435,9 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o } var currentProvider = CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCurrentCompilation( - type.Namespace, + GetMetadataNamespace(type), type.Name, - type.DeclaringType?.Name, + type.DeclaringType?.ClrMetadataName, includeReferencedAssemblies: true); if (currentProvider is null) { @@ -449,6 +449,16 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o return TryUseProviderAsBase(currentProvider, out resolvedProvider); } + private static string GetMetadataNamespace(CSharpType type) + { + while (type.DeclaringType is not null) + { + type = type.DeclaringType; + } + + return type.Namespace; + } + private static bool TryUseProviderAsBase(TypeProvider provider, [NotNullWhen(true)] out TypeProvider? resolvedProvider) { // Generated model bases already participate in ModelProvider's constructor chaining. A 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 79ae3157077..4227d3c07c3 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 @@ -84,6 +84,10 @@ private static string GetMetadataName(INamedTypeSymbol symbol) protected override string BuildNamespace() => _namedTypeSymbol.ContainingNamespace.GetFullyQualifiedNameFromDisplayString(); + protected override TypeProvider? BuildDeclaringTypeProvider() => _namedTypeSymbol.ContainingType is null + ? null + : new NamedTypeSymbolProvider(_namedTypeSymbol.ContainingType, _compilation); + protected override IReadOnlyList BuildAttributes() => [.._namedTypeSymbol.GetAttributes().Select(a => new AttributeStatement(a))]; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 090153891e1..2790ad2da70 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -702,26 +702,47 @@ await MockHelpers.LoadMockGeneratorAsync( [Test] public async Task BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType() { + const string nestedBaseSource = """ + namespace Sample.Models + { + public class Outer + { + public class Middle + { + public class NestedBase + { + } + } + } + } + """; var currentBase = InputFactory.Model("CurrentBase", properties: []); var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); + var nestedDerivedModel = InputFactory.Model("NestedDerivedModel", properties: [], baseModel: currentBase); await MockHelpers.LoadMockGeneratorAsync( - inputModelTypes: [currentBase, derivedModel], - compilation: async () => await Helpers.GetCompilationFromSourceFilesAsync([]), + inputModelTypes: [currentBase, derivedModel, nestedDerivedModel], + compilation: async () => await Helpers.GetCompilationFromSourceFilesAsync( + [("NestedBase.cs", nestedBaseSource)]), lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); - var modelProvider = CodeModelGenerator.Instance.OutputLibrary.TypeProviders + var modelProviders = CodeModelGenerator.Instance.OutputLibrary.TypeProviders .OfType() - .Single(t => t.Name == "DerivedModel"); + .ToArray(); + var modelProvider = modelProviders.Single(t => t.Name == "DerivedModel"); + var nestedModelProvider = modelProviders.Single(t => t.Name == "NestedDerivedModel"); Assert.Multiple(() => { Assert.AreEqual(nameof(Exception), modelProvider.LastContractView?.BaseType?.Name); Assert.AreEqual(nameof(System), modelProvider.LastContractView?.BaseType?.Namespace, "The regression requires the previously shipped framework base type to be available from the last contract"); + Assert.AreEqual("NestedBase", nestedModelProvider.LastContractView?.BaseType?.Name, + "The regression requires a multi-level nested base type"); }); modelProvider.ProcessTypeForBackCompatibility(); + nestedModelProvider.ProcessTypeForBackCompatibility(); Assert.Multiple(() => { @@ -730,6 +751,9 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.AreEqual(nameof(System), modelProvider.BaseType?.Namespace); Assert.IsInstanceOf(modelProvider.BaseTypeProvider, "The preserved base should resolve from the current referenced assemblies without a generated model provider"); + Assert.AreEqual("NestedBase", nestedModelProvider.BaseType?.Name, + "A nested base should resolve using its complete CLR declaring-type metadata name"); + Assert.AreEqual("Outer", nestedModelProvider.BaseType?.DeclaringType?.DeclaringType?.Name); }); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs index b996bb27ce4..be3ea5a0322 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs @@ -3,4 +3,18 @@ namespace Sample.Models public partial class DerivedModel : System.Exception { } + + public class Outer + { + public class Middle + { + public class NestedBase + { + } + } + } + + public partial class NestedDerivedModel : Outer.Middle.NestedBase + { + } } From 924e18ca5a084b1057f665a46c3240f6de7c5452 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 08:48:49 +0000 Subject: [PATCH 13/18] Continue past unusable base type candidates --- .../src/Providers/ScmModelProvider.cs | 14 -------------- .../src/Providers/ModelProvider.cs | 19 +++++++++++++------ .../ModelProviders/ModelProviderTests.cs | 5 ++++- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs index ff9d692f5f8..b5b1e8fc5d5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs @@ -89,20 +89,6 @@ previousBase is not null && return base.BuildBaseTypeForBackCompatibility(currentBase); } - private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType expectedBase) - { - var visited = new HashSet(StringComparer.Ordinal); - for (var type = currentBase; type is not null && visited.Add(type.FullyQualifiedName); type = type.BaseType) - { - if (type.AreNamesEqual(expectedBase)) - { - return true; - } - } - - return false; - } - protected override FieldProvider[] BuildFields() { if (JsonPatchField is null) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index f6d58eddb45..424f6d26466 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -360,10 +360,10 @@ protected void ReportIncompatibleBackcompatBaseType(CSharpType previousBase, str : CodeModelGenerator.Instance.TypeFactory.CreateModel(_inputModel.BaseModel)?.Type; } - private static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType previousBase) + protected static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType previousBase) { var visited = new HashSet(StringComparer.Ordinal); - for (var type = currentBase; type is not null && visited.Add(type.FullyQualifiedName); type = type.BaseType) + for (var type = currentBase; type is not null && visited.Add(GetMetadataTypeIdentity(type)); type = type.BaseType) { if (type.AreNamesEqual(previousBase)) { @@ -413,9 +413,11 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o { foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) { - if (provider is not null && provider.Type.AreNamesEqual(type)) + if (provider is not null && + provider.Type.AreNamesEqual(type) && + TryUseProviderAsBase(provider, out resolvedProvider)) { - return TryUseProviderAsBase(provider, out resolvedProvider); + return true; } } @@ -428,9 +430,11 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) { - if (provider is not null && provider.Type.AreNamesEqual(type)) + if (provider is not null && + provider.Type.AreNamesEqual(type) && + TryUseProviderAsBase(provider, out resolvedProvider)) { - return TryUseProviderAsBase(provider, out resolvedProvider); + return true; } } @@ -449,6 +453,9 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o return TryUseProviderAsBase(currentProvider, out resolvedProvider); } + private static string GetMetadataTypeIdentity(CSharpType type) + => $"{GetMetadataNamespace(type)}.{type.ClrMetadataName}"; + private static string GetMetadataNamespace(CSharpType type) { while (type.DeclaringType is not null) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 2790ad2da70..af91a271ee0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -720,12 +720,15 @@ public class NestedBase var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); var nestedDerivedModel = InputFactory.Model("NestedDerivedModel", properties: [], baseModel: currentBase); - await MockHelpers.LoadMockGeneratorAsync( + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( inputModelTypes: [currentBase, derivedModel, nestedDerivedModel], compilation: async () => await Helpers.GetCompilationFromSourceFilesAsync( [("NestedBase.cs", nestedBaseSource)]), lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + var placeholderType = new CSharpType(typeof(Exception)); + mockGenerator.Object.TypeFactory.CSharpTypeMap[placeholderType] = new SystemObjectTypeProvider(placeholderType); + var modelProviders = CodeModelGenerator.Instance.OutputLibrary.TypeProviders .OfType() .ToArray(); From 690a2bf0f2d9046f4acfdfa9bc1e02a2e2c032fa Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 09:01:47 +0000 Subject: [PATCH 14/18] Use metadata identity for base type matching --- .../src/Providers/ModelProvider.cs | 22 +++++++++++++++---- .../ModelProviders/ModelProviderTests.cs | 16 ++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 424f6d26466..d2b6e3b9bc7 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -365,7 +365,7 @@ protected static bool IsInBaseTypeHierarchy(CSharpType? currentBase, CSharpType var visited = new HashSet(StringComparer.Ordinal); for (var type = currentBase; type is not null && visited.Add(GetMetadataTypeIdentity(type)); type = type.BaseType) { - if (type.AreNamesEqual(previousBase)) + if (AreMetadataTypesEqual(type, previousBase)) { return true; } @@ -414,7 +414,7 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) { if (provider is not null && - provider.Type.AreNamesEqual(type) && + AreMetadataTypesEqual(provider.Type, type) && TryUseProviderAsBase(provider, out resolvedProvider)) { return true; @@ -431,7 +431,7 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o foreach (var provider in CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.Values) { if (provider is not null && - provider.Type.AreNamesEqual(type) && + AreMetadataTypesEqual(provider.Type, type) && TryUseProviderAsBase(provider, out resolvedProvider)) { return true; @@ -453,8 +453,22 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o return TryUseProviderAsBase(currentProvider, out resolvedProvider); } + private static bool AreMetadataTypesEqual(CSharpType left, CSharpType right) + => string.Equals( + GetMetadataTypeIdentity(left), + GetMetadataTypeIdentity(right), + StringComparison.Ordinal); + private static string GetMetadataTypeIdentity(CSharpType type) - => $"{GetMetadataNamespace(type)}.{type.ClrMetadataName}"; + { + var typeArguments = type.Arguments.Count == 0 + ? string.Empty + : $"[{string.Join(",", type.Arguments.Select(GetMetadataTypeIdentity))}]"; + var declaringType = type.DeclaringType is null + ? string.Empty + : $"@{GetMetadataTypeIdentity(type.DeclaringType)}"; + return $"{GetMetadataNamespace(type)}.{type.ClrMetadataName}{typeArguments}{declaringType}"; + } private static string GetMetadataNamespace(CSharpType type) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index af91a271ee0..928215fde6e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -714,6 +714,16 @@ public class NestedBase } } } + + public class OtherOuter + { + public class Middle + { + public class NestedBase + { + } + } + } } """; var currentBase = InputFactory.Model("CurrentBase", properties: []); @@ -728,6 +738,12 @@ public class NestedBase var placeholderType = new CSharpType(typeof(Exception)); mockGenerator.Object.TypeFactory.CSharpTypeMap[placeholderType] = new SystemObjectTypeProvider(placeholderType); + var unrelatedNestedBase = mockGenerator.Object.SourceInputModel.FindForTypeInCurrentCompilation( + "Sample.Models", + "NestedBase", + "OtherOuter+Middle"); + Assert.IsNotNull(unrelatedNestedBase); + mockGenerator.Object.TypeFactory.CSharpTypeMap[unrelatedNestedBase!.Type] = unrelatedNestedBase; var modelProviders = CodeModelGenerator.Instance.OutputLibrary.TypeProviders .OfType() From d6e041c7c95810c6c2f2a0515e6a74b1e8cecd43 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 09:11:03 +0000 Subject: [PATCH 15/18] Fail model compilation regressions on warnings --- .../test/Providers/ScmModelProvider/ScmModelProviderTests.cs | 2 +- .../test/Providers/ModelProviders/ModelProviderTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs index 78d51259732..6266a428561 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ScmModelProvider/ScmModelProviderTests.cs @@ -328,7 +328,7 @@ await MockHelpers.LoadMockGeneratorAsync( references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); Assert.That( - compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + compilation.GetDiagnostics().Where(d => d.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error), Is.Empty, "The discriminator deserializer should compile after incompatible base restoration is skipped"); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 928215fde6e..eb7d1e1c615 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -608,7 +608,7 @@ await MockHelpers.LoadMockGeneratorAsync( references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); Assert.That( - compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + compilation.GetDiagnostics().Where(d => d.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error), Is.Empty, "The generated model hierarchy should compile after the incompatible base restoration is skipped"); } @@ -650,7 +650,7 @@ public async Task BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIs references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); Assert.That( - compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + compilation.GetDiagnostics().Where(d => d.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error), Is.Empty, "The generated model hierarchy should compile after the incompatible symbol-backed base restoration is skipped"); } From 454e54994f8464989bd0ed7eadd5abdb49c21e37 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 3 Sep 2026 09:22:32 +0000 Subject: [PATCH 16/18] Handle generic bases and private property collisions --- .../src/Providers/ModelProvider.cs | 52 +++++++++++++++++-- .../ModelProviders/ModelProviderTests.cs | 33 ++++++++++-- .../DerivedModel.cs | 8 +++ .../ExternalBase.cs | 1 + .../Models.cs | 5 ++ 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index d2b6e3b9bc7..11b7c83f140 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -156,7 +156,7 @@ private IReadOnlyList BuildDerivedModels() var baseTypeProvider = CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCurrentCompilation( GetMetadataNamespace(baseType), - baseType.Name, + GetMetadataSimpleName(baseType), baseType.DeclaringType?.ClrMetadataName, includeReferencedAssemblies: true); @@ -293,7 +293,8 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N return currentBase; } - var resolvedPreviousBase = resolvedPreviousBaseProvider.Type; + var resolvedPreviousBase = GetResolvedBaseType(previousBase, resolvedPreviousBaseProvider); + CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap[resolvedPreviousBase] = resolvedPreviousBaseProvider; CodeModelGenerator.Instance.Emitter.Info( $"Changed base type of model '{BuildName()}' from '{currentBase?.FullyQualifiedName ?? "object"}' to '{resolvedPreviousBase.FullyQualifiedName}' to match the last contract.", BackCompatibilityChangeCategory.ModelBaseTypePreserved); @@ -388,7 +389,8 @@ private bool HasDirectPropertyNameCollision(TypeProvider previousBase) var visited = new HashSet(); for (TypeProvider? provider = previousBase; provider is not null && visited.Add(provider); provider = provider.BaseTypeProvider) { - if (provider.Properties.Any(property => directPropertyNames.Contains(property.Name))) + if (provider.Properties.Any(property => + IsInheritedProperty(property) && directPropertyNames.Contains(property.Name))) { return true; } @@ -397,6 +399,10 @@ private bool HasDirectPropertyNameCollision(TypeProvider previousBase) return false; } + private static bool IsInheritedProperty(PropertyProvider property) + => !property.Modifiers.HasFlag(MethodSignatureModifiers.Private) || + property.Modifiers.HasFlag(MethodSignatureModifiers.Protected); + private string GetGeneratedPropertyName(InputModelProperty property, string enclosingTypeName) { var propertyType = CodeModelGenerator.Instance.TypeFactory.CreateCSharpType(property.Type); @@ -440,7 +446,7 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o var currentProvider = CodeModelGenerator.Instance.SourceInputModel.FindForTypeInCurrentCompilation( GetMetadataNamespace(type), - type.Name, + GetMetadataSimpleName(type), type.DeclaringType?.ClrMetadataName, includeReferencedAssemblies: true); if (currentProvider is null) @@ -453,6 +459,44 @@ private bool TryResolveTypeInCurrentBuild(CSharpType type, [NotNullWhen(true)] o return TryUseProviderAsBase(currentProvider, out resolvedProvider); } + private static string GetMetadataSimpleName(CSharpType type) + { + var metadataName = type.ClrMetadataName; + var separatorIndex = metadataName.LastIndexOf('+'); + return separatorIndex < 0 ? metadataName : metadataName[(separatorIndex + 1)..]; + } + + private static CSharpType GetResolvedBaseType(CSharpType requestedType, TypeProvider resolvedProvider) + { + var resolvedType = resolvedProvider.Type; + return resolvedProvider is NamedTypeSymbolProvider + ? ApplyTypeConstruction(resolvedType, requestedType) + : resolvedType; + } + + private static CSharpType ApplyTypeConstruction(CSharpType resolvedType, CSharpType requestedType) + { + var declaringType = resolvedType.DeclaringType; + if (declaringType is not null && requestedType.DeclaringType is not null) + { + declaringType = ApplyTypeConstruction(declaringType, requestedType.DeclaringType); + } + + var arguments = resolvedType.Arguments.Count == requestedType.Arguments.Count + ? requestedType.Arguments + : resolvedType.Arguments; + return new CSharpType( + resolvedType.Name, + resolvedType.Namespace, + resolvedType.IsValueType, + resolvedType.IsNullable, + declaringType, + arguments, + resolvedType.IsPublic, + resolvedType.IsStruct, + resolvedType.BaseType); + } + private static bool AreMetadataTypesEqual(CSharpType left, CSharpType right) => string.Equals( GetMetadataTypeIdentity(left), diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index eb7d1e1c615..d1730b3fb77 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -621,9 +621,13 @@ public async Task BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIs "DerivedModel", properties: [InputFactory.Property("sharedProperty", InputPrimitiveType.String)], baseModel: currentBase); + var privatePropertyDerivedModel = InputFactory.Model( + "PrivatePropertyDerivedModel", + properties: [InputFactory.Property("privateProperty", InputPrimitiveType.String)], + baseModel: currentBase); var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( - inputModelTypes: [currentBase, derivedModel], + inputModelTypes: [currentBase, derivedModel, privatePropertyDerivedModel], compilation: async () => await Helpers.GetCompilationFromDirectoryAsync("Current"), lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync("LastContract")); @@ -631,11 +635,18 @@ public async Task BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIs .OfType() .ToArray(); var modelProvider = modelProviders.Single(t => t.Name == "DerivedModel"); + var privatePropertyModelProvider = modelProviders.Single(t => t.Name == "PrivatePropertyDerivedModel"); modelProvider.ProcessTypeForBackCompatibility(); + privatePropertyModelProvider.ProcessTypeForBackCompatibility(); - Assert.AreEqual(currentBase.Name, modelProvider.BaseType?.Name, - "The previous symbol-backed base must not be restored when one of its properties collides with a directly declared current property"); + Assert.Multiple(() => + { + Assert.AreEqual(currentBase.Name, modelProvider.BaseType?.Name, + "The previous symbol-backed base must not be restored when one of its properties collides with a directly declared current property"); + Assert.AreEqual("ExternalBase", privatePropertyModelProvider.BaseType?.Name, + "A private base property is not inherited and must not block restoration"); + }); var syntaxTrees = modelProviders .Select(provider => CSharpSyntaxTree.ParseText(new TypeProviderWriter(provider).Write().Content)) @@ -724,14 +735,19 @@ public class NestedBase } } } + + public class GenericBase + { + } } """; var currentBase = InputFactory.Model("CurrentBase", properties: []); var derivedModel = InputFactory.Model("DerivedModel", properties: [], baseModel: currentBase); var nestedDerivedModel = InputFactory.Model("NestedDerivedModel", properties: [], baseModel: currentBase); + var genericDerivedModel = InputFactory.Model("GenericDerivedModel", properties: [], baseModel: currentBase); var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( - inputModelTypes: [currentBase, derivedModel, nestedDerivedModel], + inputModelTypes: [currentBase, derivedModel, nestedDerivedModel, genericDerivedModel], compilation: async () => await Helpers.GetCompilationFromSourceFilesAsync( [("NestedBase.cs", nestedBaseSource)]), lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); @@ -750,6 +766,7 @@ public class NestedBase .ToArray(); var modelProvider = modelProviders.Single(t => t.Name == "DerivedModel"); var nestedModelProvider = modelProviders.Single(t => t.Name == "NestedDerivedModel"); + var genericModelProvider = modelProviders.Single(t => t.Name == "GenericDerivedModel"); Assert.Multiple(() => { @@ -758,10 +775,13 @@ public class NestedBase "The regression requires the previously shipped framework base type to be available from the last contract"); Assert.AreEqual("NestedBase", nestedModelProvider.LastContractView?.BaseType?.Name, "The regression requires a multi-level nested base type"); + Assert.AreEqual("GenericBase", genericModelProvider.LastContractView?.BaseType?.Name, + "The regression requires a constructed generic base type"); }); modelProvider.ProcessTypeForBackCompatibility(); nestedModelProvider.ProcessTypeForBackCompatibility(); + genericModelProvider.ProcessTypeForBackCompatibility(); Assert.Multiple(() => { @@ -773,6 +793,11 @@ public class NestedBase Assert.AreEqual("NestedBase", nestedModelProvider.BaseType?.Name, "A nested base should resolve using its complete CLR declaring-type metadata name"); Assert.AreEqual("Outer", nestedModelProvider.BaseType?.DeclaringType?.DeclaringType?.Name); + Assert.AreEqual("GenericBase", genericModelProvider.BaseType?.Name, + "A generic base should resolve using its metadata arity"); + Assert.That(genericModelProvider.BaseType?.Arguments, Has.Count.EqualTo(1)); + Assert.AreEqual("global::Sample.Models.GenericBase", genericModelProvider.BaseType?.ToString(), + "The restored base must retain its last-contract generic construction"); }); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs index be3ea5a0322..bdb693b1275 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_BaseTypeChangePreservesNonGeneratedLastContractBaseType/DerivedModel.cs @@ -17,4 +17,12 @@ public class NestedBase public partial class NestedDerivedModel : Outer.Middle.NestedBase { } + + public class GenericBase + { + } + + public partial class GenericDerivedModel : GenericBase + { + } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs index 3425dfe2c4e..3fcbfd73534 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(Current)/ExternalBase.cs @@ -3,5 +3,6 @@ namespace Sample.Models public class ExternalBase { public string SharedProperty { get; set; } + private string PrivateProperty { get; set; } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs index cb63177c20f..23bab7f15fb 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelProviderTests/BackCompat_SymbolBackedLastContractBaseWithPropertyCollisionIsNotRestored(LastContract)/Models.cs @@ -3,9 +3,14 @@ namespace Sample.Models public class ExternalBase { public string SharedProperty { get; set; } + private string PrivateProperty { get; set; } } public class DerivedModel : ExternalBase { } + + public class PrivatePropertyDerivedModel : ExternalBase + { + } } From 11989421f77db6a523d41b8f0937f43807cd3284 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 4 Sep 2026 02:08:59 +0000 Subject: [PATCH 17/18] Keep compatibility diagnostics private --- .../src/Providers/ScmModelProvider.cs | 7 ++++--- .../src/Providers/ModelProvider.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs index b5b1e8fc5d5..c06c97a44b8 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmModelProvider.cs @@ -46,6 +46,7 @@ public class ScmModelProvider : ModelProvider internal const string ScmEvaluationTypeDiagnosticId = "SCME0001"; internal const string FileBinaryContentDiagnosticId = "SCME0004"; + private const string IncompatibleBackcompatBaseTypeDiagnostic = "incompatible-backcompat-base-type"; internal const string ScmEvaluationTypeSuppressionJustification = "Type is for evaluation purposes only and is subject to change or removal in future updates."; @@ -80,9 +81,9 @@ public ScmModelProvider(InputModelType inputModel) : base(inputModel) previousBase is not null && !IsInBaseTypeHierarchy(currentBase, previousBase)) { - ReportIncompatibleBackcompatBaseType( - previousBase, - "the model participates in the current discriminator hierarchy"); + CodeModelGenerator.Instance.Emitter.ReportDiagnostic( + IncompatibleBackcompatBaseTypeDiagnostic, + $"Could not preserve base type '{previousBase.FullyQualifiedName}' on model '{BuildNamespace()}.{BuildName()}' because the model participates in the current discriminator hierarchy."); return currentBase; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 11b7c83f140..356a5757e89 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -306,7 +306,7 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N /// /// The base type from the last contract. /// The reason the base type cannot be restored. - protected void ReportIncompatibleBackcompatBaseType(CSharpType previousBase, string reason) + private void ReportIncompatibleBackcompatBaseType(CSharpType previousBase, string reason) { CodeModelGenerator.Instance.Emitter.ReportDiagnostic( DiagnosticCodes.IncompatibleBackcompatBaseType, From c0d3c88d39ff86614ed1cdd823feb98fc1c6b418 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 4 Sep 2026 05:29:32 +0000 Subject: [PATCH 18/18] Limit base compatibility hook to derived providers --- .../Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs | 2 +- .../test/Providers/ModelProviders/ModelProviderTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 356a5757e89..c1ea18757cd 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -258,7 +258,7 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N /// This hook runs while the base type is being built, before model members and serialization are materialized. /// /// The base type selected from custom code or the current input model. - protected internal virtual CSharpType? BuildBaseTypeForBackCompatibility(CSharpType? currentBase) + protected virtual CSharpType? BuildBaseTypeForBackCompatibility(CSharpType? currentBase) { var previousBase = LastContractView?.BaseType; if (previousBase is null || IsInBaseTypeHierarchy(currentBase, previousBase)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index d1730b3fb77..ecbe7ff7e16 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -1038,7 +1038,7 @@ public BaseTypeBackCompatibilityOverridingModelProvider(InputModelType inputMode public CSharpType? CapturedCurrentBase { get; private set; } - protected internal override CSharpType? BuildBaseTypeForBackCompatibility(CSharpType? currentBase) + protected override CSharpType? BuildBaseTypeForBackCompatibility(CSharpType? currentBase) { CapturedCurrentBase = currentBase; return currentBase;