diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs index 7ab35ce7e77..406da6c37fa 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ExtensibleEnumProvider.cs @@ -288,7 +288,7 @@ protected override TypeProvider[] BuildSerializationProviders() protected internal override IReadOnlyList? BuildEnumValuesForBackCompatibility(IReadOnlyList currentValues) { var lastContractProperties = LastContractView?.Properties - .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers)); + .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers, p.Attributes)); if (lastContractProperties == null || !lastContractProperties.Any()) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelBaseTypeCompatibility.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelBaseTypeCompatibility.cs index e4487477eec..f4f8a2b903c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelBaseTypeCompatibility.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelBaseTypeCompatibility.cs @@ -91,19 +91,17 @@ private static bool HasNoUnsupportedLastContractMembers(ModelProvider candidate) return lastContract.Implements.All(previous => currentInterfaces.Any(current => ModelBaseMemberCompatibility.AreTypesCompatible(previous, current))) && lastContract.Constructors - .Where(constructor => MethodSignatureHelper.IsPublicApi(constructor.Signature.Modifiers)) + .Where(constructor => MethodSignatureHelper.IsPublicApi(constructor.Signature)) .All(constructor => SystemObjectModelProvider.HasSupportedConstructorParameters(constructor.Signature.Parameters) && constructor.Signature.Parameters.All(parameter => candidate.Properties.Any(property => property.AsParameter.Name == parameter.Name && (ModelBaseMemberCompatibility.AreTypesCompatible(parameter.Type, property.Type) || ModelBaseMemberCompatibility.AreTypesCompatible(parameter.Type, property.Type.InputType))))) && lastContract.Methods - .Where(method => MethodSignatureHelper.IsPublicApi(method.Signature.Modifiers)) + .Where(method => MethodSignatureHelper.IsPublicApi(method.Signature)) .All(previous => currentGeneratedMethods.Any(current => AreGeneratedMethodSignaturesEquivalent(previous.Signature, current.Signature))) && - !lastContract.Fields.Any(field => - field.Modifiers.HasFlag(FieldModifiers.Public) || - field.Modifiers.HasFlag(FieldModifiers.Protected)); + !lastContract.Fields.Any(field => MethodSignatureHelper.IsPublicApi(field.Modifiers, field.Attributes)); } private static bool AreGeneratedMethodSignaturesEquivalent( @@ -128,8 +126,8 @@ private bool HasCompatibleLastContractProperties(ModelProvider candidate) return true; } - var currentProperties = GetUniquePublicProperties(candidate.Properties); - var previousProperties = GetUniquePublicProperties(lastContract.Properties); + var currentProperties = GetUniquePublicProperties(candidate.Properties, isLastContract: false); + var previousProperties = GetUniquePublicProperties(lastContract.Properties, isLastContract: true); if (currentProperties is null || previousProperties is null) { return false; @@ -143,10 +141,14 @@ private bool HasCompatibleLastContractProperties(ModelProvider candidate) !LastContractDerivedConstructorHasRequiredParameter(property))); } - private static Dictionary? GetUniquePublicProperties(IReadOnlyList properties) + private static Dictionary? GetUniquePublicProperties( + IReadOnlyList properties, + bool isLastContract) { var result = new Dictionary(StringComparer.Ordinal); - foreach (var property in properties.Where(property => MethodSignatureHelper.IsPublicApi(property.Modifiers))) + foreach (var property in properties.Where(property => isLastContract + ? MethodSignatureHelper.IsPublicApi(property.Modifiers, property.Attributes) + : MethodSignatureHelper.IsPublicApi(property.Modifiers))) { // Overloaded indexers can share a symbol name. They are unsupported contracts, not // malformed input that should abort generation with a duplicate-key exception. @@ -164,7 +166,7 @@ private static bool IsRequiredInitializationProperty(PropertyProvider property) private bool LastContractDerivedConstructorHasRequiredParameter(PropertyProvider property) => _model.LastContractView?.Constructors.Any(constructor => - MethodSignatureHelper.IsPublicApi(constructor.Signature.Modifiers) && + MethodSignatureHelper.IsPublicApi(constructor.Signature) && constructor.Signature.Parameters.Any(parameter => parameter.Name == property.AsParameter.Name && parameter.Type.Equals(property.Type, ignoreNullable: true) && diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelFactoryProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelFactoryProvider.cs index f60171bd37d..d9bbad0580c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelFactoryProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelFactoryProvider.cs @@ -127,7 +127,7 @@ protected internal sealed override IReadOnlyList BuildMethodsFor foreach (var previousMethod in LastContractView.Methods) { - if (!MethodSignatureHelper.IsPublicApi(previousMethod.Signature.Modifiers)) + if (!MethodSignatureHelper.IsPublicApi(previousMethod.Signature)) { continue; } 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 f0cedac065f..0ffb630c947 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 @@ -107,7 +107,7 @@ _inputModel.BaseModel is not null private IDictionary LastContractPropertiesMap => _lastContractPropertiesMap ??= LastContractView?.Properties - .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers)) + .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers, p.Attributes)) .ToDictionary(p => p.Name, p => p.Type) ?? []; private IDictionary? _lastContractPropertiesMap; @@ -1111,7 +1111,7 @@ protected internal override IReadOnlyList BuildConstructors foreach (var previousConstructor in restorablePreviousConstructors) { - if (!MethodSignatureHelper.IsPublicApi(previousConstructor.Signature.Modifiers)) + if (!MethodSignatureHelper.IsPublicApi(previousConstructor.Signature)) { continue; } @@ -1183,7 +1183,7 @@ private void RestorePreviousConstructorParameterNames( const MethodSignatureModifiers privateProtected = MethodSignatureModifiers.Private | MethodSignatureModifiers.Protected; foreach (var previousConstructor in previousConstructors) { - if (!MethodSignatureHelper.IsPublicApi(previousConstructor.Signature.Modifiers)) + if (!MethodSignatureHelper.IsPublicApi(previousConstructor.Signature)) { continue; } @@ -2056,7 +2056,7 @@ private bool ShouldUseObjectAdditionalProperties() // Check if the property exists in the last contract by name var lastContractProperty = LastContractView.Properties.FirstOrDefault(p => - MethodSignatureHelper.IsPublicApi(p.Modifiers) && + MethodSignatureHelper.IsPublicApi(p.Modifiers, p.Attributes) && p.Name == AdditionalPropertiesHelper.DefaultAdditionalPropertiesPropertyName); if (lastContractProperty == null) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs index 0ae3540d45a..5903522f740 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 @@ -31,6 +31,7 @@ public NamedTypeSymbolProvider(INamedTypeSymbol namedTypeSymbol, Compilation com { _namedTypeSymbol = namedTypeSymbol; _compilation = compilation; + IsExperimental = HasExperimentalAttribute(namedTypeSymbol); } internal string MetadataName @@ -51,6 +52,11 @@ internal string MetadataName internal string MetadataSimpleName => _metadataSimpleName ??= _namedTypeSymbol.Name; + internal bool IsExperimental { get; } + + private static bool HasExperimentalAttribute(ISymbol symbol) + => symbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == typeof(ExperimentalAttribute).FullName); + // Nested declarations are not materialized as providers for this symbol-backed view. internal bool HasPublicApiNestedTypes => _namedTypeSymbol.GetTypeMembers().Any(type => type.DeclaredAccessibility is Accessibility.Public or Accessibility.Protected or @@ -329,6 +335,9 @@ protected internal override ConstructorProvider[] BuildConstructors() GetSymbolXmlDoc(constructorSymbol, "summary"), GetAccessModifier(constructorSymbol.DeclaredAccessibility), [.. constructorSymbol.Parameters.Select(p => ConvertToParameterProvider(constructorSymbol, p))], + attributes: [.. constructorSymbol.GetAttributes() + .Where(a => a.AttributeClass?.ToDisplayString() == typeof(ExperimentalAttribute).FullName) + .Select(a => new AttributeStatement(a))], initializer: initializer); constructors.Add(new ConstructorProvider(signature, MethodBodyStatement.Empty, this)); } 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 f060d44cf5f..d3a14740a97 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 @@ -113,7 +113,7 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime); var enclosingTypeName = enclosingType.Name; var lastContractProperties = enclosingType.LastContractView?.Properties - .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers)) + .Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers, p.Attributes)) .ToList(); // An exact input-identifier match is authoritative: it is the name this property would have had diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/SystemObjectModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/SystemObjectModelProvider.cs index df21d9faed0..9c4ab1ffa10 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/SystemObjectModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/SystemObjectModelProvider.cs @@ -159,7 +159,7 @@ private bool HasCallableInitializationConstructor() } foreach (var constructor in constructors.Where(constructor => - MethodSignatureHelper.IsPublicApi(constructor.Signature.Modifiers) && + MethodSignatureHelper.IsPublicApi(constructor.Signature) && HasSupportedConstructorParameters(constructor.Signature.Parameters))) { var matches = constructor.Signature.Parameters @@ -269,7 +269,7 @@ private bool TryGetLastContractConstructor( { var properties = Properties; foreach (var candidate in _lastContractType!.Constructors - .Where(constructor => MethodSignatureHelper.IsPublicApi(constructor.Signature.Modifiers) && + .Where(constructor => MethodSignatureHelper.IsPublicApi(constructor.Signature) && HasSupportedConstructorParameters(constructor.Signature.Parameters)) .OrderByDescending(constructor => constructor.Signature.Parameters.Count)) { @@ -316,7 +316,7 @@ protected internal override PropertyProvider[] BuildProperties() for (var provider = _lastContractType; provider is not null; provider = provider.BaseTypeProvider) { foreach (var property in provider.Properties.Where(property => - MethodSignatureHelper.IsPublicApi(property.Modifiers) && seenPropertyNames.Add(property.Name))) + MethodSignatureHelper.IsPublicApi(property.Modifiers, property.Attributes) && seenPropertyNames.Add(property.Name))) { if (ApplyCurrentInputMetadata(property) is { } projectedProperty) { @@ -342,7 +342,7 @@ internal bool HasCompatibleLastContractProperties() for (var provider = _lastContractType; provider is not null; provider = provider.BaseTypeProvider) { if (provider.Properties - .Where(property => MethodSignatureHelper.IsPublicApi(property.Modifiers)) + .Where(property => MethodSignatureHelper.IsPublicApi(property.Modifiers, property.Attributes)) .Any(property => FindEffectiveFrameworkProperty(property.Name) is not { } candidate || !IsCompatibleProperty(property, candidate))) { @@ -391,7 +391,7 @@ internal bool HasCompatibleLastContractNonPropertyMembers() } foreach (var method in provider.Methods.Where(method => - MethodSignatureHelper.IsPublicApi(method.Signature.Modifiers))) + MethodSignatureHelper.IsPublicApi(method.Signature))) { var signature = method.Signature; if (overriddenMethods.Any(overrideSignature => @@ -407,7 +407,7 @@ internal bool HasCompatibleLastContractNonPropertyMembers() overriddenMethods.AddRange(provider.Methods.Select(method => method.Signature) .Where(signature => signature.Modifiers.HasFlag(MethodSignatureModifiers.Override))); if (provider.Fields - .Where(field => IsPublicApiField(field.Modifiers)) + .Where(field => IsPublicApiField(field)) .Any(field => FindEffectiveFrameworkMember(field.Name) is not FieldInfo candidate || !IsCompatibleField(field, candidate))) { @@ -425,16 +425,16 @@ internal IEnumerable GetPublicApiMemberNames() for (var provider = _lastContractType; provider is not null; provider = provider.BaseTypeProvider) { foreach (var property in provider.Properties.Where(property => - MethodSignatureHelper.IsPublicApi(property.Modifiers))) + MethodSignatureHelper.IsPublicApi(property.Modifiers, property.Attributes))) { yield return property.Name; } foreach (var method in provider.Methods.Where(method => - MethodSignatureHelper.IsPublicApi(method.Signature.Modifiers))) + MethodSignatureHelper.IsPublicApi(method.Signature))) { yield return GetPublicApiMemberName(method.Signature.Name); } - foreach (var field in provider.Fields.Where(field => IsPublicApiField(field.Modifiers))) + foreach (var field in provider.Fields.Where(IsPublicApiField)) { yield return field.Name; } @@ -669,9 +669,8 @@ private static bool RequiresOverridableMethod(MethodSignatureModifiers modifiers modifiers.HasFlag(MethodSignatureModifiers.Abstract) || modifiers.HasFlag(MethodSignatureModifiers.Override)); - private static bool IsPublicApiField(FieldModifiers modifiers) - => modifiers.HasFlag(FieldModifiers.Public) || - modifiers.HasFlag(FieldModifiers.Protected) && !modifiers.HasFlag(FieldModifiers.Private); + private static bool IsPublicApiField(FieldProvider field) + => MethodSignatureHelper.IsPublicApi(field.Modifiers, field.Attributes); private static bool IsCompatibleField(FieldProvider previous, FieldInfo current) // Historical constant expressions are not necessarily available (e.g. metadata symbols). diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 807643f3ea8..73d7b9d3207 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -1218,7 +1218,7 @@ protected internal virtual IReadOnlyList BuildMethodsForBackComp foreach (var previousMethod in previousMethods) { if (currentMethodSignatures.ContainsKey(previousMethod.Signature) - || !MethodSignatureHelper.IsPublicApi(previousMethod.Signature.Modifiers) + || !MethodSignatureHelper.IsPublicApi(previousMethod.Signature) || BackCompatHelper.IsMethodRemovalAcceptedInBaseline(this, previousMethod.Signature)) { continue; @@ -1283,7 +1283,8 @@ protected internal virtual IReadOnlyList BuildConstructorsF // Check if the last contract had a public constructor with matching parameters foreach (var previousConstructor in LastContractView.Constructors) { - if (!previousConstructor.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Public)) + if (!previousConstructor.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Public) || + !MethodSignatureHelper.IsPublicApi(previousConstructor.Signature)) { continue; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/MethodSignatureHelper.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/MethodSignatureHelper.cs index 5ea3e2058a2..2cfed63b4da 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/MethodSignatureHelper.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Shared/MethodSignatureHelper.cs @@ -10,14 +10,28 @@ using Microsoft.TypeSpec.Generator.Providers; using Microsoft.TypeSpec.Generator.Snippets; using Microsoft.TypeSpec.Generator.Statements; +using Microsoft.TypeSpec.Generator.Utilities; namespace Microsoft.TypeSpec.Generator { internal class MethodSignatureHelper { - internal static bool IsPublicApi(MethodSignatureModifiers modifiers) + internal static bool IsPublicApi(MethodSignatureBase signature) + => IsPublicApi(signature.Modifiers, signature.Attributes); + + internal static bool IsPublicApi( + MethodSignatureModifiers modifiers, + IReadOnlyList? attributes = null) => (modifiers.HasFlag(MethodSignatureModifiers.Public) || modifiers.HasFlag(MethodSignatureModifiers.Protected)) - && !modifiers.HasFlag(MethodSignatureModifiers.Private); + && !modifiers.HasFlag(MethodSignatureModifiers.Private) + && attributes?.Any(ExperimentalApiHelpers.IsExperimentalAttribute) != true; + + internal static bool IsPublicApi( + FieldModifiers modifiers, + IReadOnlyList? attributes = null) + => (modifiers.HasFlag(FieldModifiers.Public) || modifiers.HasFlag(FieldModifiers.Protected)) + && !modifiers.HasFlag(FieldModifiers.Private) + && attributes?.Any(ExperimentalApiHelpers.IsExperimentalAttribute) != true; internal static bool ContainsSameParameters(MethodSignature method1, MethodSignature method2) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/SourceInput/SourceInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/SourceInput/SourceInputModel.cs index 18ed06828ff..71f096d5f78 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/SourceInput/SourceInputModel.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/SourceInput/SourceInputModel.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.TypeSpec.Generator.Providers; +using Microsoft.TypeSpec.Generator.Utilities; namespace Microsoft.TypeSpec.Generator.SourceInput { @@ -69,7 +70,8 @@ private IReadOnlyDictionary PopulateNameMap() public TypeProvider? FindForTypeInLastContract(string ns, string name, string? declaringTypeName = null) { - return FindTypeInCompilation(LastContract, ns, name, true, declaringTypeName, includeInternal: false); + var type = FindTypeInCompilation(LastContract, ns, name, true, declaringTypeName, includeInternal: false); + return type is NamedTypeSymbolProvider { IsExperimental: true } ? null : type; } private IReadOnlyList PopulateCustomizationTypeProviders() diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs index 1cf21325f0e..efaa8b260d5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs @@ -69,7 +69,8 @@ internal bool IsLastContractModelBasePropertyCompatible( CSharpType mappedBase, InputModelProperty currentProperty, PropertyProvider lastContractProperty) - => !lastContractProperty.Modifiers.HasFlag(MethodSignatureModifiers.Static) && + => MethodSignatureHelper.IsPublicApi(lastContractProperty.Modifiers, lastContractProperty.Attributes) && + !lastContractProperty.Modifiers.HasFlag(MethodSignatureModifiers.Static) && IsLastContractModelBasePropertyCompatibleCore(mappedBase, currentProperty, lastContractProperty); /// diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/BackCompatHelper.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/BackCompatHelper.cs index a0feb39b122..2ea0b03eb78 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/BackCompatHelper.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/BackCompatHelper.cs @@ -144,7 +144,7 @@ public static bool ParametersMatch(IReadOnlyList params1, IRe } IEnumerable scopedMethods = lastContractMethods - .Where(m => MethodSignatureHelper.IsPublicApi(m.Signature.Modifiers)); + .Where(m => MethodSignatureHelper.IsPublicApi(m.Signature)); if (methodName != null) { scopedMethods = scopedMethods.Where(m => @@ -252,7 +252,7 @@ public static void RestorePreviousParameterNames( { foreach (var previousMethod in previousMethods) { - if (!MethodSignatureHelper.IsPublicApi(previousMethod.Signature.Modifiers)) + if (!MethodSignatureHelper.IsPublicApi(previousMethod.Signature)) { continue; } @@ -388,7 +388,7 @@ public static void AddBackCompatOverloads(TypeProvider enclosingType, List + { + var compilation = await Helpers.GetCompilationFromDirectoryAsync(shape); + if (previouslyExperimental) + { + return compilation; + } + var tree = compilation.SyntaxTrees.Single(t => t.FilePath.EndsWith("Choice.cs", StringComparison.Ordinal)); + var root = tree.GetRoot(); + var updatedRoot = root.ReplaceNodes(root.DescendantNodes().OfType(), + (_, declaration) => declaration.WithAttributeLists(default)); + return compilation.ReplaceSyntaxTree(tree, tree.WithRootAndOptions(updatedRoot, tree.Options)); + }); + var input = InputFactory.Int32Enum("Choice", [("Preview", 10)], isExtensible: shape == "Extensible"); + if (currentlyExperimental) + { + InputFactory.Experimental(input, "CURRENT001"); + } + var provider = EnumProvider.Create(input); + provider.EnsureBuilt(); + provider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(previouslyExperimental ? 1 : 2, provider.EnumValues.Count); + Assert.AreEqual(previouslyExperimental || shape == "Extensible" ? 10 : 42, + provider.EnumValues.Single(v => v.Name == "Preview").Value); + } + [Test] public async Task ExperimentalFixedEnumDependenciesSurviveBackCompatibility( [Values] bool numeric, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompatibilityIgnoresExperimentalEnum(Extensible)/Choice.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompatibilityIgnoresExperimentalEnum(Extensible)/Choice.cs new file mode 100644 index 00000000000..d427c1ddc92 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompatibilityIgnoresExperimentalEnum(Extensible)/Choice.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Sample.Models +{ + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public readonly partial struct Choice + { + private readonly int _value; + private const int PreviewValue = 42; + private const int RemovedValue = 99; + + public Choice(int value) + { + _value = value; + } + + public static Choice Preview { get; } = new Choice(PreviewValue); + public static Choice Removed { get; } = new Choice(RemovedValue); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompatibilityIgnoresExperimentalEnum(Fixed)/Choice.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompatibilityIgnoresExperimentalEnum(Fixed)/Choice.cs new file mode 100644 index 00000000000..c191e1a1af0 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompatibilityIgnoresExperimentalEnum(Fixed)/Choice.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Sample.Models +{ + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public enum Choice + { + Preview = 42, + Removed = 99 + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs index ecddb0eadce..2daad41fa1c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs @@ -120,6 +120,33 @@ public class GenericType Assert.AreEqual("TMethod", method.Signature.GenericArguments!.Single().Name); } + [Test] + public async Task ValidateAttributes() + { + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + var compilation = mockGenerator.Object.SourceInputModel.Customization!; + var symbol = compilation.GetTypeByMetadataName("Sample.ExperimentalType"); + Assert.IsNotNull(symbol); + + var provider = new NamedTypeSymbolProvider(symbol!, compilation); + var constructor = provider.Constructors.Single(); + + Assert.IsTrue(provider.IsExperimental); + Assert.AreEqual( + Literal("TYPE001").ToDisplayString(), + provider.Attributes.Single(attribute => attribute.Type.Equals(typeof(System.Diagnostics.CodeAnalysis.ExperimentalAttribute))) + .Arguments.Single().ToDisplayString()); + Assert.AreEqual( + Literal("CTOR001").ToDisplayString(), + constructor.Signature.Attributes.Single(attribute => attribute.Type.Equals(typeof(System.Diagnostics.CodeAnalysis.ExperimentalAttribute))) + .Arguments.Single().ToDisplayString()); + + var stableSymbol = compilation.GetTypeByMetadataName("Sample.StableType"); + Assert.IsNotNull(stableSymbol); + Assert.IsFalse(new NamedTypeSymbolProvider(stableSymbol!, compilation).IsExperimental); + } + [Test] public void ValidateNamespaceNestedType() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidateAttributes/Types.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidateAttributes/Types.cs new file mode 100644 index 00000000000..7b693e881da --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidateAttributes/Types.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace System.Diagnostics.CodeAnalysis +{ + [System.AttributeUsage(System.AttributeTargets.All)] + internal sealed class ExperimentalAttribute : System.Attribute + { + public ExperimentalAttribute(string diagnosticId) { } + } +} + +namespace Sample +{ + [System.Diagnostics.CodeAnalysis.Experimental("TYPE001")] + public class ExperimentalType + { + [System.Diagnostics.CodeAnalysis.Experimental("CTOR001")] + public ExperimentalType() { } + } + + public class StableType + { + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/SystemObjectModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/SystemObjectModelProviderTests.cs index 64c7a280f7d..209076afe56 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/SystemObjectModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/SystemObjectModelProviderTests.cs @@ -1079,6 +1079,25 @@ public void DefaultLastContractMappingIgnoresReferenceNullabilityAnnotations() new CSharpType(typeof(InitOnlyPropertyTarget)), currentProperty, historicalProperty), Is.True); } + [Test] + public void LastContractMappingRejectsExperimentalHistoricalProperty() + { + var currentProperty = InputFactory.Property("value", InputPrimitiveType.String); + var historicalProperty = new PropertyProvider( + $"", + MethodSignatureModifiers.Public, + typeof(string), + "Value", + new AutoPropertyBody(true), + new TestTypeProvider(), + attributes: [new AttributeStatement( + typeof(System.Diagnostics.CodeAnalysis.ExperimentalAttribute), + Snippet.Literal("TEST001"))]); + + Assert.That(CodeModelGenerator.Instance.TypeFactory.IsLastContractModelBasePropertyCompatible( + new CSharpType(typeof(InitOnlyPropertyTarget)), currentProperty, historicalProperty), Is.False); + } + [Test] public void DownstreamLastContractMappingCanExplicitlyReconcileValueNullability() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityDoesNotPromoteProtectedConstructor/Types.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityDoesNotPromoteProtectedConstructor/Types.cs new file mode 100644 index 00000000000..161410ee1d1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityDoesNotPromoteProtectedConstructor/Types.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Test +{ + public abstract class ProtectedConstructorType + { + protected ProtectedConstructorType(int value) { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresExperimentalLastContract/Types.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresExperimentalLastContract/Types.cs new file mode 100644 index 00000000000..95666ca6fa6 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresExperimentalLastContract/Types.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Test +{ + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public class ExperimentalType + { + public void DoWork(int value) { } + } + + public class StableType + { + public void DoWork(int value) { } + } + + [Unrelated.Experimental("PREVIOUS001")] + public class UnrelatedExperimentalType + { + public void DoWork(int value) { } + } +} + +namespace Unrelated +{ + internal sealed class ExperimentalAttribute : System.Attribute + { + public ExperimentalAttribute(string diagnosticId) { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresExperimentalMembersLastContract/Types.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresExperimentalMembersLastContract/Types.cs new file mode 100644 index 00000000000..cceb91cfcf8 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresExperimentalMembersLastContract/Types.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Test +{ + public abstract class ExperimentalMembersType + { + public ExperimentalMembersType() { } + + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public ExperimentalMembersType(int value) { } + + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public int ExperimentalProperty { get; set; } + + public int StableProperty { get; set; } + + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public const int ExperimentalField = 1; + + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public void ExperimentalMethod() { } + + public void StableMethod() { } + } +} + +namespace System.Diagnostics.CodeAnalysis +{ + internal sealed class ExperimentalAttribute : System.Attribute + { + public ExperimentalAttribute(string diagnosticId) { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresPolyfilledExperimentalAttribute/Types.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresPolyfilledExperimentalAttribute/Types.cs new file mode 100644 index 00000000000..96db1ce191f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TestData/TypeProviderTests/BackCompatibilityIgnoresPolyfilledExperimentalAttribute/Types.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace System.Diagnostics.CodeAnalysis +{ + internal sealed class ExperimentalAttribute : System.Attribute + { + public ExperimentalAttribute(string diagnosticId) { } + } +} + +namespace Test +{ + [System.Diagnostics.CodeAnalysis.Experimental("PREVIOUS001")] + public class ExperimentalType + { + public void DoWork(int value) { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TypeProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TypeProviderTests.cs index 05fae1279ce..37321fd9398 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TypeProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TypeProviderTests.cs @@ -152,7 +152,7 @@ await MockHelpers.LoadMockGeneratorAsync(lastContractCompilation: async () => owner.ProcessTypeForBackCompatibility(); var shims = owner.Methods.Where(m => m.Signature.Attributes.Any(a => a.Type.Equals(typeof(EditorBrowsableAttribute)))).ToArray(); - Assert.AreEqual(3, shims.Length); + Assert.AreEqual(previouslyExperimental ? 0 : 3, shims.Length); foreach (var shim in shims) { var experiment = shim.Signature.Attributes.SingleOrDefault(a => a.Type.Equals(typeof(ExperimentalAttribute))); @@ -302,6 +302,111 @@ public async Task TestLoadLastContractView() Assert.AreEqual("p1", signature.Parameters[0].Name); } + [Test] + public async Task BackCompatibilityIgnoresExperimentalLastContract( + [Values("ExperimentalType", "StableType", "UnrelatedExperimentalType")] string name, + [Values] bool currentlyExperimental) + { + await MockHelpers.LoadMockGeneratorAsync( + lastContractCompilation: () => Helpers.GetCompilationFromDirectoryAsync()); + var typeProvider = new TestTypeProvider( + name: name, + declarationModifiers: TypeSignatureModifiers.Public | TypeSignatureModifiers.Abstract | TypeSignatureModifiers.Class); + typeProvider.Update( + attributes: ExperimentalApiHelpers.BuildAttributes( + currentlyExperimental ? new InputExperimentalDetails("CURRENT001") : null), + methods: + [ + new MethodProvider( + new MethodSignature("DoWork", null, MethodSignatureModifiers.Public, null, null, + [ + new ParameterProvider("value", $"", typeof(int)), + new ParameterProvider("added", $"", typeof(bool), defaultValue: Snippet.Default, location: ParameterLocation.Query) + ]), + MethodBodyStatement.Empty, + typeProvider) + ]); + + var previouslyExperimental = name == "ExperimentalType"; + Assert.AreEqual(previouslyExperimental, typeProvider.LastContractView is null); + Assert.AreEqual(previouslyExperimental, typeProvider.DeclarationModifiers.HasFlag(TypeSignatureModifiers.Abstract)); + + typeProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual(previouslyExperimental ? 1 : 2, typeProvider.Methods.Count); + Assert.AreEqual(2, typeProvider.Methods[0].Signature.Parameters.Count); + } + + [Test] + public async Task BackCompatibilityIgnoresPolyfilledExperimentalAttribute() + { + await MockHelpers.LoadMockGeneratorAsync( + lastContractCompilation: () => Helpers.GetCompilationFromDirectoryAsync()); + var typeProvider = new TestTypeProvider(name: "ExperimentalType"); + + Assert.IsNull(typeProvider.LastContractView); + } + + [Test] + public async Task ExperimentalAttributesAreExcludedForMethodConstructorAndFieldDetection() + { + await MockHelpers.LoadMockGeneratorAsync( + lastContractCompilation: () => Helpers.GetCompilationFromDirectoryAsync( + method: "BackCompatibilityIgnoresExperimentalMembersLastContract")); + var typeProvider = new TestTypeProvider(name: "ExperimentalMembersType"); + var lastContractView = typeProvider.LastContractView; + + Assert.IsNotNull(lastContractView); + CollectionAssert.AreEquivalent( + new[] { "ExperimentalMethod", "StableMethod" }, + lastContractView!.Methods.Select(method => method.Signature.Name).ToArray()); + CollectionAssert.AreEquivalent( + new[] { "ExperimentalProperty", "StableProperty" }, + lastContractView.Properties.Select(property => property.Name).ToArray()); + CollectionAssert.AreEquivalent( + new[] { 0, 1 }, + lastContractView.Constructors.Select(constructor => constructor.Signature.Parameters.Count).ToArray()); + var experimentalMethod = lastContractView.Methods.Single(method => method.Signature.Name == "ExperimentalMethod"); + var stableMethod = lastContractView.Methods.Single(method => method.Signature.Name == "StableMethod"); + var experimentalProperty = lastContractView.Properties.Single(property => property.Name == "ExperimentalProperty"); + var stableProperty = lastContractView.Properties.Single(property => property.Name == "StableProperty"); + var experimentalConstructor = lastContractView.Constructors.Single(constructor => constructor.Signature.Parameters.Count == 1); + var experimentalField = lastContractView.Fields.Single(field => field.Name == "ExperimentalField"); + + Assert.IsFalse(MethodSignatureHelper.IsPublicApi(experimentalMethod.Signature)); + Assert.IsTrue(MethodSignatureHelper.IsPublicApi(stableMethod.Signature)); + Assert.IsFalse(MethodSignatureHelper.IsPublicApi(experimentalProperty.Modifiers, experimentalProperty.Attributes)); + Assert.IsTrue(MethodSignatureHelper.IsPublicApi(stableProperty.Modifiers)); + Assert.IsFalse(MethodSignatureHelper.IsPublicApi(experimentalConstructor.Signature)); + Assert.IsFalse(MethodSignatureHelper.IsPublicApi(experimentalField.Modifiers, experimentalField.Attributes)); + } + + [Test] + public async Task BackCompatibilityDoesNotPromoteProtectedConstructor() + { + await MockHelpers.LoadMockGeneratorAsync( + lastContractCompilation: () => Helpers.GetCompilationFromDirectoryAsync( + method: nameof(BackCompatibilityDoesNotPromoteProtectedConstructor))); + var typeProvider = new TestTypeProvider( + name: "ProtectedConstructorType", + declarationModifiers: TypeSignatureModifiers.Public | TypeSignatureModifiers.Abstract | TypeSignatureModifiers.Class); + var privateProtectedConstructor = new ConstructorProvider( + new ConstructorSignature( + typeProvider.Type, + null, + MethodSignatureModifiers.Private | MethodSignatureModifiers.Protected, + [new ParameterProvider("value", $"", typeof(int))]), + MethodBodyStatement.Empty, + typeProvider); + typeProvider.Update(constructors: [privateProtectedConstructor]); + + typeProvider.ProcessTypeForBackCompatibility(); + + Assert.AreEqual( + MethodSignatureModifiers.Private | MethodSignatureModifiers.Protected, + privateProtectedConstructor.Signature.Modifiers); + } + // Validates that a custom TypeProvider inherits the parameter-reordering back-compat behavior // from the base TypeProvider. [Test] diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Shared/MethodSignatureHelperTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Shared/MethodSignatureHelperTests.cs index fb999456636..6f2e55b9336 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Shared/MethodSignatureHelperTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Shared/MethodSignatureHelperTests.cs @@ -22,6 +22,26 @@ public void Setup() MockHelpers.LoadMockGenerator(); } + [Test] + public void IsPublicApi_ExperimentalSignature_ReturnsFalse() + { + var signature = CreateMethodSignature( + "Method", + attributes: [new AttributeStatement( + typeof(System.Diagnostics.CodeAnalysis.ExperimentalAttribute), + Literal("001"))]); + + Assert.IsFalse(MethodSignatureHelper.IsPublicApi(signature)); + } + + [Test] + public void IsPublicApi_StableSignature_ReturnsTrue() + { + var signature = CreateMethodSignature("Method"); + + Assert.IsTrue(MethodSignatureHelper.IsPublicApi(signature)); + } + [Test] public void ContainsSameParameters_SameParametersInSameOrder_ReturnsTrue() { diff --git a/packages/http-client-csharp/generator/docs/backward-compatibility.md b/packages/http-client-csharp/generator/docs/backward-compatibility.md index e71cc90f943..44e74ff2451 100644 --- a/packages/http-client-csharp/generator/docs/backward-compatibility.md +++ b/packages/http-client-csharp/generator/docs/backward-compatibility.md @@ -4,6 +4,7 @@ - [Overview](#overview) - [How It Works](#how-it-works) +- [Experimental Types](#experimental-types) - [Supported Scenarios](#supported-scenarios) - [Model Factory Methods](#model-factory-methods) - [New Model Property Added](#scenario-new-model-property-added) @@ -51,6 +52,12 @@ When generating code, the generator can optionally receive a compiled assembly f 2. Compares the generated types against the types in the last contract 3. Automatically generates compatibility methods, properties, or enum members where differences are detected +### Experimental Types + +Types in the last released contract marked with `System.Diagnostics.CodeAnalysis.ExperimentalAttribute` are excluded from backward-compatibility lookup. The generator does not preserve their previous type shape or restore their previous members as compatibility shims, allowing those types to change when they graduate to stable or are removed. + +This decision uses the attribute on the last released type, including polyfilled definitions of the attribute. Marking a previously stable type experimental in the current generation does not remove its compatibility guarantees. Experimental attributes on individual members in the last released contract also exclude those members from compatibility handling; attributes added only in the current generation do not remove guarantees for previously stable members. + ### ApiCompat Baseline Awareness Sometimes a breaking change (such as removing a model or a model factory method) is intentional and has already been reviewed and accepted. In the Azure SDK, such accepted breaking changes are recorded in an [ApiCompat](https://github.com/dotnet/sdk/tree/main/src/Compatibility) baseline (suppression) file, conventionally located at `eng/apicompatbaselines/.txt`.