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
}
}