From d47f384768109c4119c4302556c291cec71252be Mon Sep 17 00:00:00 2001 From: Joey Robichaud Date: Wed, 5 Aug 2026 12:57:00 -0700 Subject: [PATCH] Teaches ApiCompat about the Experimental attribute --- .../ApiStability.cs | 11 ++ .../ApiStabilityClassifier.cs | 28 +++ .../CompatDifference.cs | 26 ++- .../DiagnosticIds.cs | 1 + .../DifferenceSeverity.cs | 11 ++ .../DifferenceVisitor.cs | 24 ++- .../Resources.resx | 3 + .../Rules/AttributesMustMatch.cs | 5 + .../Rules/ExperimentalApiBecomesStable.cs | 56 ++++++ .../Rules/RuleFactory.cs | 1 + .../Rules/RuleRunner.cs | 22 ++- .../Runner/ApiCompatRunner.cs | 6 + .../xlf/Resources.cs.xlf | 5 + .../xlf/Resources.de.xlf | 5 + .../xlf/Resources.es.xlf | 5 + .../xlf/Resources.fr.xlf | 5 + .../xlf/Resources.it.xlf | 5 + .../xlf/Resources.ja.xlf | 5 + .../xlf/Resources.ko.xlf | 5 + .../xlf/Resources.pl.xlf | 5 + .../xlf/Resources.pt-BR.xlf | 5 + .../xlf/Resources.ru.xlf | 5 + .../xlf/Resources.tr.xlf | 5 + .../xlf/Resources.zh-Hans.xlf | 5 + .../xlf/Resources.zh-Hant.xlf | 5 + .../Tool/ApiCompatToolIntegrationTests.cs | 67 ++++++- .../ExperimentalApiTests.cs | 186 ++++++++++++++++++ ...CompatValidateAssembliesTestProject.csproj | 2 + .../Greeter.cs | 12 ++ 29 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStability.cs create mode 100644 src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStabilityClassifier.cs create mode 100644 src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceSeverity.cs create mode 100644 src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/ExperimentalApiBecomesStable.cs create mode 100644 test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility.Tests/ExperimentalApiTests.cs diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStability.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStability.cs new file mode 100644 index 000000000000..40140746708a --- /dev/null +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStability.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.ApiCompatibility +{ + internal enum ApiStability + { + Stable, + Experimental, + } +} diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStabilityClassifier.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStabilityClassifier.cs new file mode 100644 index 000000000000..9d4481a15675 --- /dev/null +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/ApiStabilityClassifier.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.DotNet.ApiCompatibility +{ + internal static class ApiStabilityClassifier + { + internal const string ExperimentalAttributeMetadataName = "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"; + + public static ApiStability Classify(ISymbol? symbol) + { + for (ISymbol? current = symbol; current != null; current = current.ContainingType) + { + if (current.GetAttributes().Any(IsExperimentalAttribute)) + { + return ApiStability.Experimental; + } + } + + return ApiStability.Stable; + } + + public static bool IsExperimentalAttribute(AttributeData attribute) => + attribute.AttributeClass?.ToDisplayString() == ExperimentalAttributeMetadataName; + } +} diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/CompatDifference.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/CompatDifference.cs index 662836127a9f..9f9dde4a5a78 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/CompatDifference.cs +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/CompatDifference.cs @@ -15,7 +15,7 @@ namespace Microsoft.DotNet.ApiCompatibility /// message describing the difference. /// to describe the type of the difference. /// containing the member ID for which the difference is associated to. - public readonly struct CompatDifference(MetadataInformation left, MetadataInformation right, string diagnosticId, string message, DifferenceType type, string? memberId) : IDiagnostic, IEquatable + public readonly struct CompatDifference(MetadataInformation left, MetadataInformation right, string diagnosticId, string message, DifferenceType type, string? memberId, DifferenceSeverity severity = DifferenceSeverity.Error) : IDiagnostic, IEquatable { /// public string DiagnosticId { get; } = diagnosticId; @@ -25,6 +25,15 @@ public readonly struct CompatDifference(MetadataInformation left, MetadataInform /// public DifferenceType Type { get; } = type; + /// + /// The severity of the compatibility difference. + /// + public DifferenceSeverity Severity { get; } = severity; + + internal ISymbol? LeftSymbol { get; } + + internal ISymbol? RightSymbol { get; } + /// public string Message { get; } = message; @@ -55,6 +64,19 @@ public CompatDifference(MetadataInformation left, MetadataInformation right, str { } + internal CompatDifference WithSeverity(DifferenceSeverity newSeverity) => + new(Left, Right, DiagnosticId, Message, Type, ReferenceId, newSeverity, LeftSymbol, RightSymbol); + + internal CompatDifference WithSymbols(ISymbol? leftSymbol, ISymbol? rightSymbol) => + new(Left, Right, DiagnosticId, Message, Type, ReferenceId, Severity, leftSymbol, rightSymbol); + + private CompatDifference(MetadataInformation left, MetadataInformation right, string diagnosticId, string message, DifferenceType type, string? memberId, DifferenceSeverity severity, ISymbol? leftSymbol, ISymbol? rightSymbol) + : this(left, right, diagnosticId, message, type, memberId, severity) + { + LeftSymbol = leftSymbol; + RightSymbol = rightSymbol; + } + /// /// Create a compatibility difference object with default left and right metadata for which the difference occurred. /// @@ -78,6 +100,7 @@ public bool Equals(CompatDifference other) => Right.Equals(other.Right) && DiagnosticId.Equals(other.DiagnosticId, StringComparison.InvariantCultureIgnoreCase) && Type.Equals(other.Type) && + Severity.Equals(other.Severity) && string.Equals(ReferenceId, other.ReferenceId, StringComparison.InvariantCultureIgnoreCase); /// @@ -91,6 +114,7 @@ public override int GetHashCode() hashCode = hashCode * -1521134295 + Right.GetHashCode(); hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(DiagnosticId.ToLowerInvariant()); hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Type.ToString().ToLowerInvariant()); + hashCode = hashCode * -1521134295 + Severity.GetHashCode(); if (ReferenceId != null) { hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(ReferenceId.ToLowerInvariant()); diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DiagnosticIds.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DiagnosticIds.cs index cb1f09738d0d..f591e1ed093f 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DiagnosticIds.cs +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DiagnosticIds.cs @@ -29,5 +29,6 @@ public static class DiagnosticIds public const string CannotReduceVisibility = "CP0019"; public const string CannotExpandVisibility = "CP0020"; public const string CannotChangeGenericConstraint = "CP0021"; + public const string ExperimentalApiBecomesStable = "CP0022"; } } diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceSeverity.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceSeverity.cs new file mode 100644 index 000000000000..2a0b624a29ee --- /dev/null +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceSeverity.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.ApiCompatibility +{ + public enum DifferenceSeverity + { + Error, + Informational, + } +} diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceVisitor.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceVisitor.cs index 4bc4e335d1e8..a6d3b7451929 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceVisitor.cs +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/DifferenceVisitor.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.CodeAnalysis; using Microsoft.DotNet.ApiCompatibility.Mapping; namespace Microsoft.DotNet.ApiCompatibility @@ -72,7 +73,7 @@ public void Visit(INamespaceMapper @namespace) /// public void Visit(ITypeMapper type) { - AddDifferences(type); + AddSymbolDifferences(type); if (type.ShouldDiffMembers) { @@ -91,7 +92,26 @@ public void Visit(ITypeMapper type) /// public void Visit(IMemberMapper member) { - AddDifferences(member); + AddSymbolDifferences(member); + } + + private void AddSymbolDifferences(IElementMapper mapper) + where T : ISymbol + { + foreach (CompatDifference item in mapper.GetDifferences()) + { + ApiStability leftStability = ApiStabilityClassifier.Classify(item.LeftSymbol); + ApiStability rightStability = ApiStabilityClassifier.Classify(item.RightSymbol); + + bool isExperimentalDifference = + (item.LeftSymbol is null && rightStability == ApiStability.Experimental) || + (item.RightSymbol is null && leftStability == ApiStability.Experimental) || + (leftStability == ApiStability.Experimental && rightStability == ApiStability.Experimental); + + _compatDifferences.Add(isExperimentalDifference + ? item.WithSeverity(DifferenceSeverity.Informational) + : item); + } } private void AddDifferences(IElementMapper mapper) diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Resources.resx b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Resources.resx index f9f0ed9668e6..0970f3a1b42c 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Resources.resx +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Resources.resx @@ -183,6 +183,9 @@ Underlying type of enum '{0}' changed from '{1}' to '{2}'. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + Cannot add virtual keyword to member '{0}'. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/AttributesMustMatch.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/AttributesMustMatch.cs index ba27863437c9..6f2bbe663d90 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/AttributesMustMatch.cs +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/AttributesMustMatch.cs @@ -146,6 +146,11 @@ private void ReportAttributeDifferences(ISymbol containing, // Loop over left and issue "removed" diagnostic for each one. foreach (AttributeData leftAttribute in leftGroup.Attributes) { + if (ApiStabilityClassifier.IsExperimentalAttribute(leftAttribute)) + { + continue; + } + AddDifference(differences, DifferenceType.Removed, leftMetadata, rightMetadata, containing, itemRef, leftAttribute); } } diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/ExperimentalApiBecomesStable.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/ExperimentalApiBecomesStable.cs new file mode 100644 index 000000000000..6315339ab5bc --- /dev/null +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/ExperimentalApiBecomesStable.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.ApiSymbolExtensions; + +namespace Microsoft.DotNet.ApiCompatibility.Rules +{ + public class ExperimentalApiBecomesStable : IRule + { + public ExperimentalApiBecomesStable(IRuleRegistrationContext context) + { + context.RegisterOnTypeSymbolAction(RunOnTypeSymbol); + context.RegisterOnMemberSymbolAction(RunOnMemberSymbol); + } + + private static void RunOnTypeSymbol(ITypeSymbol? left, + ITypeSymbol? right, + MetadataInformation leftMetadata, + MetadataInformation rightMetadata, + IList differences) => + AddDifference(left, right, leftMetadata, rightMetadata, differences); + + private static void RunOnMemberSymbol(ISymbol? left, + ISymbol? right, + ITypeSymbol leftContainingType, + ITypeSymbol rightContainingType, + MetadataInformation leftMetadata, + MetadataInformation rightMetadata, + IList differences) => + AddDifference(left, right, leftMetadata, rightMetadata, differences); + + private static void AddDifference(ISymbol? left, + ISymbol? right, + MetadataInformation leftMetadata, + MetadataInformation rightMetadata, + IList differences) + { + if (left is null || right is null || + ApiStabilityClassifier.Classify(left) != ApiStability.Experimental || + ApiStabilityClassifier.Classify(right) != ApiStability.Stable) + { + return; + } + + differences.Add(new CompatDifference( + leftMetadata, + rightMetadata, + DiagnosticIds.ExperimentalApiBecomesStable, + string.Format(Resources.ExperimentalApiBecomesStable, right.ToDisplayString(SymbolExtensions.DisplayFormat)), + DifferenceType.Changed, + right.GetDocumentationCommentId(), + DifferenceSeverity.Error)); + } + } +} diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleFactory.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleFactory.cs index a394e9ff0ecd..0993b1835d11 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleFactory.cs +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleFactory.cs @@ -24,6 +24,7 @@ public IRule[] CreateRules(IRuleSettings settings, IRuleRegistrationContext cont new CannotRemoveBaseTypeOrInterface(settings, context), new CannotSealType(settings, context), new EnumsMustMatch(settings, context), + new ExperimentalApiBecomesStable(context), new MembersMustExist(settings, context), new CannotChangeVisibility(settings, context), new CannotChangeGenericConstraints(settings, context), diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunner.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunner.cs index bc22e230b0fc..2252ba7bc08e 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunner.cs +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunner.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.CodeAnalysis; using Microsoft.DotNet.ApiCompatibility.Mapping; namespace Microsoft.DotNet.ApiCompatibility.Rules @@ -26,6 +27,10 @@ public IEnumerable Run(IElementMapper mapper) int rightLength = mapper.Right.Length; for (int rightIndex = 0; rightIndex < rightLength; rightIndex++) { + int initialDifferenceCount = differences.Count; + ISymbol? leftSymbol = null; + ISymbol? rightSymbol = null; + if (mapper is AssemblyMapper am) { // Ignore assembly mappings which are null on both sides, i.e. when different assembly identities are marked as compatible. @@ -37,8 +42,11 @@ public IEnumerable Run(IElementMapper mapper) the assembly mapper is directly visited. */ bool containsSingleAssembly = am.ContainingAssemblySet == null || am.ContainingAssemblySet.AssemblyCount < 2; - context.RunOnAssemblySymbolActions(am.Left?.Element, - am.Right[rightIndex]?.Element, + leftSymbol = am.Left?.Element; + rightSymbol = am.Right[rightIndex]?.Element; + + context.RunOnAssemblySymbolActions(leftSymbol as IAssemblySymbol, + rightSymbol as IAssemblySymbol, am.Left?.MetadataInformation ?? MetadataInformation.DefaultLeft, am.Right[rightIndex]?.MetadataInformation ?? MetadataInformation.DefaultRight, containsSingleAssembly, @@ -48,6 +56,8 @@ the assembly mapper is directly visited. */ { if (tm.ShouldDiffElement(rightIndex)) { + leftSymbol = tm.Left; + rightSymbol = tm.Right[rightIndex]; context.RunOnTypeSymbolActions(tm.Left, tm.Right[rightIndex], tm.ContainingNamespace.ContainingAssembly.Left?.MetadataInformation ?? MetadataInformation.DefaultLeft, @@ -63,6 +73,9 @@ the assembly mapper is directly visited. */ Debug.Assert(mm.ContainingType.Left != null); Debug.Assert(mm.ContainingType.Right[rightIndex] != null); + leftSymbol = mm.Left; + rightSymbol = mm.Right[rightIndex]; + context.RunOnMemberSymbolActions( mm.Left, mm.Right[rightIndex], @@ -77,6 +90,11 @@ the assembly mapper is directly visited. */ { throw new ArgumentOutOfRangeException(nameof(mapper)); } + + for (int differenceIndex = initialDifferenceCount; differenceIndex < differences.Count; differenceIndex++) + { + differences[differenceIndex] = differences[differenceIndex].WithSymbols(leftSymbol, rightSymbol); + } } return differences; diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Runner/ApiCompatRunner.cs b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Runner/ApiCompatRunner.cs index ac567243dbf0..9700d6b6e016 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Runner/ApiCompatRunner.cs +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/Runner/ApiCompatRunner.cs @@ -65,6 +65,12 @@ public void ExecuteWorkItems() foreach (CompatDifference difference in differenceGroup) { + if (difference.Severity == DifferenceSeverity.Informational) + { + log.LogMessage(MessageImportance.Normal, difference.ToString()); + continue; + } + Suppression suppression = new(difference.DiagnosticId) { Target = difference.ReferenceId, diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.cs.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.cs.xlf index 2d1819a25975..6bbdf9e50850 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.cs.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.cs.xlf @@ -142,6 +142,11 @@ Hodnota pole {1} ve výčtu {0} se změnila z {2} na {3}. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. Index {0} by měl být v rozsahu nula až {1} včetně. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.de.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.de.xlf index 2629d8f0e319..743729cc77f7 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.de.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.de.xlf @@ -142,6 +142,11 @@ Wert des Felds „{1}2 in der Enumeration „{0}2 wurde von „{2}“ in „{3}“ geändert. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. Der {0} Index sollte zwischen null und einschließlich {1} liegen. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.es.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.es.xlf index 64a1ed7d8a9a..1df07faef4f1 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.es.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.es.xlf @@ -142,6 +142,11 @@ El valor del campo "{1}" en la enumeración "{0}" ha cambiado de "{2}" a "{3}". + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. El índice de {0} debe estar comprendido entre cero y {1} inclusive. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.fr.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.fr.xlf index ab34fb8c5d76..5908f030eae9 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.fr.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.fr.xlf @@ -142,6 +142,11 @@ La valeur du champ '{1}' dans l'énumération '{0}' est passée de '{2}' à '{3}'. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. L’index {0} doit être compris entre zéro et {1} inclus. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.it.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.it.xlf index f86e54a8c27a..6de4d98289a6 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.it.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.it.xlf @@ -142,6 +142,11 @@ Il valore del campo '{1}' nell'enumerazione '{0}' è stato modificato da '{2}' a '{3}'. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. L'indice {0} deve essere compreso nell'intervallo compreso tra zero e {1} incluso. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ja.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ja.xlf index 2c7e0a71341a..e897302a681b 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ja.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ja.xlf @@ -142,6 +142,11 @@ 列挙型 '{0}' のフィールド '{1}' の値が '{2}' から '{3}' に変更されました。 + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. {0} インデックスは、0 から {1} の範囲内である必要があります。 diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ko.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ko.xlf index 6a9b598816f6..4985adec561f 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ko.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ko.xlf @@ -142,6 +142,11 @@ 열거형 '{0}'의 필드 '{1}' 값이 '{2}'에서 '{3}'(으)로 변경되었습니다. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. {0} 인덱스는 0 이상 {1} 이하의 범위 안에 있어야 합니다. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pl.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pl.xlf index 5436db63177e..3e2a09f64e2c 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pl.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pl.xlf @@ -142,6 +142,11 @@ Wartość pola „{1}” w wyliczeniu „{0}” zmieniona z „{2}” na „{3}”. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. Indeks {0} powinien należeć do zakresu od zera do {1} włącznie. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pt-BR.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pt-BR.xlf index 23b41c6a50e2..a4a383bd7ab4 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pt-BR.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.pt-BR.xlf @@ -142,6 +142,11 @@ Valor do campo '{1}' na enumeração '{0}' alterado de '{2}' para '{3}'. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. O índice {0} deve estar no intervalo de zero até {1} inclusivo. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ru.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ru.xlf index 71cc43ecc24b..f9d13dc68c97 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ru.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.ru.xlf @@ -142,6 +142,11 @@ Значение поля "{1}" в перечислении "{0}" изменено с "{2}" на "{3}". + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. Индекс {0} должен находиться в диапазоне от нуля до {1} включительно. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.tr.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.tr.xlf index 610b8c3c6185..dde0cfe715d4 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.tr.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.tr.xlf @@ -142,6 +142,11 @@ '{0}' sabit listesindeki '{1}' alan değeri, '{2}' öğesinden '{3}' öğesine değiştirildi. + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. {0} dizini sıfırdan başlayarak {1} dahil olmak üzere bu aralıkta olmalıdır. diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hans.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hans.xlf index dc351c32b74c..e7909d394dee 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hans.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hans.xlf @@ -142,6 +142,11 @@ 枚举“{0}”中字段“{1}”的值已从“{2}”更改为“{3}”。 + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. {0} 索引应在 0 到 {1} (含)范围内。 diff --git a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hant.xlf b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hant.xlf index 3f9d89864de5..0b75377c3e08 100644 --- a/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hant.xlf +++ b/src/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility/xlf/Resources.zh-Hant.xlf @@ -142,6 +142,11 @@ 列舉 '{1}' 中的欄位 '{0}' 值已從 '{2}' 變更為 '{3}'。 + + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + API '{0}' was previously marked experimental and is now stable. Treat this as a new stable API and complete the required API documentation and review. + + The {0} index should be in the range zero through {1} inclusive. {0} 索引應在零到 {1} (含) 之間的範圍。 diff --git a/test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompat.IntegrationTests/Tool/ApiCompatToolIntegrationTests.cs b/test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompat.IntegrationTests/Tool/ApiCompatToolIntegrationTests.cs index 8b80823a41c1..92fdcb69cabe 100644 --- a/test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompat.IntegrationTests/Tool/ApiCompatToolIntegrationTests.cs +++ b/test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompat.IntegrationTests/Tool/ApiCompatToolIntegrationTests.cs @@ -15,7 +15,7 @@ public class ApiCompatToolIntegrationTests : SdkTest { private const string TestAssetName = "ApiCompatValidateAssembliesTestProject"; - [TestMethod] + [TestMethod] public void ApiCompatTool_AssembliesIdentical_ExitsZero() { string assembly = BuildAsset(nameof(ApiCompatTool_AssembliesIdentical_ExitsZero), forceBreakingChange: false); @@ -25,7 +25,7 @@ public void ApiCompatTool_AssembliesIdentical_ExitsZero() result.Should().Pass(); } - [TestMethod] + [TestMethod] public void ApiCompatTool_BreakingChange_ReportsCP0002() { string contractAssembly = BuildAsset($"{nameof(ApiCompatTool_BreakingChange_ReportsCP0002)}_left", forceBreakingChange: false); @@ -39,7 +39,7 @@ public void ApiCompatTool_BreakingChange_ReportsCP0002() .And.Contain("Goodbye"); } - [TestMethod] + [TestMethod] public void ApiCompatTool_SuppressionFile_RoundTrip() { string contractAssembly = BuildAsset($"{nameof(ApiCompatTool_SuppressionFile_RoundTrip)}_left", forceBreakingChange: false); @@ -68,7 +68,55 @@ public void ApiCompatTool_SuppressionFile_RoundTrip() consumeResult.StdOut.Should().NotContain("error CP0002"); } - [TestMethod] + [TestMethod] + public void ApiCompatTool_ExperimentalRemoval_IsInformational() + { + string contractAssembly = BuildAsset($"{nameof(ApiCompatTool_ExperimentalRemoval_IsInformational)}_left", forceBreakingChange: false, + "-p:IncludeExperimentalApis=true"); + string implementationAssembly = BuildAsset($"{nameof(ApiCompatTool_ExperimentalRemoval_IsInformational)}_right", forceBreakingChange: false); + + var result = Run("--left", contractAssembly, "--right", implementationAssembly); + + result.Should().Pass(); + string output = result.StdOut + result.StdErr; + output.Should().Contain("CP0002") + .And.Contain("ExperimentalRemoved"); + } + + [TestMethod] + public void ApiCompatTool_ExperimentalPromotion_FailsAndCanBeSuppressed() + { + string contractAssembly = BuildAsset($"{nameof(ApiCompatTool_ExperimentalPromotion_FailsAndCanBeSuppressed)}_left", forceBreakingChange: false, + "-p:IncludeExperimentalApis=true"); + string implementationAssembly = BuildAsset($"{nameof(ApiCompatTool_ExperimentalPromotion_FailsAndCanBeSuppressed)}_right", forceBreakingChange: false, + "-p:IncludeStablePromotedApi=true"); + string suppressionFile = Path.Combine(Path.GetDirectoryName(implementationAssembly)!, "experimental-suppressions.xml"); + + var result = Run("--left", contractAssembly, "--right", implementationAssembly); + + result.Should().Fail(); + (result.StdOut + result.StdErr).Should().Contain("CP0022") + .And.Contain("Promoted"); + + Run( + "--left", contractAssembly, + "--right", implementationAssembly, + "--generate-suppression-file", + "--suppression-output-file", suppressionFile) + .Should().Pass(); + + string suppressions = File.ReadAllText(suppressionFile); + suppressions.Should().Contain("CP0022") + .And.NotContain("CP0002"); + + Run( + "--left", contractAssembly, + "--right", implementationAssembly, + "--suppression-file", suppressionFile) + .Should().Pass(); + } + + [TestMethod] public void ApiCompatTool_PackageMode_DetectsRemovedApi() { // Pack the existing PackageValidationTestProject twice to produce two .nupkg files @@ -99,14 +147,19 @@ private CommandResult Run(params string[] args) /// /// Builds a copy of the test asset and returns the absolute path to the produced assembly. /// - private string BuildAsset(string identifier, bool forceBreakingChange) + private string BuildAsset(string identifier, bool forceBreakingChange, params string[] additionalArguments) { TestAsset asset = TestAssetsManager .CopyTestAsset(TestAssetName, identifier: identifier) .WithSource(); - var args = forceBreakingChange ? new[] { "-p:ForceBreakingChange=true" } : Array.Empty(); - new BuildCommand(asset).Execute(args).Should().Pass(); + var args = new List(additionalArguments); + if (forceBreakingChange) + { + args.Add("-p:ForceBreakingChange=true"); + } + + new BuildCommand(asset).Execute(args.ToArray()).Should().Pass(); return Path.Combine(asset.TestRoot, "bin", "Debug", ToolsetInfo.CurrentTargetFramework, $"{TestAssetName}.dll"); diff --git a/test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility.Tests/ExperimentalApiTests.cs b/test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility.Tests/ExperimentalApiTests.cs new file mode 100644 index 000000000000..10bfd4222e6c --- /dev/null +++ b/test/Compatibility/ApiCompat/Microsoft.DotNet.ApiCompatibility.Tests/ExperimentalApiTests.cs @@ -0,0 +1,186 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.ApiCompatibility.Rules; +using Microsoft.DotNet.ApiSymbolExtensions.Tests; + +namespace Microsoft.DotNet.ApiCompatibility.Tests +{ + [TestClass] + public class ExperimentalApiTests + { + private const string ExperimentalAttribute = "[System.Diagnostics.CodeAnalysis.Experimental(\"TEST001\")]"; + + [TestMethod] + public void RemovedExperimentalTypeAndMemberAreInformational() + { + string leftSyntax = $$""" + namespace CompatTests; + {{ExperimentalAttribute}} + public class RemovedType { } + public class Api + { + {{ExperimentalAttribute}} + public void Removed() { } + } + """; + string rightSyntax = "namespace CompatTests; public class Api { }"; + + CompatDifference[] differences = GetDifferences(leftSyntax, rightSyntax); + + Assert.IsNotEmpty(differences); + Assert.IsTrue(differences.All(difference => difference.Severity == DifferenceSeverity.Informational)); + } + + [TestMethod] + public void NewExperimentalTypeAndMemberAreInformationalInStrictMode() + { + string leftSyntax = "namespace CompatTests; public class Api { }"; + string rightSyntax = $$""" + namespace CompatTests; + {{ExperimentalAttribute}} + public class AddedType { } + public class Api + { + {{ExperimentalAttribute}} + public void Added() { } + } + """; + + CompatDifference[] differences = GetDifferences(leftSyntax, rightSyntax, strictMode: true); + + Assert.IsNotEmpty(differences); + Assert.IsTrue(differences.All(difference => difference.Severity == DifferenceSeverity.Informational)); + } + + [TestMethod] + public void NewStableTypeAndMemberRemainErrorsInStrictMode() + { + string leftSyntax = "namespace CompatTests; public class Api { }"; + string rightSyntax = "namespace CompatTests; public class AddedType { } public class Api { public void Added() { } }"; + + CompatDifference[] differences = GetDifferences(leftSyntax, rightSyntax, strictMode: true); + + Assert.IsNotEmpty(differences); + Assert.IsTrue(differences.All(difference => difference.Severity == DifferenceSeverity.Error)); + } + + [TestMethod] + public void BreakingChangesRemainInformationalWhileApiRemainsExperimental() + { + string leftSyntax = $$""" + namespace CompatTests; + {{ExperimentalAttribute}} + public class Api { public string Changed() => string.Empty; } + """; + string rightSyntax = $$""" + namespace CompatTests; + {{ExperimentalAttribute}} + public class Api { public int Changed() => 0; } + """; + + CompatDifference[] differences = GetDifferences(leftSyntax, rightSyntax); + + Assert.IsNotEmpty(differences); + Assert.IsTrue(differences.All(difference => difference.Severity == DifferenceSeverity.Informational)); + } + + [TestMethod] + public void ExperimentalTypeAndMemberPromotionIsReportedAsError() + { + string leftSyntax = $$""" + namespace CompatTests; + {{ExperimentalAttribute}} + public class ExperimentalType { } + public class Api + { + {{ExperimentalAttribute}} + public void ExperimentalMember() { } + } + """; + string rightSyntax = "namespace CompatTests; public class ExperimentalType { } public class Api { public void ExperimentalMember() { } }"; + + CompatDifference[] differences = GetDifferences(leftSyntax, rightSyntax); + + Assert.HasCount(1, differences.Where(difference => difference.DiagnosticId == DiagnosticIds.ExperimentalApiBecomesStable && difference.ReferenceId == "T:CompatTests.ExperimentalType")); + Assert.HasCount(1, differences.Where(difference => difference.DiagnosticId == DiagnosticIds.ExperimentalApiBecomesStable && difference.ReferenceId == "M:CompatTests.Api.ExperimentalMember")); + Assert.IsTrue(differences.Where(difference => difference.DiagnosticId == DiagnosticIds.ExperimentalApiBecomesStable) + .All(difference => difference.Severity == DifferenceSeverity.Error)); + } + + [TestMethod] + public void MemberInExperimentalContainingTypeUsesContainingTypeStability() + { + string leftSyntax = $$""" + namespace CompatTests; + {{ExperimentalAttribute}} + public class Api { public void Changed() { } } + """; + string rightSyntax = $$""" + namespace CompatTests; + {{ExperimentalAttribute}} + public class Api { } + """; + + CompatDifference difference = Assert.ContainsSingle(GetDifferences(leftSyntax, rightSyntax) + .Where(difference => difference.ReferenceId == "M:CompatTests.Api.Changed")); + Assert.AreEqual(DifferenceSeverity.Informational, difference.Severity); + } + + [TestMethod] + public void StableToExperimentalRemainsGenericAttributeError() + { + string leftSyntax = "namespace CompatTests; public class Api { public void Changed() { } }"; + string rightSyntax = $$""" + namespace CompatTests; + public class Api + { + {{ExperimentalAttribute}} + public void Changed() { } + } + """; + + CompatDifference difference = Assert.ContainsSingle(GetDifferences(leftSyntax, rightSyntax, strictMode: true, includeAttributesRule: true) + .Where(difference => difference.DiagnosticId == DiagnosticIds.CannotAddAttribute)); + Assert.AreEqual(DifferenceSeverity.Error, difference.Severity); + } + + [TestMethod] + public void PromotionDoesNotDuplicateGenericAttributeDiagnostic() + { + string leftSyntax = $$""" + namespace CompatTests; + public class Api + { + {{ExperimentalAttribute}} + public void Changed() { } + } + """; + string rightSyntax = "namespace CompatTests; public class Api { public void Changed() { } }"; + + CompatDifference[] differences = GetDifferences(leftSyntax, rightSyntax, includeAttributesRule: true); + + Assert.ContainsSingle(differences.Where(difference => difference.ReferenceId == "M:CompatTests.Api.Changed")); + Assert.IsTrue(differences.All(difference => difference.DiagnosticId == DiagnosticIds.ExperimentalApiBecomesStable)); + } + + private static CompatDifference[] GetDifferences(string leftSyntax, string rightSyntax, bool strictMode = false, bool includeAttributesRule = false) + { + TestRuleFactory ruleFactory = new( + (settings, context) => new MembersMustExist(settings, context), + (settings, context) => new ExperimentalApiBecomesStable(context)); + + if (includeAttributesRule) + { + ruleFactory = ruleFactory.WithRule((settings, context) => new AttributesMustMatch(settings, context)); + } + + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + ApiComparer comparer = new(ruleFactory, new ApiComparerSettings(strictMode: strictMode)); + + return comparer.GetDifferences(left, right).ToArray(); + } + } +} diff --git a/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/ApiCompatValidateAssembliesTestProject.csproj b/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/ApiCompatValidateAssembliesTestProject.csproj index 27680012c5a1..d4ca760c6904 100644 --- a/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/ApiCompatValidateAssembliesTestProject.csproj +++ b/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/ApiCompatValidateAssembliesTestProject.csproj @@ -4,6 +4,8 @@ $(CurrentTargetFramework) $(DefineConstants);ForceBreakingChange $(DefineConstants);AddNewMember + $(DefineConstants);IncludeExperimentalApis + $(DefineConstants);IncludeStablePromotedApi diff --git a/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/Greeter.cs b/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/Greeter.cs index a6ff05078593..4a9ae330964a 100644 --- a/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/Greeter.cs +++ b/test/TestAssets/TestProjects/ApiCompatValidateAssembliesTestProject/Greeter.cs @@ -14,5 +14,17 @@ public class Greeter #if AddNewMember public string Welcome(string name) => $"Welcome, {name}!"; #endif + +#if IncludeExperimentalApis + [System.Diagnostics.CodeAnalysis.Experimental("TEST001")] + public string ExperimentalRemoved(string name) => $"Experimental goodbye, {name}!"; + + [System.Diagnostics.CodeAnalysis.Experimental("TEST002")] + public string Promoted(string name) => $"Promoted hello, {name}!"; +#endif + +#if IncludeStablePromotedApi + public string Promoted(string name) => $"Promoted hello, {name}!"; +#endif } }