diff --git a/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs b/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs new file mode 100644 index 0000000..b332e9f --- /dev/null +++ b/Stackworx.Analyzers.Tests/AvoidMicrosoftExtensionsAzureAnalyzerTests.cs @@ -0,0 +1,121 @@ +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< + 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 FakeServiceCollection = + """ + namespace Microsoft.Extensions.DependencyInjection + { + public interface IServiceCollection { } + public sealed class ServiceCollection : IServiceCollection { } + } + """; + + private const string FakeAzureExtensions = + """ + 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 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] + public async Task Flags_FullyQualified_Invocation_InForbiddenNamespace() + { + const string src = + """ + class C + { + void M() + { + Microsoft.Extensions.DependencyInjection.IServiceCollection services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + {|#0:Microsoft.Extensions.Azure.AzureClientFactoryBuilderExtensions.AddAzureClients(services)|}; + } + } + """; + + 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] + 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..8b16ffa --- /dev/null +++ b/docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md @@ -0,0 +1,136 @@ +--- +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. + +### Code samples + +#### 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 + +- 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. + +## 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 +```