From 9232ca2c6115437e2f30978056115fb7cebd7364 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 1 Aug 2026 13:44:20 -0700 Subject: [PATCH 1/3] Add CSWINRT2021 diagnostic descriptor for [Obsolete] without [Deprecated] '[Obsolete]' is a .NET concept with no Windows Runtime counterpart, so the WinMD generator copies it verbatim into the '.winmd' rather than translating it, where no other language projection can see it. Deprecating an API of an authored component requires '[Windows.Foundation.Metadata.Deprecated]', which is the only deprecation Windows Runtime metadata can carry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed5549ac-fb33-4d08-a8f3-d6cf3b8bc8e1 --- .../AnalyzerReleases.Shipped.md | 3 ++- .../Diagnostics/DiagnosticDescriptors.cs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Authoring/WinRT.SourceGenerator2/AnalyzerReleases.Shipped.md b/src/Authoring/WinRT.SourceGenerator2/AnalyzerReleases.Shipped.md index e1c6e605f..0fc27d5da 100644 --- a/src/Authoring/WinRT.SourceGenerator2/AnalyzerReleases.Shipped.md +++ b/src/Authoring/WinRT.SourceGenerator2/AnalyzerReleases.Shipped.md @@ -26,4 +26,5 @@ CSWINRT2016 | WindowsRuntime.SourceGenerator | Warning | Public authored type mi CSWINRT2017 | WindowsRuntime.SourceGenerator | Warning | Public authored type mixing '[ContractVersion]' and '[Version]' CSWINRT2018 | WindowsRuntime.SourceGenerator | Warning | '[WindowsRuntimeNativeExposedType]' target type cannot be instantiated CSWINRT2019 | WindowsRuntime.SourceGenerator | Warning | '[WindowsRuntimeNativeExposedType]' target type is not a projected class -CSWINRT2020 | WindowsRuntime.SourceGenerator | Warning | Duplicate '[WindowsRuntimeNativeExposedType]' target type \ No newline at end of file +CSWINRT2020 | WindowsRuntime.SourceGenerator | Warning | Duplicate '[WindowsRuntimeNativeExposedType]' target type +CSWINRT2021 | WindowsRuntime.SourceGenerator | Warning | '[Obsolete]' on an authored API without '[Deprecated]' \ No newline at end of file diff --git a/src/Authoring/WinRT.SourceGenerator2/Diagnostics/DiagnosticDescriptors.cs b/src/Authoring/WinRT.SourceGenerator2/Diagnostics/DiagnosticDescriptors.cs index 84567ce6c..1ed9d9bae 100644 --- a/src/Authoring/WinRT.SourceGenerator2/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Authoring/WinRT.SourceGenerator2/Diagnostics/DiagnosticDescriptors.cs @@ -286,4 +286,17 @@ internal static partial class DiagnosticDescriptors description: "A given type should only be used as the target of a single '[WindowsRuntimeNativeExposedType]' attribute in an assembly. CCW marshalling code is only generated once per type, so additional applications of the attribute for the same type have no effect.", helpLinkUri: "https://github.com/microsoft/CsWinRT", customTags: WellKnownDiagnosticTags.CompilationEnd); + + /// + /// Gets a for an authored API with an [Obsolete] attribute, but no [Deprecated] attribute. + /// + public static readonly DiagnosticDescriptor ObsoleteWithoutDeprecated = new( + id: "CSWINRT2021", + title: "'[Obsolete]' on an authored API without '[Deprecated]'", + messageFormat: """The API '{0}' is publicly exposed from a Windows Runtime component and has an '[Obsolete]' attribute applied, but no '[Windows.Foundation.Metadata.Deprecated]' attribute. '[Obsolete]' has no Windows Runtime counterpart, so it is not translated when the '.winmd' is generated, and the deprecation is invisible to every consumer of the component. Apply '[Windows.Foundation.Metadata.Deprecated]' to also deprecate '{0}' in the Windows Runtime metadata.""", + category: "WindowsRuntime.SourceGenerator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Deprecating a publicly exposed API of a Windows Runtime component requires '[Windows.Foundation.Metadata.Deprecated]', which is the only deprecation the '.winmd' can carry. '[Obsolete]' is a .NET concept with no Windows Runtime counterpart: it is not translated by the WinMD generator, so on its own it deprecates the API for nobody but the C# code inside the component itself.", + helpLinkUri: "https://github.com/microsoft/CsWinRT"); } \ No newline at end of file From ba9f89e8f91ce2dda6e939275953fd385e95f9c5 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 1 Aug 2026 14:18:06 -0700 Subject: [PATCH 2/3] Add the ObsoleteWithoutDeprecated analyzer for CSWINRT2021 Reports publicly exposed APIs of a Windows Runtime component that carry '[Obsolete]' but no '[Windows.Foundation.Metadata.Deprecated]'. Having both applied is the supported way to deprecate an API for .NET and Windows Runtime consumers alike, so that combination is not reported. The check covers types and their members, not just types: the WinMD generator supports '[Deprecated]' on methods, properties and events too, so '[Obsolete]' on a member is exactly as silently ineffective as it is on a type. Only what actually reaches the '.winmd' is considered, so that every report has an action the developer can take: - Types are only reported when public and top level, as Windows Runtime has no nested types. - Members are only reported when public and declared by such a type. They are also restricted to classes and interfaces: a Windows Runtime struct is a plain field aggregate, so the generator drops every member of one other than its public instance fields. - Accessors are skipped, as they are only exported as part of their property or event (which is reported instead). The generator moves a '[Deprecated]' from the property or event down onto the accessor row, and never reads one written on a C# accessor. - Constructors are skipped, because '[Deprecated]' does not include 'AttributeTargets.Constructor' in its usage and so cannot be applied to one at all. Reporting them would produce a warning whose only resolutions are suppressing it or dropping the '[Obsolete]'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed5549ac-fb33-4d08-a8f3-d6cf3b8bc8e1 --- .../ObsoleteWithoutDeprecatedAnalyzer.cs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/Authoring/WinRT.SourceGenerator2/Diagnostics/Analyzers/ObsoleteWithoutDeprecatedAnalyzer.cs diff --git a/src/Authoring/WinRT.SourceGenerator2/Diagnostics/Analyzers/ObsoleteWithoutDeprecatedAnalyzer.cs b/src/Authoring/WinRT.SourceGenerator2/Diagnostics/Analyzers/ObsoleteWithoutDeprecatedAnalyzer.cs new file mode 100644 index 000000000..160d016ee --- /dev/null +++ b/src/Authoring/WinRT.SourceGenerator2/Diagnostics/Analyzers/ObsoleteWithoutDeprecatedAnalyzer.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace WindowsRuntime.SourceGenerator.Diagnostics; + +/// +/// A diagnostic analyzer that reports when a publicly exposed API of a Windows Runtime component has an +/// [Obsolete] attribute applied, but no [Windows.Foundation.Metadata.Deprecated] attribute. +/// +/// +/// [Obsolete] has no Windows Runtime counterpart, so the WinMD generator copies it verbatim rather +/// than translating it, which leaves the deprecation invisible to every consumer of the component. Having +/// both attributes applied is the supported way to deprecate an API for .NET and Windows Runtime consumers +/// alike, so that combination is not reported. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ObsoleteWithoutDeprecatedAnalyzer : DiagnosticAnalyzer +{ + /// + public override ImmutableArray SupportedDiagnostics { get; } = [DiagnosticDescriptors.ObsoleteWithoutDeprecated]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(static context => + { + // This analyzer only applies to Windows Runtime component authoring scenarios + if (!context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.GetCsWinRTComponent()) + { + return; + } + + // Get the '[Obsolete]' symbol + if (context.Compilation.GetTypeByMetadataName("System.ObsoleteAttribute") is not { } obsoleteAttributeType) + { + return; + } + + // Get the '[Deprecated]' symbol. Without it there is no way to author the deprecation the + // diagnostic would be suggesting, so nothing is reported (this also means the analyzer is + // inert when the component does not reference a Windows SDK projection at all). + if (context.Compilation.GetTypeByMetadataName("Windows.Foundation.Metadata.DeprecatedAttribute") is not { } deprecatedAttributeType) + { + return; + } + + context.RegisterSymbolAction(context => + { + if (!IsPubliclyExposedFromComponent(context.Symbol)) + { + return; + } + + // Only report APIs that are deprecated for .NET consumers, but not for Windows Runtime ones + if (!context.Symbol.HasAttributeWithType(obsoleteAttributeType) || + context.Symbol.HasAttributeWithType(deprecatedAttributeType)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ObsoleteWithoutDeprecated, + context.Symbol.Locations.FirstOrDefault(), + context.Symbol)); + }, SymbolKind.NamedType, SymbolKind.Method, SymbolKind.Property, SymbolKind.Event); + }); + } + + /// + /// Checks whether a given symbol is publicly exposed from a Windows Runtime component, and so ends up + /// in the generated .winmd. + /// + /// The symbol to check. + /// Whether is publicly exposed from the component. + private static bool IsPubliclyExposedFromComponent(ISymbol symbol) + { + // Skip symbols with no declaration in source (eg. the implicit parameterless constructor of a + // class), which cannot carry an attribute of their own and have nowhere to report a diagnostic + if (symbol.IsImplicitlyDeclared) + { + return false; + } + + if (symbol.DeclaredAccessibility is not Accessibility.Public) + { + return false; + } + + // Types are only exported when they are top level: Windows Runtime has no nested types + if (symbol is INamedTypeSymbol) + { + return symbol.ContainingType is null; + } + + // Property and event accessors are only exported as part of the property or event they belong to, + // which is the symbol reported instead. The generator moves a '[Deprecated]' from the property or + // event down onto the accessor row itself, and never reads one written on a C# accessor. + if (symbol is IMethodSymbol { AssociatedSymbol: not null }) + { + return false; + } + + // Constructors are exported as activation factory methods, and '[Deprecated]' cannot be applied to + // them ('AttributeTargets.Constructor' is not part of its usage), so there would be no way to act + // on the diagnostic. Deprecating the whole type is the only option, and that is reported already. + if (symbol is IMethodSymbol { MethodKind: MethodKind.Constructor or MethodKind.StaticConstructor }) + { + return false; + } + + // Members are exported with their declaring type, so they are only exported when it is. Only classes + // and interfaces export members at all: a Windows Runtime struct is a plain field aggregate, so the + // generator drops every member of one other than its public instance fields. + return symbol.ContainingType is { DeclaredAccessibility: Accessibility.Public, ContainingType: null, TypeKind: TypeKind.Class or TypeKind.Interface }; + } +} From fb360622d92bc890633cee3ff7028ff881f82e1e Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 1 Aug 2026 14:18:18 -0700 Subject: [PATCH 3/3] Add tests for the ObsoleteWithoutDeprecated analyzer Covers the cases the analyzer must stay quiet for (no attributes, only '[Deprecated]', both attributes, non component projects, non public types and members, nested types, struct members, constructors and accessors) and the ones it must report (every public type kind, and public methods, properties and events of both classes and interfaces). The accessor case is the one most likely to regress into a vacuous test, so it puts '[Obsolete]' on the accessor itself rather than on the property: an attribute written on a property is never surfaced on its accessor symbols, so a test that relies on that would pass with the guard removed. Verified that this one does fail without it. Also links the diagnostic from the '[Obsolete]' note in the attribute projections doc, which is where the behavior it guards is documented. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed5549ac-fb33-4d08-a8f3-d6cf3b8bc8e1 --- docs/attribute-projections.md | 2 +- .../Test_ObsoleteWithoutDeprecatedAnalyzer.cs | 334 ++++++++++++++++++ 2 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 src/Tests/SourceGenerator2Test/Test_ObsoleteWithoutDeprecatedAnalyzer.cs diff --git a/docs/attribute-projections.md b/docs/attribute-projections.md index 90c1dd45b..3fef9835d 100644 --- a/docs/attribute-projections.md +++ b/docs/attribute-projections.md @@ -52,7 +52,7 @@ What the WinMD generator emits into an authored component's `.winmd` for an attr | `[WindowsRuntime.Xaml.GeneratedCustomPropertyProvider]`, `[System.Reflection.DefaultMember]`, and anything under `System.Runtime.CompilerServices` | *nothing* | Either handled by CsWinRT itself or meaningless in Windows Runtime metadata. | | Any other public attribute type | itself | Non-public attribute types, and attributes whose signature cannot be read, are skipped. | -> **Note**: `[System.Obsolete]` is **not** translated into `[Windows.Foundation.Metadata.Deprecated]`. It is copied as-is, so it is invisible to every other language projection. Use `[Windows.Foundation.Metadata.Deprecated]` to deprecate an API of an authored component. `[Experimental]` is the exception to that rule only because the Windows Runtime attribute has no projected form to apply. +> **Note**: `[System.Obsolete]` is **not** translated into `[Windows.Foundation.Metadata.Deprecated]`. It is copied as-is, so it is invisible to every other language projection. Use `[Windows.Foundation.Metadata.Deprecated]` to deprecate an API of an authored component; applying `[Obsolete]` without it is reported as `CSWINRT2021`. `[Experimental]` is the exception to that rule only because the Windows Runtime attribute has no projected form to apply. ## Related documentation diff --git a/src/Tests/SourceGenerator2Test/Test_ObsoleteWithoutDeprecatedAnalyzer.cs b/src/Tests/SourceGenerator2Test/Test_ObsoleteWithoutDeprecatedAnalyzer.cs new file mode 100644 index 000000000..d46fcacab --- /dev/null +++ b/src/Tests/SourceGenerator2Test/Test_ObsoleteWithoutDeprecatedAnalyzer.cs @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using WindowsRuntime.SourceGenerator.Diagnostics; +using WindowsRuntime.SourceGenerator.Tests.Helpers; + +namespace WindowsRuntime.SourceGenerator.Tests; + +using VerifyCS = CSharpAnalyzerTest; + +/// +/// Tests for . +/// +[TestClass] +public sealed class Test_ObsoleteWithoutDeprecatedAnalyzer +{ + [TestMethod] + public async Task PublicClass_NoAttributes_DoesNotWarn() + { + const string source = """ + public sealed class MyClass; + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicClass_OnlyDeprecated_DoesNotWarn() + { + const string source = """ + using Windows.Foundation.Metadata; + + [Deprecated("Use MyOtherClass instead", DeprecationType.Deprecate, 1u)] + public sealed class MyClass; + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicClass_ObsoleteAndDeprecated_DoesNotWarn() + { + const string source = """ + using System; + using Windows.Foundation.Metadata; + + [Obsolete("Use MyOtherClass instead")] + [Deprecated("Use MyOtherClass instead", DeprecationType.Deprecate, 1u)] + public sealed class MyClass; + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicClass_OnlyObsolete_NotComponent_DoesNotWarn() + { + const string source = """ + using System; + + [Obsolete("Use MyOtherClass instead")] + public sealed class MyClass; + """; + + await VerifyCS.VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task InternalClass_OnlyObsolete_DoesNotWarn() + { + const string source = """ + using System; + + [Obsolete("Use MyOtherClass instead")] + internal sealed class MyClass; + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task NestedPublicClass_OnlyObsolete_DoesNotWarn() + { + // Windows Runtime has no nested types, so a nested type never reaches the '.winmd' + const string source = """ + using System; + + public sealed class Outer + { + [Obsolete("Use something else instead")] + public sealed class Nested; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task NonPublicMember_OnlyObsolete_DoesNotWarn() + { + const string source = """ + using System; + + public sealed class MyClass + { + [Obsolete("Use NewMethod instead")] + internal void OldMethod() + { + } + + [Obsolete("Use NewProperty instead")] + private int OldProperty => 42; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicMemberOfInternalType_OnlyObsolete_DoesNotWarn() + { + const string source = """ + using System; + + internal sealed class MyClass + { + [Obsolete("Use NewMethod instead")] + public void OldMethod() + { + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + [DataRow("class")] + [DataRow("struct")] + public async Task PublicType_OnlyObsolete_Warns(string typeKeyword) + { + string source = $$""" + using System; + + [Obsolete("Use MyOtherType instead")] + public {{typeKeyword}} {|CSWINRT2021:MyType|}; + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicInterface_OnlyObsolete_Warns() + { + const string source = """ + using System; + + [Obsolete("Use IMyOtherInterface instead")] + public interface {|CSWINRT2021:IMyInterface|}; + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicEnum_OnlyObsolete_Warns() + { + const string source = """ + using System; + + [Obsolete("Use MyOtherEnum instead")] + public enum {|CSWINRT2021:MyEnum|} + { + A, + B + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicDelegate_OnlyObsolete_Warns() + { + const string source = """ + using System; + + [Obsolete("Use MyOtherDelegate instead")] + public delegate void {|CSWINRT2021:MyDelegate|}(); + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicMembers_OnlyObsolete_Warn() + { + // '[Deprecated]' is supported on members too, so '[Obsolete]' is just as ineffective there + const string source = """ + using System; + + public sealed class MyClass + { + [Obsolete("Use NewMethod instead")] + public void {|CSWINRT2021:OldMethod|}() + { + } + + [Obsolete("Use NewProperty instead")] + public int {|CSWINRT2021:OldProperty|} => 42; + + [Obsolete("Use NewEvent instead")] + public event EventHandler {|CSWINRT2021:OldEvent|}; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicMembers_ObsoleteAndDeprecated_DoNotWarn() + { + const string source = """ + using System; + using Windows.Foundation.Metadata; + + public sealed class MyClass + { + [Obsolete("Use NewMethod instead")] + [Deprecated("Use NewMethod instead", DeprecationType.Deprecate, 1u)] + public void OldMethod() + { + } + + [Obsolete("Use NewProperty instead")] + [Deprecated("Use NewProperty instead", DeprecationType.Deprecate, 1u)] + public int OldProperty => 42; + + [Obsolete("Use NewEvent instead")] + [Deprecated("Use NewEvent instead", DeprecationType.Deprecate, 1u)] + public event EventHandler OldEvent; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicConstructor_OnlyObsolete_DoesNotWarn() + { + // '[Deprecated]' does not include 'AttributeTargets.Constructor' in its usage, so it cannot be + // applied to a constructor at all: there would be no way to act on the diagnostic + const string source = """ + using System; + + public sealed class MyClass + { + [Obsolete("Use the parameterless constructor instead")] + public MyClass(int value) + { + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task ObsoleteOnPropertyAccessor_DoesNotWarn() + { + // Accessors are exported as part of their property, and the generator only ever moves a + // '[Deprecated]' from the property down onto the accessor row, never the other way around + const string source = """ + using System; + + public sealed class MyClass + { + public int OldProperty + { + [Obsolete("Use NewProperty instead")] + get; + + set; + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task StructMembers_OnlyObsolete_DoNotWarn() + { + // A Windows Runtime struct is a plain field aggregate: the generator drops every member of one + // other than its public instance fields, so those members never reach the '.winmd' + const string source = """ + using System; + + public struct MyStruct + { + public int Value; + + [Obsolete("Use Value instead")] + public int GetValue() + { + return Value; + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } + + [TestMethod] + public async Task PublicInterfaceMembers_OnlyObsolete_Warn() + { + // Interface members are implicitly public, so they are exported with the interface + const string source = """ + using System; + + public interface IMyInterface + { + [Obsolete("Use NewMethod instead")] + void {|CSWINRT2021:OldMethod|}(); + + [Obsolete("Use NewProperty instead")] + int {|CSWINRT2021:OldProperty|} { get; } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(source, isCsWinRTComponent: true); + } +}