Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer, Microsoft.CodeAnalysis.Testing.DefaultVerifier>
{
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<Stackworx.Analyzers.AvoidMicrosoftExtensionsAzureAnalyzer, Microsoft.CodeAnalysis.Testing.DefaultVerifier>
{
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);
}
}
81 changes: 81 additions & 0 deletions Stackworx.Analyzers/AvoidMicrosoftExtensionsAzureAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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()));
}
}
}
2 changes: 2 additions & 0 deletions docs/docs/rules/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
136 changes: 136 additions & 0 deletions docs/docs/rules/sw103-avoid-microsoft-extensions-azure.md
Original file line number Diff line number Diff line change
@@ -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<TClient>`.
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<BlobServiceClient>("blob-storage", (sp, _) =>
new BlobServiceClient(
builder.Configuration.GetConnectionString("BlobStorage")));

builder.Services.AddKeyedSingleton<QueueServiceClient>("order-queue", (sp, _) =>
new QueueServiceClient(
builder.Configuration.GetConnectionString("OrderQueue")));
```

#### DI callsite — before (`IAzureClientFactory<T>`)

```csharp
public class BlobUploadService
{
private readonly BlobServiceClient _blobClient;

public BlobUploadService(IAzureClientFactory<BlobServiceClient> 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
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add some migration samples to the repo.

DI callsites and AddAzureClients config

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added four concrete code samples to the Migration Notes section:

  1. Registration before — full AddAzureClients block with two named clients (blob-storage, order-queue) and configuration sections.
  2. Registration after — equivalent AddKeyedSingleton registrations using connection strings, no Microsoft.Extensions.Azure import.
  3. DI callsite before — constructor injection via IAzureClientFactory<BlobServiceClient> + CreateClient("blob-storage").
  4. DI callsite after — constructor injection using [FromKeyedServices("blob-storage")] attribute.

Loading