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
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,38 @@ jobs:
working-directory: DesignPatterns.Samples
run: dotnet run --project build/_build.csproj -- --root . --target Ci --configuration Release

build-test-net48:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x

- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/global.json', '**/*.slnx') }}
restore-keys: |
${{ runner.os }}-nuget-

- name: Run net48 tests via Nuke
run: dotnet run --project build/_build.csproj -- --root . --target CiNet48 --configuration Release

- name: Upload net48 test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-net48
path: TestResults/
if-no-files-found: warn

pack:
runs-on: ubuntu-latest
needs: [build-test, samples]
needs: [build-test, build-test-net48, samples]
steps:
- uses: actions/checkout@v4

Expand Down
4 changes: 3 additions & 1 deletion .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"type": "string",
"enum": [
"Ci",
"CiNet48",
"CiPack",
"Clean",
"Compile",
Expand All @@ -44,7 +45,8 @@
"Publish",
"Restore",
"Test",
"UnitTest"
"UnitTest",
"UnitTestNet48"
]
},
"Verbosity": {
Expand Down
4 changes: 4 additions & 0 deletions DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
DP066 | DesignPatterns.Analyzers | Warning | Singleton factory delegate captures shorter-lived service
DP068 | DesignPatterns.Analyzers | Warning | Generated singleton is also registered with DI
DP069 | DesignPatterns.Analyzers | Info | Non-thread-safe singleton has mutable instance state
DP070 | DesignPatterns.Analyzers | Info | Static mutable singleton candidate
DP071 | DesignPatterns.Analyzers | Warning | Static mutable singleton is also registered with DI
180 changes: 180 additions & 0 deletions DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using DesignPatterns.Diagnostics;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace DesignPatterns.Analyzers;

/// <summary>
/// Detects conflicting generated or mutable static singleton lifecycles.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class SingletonLifecycleAnalyzer : DiagnosticAnalyzer
{
private const string GenerateSingletonAttributeName = "DesignPatterns.Creational.GenerateSingletonAttribute";

private static readonly DiagnosticDescriptor GeneratedAndDiRule =
DesignPatternsDiagnosticDescriptors.GenerateSingletonDiDoubleRegistration;
private static readonly DiagnosticDescriptor NonThreadSafeStateRule =
DesignPatternsDiagnosticDescriptors.GenerateSingletonNonThreadSafeMutableState;
private static readonly DiagnosticDescriptor StaticMutableRule =
DesignPatternsDiagnosticDescriptors.StaticMutableSingleton;
private static readonly DiagnosticDescriptor StaticMutableAndDiRule =
DesignPatternsDiagnosticDescriptors.StaticMutableSingletonDiDoubleRegistration;

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(GeneratedAndDiRule, NonThreadSafeStateRule, StaticMutableRule, StaticMutableAndDiRule);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(OnCompilationStart);
}

private static void OnCompilationStart(CompilationStartAnalysisContext context)
{
var singletonRegistrations = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
context.RegisterSyntaxNodeAction(
syntaxContext => CollectSingletonRegistration(syntaxContext, singletonRegistrations),
SyntaxKind.InvocationExpression);
context.RegisterCompilationEndAction(endContext => Analyze(endContext, singletonRegistrations));
}

private static void CollectSingletonRegistration(
SyntaxNodeAnalysisContext context,
HashSet<INamedTypeSymbol> registrations)
{
var invocation = (InvocationExpressionSyntax)context.Node;
if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method)
{
return;
}

var isMsDiSingleton = method.Name == "AddSingleton" &&
method.ContainingNamespace.ToDisplayString().StartsWith("Microsoft.Extensions.DependencyInjection", StringComparison.Ordinal);
var isAutofacRegistration = method.Name == "RegisterType" &&
method.ContainingNamespace.ToDisplayString().StartsWith("Autofac", StringComparison.Ordinal) &&
HasSingleInstanceInChain(invocation);

if (!isMsDiSingleton && !isAutofacRegistration)
{
return;
}

var implementation = method.TypeArguments.LastOrDefault() as INamedTypeSymbol;
if (implementation is not null)
{
registrations.Add(implementation);
}
}

private static void Analyze(
CompilationAnalysisContext context,
HashSet<INamedTypeSymbol> registrations)
{
foreach (var type in AnalyzerSymbolHelper.GetAllTypes(context.Compilation.Assembly.GlobalNamespace))
{
var generatedSingleton = type.GetAttributes()
.FirstOrDefault(attribute => attribute.AttributeClass?.ToDisplayString() == GenerateSingletonAttributeName);

if (generatedSingleton is not null)
{
AnalyzeGeneratedSingleton(context, type, generatedSingleton, registrations);
}

AnalyzeStaticMutableSingleton(context, type, registrations);
}
}

private static void AnalyzeGeneratedSingleton(
CompilationAnalysisContext context,
INamedTypeSymbol type,
AttributeData attribute,
HashSet<INamedTypeSymbol> registrations)
{
var location = type.Locations.FirstOrDefault();
if (location is null)
{
return;
}

if (registrations.Contains(type))
{
context.ReportDiagnostic(Diagnostic.Create(
GeneratedAndDiRule,
location,
type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
}

var threadSafe = attribute.NamedArguments
.FirstOrDefault(argument => argument.Key == "ThreadSafe").Value.Value as bool? ?? true;
if (threadSafe)
{
return;
}

foreach (var field in type.GetMembers().OfType<IFieldSymbol>()
.Where(field => !field.IsStatic && !field.IsReadOnly && !field.IsConst))
{
context.ReportDiagnostic(Diagnostic.Create(
NonThreadSafeStateRule,
field.Locations.FirstOrDefault() ?? location,
type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
field.Name));
}
}

private static void AnalyzeStaticMutableSingleton(
CompilationAnalysisContext context,
INamedTypeSymbol type,
HashSet<INamedTypeSymbol> registrations)
{
ISymbol? candidate = type.GetMembers()
.OfType<IFieldSymbol>()
.FirstOrDefault(field => field.IsStatic && !field.IsReadOnly && IsSingletonName(field.Name));

candidate ??= type.GetMembers()
.OfType<IPropertySymbol>()
.FirstOrDefault(property => property.IsStatic && property.SetMethod is not null && IsSingletonName(property.Name));

if (candidate is null)
{
return;
}

var location = candidate.Locations.FirstOrDefault() ?? type.Locations.FirstOrDefault();
if (location is null)
{
return;
}

context.ReportDiagnostic(Diagnostic.Create(
StaticMutableRule,
location,
type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
candidate.Name));

if (registrations.Contains(type))
{
context.ReportDiagnostic(Diagnostic.Create(
StaticMutableAndDiRule,
location,
type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
}
}

private static bool HasSingleInstanceInChain(InvocationExpressionSyntax invocation) =>
invocation.Parent is MemberAccessExpressionSyntax memberAccess &&
memberAccess.Parent is InvocationExpressionSyntax parent &&
(memberAccess.Name.Identifier.ValueText == "SingleInstance" || HasSingleInstanceInChain(parent));

private static bool IsSingletonName(string name) =>
name.IndexOf("instance", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("singleton", StringComparison.OrdinalIgnoreCase) >= 0;
}
40 changes: 40 additions & 0 deletions DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,46 @@ public static class DesignPatternsDiagnosticDescriptors
DiagnosticSeverity.Error,
GeneratorCategory);

public static DiagnosticDescriptor GenerateSingletonInitializeAsyncInvalid { get; } = Create(
DiagnosticIds.GenerateSingletonInitializeAsyncInvalid,
"GenerateSingleton asynchronous initializer is invalid",
"InitializeAsync method '{0}' on '{1}' must be static and have signature 'Task|ValueTask {0}({1} instance, CancellationToken cancellationToken)'.",
"An asynchronous singleton initializer must accept the generated instance and a cancellation token, and return Task or ValueTask.",
DiagnosticSeverity.Error,
GeneratorCategory);

public static DiagnosticDescriptor GenerateSingletonDiDoubleRegistration { get; } = Create(
DiagnosticIds.GenerateSingletonDiDoubleRegistration,
"Generated singleton is also registered with DI",
"Type '{0}' uses [GenerateSingleton] and is also registered as a DI Singleton. Use one lifecycle owner to avoid two independent singleton instances.",
"Generated singleton instances and DI singleton registrations have separate lifecycles.",
DiagnosticSeverity.Warning,
AnalyzerCategory);

public static DiagnosticDescriptor GenerateSingletonNonThreadSafeMutableState { get; } = Create(
DiagnosticIds.GenerateSingletonNonThreadSafeMutableState,
"Non-thread-safe singleton has mutable instance state",
"Type '{0}' sets ThreadSafe=false but contains mutable instance field '{1}'. Concurrent access can race; make the field readonly or enable thread safety.",
"Non-thread-safe singletons should not expose mutable instance state to concurrent callers.",
DiagnosticSeverity.Info,
AnalyzerCategory);

public static DiagnosticDescriptor StaticMutableSingleton { get; } = Create(
DiagnosticIds.StaticMutableSingleton,
"Static mutable singleton candidate",
"Type '{0}' exposes mutable static singleton state through '{1}'. Prefer an immutable Lazy-backed singleton or a DI-managed lifecycle.",
"Mutable static singleton state is vulnerable to accidental replacement and concurrent access.",
DiagnosticSeverity.Info,
AnalyzerCategory);

public static DiagnosticDescriptor StaticMutableSingletonDiDoubleRegistration { get; } = Create(
DiagnosticIds.StaticMutableSingletonDiDoubleRegistration,
"Static mutable singleton is also registered with DI",
"Type '{0}' exposes mutable static singleton state and is also registered as a DI Singleton. Use one lifecycle owner.",
"Combining mutable static singleton state with DI singleton registration creates two independent lifecycle owners.",
DiagnosticSeverity.Warning,
AnalyzerCategory);

// Strategy (DP003–DP004, DP007)

public static KeyedRegistrationDiagnostics RegisterStrategy { get; } = new(
Expand Down
5 changes: 5 additions & 0 deletions DesignPatterns.Diagnostics/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,9 @@ public static class DiagnosticIds
public const string CompositeChildTypeNotAllowed = "DP064";
public const string CompositeNodeCountExceeded = "DP065";
public const string FactoryDelegateCaptiveDependency = "DP066";
public const string GenerateSingletonInitializeAsyncInvalid = "DP067";
public const string GenerateSingletonDiDoubleRegistration = "DP068";
public const string GenerateSingletonNonThreadSafeMutableState = "DP069";
public const string StaticMutableSingleton = "DP070";
public const string StaticMutableSingletonDiDoubleRegistration = "DP071";
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,26 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>false</IsPackable>
<IsPackable>true</IsPackable>
<PackageId>Skymly.DesignPatterns.Extensions.Autofac</PackageId>
<Title>Skymly DesignPatterns Autofac Extensions</Title>
<Description>Autofac integration for Skymly.DesignPatterns.</Description>
<PackageTags>design-patterns;dependency-injection;autofac</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<None Include="build\DesignPatterns.Extensions.Autofac.targets" Pack="true" PackagePath="build\DesignPatterns.Extensions.Autofac.targets" />
<None Include="..\README.md" Pack="true" PackagePath="\" />
<None Include="build\DesignPatterns.Extensions.Autofac.targets" Pack="true" PackagePath="build\Skymly.DesignPatterns.Extensions.Autofac.targets" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Autofac" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\DesignPatterns\DesignPatterns.csproj" />
<ProjectReference Include="..\DesignPatterns\DesignPatterns.csproj" PrivateAssets="all" Pack="false" />
<PackageReference Include="Skymly.DesignPatterns" Condition="'$(DesignPatternsPackageDependency)' == 'true'" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>false</IsPackable>
<IsPackable>true</IsPackable>
<PackageId>Skymly.DesignPatterns.Extensions.DependencyInjection</PackageId>
<Title>Skymly DesignPatterns Microsoft DI Extensions</Title>
<Description>Microsoft.Extensions.DependencyInjection integration for Skymly.DesignPatterns.</Description>
<PackageTags>design-patterns;dependency-injection;msdi</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<None Include="build\DesignPatterns.Extensions.DependencyInjection.targets" Pack="true" PackagePath="build\DesignPatterns.Extensions.DependencyInjection.targets" />
<None Include="..\README.md" Pack="true" PackagePath="\" />
<None Include="build\DesignPatterns.Extensions.DependencyInjection.targets" Pack="true" PackagePath="build\Skymly.DesignPatterns.Extensions.DependencyInjection.targets" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
Expand All @@ -25,7 +31,8 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\DesignPatterns\DesignPatterns.csproj" />
<ProjectReference Include="..\DesignPatterns\DesignPatterns.csproj" PrivateAssets="all" Pack="false" />
<PackageReference Include="Skymly.DesignPatterns" Condition="'$(DesignPatternsPackageDependency)' == 'true'" />
</ItemGroup>

</Project>
5 changes: 5 additions & 0 deletions DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@ DP063 | DesignPatterns.Generators | Warning | Composite tree max depth excee
DP064 | DesignPatterns.Generators | Error | Composite child type not allowed by parent AllowedChildTypes
DP065 | DesignPatterns.Generators | Warning | Composite node count exceeds MaxNodes limit
DP066 | DesignPatterns.Analyzers | Warning | Singleton factory delegate captures shorter-lived service
DP067 | DesignPatterns.Generators | Error | GenerateSingleton asynchronous initializer has an invalid signature
DP068 | DesignPatterns.Analyzers | Warning | Generated singleton is also registered with DI
DP069 | DesignPatterns.Analyzers | Info | Non-thread-safe singleton has mutable instance state
DP070 | DesignPatterns.Analyzers | Info | Static mutable singleton candidate
DP071 | DesignPatterns.Analyzers | Warning | Static mutable singleton is also registered with DI
Loading
Loading