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/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/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 };
+ }
+}
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
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);
+ }
+}