From bce97564acad83363ff527234c4cffc88d7c7409 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=90=BD=E7=AC=94wys?=
<46271592+Skymly@users.noreply.github.com>
Date: Sat, 18 Jul 2026 15:16:58 +0800
Subject: [PATCH] Add Singleton lifecycle diagnostics
---
.github/workflows/ci.yml | 31 ++-
.nuke/build.schema.json | 4 +-
.../AnalyzerReleases.Unshipped.md | 4 +
.../SingletonLifecycleAnalyzer.cs | 180 ++++++++++++++++++
.../DesignPatternsDiagnosticDescriptors.cs | 40 ++++
DesignPatterns.Diagnostics/DiagnosticIds.cs | 5 +
.../DesignPatterns.Extensions.Autofac.csproj | 13 +-
...erns.Extensions.DependencyInjection.csproj | 13 +-
.../AnalyzerReleases.Unshipped.md | 5 +
.../Generators/GenerateSingletonGenerator.cs | 53 +++++-
.../Syntax/SingletonSyntaxFactory.cs | 49 ++++-
.../Creational/GenerateSingletonAttribute.cs | 10 +
Directory.Packages.props | 1 +
README.md | 8 +-
build/Program.cs | 95 +++++++--
.../SingletonLifecycleAnalyzerTests.cs | 91 +++++++++
.../GenerateSingletonGeneratorTests.cs | 26 +++
.../GenerateSingletonIntegrationTests.cs | 46 +++++
18 files changed, 636 insertions(+), 38 deletions(-)
create mode 100644 DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs
create mode 100644 tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 866f5c8..5458a70 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json
index 5c9aa20..410b20f 100644
--- a/.nuke/build.schema.json
+++ b/.nuke/build.schema.json
@@ -33,6 +33,7 @@
"type": "string",
"enum": [
"Ci",
+ "CiNet48",
"CiPack",
"Clean",
"Compile",
@@ -44,7 +45,8 @@
"Publish",
"Restore",
"Test",
- "UnitTest"
+ "UnitTest",
+ "UnitTestNet48"
]
},
"Verbosity": {
diff --git a/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md b/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md
index 9128640..fa74702 100644
--- a/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md
@@ -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
diff --git a/DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs b/DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs
new file mode 100644
index 0000000..d485530
--- /dev/null
+++ b/DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs
@@ -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;
+
+///
+/// Detects conflicting generated or mutable static singleton lifecycles.
+///
+[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 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(SymbolEqualityComparer.Default);
+ context.RegisterSyntaxNodeAction(
+ syntaxContext => CollectSingletonRegistration(syntaxContext, singletonRegistrations),
+ SyntaxKind.InvocationExpression);
+ context.RegisterCompilationEndAction(endContext => Analyze(endContext, singletonRegistrations));
+ }
+
+ private static void CollectSingletonRegistration(
+ SyntaxNodeAnalysisContext context,
+ HashSet 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 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 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()
+ .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 registrations)
+ {
+ ISymbol? candidate = type.GetMembers()
+ .OfType()
+ .FirstOrDefault(field => field.IsStatic && !field.IsReadOnly && IsSingletonName(field.Name));
+
+ candidate ??= type.GetMembers()
+ .OfType()
+ .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;
+}
diff --git a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs
index 68a12a9..50151ae 100644
--- a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs
+++ b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs
@@ -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(
diff --git a/DesignPatterns.Diagnostics/DiagnosticIds.cs b/DesignPatterns.Diagnostics/DiagnosticIds.cs
index d5259f8..ad11f15 100644
--- a/DesignPatterns.Diagnostics/DiagnosticIds.cs
+++ b/DesignPatterns.Diagnostics/DiagnosticIds.cs
@@ -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";
}
diff --git a/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj b/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj
index 16aa6c0..be45133 100644
--- a/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj
+++ b/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj
@@ -7,11 +7,17 @@
latest
enable
true
- false
+ true
+ Skymly.DesignPatterns.Extensions.Autofac
+ Skymly DesignPatterns Autofac Extensions
+ Autofac integration for Skymly.DesignPatterns.
+ design-patterns;dependency-injection;autofac
+ README.md
-
+
+
@@ -19,7 +25,8 @@
-
+
+
diff --git a/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj b/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj
index 7b0f856..4150bf5 100644
--- a/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj
+++ b/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj
@@ -7,11 +7,17 @@
latest
enable
true
- false
+ true
+ Skymly.DesignPatterns.Extensions.DependencyInjection
+ Skymly DesignPatterns Microsoft DI Extensions
+ Microsoft.Extensions.DependencyInjection integration for Skymly.DesignPatterns.
+ design-patterns;dependency-injection;msdi
+ README.md
-
+
+
@@ -25,7 +31,8 @@
-
+
+
diff --git a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md
index 552e61c..433a71c 100644
--- a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -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
diff --git a/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs b/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs
index a70ca88..afa51c5 100644
--- a/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs
+++ b/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs
@@ -25,6 +25,9 @@ public sealed class GenerateSingletonGenerator : IIncrementalGenerator
private static readonly DiagnosticDescriptor InvalidTargetDescriptor =
DesignPatternsDiagnosticDescriptors.GenerateSingletonInvalidTarget;
+ private static readonly DiagnosticDescriptor InvalidInitializeAsyncDescriptor =
+ DesignPatternsDiagnosticDescriptors.GenerateSingletonInitializeAsyncInvalid;
+
///
public void Initialize(IncrementalGeneratorInitializationContext context)
{
@@ -52,6 +55,7 @@ private static Result GetTargetInfo(GeneratorAttributeSynta
}
var threadSafe = true;
+ string? initializeAsync = null;
foreach (var attribute in context.Attributes)
{
foreach (var named in attribute.NamedArguments)
@@ -60,6 +64,10 @@ private static Result GetTargetInfo(GeneratorAttributeSynta
{
threadSafe = value;
}
+ else if (named.Key == "InitializeAsync" && named.Value.Value is string methodName)
+ {
+ initializeAsync = methodName;
+ }
}
}
@@ -81,6 +89,14 @@ private static Result GetTargetInfo(GeneratorAttributeSynta
new DiagnosticInfo(NotPartialDescriptor, location, className));
}
+ if (initializeAsync is not null &&
+ (string.IsNullOrWhiteSpace(initializeAsync) ||
+ !TryValidateInitializeAsync(symbol, initializeAsync)))
+ {
+ return Result.Failure(
+ new DiagnosticInfo(InvalidInitializeAsyncDescriptor, location, initializeAsync, className));
+ }
+
return Result.Success(new SingletonTargetInfo(
location,
className,
@@ -90,7 +106,8 @@ private static Result GetTargetInfo(GeneratorAttributeSynta
threadSafe,
isStatic,
typeKind,
- isPartial));
+ isPartial,
+ initializeAsync));
}
private static void Execute(SourceProductionContext context, Result result)
@@ -103,7 +120,8 @@ private static void Execute(SourceProductionContext context, Result())
+ {
+ if (!method.IsStatic || method.Parameters.Length != 2 ||
+ !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, type) ||
+ method.Parameters[1].Type.ToDisplayString() != "System.Threading.CancellationToken")
+ {
+ continue;
+ }
+
+ var returnType = method.ReturnType.ToDisplayString();
+ if (returnType == "System.Threading.Tasks.Task")
+ {
+ return true;
+ }
+
+ if (returnType == "System.Threading.Tasks.ValueTask")
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
diff --git a/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs b/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs
index 1e10ded..e69884f 100644
--- a/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs
+++ b/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs
@@ -15,12 +15,24 @@ internal static class SingletonSyntaxFactory
public static CompilationUnitSyntax CreateCompilationUnit(
string? namespaceName,
string className,
- bool threadSafe)
+ bool threadSafe,
+ string? initializeAsync)
{
- var member = CreateSingletonMembers(className, threadSafe);
var partialClass = SyntaxFactory.ClassDeclaration(className)
- .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PartialKeyword)))
- .AddMembers(member.field, member.instanceProperty);
+ .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PartialKeyword)));
+
+ if (initializeAsync is null)
+ {
+ var member = CreateSingletonMembers(className, threadSafe);
+ partialClass = partialClass.AddMembers(member.field, member.instanceProperty);
+ }
+ else
+ {
+ partialClass = partialClass.AddMembers(
+ CreateAsyncSingletonField(className, threadSafe, initializeAsync),
+ CreateGetInstanceAsyncMethod(className));
+ }
+
partialClass = GeneratedCodeHelper.AddGeneratedCodeAttribute(partialClass);
partialClass = GeneratedCodeHelper.WithXmlDoc(partialClass, $"Provides the singleton instance for {className}.");
@@ -31,15 +43,38 @@ public static CompilationUnitSyntax CreateCompilationUnit(
.AddMembers(partialClass);
}
- return SyntaxFactory.CompilationUnit()
+ var compilationUnit = SyntaxFactory.CompilationUnit()
.WithLeadingTrivia(GeneratedCodeHelper.CreateAutoGeneratedHeader("GenerateSingletonGenerator"))
.AddMembers(namespaceMember)
.AddUsings(
SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System")),
- SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Threading")))
- .NormalizeWhitespace();
+ SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Threading")));
+
+ if (initializeAsync is not null)
+ {
+ compilationUnit = compilationUnit.AddUsings(
+ SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Threading.Tasks")));
+ }
+
+ return compilationUnit.NormalizeWhitespace();
+ }
+
+ private static FieldDeclarationSyntax CreateAsyncSingletonField(
+ string className,
+ bool threadSafe,
+ string initializeAsync)
+ {
+ var mode = threadSafe ? "LazyThreadSafetyMode.ExecutionAndPublication" : "LazyThreadSafetyMode.None";
+ return (FieldDeclarationSyntax)SyntaxFactory.ParseMemberDeclaration(
+ $"private static readonly Lazy> _instance = new Lazy>(async () => {{ var instance = new {className}(); await {initializeAsync}(instance, CancellationToken.None); return instance; }}, {mode});")!;
}
+ private static MethodDeclarationSyntax CreateGetInstanceAsyncMethod(string className) =>
+ GeneratedCodeHelper.WithXmlDoc(
+ (MethodDeclarationSyntax)SyntaxFactory.ParseMemberDeclaration(
+ $"public static ValueTask<{className}> GetInstanceAsync() => new ValueTask<{className}>(_instance.Value);")!,
+ "Gets the singleton instance after asynchronous initialization.");
+
private static (FieldDeclarationSyntax field, PropertyDeclarationSyntax instanceProperty) CreateSingletonMembers(
string className,
bool threadSafe)
diff --git a/DesignPatterns/Creational/GenerateSingletonAttribute.cs b/DesignPatterns/Creational/GenerateSingletonAttribute.cs
index 6251cbe..65cc53e 100644
--- a/DesignPatterns/Creational/GenerateSingletonAttribute.cs
+++ b/DesignPatterns/Creational/GenerateSingletonAttribute.cs
@@ -14,4 +14,14 @@ public sealed class GenerateSingletonAttribute : Attribute
/// When , uses .
///
public bool ThreadSafe { get; set; } = true;
+
+ ///
+ /// Gets or sets the name of a static asynchronous initialization method.
+ /// When specified, the generator emits GetInstanceAsync() instead
+ /// of Instance. The method must accept the generated instance and a
+ /// , and return
+ /// or
+ /// .
+ ///
+ public string? InitializeAsync { get; set; }
}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a84f0c0..165d476 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/README.md b/README.md
index 40db675..39d539c 100644
--- a/README.md
+++ b/README.md
@@ -47,12 +47,12 @@
| 手动注册 | `AddStrategyRegistry` / `AddFactoryRegistry` / `AddHandlerPipeline` / `AddTransitionTable` / `AddStateMachine` | `RegisterTransitionTable` / `RegisterStateMachine` |
| 生成器输出 | `{Contract}Registry.RegisterDi(services)` | `{Contract}Registry.RegisterAutofac(builder)` |
-引用对应扩展项目/包时,源生成器自动输出 `RegisterDi`(MSDI)或 `RegisterAutofac`(Autofac)方法,从容器解析实例。
+引用元包与对应扩展包时,源生成器自动输出 `RegisterDi`(MSDI)或 `RegisterAutofac`(Autofac)方法,从容器解析实例。
-- **MSDI**:引用 `DesignPatterns.Extensions.DependencyInjection`(自动导入 `build/*.targets`)
-- **Autofac**:引用 `DesignPatterns.Extensions.Autofac`(自动导入 `build/*.targets`)
+- **MSDI**:引用 `Skymly.DesignPatterns` 与 `Skymly.DesignPatterns.Extensions.DependencyInjection`(自动导入 `build/*.targets`)
+- **Autofac**:引用 `Skymly.DesignPatterns` 与 `Skymly.DesignPatterns.Extensions.Autofac`(自动导入 `build/*.targets`)
-当前元包 **`Skymly.DesignPatterns`** 不包含 DI 扩展;独立 NuGet 发布策略待 API 与发版流程稳定后再确定。
+元包 **`Skymly.DesignPatterns`** 不包含 DI 扩展;两个扩展保持独立、与元包锁步发布。
## 仓库结构
diff --git a/build/Program.cs b/build/Program.cs
index 5962f35..777feb7 100644
--- a/build/Program.cs
+++ b/build/Program.cs
@@ -40,6 +40,8 @@ sealed class Build : NukeBuild
readonly NuGetConsumerFeed ConsumerFeed = NuGetConsumerFeed.Local;
AbsolutePath PackProject => Root / "DesignPatterns.Package" / "DesignPatterns.Package.csproj";
+ AbsolutePath DependencyInjectionPackProject => Root / "DesignPatterns.Extensions.DependencyInjection" / "DesignPatterns.Extensions.DependencyInjection.csproj";
+ AbsolutePath AutofacPackProject => Root / "DesignPatterns.Extensions.Autofac" / "DesignPatterns.Extensions.Autofac.csproj";
static readonly string[] TestProjectRelativePaths =
[
@@ -52,7 +54,18 @@ sealed class Build : NukeBuild
"tests/DesignPatterns.Extensions.Configuration.Tests/DesignPatterns.Extensions.Configuration.Tests.csproj",
];
+ static readonly string[] Net48TestProjectRelativePaths =
+ [
+ "tests/DesignPatterns.Extensions.AppSettings.Tests.Net48/DesignPatterns.Extensions.AppSettings.Tests.Net48.csproj",
+ "tests/DesignPatterns.Extensions.Configuration.Tests.Net48/DesignPatterns.Extensions.Configuration.Tests.Net48.csproj",
+ ];
+
const string ExpectedPackageId = "Skymly.DesignPatterns";
+ static readonly string[] ExtensionPackageIds =
+ [
+ "Skymly.DesignPatterns.Extensions.DependencyInjection",
+ "Skymly.DesignPatterns.Extensions.Autofac",
+ ];
public static int Main() => Execute(x => x.Ci);
@@ -106,27 +119,49 @@ sealed class Build : NukeBuild
}
});
+ Target UnitTestNet48 => _ => _
+ .DependsOn(Compile)
+ .Executes(() =>
+ {
+ foreach (string relativePath in Net48TestProjectRelativePaths)
+ {
+ AbsolutePath projectFile = Root / relativePath;
+ Assert.FileExists(projectFile, $"Test project not found: {projectFile}");
+ DotNetTest(s => s
+ .SetProjectFile(projectFile)
+ .SetConfiguration(Configuration)
+ .SetNoBuild(true)
+ .SetResultsDirectory(TestResultsDirectory)
+ .SetLoggers("trx;LogFileName=" + projectFile.NameWithoutExtension + ".trx"));
+ }
+ });
+
Target Pack => _ => _
.DependsOn(UnitTest)
.Executes(() =>
{
PackageOutputDirectory.CreateOrCleanDirectory();
- DotNetPack(s =>
+ foreach (AbsolutePath project in new[] { PackProject, DependencyInjectionPackProject, AutofacPackProject })
{
- s = s
- .SetProject(PackProject)
- .SetConfiguration(Configuration)
- .SetProperty("PackageOutputPath", PackageOutputDirectory)
- .SetProperty("ContinuousIntegrationBuild", "true");
-
- if (!string.IsNullOrWhiteSpace(Version))
+ DotNetPack(s =>
{
- s = s.SetVersion(Version);
- }
-
- return s;
- });
+ s = s
+ .SetProject(project)
+ .SetConfiguration(Configuration)
+ .SetProperty("PackageOutputPath", PackageOutputDirectory)
+ .SetProperty("ContinuousIntegrationBuild", "true")
+ .SetProperty("DesignPatternsPackageDependency", "true")
+ .EnableNoBuild();
+
+ if (!string.IsNullOrWhiteSpace(Version))
+ {
+ s = s.SetVersion(Version);
+ }
+
+ return s;
+ });
+ }
});
Target PackVerify => _ => _
@@ -160,6 +195,11 @@ sealed class Build : NukeBuild
Assert.True(entries.Contains("README.md"), $"{ExpectedPackageId}: missing package README.md");
Assert.True(entries.Contains("LICENSE") || entries.Contains("LICENSE.md"), $"{ExpectedPackageId}: missing LICENSE");
+
+ foreach (string packageId in ExtensionPackageIds)
+ {
+ VerifyExtensionPackage(packageId);
+ }
});
Target NuGetConsumerSmoke => _ => _
@@ -225,6 +265,9 @@ void RunNuGetConsumerSmoke(
Target Ci => _ => _
.DependsOn(UnitTest);
+ Target CiNet48 => _ => _
+ .DependsOn(UnitTestNet48);
+
Target Test => _ => _
.DependsOn(UnitTest);
@@ -280,11 +323,31 @@ AbsolutePath GetExpectedPackage()
throw new InvalidOperationException($"No packages found in {PackageOutputDirectory}");
}
- return nupkgs.SingleOrDefault(p => p.Name.StartsWith(ExpectedPackageId + ".", StringComparison.Ordinal))
- ?? throw new InvalidOperationException(
- $"Expected a single '{ExpectedPackageId}.*.nupkg' package, found: {string.Join(", ", nupkgs.Select(p => p.Name))}");
+ return GetPackage(ExpectedPackageId);
}
+ void VerifyExtensionPackage(string packageId)
+ {
+ AbsolutePath nupkg = GetPackage(packageId);
+ using ZipArchive archive = ZipFile.OpenRead(nupkg);
+ HashSet entries = archive.Entries
+ .Select(e => e.FullName.Replace('\\', '/'))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ string assemblyName = packageId.Replace("Skymly.", string.Empty);
+ Assert.True(entries.Contains($"lib/netstandard2.0/{assemblyName}.dll"), $"{packageId}: missing netstandard2.0 assembly");
+ Assert.True(entries.Contains($"lib/net8.0/{assemblyName}.dll"), $"{packageId}: missing net8.0 assembly");
+ Assert.True(entries.Contains($"build/{packageId}.targets"), $"{packageId}: missing automatic integration targets");
+ Assert.True(entries.Contains("README.md"), $"{packageId}: missing package README.md");
+ }
+
+ AbsolutePath GetPackage(string packageId) =>
+ PackageOutputDirectory.GlobFiles("*.nupkg")
+ .SingleOrDefault(p => p.Name.StartsWith(packageId + ".", StringComparison.Ordinal)
+ && p.Name.Length > packageId.Length + 1
+ && char.IsDigit(p.Name[packageId.Length + 1]))
+ ?? throw new InvalidOperationException($"Expected a '{packageId}.*.nupkg' package.");
+
static string GetPackageVersion(AbsolutePath nupkg)
{
string fileName = nupkg.Name;
diff --git a/tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs b/tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs
new file mode 100644
index 0000000..1535f1b
--- /dev/null
+++ b/tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs
@@ -0,0 +1,91 @@
+using System.Linq;
+using DesignPatterns.Analyzers;
+
+namespace DesignPatterns.Analyzers.Tests;
+
+public sealed class SingletonLifecycleAnalyzerTests
+{
+ [Fact]
+ public async Task ReportsDp068AndDp069_ForGeneratedNonThreadSafeDiSingleton()
+ {
+ const string source = """
+ using DesignPatterns.Creational;
+ using Microsoft.Extensions.DependencyInjection;
+
+ namespace TestAssembly;
+
+ [GenerateSingleton(ThreadSafe = false)]
+ public partial class Settings
+ {
+ private int _version;
+ }
+
+ public static class Startup
+ {
+ public static void Configure(IServiceCollection services)
+ {
+ services.AddSingleton();
+ }
+ }
+ """;
+
+ var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync(
+ source,
+ new SingletonLifecycleAnalyzer());
+
+ Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP068");
+ Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP069");
+ }
+
+ [Fact]
+ public async Task ReportsDp070AndDp071_ForMutableStaticDiSingleton()
+ {
+ const string source = """
+ using Microsoft.Extensions.DependencyInjection;
+
+ namespace TestAssembly;
+
+ public class Settings
+ {
+ public static Settings Instance = new Settings();
+ }
+
+ public static class Startup
+ {
+ public static void Configure(IServiceCollection services)
+ {
+ services.AddSingleton();
+ }
+ }
+ """;
+
+ var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync(
+ source,
+ new SingletonLifecycleAnalyzer());
+
+ Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP070");
+ Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP071");
+ }
+
+ [Fact]
+ public async Task DoesNotReportDp069_ForConstInstanceData()
+ {
+ const string source = """
+ using DesignPatterns.Creational;
+
+ namespace TestAssembly;
+
+ [GenerateSingleton(ThreadSafe = false)]
+ public partial class Settings
+ {
+ private const int Version = 1;
+ }
+ """;
+
+ var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync(
+ source,
+ new SingletonLifecycleAnalyzer());
+
+ Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP069");
+ }
+}
diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs
index 225ac67..dbaa335 100644
--- a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs
+++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs
@@ -84,4 +84,30 @@ public static class InvalidStatic
return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult));
}
+
+ [Fact]
+ public void ReportsDp067WhenAsyncInitializerSignatureIsInvalid()
+ {
+ const string source = """
+ using DesignPatterns.Creational;
+
+ namespace TestAssembly;
+
+ [GenerateSingleton(InitializeAsync = nameof(InitializeAsync))]
+ public partial class InvalidAsyncInitializer
+ {
+ public void InitializeAsync(
+ InvalidAsyncInitializer instance,
+ System.Threading.CancellationToken cancellationToken)
+ {
+ }
+ }
+ """;
+
+ var runResult = SourceGeneratorTestContext.Run(
+ ("InvalidAsyncInitializer.cs", source));
+
+ var diagnostics = SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult);
+ Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP067");
+ }
}
diff --git a/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs b/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs
index e0f0378..db8bccd 100644
--- a/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs
+++ b/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs
@@ -14,6 +14,34 @@ public partial class IntegrationFastCache
public int Value { get; set; }
}
+[GenerateSingleton(InitializeAsync = nameof(InitializeAsync))]
+public partial class IntegrationAsyncSettings
+{
+ public bool Initialized { get; private set; }
+
+ public static System.Threading.Tasks.Task InitializeAsync(
+ IntegrationAsyncSettings settings,
+ System.Threading.CancellationToken cancellationToken)
+ {
+ settings.Initialized = true;
+ return System.Threading.Tasks.Task.CompletedTask;
+ }
+}
+
+[GenerateSingleton(InitializeAsync = nameof(InitializeAsync))]
+public partial class IntegrationValueTaskSettings
+{
+ public bool Initialized { get; private set; }
+
+ public static System.Threading.Tasks.ValueTask InitializeAsync(
+ IntegrationValueTaskSettings settings,
+ System.Threading.CancellationToken cancellationToken)
+ {
+ settings.Initialized = true;
+ return System.Threading.Tasks.ValueTask.CompletedTask;
+ }
+}
+
public sealed class GenerateSingletonIntegrationTests
{
[Fact]
@@ -34,4 +62,22 @@ public void GeneratedSingleton_NonThreadSafeInstance_ReturnsSameReference()
Assert.Same(first, second);
}
+
+ [Fact]
+ public async System.Threading.Tasks.Task GeneratedSingleton_AsyncInitializer_ReturnsInitializedSameReference()
+ {
+ var first = await IntegrationAsyncSettings.GetInstanceAsync();
+ var second = await IntegrationAsyncSettings.GetInstanceAsync();
+
+ Assert.Same(first, second);
+ Assert.True(first.Initialized);
+ }
+
+ [Fact]
+ public async System.Threading.Tasks.Task GeneratedSingleton_ValueTaskInitializer_ReturnsInitializedInstance()
+ {
+ var instance = await IntegrationValueTaskSettings.GetInstanceAsync();
+
+ Assert.True(instance.Initialized);
+ }
}