From adb73e4bc0daac53f6da6237bf12a032df507694 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 10:41:39 +0000 Subject: [PATCH 1/4] Add SW103 analyzer, tests, and migration docs --- ...idMicrosoftExtensionsAzureAnalyzerTests.cs | 93 +++++++++++++++++++ .../AvoidMicrosoftExtensionsAzureAnalyzer.cs | 81 ++++++++++++++++ docs/docs/rules/overview.md | 2 + .../sw103-avoid-microsoft-extensions-azure.md | 78 ++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs create mode 100644 Stackworx.Analyzers/AvoidMicrosoftExtensionsAzureAnalyzer.cs create mode 100644 docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md diff --git a/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs b/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs new file mode 100644 index 0000000..33b4e25 --- /dev/null +++ b/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs @@ -0,0 +1,93 @@ +namespace Stackworx.Analyzers.Tests; + +using System.Threading.Tasks; +using Xunit; +using Verifier = + Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier< + Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer, + Microsoft.CodeAnalysis.Testing.DefaultVerifier>; + +public class AvoidMicrosoftExtensionsAzureAnalyzerTests +{ + private const string FakeAzureNamespace = + """ + namespace Microsoft.Extensions.Azure; + public sealed class AzureMarker { } + """; + + private const string FakeAzureExtensions = + """ + namespace Microsoft.Extensions.DependencyInjection; + public interface IServiceCollection { } + public sealed class ServiceCollection : IServiceCollection { } + + namespace Microsoft.Extensions.Azure; + using Microsoft.Extensions.DependencyInjection; + + public static class AzureClientFactoryBuilderExtensions + { + public static IServiceCollection AddAzureClients(this IServiceCollection services) => services; + } + """; + + [Fact] + public async Task Flags_UsingDirective_ForMicrosoftExtensionsAzure() + { + const string src = + """ + using {|#0:Microsoft.Extensions.Azure|}; + + class C { } + """; + + var expected = Verifier + .Diagnostic(Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer.Rule) + .WithLocation(0); + + await Verifier.VerifyAnalyzerAsync([src, FakeAzureNamespace], expected); + } + + [Fact] + public async Task Flags_FullyQualified_Invocation_InForbiddenNamespace() + { + const string src = + """ + class C + { + void M() + { + Microsoft.Extensions.DependencyInjection.IServiceCollection services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.Azure.AzureClientFactoryBuilderExtensions.{|#0:AddAzureClients|}(services); + } + } + """; + + var expected = Verifier + .Diagnostic(Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer.Rule) + .WithLocation(0); + + await Verifier.VerifyAnalyzerAsync([src, FakeAzureExtensions], expected); + } + + [Fact] + public async Task DoesNotFlag_NearMatch_Namespace() + { + const string src = + """ + using Microsoft.Extensions.Azureish; + + class C + { + void M() + { + var marker = new Microsoft.Extensions.Azureish.AzureMarker(); + } + } + + namespace Microsoft.Extensions.Azureish; + public sealed class AzureMarker { } + """; + + await Verifier.VerifyAnalyzerAsync(src); + } +} diff --git a/Stackworx.Analyzers/AvoidMicrosoftExtensionsAzureAnalyzer.cs b/Stackworx.Analyzers/AvoidMicrosoftExtensionsAzureAnalyzer.cs new file mode 100644 index 0000000..3e8f3d2 --- /dev/null +++ b/Stackworx.Analyzers/AvoidMicrosoftExtensionsAzureAnalyzer.cs @@ -0,0 +1,81 @@ +namespace Stackworx.Analyzers; + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AvoidMicrosoftExtensionsAzureAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "SW103"; + + private const string ForbiddenNamespace = "Microsoft.Extensions.Azure"; + private const string Category = "Architecture"; + + private static readonly LocalizableString Title = + "Avoid Microsoft.Extensions.Azure usage"; + + private static readonly LocalizableString MessageFormat = + "Usage of Microsoft.Extensions.Azure is discouraged; migrate to keyed service registrations"; + + private static readonly LocalizableString Description = + "Microsoft.Extensions.Azure registrations should be replaced by keyed service registrations."; + + public static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticId, + title: Title, + messageFormat: MessageFormat, + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Description); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterSyntaxNodeAction(AnalyzeUsingDirective, Microsoft.CodeAnalysis.CSharp.SyntaxKind.UsingDirective); + context.RegisterOperationAction(AnalyzeOperation, OperationKind.Invocation, OperationKind.ObjectCreation); + } + + private static void AnalyzeUsingDirective(SyntaxNodeAnalysisContext context) + { + var usingDirective = (UsingDirectiveSyntax)context.Node; + var namespaceText = usingDirective.Name?.ToString(); + if (namespaceText is null) + { + return; + } + + if (namespaceText == ForbiddenNamespace || namespaceText.StartsWith(ForbiddenNamespace + ".", System.StringComparison.Ordinal)) + { + context.ReportDiagnostic(Diagnostic.Create(Rule, usingDirective.Name!.GetLocation())); + } + } + + private static void AnalyzeOperation(OperationAnalysisContext context) + { + ISymbol? symbol = context.Operation switch + { + IInvocationOperation invocation => invocation.TargetMethod, + IObjectCreationOperation objectCreation => objectCreation.Constructor, + _ => null, + }; + + if (symbol is null || symbol.ContainingNamespace is null) + { + return; + } + + var namespaceText = symbol.ContainingNamespace.ToDisplayString(); + if (namespaceText == ForbiddenNamespace || namespaceText.StartsWith(ForbiddenNamespace + ".", System.StringComparison.Ordinal)) + { + context.ReportDiagnostic(Diagnostic.Create(Rule, context.Operation.Syntax.GetLocation())); + } + } +} diff --git a/docs/docs/rules/overview.md b/docs/docs/rules/overview.md index 7e3d63d..4c01a36 100644 --- a/docs/docs/rules/overview.md +++ b/docs/docs/rules/overview.md @@ -20,6 +20,7 @@ This page provides a comprehensive table of all available Stackworx analyzers an | [SWGQL06](./swgql05-06-hotchocolate-types-usedimplicitly) | [UsedImplicitly] applied to non-GraphQL type | GraphQL | Warning | Flags [UsedImplicitly] on types that don't look like HotChocolate GraphQL schema types. | | [SW101](./sw101-forbidden-namespace-reference) | Forbidden reference to feature-internal namespace | Architecture | Warning | Prevents references to feature-internal namespaces from outside the feature. | | [SW102](./sw102-forbidden-namespace-using) | Forbidden using to feature-internal namespace | Architecture | Warning | Prevents using directives that import feature-internal namespaces from outside. | +| [SW103](./sw103-avoid-microsoft-extensions-azure) | Avoid Microsoft.Extensions.Azure usage | Architecture | Warning | Detects Microsoft.Extensions.Azure usage and guides migration to keyed services. | ## Rules by Category @@ -37,3 +38,4 @@ This page provides a comprehensive table of all available Stackworx analyzers an ### Architecture Rules - [SW101 - Forbidden reference to feature-internal namespace](./sw101-forbidden-namespace-reference) - [SW102 - Forbidden using to feature-internal namespace](./sw102-forbidden-namespace-using) +- [SW103 - Avoid Microsoft.Extensions.Azure usage](./sw103-avoid-microsoft-extensions-azure) diff --git a/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md b/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md new file mode 100644 index 0000000..ea905b7 --- /dev/null +++ b/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md @@ -0,0 +1,78 @@ +--- +sidebar_position: 8 +--- + +# SW103: Avoid Microsoft.Extensions.Azure usage + +## Overview + +**Rule ID:** SW103 +**Category:** Architecture +**Severity:** Warning +**Status:** Enabled by default + +## Description + +This rule flags source usage of `Microsoft.Extensions.Azure` APIs (including `using Microsoft.Extensions.Azure;` and calls to symbols in that namespace). + +The preferred direction is keyed services registration/resolution instead of `Microsoft.Extensions.Azure` builder-based registration. + +## When This Rule Triggers + +The analyzer reports a diagnostic when: +- A `using` directive imports `Microsoft.Extensions.Azure` (or a child namespace) +- A method call or object creation targets a symbol in `Microsoft.Extensions.Azure` (or a child namespace) + +## Why This Matters + +`Microsoft.Extensions.Azure` registration patterns (`AddAzureClients`, factory builders, etc.) make named registrations harder to reason about across large solutions. + +Keyed services provide a clearer DI model where each Azure dependency is resolved with an explicit key and can be mapped directly to an Aspire resource binding. + +## Migration Notes (for automated and manual migrations) + +Use this checklist when migrating code flagged by SW103: + +1. Identify `AddAzureClients(...)` blocks and collect each registered client name/key. +2. Replace the `Microsoft.Extensions.Azure` registration with keyed DI registrations for the concrete Azure SDK client types. +3. Keep one stable key per resource (for example, `"blob-storage"`), and use that same key at injection/resolution sites. +4. Update constructors and call sites to resolve keyed services instead of `IAzureClientFactory`. +5. Remove `using Microsoft.Extensions.Azure;` once all references are migrated. +6. Validate startup and integration tests to ensure each keyed binding resolves correctly. + +### Typical replacement direction + +- **Before:** `services.AddAzureClients(builder => ...);` +- **After:** register Azure SDK clients directly as keyed services and resolve by key where needed. + +### Suggested review points after migration + +- Every previous `name:`/`WithName(...)` registration has a corresponding keyed registration. +- No remaining references to `Microsoft.Extensions.Azure` types (`IAzureClientFactory<>`, builder types, extension methods). +- Key names are centralized and reused consistently. +- Resource keys align with Aspire integration naming. + +## Related Documentation + +- Aspire Azure Storage Blobs integration (keyed/resource-oriented approach): + https://aspire.dev/integrations/cloud/azure/azure-storage-blobs/azure-storage-blobs-connect/#blob-storage-resource +- Azure SDK discussion: + https://github.com/Azure/azure-sdk-for-net/issues/40408#issuecomment-3599496883 +- Azure SDK follow-up issue: + https://github.com/Azure/azure-sdk-for-net/issues/55491 + +## Configuration + +To disable: + +```ini +[*.cs] +dotnet_diagnostic.SW103.severity = none +``` + +To enforce as error: + +```ini +[*.cs] +dotnet_diagnostic.SW103.severity = error +``` From 53c068ece6cbc71049a709df9455c352212b8aeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 10:44:37 +0000 Subject: [PATCH 2/4] Finalize SW103 analyzer with passing tests and docs --- ...idMicrosoftExtensionsAzureAnalyzerTests.cs | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs b/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs index 33b4e25..b332e9f 100644 --- a/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs +++ b/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs @@ -1,6 +1,8 @@ namespace Stackworx.Analyzers.Tests; using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; using Xunit; using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier< @@ -11,22 +13,31 @@ public class AvoidMicrosoftExtensionsAzureAnalyzerTests { private const string FakeAzureNamespace = """ - namespace Microsoft.Extensions.Azure; - public sealed class AzureMarker { } + namespace Microsoft.Extensions.Azure + { + public sealed class AzureMarker { } + } """; - private const string FakeAzureExtensions = + private const string FakeServiceCollection = """ - namespace Microsoft.Extensions.DependencyInjection; - public interface IServiceCollection { } - public sealed class ServiceCollection : IServiceCollection { } - - namespace Microsoft.Extensions.Azure; - using Microsoft.Extensions.DependencyInjection; + namespace Microsoft.Extensions.DependencyInjection + { + public interface IServiceCollection { } + public sealed class ServiceCollection : IServiceCollection { } + } + """; - public static class AzureClientFactoryBuilderExtensions + private const string FakeAzureExtensions = + """ + namespace Microsoft.Extensions.Azure { - public static IServiceCollection AddAzureClients(this IServiceCollection services) => services; + using Microsoft.Extensions.DependencyInjection; + + public static class AzureClientFactoryBuilderExtensions + { + public static IServiceCollection AddAzureClients(this IServiceCollection services) => services; + } } """; @@ -40,11 +51,18 @@ public async Task Flags_UsingDirective_ForMicrosoftExtensionsAzure() class C { } """; - var expected = Verifier - .Diagnostic(Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer.Rule) - .WithLocation(0); - - await Verifier.VerifyAnalyzerAsync([src, FakeAzureNamespace], expected); + var test = new CSharpAnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + TestCode = src, + }; + test.TestState.Sources.Add(FakeAzureNamespace); + test.ExpectedDiagnostics.Add( + Verifier + .Diagnostic(Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer.Rule) + .WithLocation(0)); + + await test.RunAsync(); } [Fact] @@ -57,16 +75,24 @@ class C void M() { Microsoft.Extensions.DependencyInjection.IServiceCollection services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); - Microsoft.Extensions.Azure.AzureClientFactoryBuilderExtensions.{|#0:AddAzureClients|}(services); + {|#0:Microsoft.Extensions.Azure.AzureClientFactoryBuilderExtensions.AddAzureClients(services)|}; } } """; - var expected = Verifier - .Diagnostic(Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer.Rule) - .WithLocation(0); - - await Verifier.VerifyAnalyzerAsync([src, FakeAzureExtensions], expected); + var test = new CSharpAnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + TestCode = src, + }; + test.TestState.Sources.Add(FakeServiceCollection); + test.TestState.Sources.Add(FakeAzureExtensions); + test.ExpectedDiagnostics.Add( + Verifier + .Diagnostic(Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer.Rule) + .WithLocation(0)); + + await test.RunAsync(); } [Fact] @@ -84,8 +110,10 @@ void M() } } - namespace Microsoft.Extensions.Azureish; - public sealed class AzureMarker { } + namespace Microsoft.Extensions.Azureish + { + public sealed class AzureMarker { } + } """; await Verifier.VerifyAnalyzerAsync(src); From c54136a84724dc0392b7db79a749c0fe0fbe46c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 11:09:06 +0000 Subject: [PATCH 3/4] docs: add concrete migration code samples for SW103 --- .../sw103-avoid-microsoft-extensions-azure.md | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md b/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md index ea905b7..8c4eddf 100644 --- a/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md +++ b/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md @@ -40,10 +40,67 @@ Use this checklist when migrating code flagged by SW103: 5. Remove `using Microsoft.Extensions.Azure;` once all references are migrated. 6. Validate startup and integration tests to ensure each keyed binding resolves correctly. -### Typical replacement direction +### Code samples -- **Before:** `services.AddAzureClients(builder => ...);` -- **After:** register Azure SDK clients directly as keyed services and resolve by key where needed. +#### Registration — before (`AddAzureClients`) + +```csharp +// Program.cs / Startup.cs +using Microsoft.Extensions.Azure; + +builder.Services.AddAzureClients(clients => +{ + clients.AddBlobServiceClient(builder.Configuration.GetSection("AzureStorage:Blob")) + .WithName("blob-storage"); + + clients.AddQueueServiceClient(builder.Configuration.GetSection("AzureStorage:Queue")) + .WithName("order-queue"); +}); +``` + +#### Registration — after (keyed services) + +```csharp +// Program.cs / Startup.cs +// No Microsoft.Extensions.Azure import required + +builder.Services.AddKeyedSingleton("blob-storage", (sp, _) => + new BlobServiceClient( + builder.Configuration.GetConnectionString("BlobStorage"))); + +builder.Services.AddKeyedSingleton("order-queue", (sp, _) => + new QueueServiceClient( + builder.Configuration.GetConnectionString("OrderQueue"))); +``` + +#### DI callsite — before (`IAzureClientFactory`) + +```csharp +public class BlobUploadService +{ + private readonly BlobServiceClient _blobClient; + + public BlobUploadService(IAzureClientFactory factory) + { + _blobClient = factory.CreateClient("blob-storage"); + } +} +``` + +#### DI callsite — after (keyed injection) + +```csharp +public class BlobUploadService +{ + private readonly BlobServiceClient _blobClient; + + public BlobUploadService( + [FromKeyedServices("blob-storage")] BlobServiceClient blobClient) + { + _blobClient = blobClient; + } +} +``` ### Suggested review points after migration From ab62f464b1afe4d717b48f5e4c282fb9e64c4204 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 11:12:14 +0000 Subject: [PATCH 4/4] docs: note preference for keyed singleton registrations --- docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md b/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md index 8c4eddf..8b16ffa 100644 --- a/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md +++ b/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md @@ -105,6 +105,7 @@ public class BlobUploadService ### Suggested review points after migration - Every previous `name:`/`WithName(...)` registration has a corresponding keyed registration. +- Prefer keyed singleton registrations for Azure SDK clients; avoid adding a default unkeyed registration for the same client type. - No remaining references to `Microsoft.Extensions.Azure` types (`IAzureClientFactory<>`, builder types, extension methods). - Key names are centralized and reused consistently. - Resource keys align with Aspire integration naming.