diff --git a/.editorconfig b/.editorconfig
index aadc998..4e52df2 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -279,12 +279,12 @@ dotnet_analyzer_diagnostic.category-Style.severity = warning
# XML Documentation
dotnet_diagnostic.CS0105.severity = error # CS0105: Using directive is unnecessary.
-dotnet_diagnostic.CS1573.severity = error # CS1573: Missing XML comment for parameter
-dotnet_diagnostic.CS1591.severity = error # CS1591: Missing XML comment for publicly visible type or member
-dotnet_diagnostic.CS1712.severity = error # CS1712: Type parameter has no matching typeparam tag in the XML comment (but other type parameters do)
+dotnet_diagnostic.CS1573.severity = none # CS1573: Missing XML comment for parameter
+dotnet_diagnostic.CS1591.severity = none # CS1591: Missing XML comment for publicly visible type or member
+dotnet_diagnostic.CS1712.severity = none # CS1712: Type parameter has no matching typeparam tag in the XML comment (but other type parameters do)
# Async
-dotnet_diagnostic.CS1998.severity = error # CS1998: Async method lacks 'await' operators and will run synchronously
+dotnet_diagnostic.CS1998.severity = none # CS1998: Async method lacks 'await' operators and will run synchronously
dotnet_diagnostic.CS4014.severity = error # CS4014: Because this call is not awaited, execution of the current method continues before the call is completed
dotnet_diagnostic.CA2007.severity = none # CA2007: Consider calling ConfigureAwait on the awaited task
@@ -298,3 +298,22 @@ dotnet_diagnostic.CA5394.severity = none # CA5394: Random is an i
dotnet_diagnostic.CA2000.severity = error # CA2000: Dispose objects before losing scope
dotnet_diagnostic.CA1515.severity = none # CA1515: Consider making public types internal
+
+# Meziantou.Analyzers
+MA0053.public_class_should_be_sealed = true
+MA0053.exceptions_should_be_sealed = true
+
+dotnet_diagnostic.MA0004.severity = none
+dotnet_diagnostic.MA0048.severity = none
+dotnet_diagnostic.MA0051.severity = none
+dotnet_diagnostic.MA0053.severity = warning
+
+[src/Immediate.Cache.Shared/**.cs]
+
+# XML Documentation
+dotnet_diagnostic.CS1573.severity = error # CS1573: Missing XML comment for parameter
+dotnet_diagnostic.CS1591.severity = error # CS1591: Missing XML comment for publicly visible type or member
+dotnet_diagnostic.CS1712.severity = error # CS1712: Type parameter has no matching typeparam tag in the XML comment (but other type parameters do)
+
+# Async
+dotnet_diagnostic.CA2007.severity = error # CA2007: Consider calling ConfigureAwait on the awaited task
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d8bc104..44f06f1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,9 +4,17 @@
+
+
+
+
+
+
+
+
diff --git a/Immediate.Cache.slnx b/Immediate.Cache.slnx
index 6492521..b5a1156 100644
--- a/Immediate.Cache.slnx
+++ b/Immediate.Cache.slnx
@@ -13,10 +13,12 @@
+
+
diff --git a/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj b/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj
new file mode 100644
index 0000000..d385291
--- /dev/null
+++ b/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj
@@ -0,0 +1,23 @@
+
+
+
+ netstandard2.0
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+ minor
+ preview.0
+ v
+
+
+
diff --git a/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs b/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs
new file mode 100644
index 0000000..1319bb8
--- /dev/null
+++ b/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs
@@ -0,0 +1,79 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Immediate.Cache.Analyzers;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class OwnedDisposableScopeSuppressor : DiagnosticSuppressor
+{
+ public override ImmutableArray SupportedSuppressions =>
+ ImmutableArray.Create([
+ new SuppressionDescriptor(
+ id: "OwnedDisposableScopeSuppression",
+ suppressedDiagnosticId: "CA2000",
+ justification: "Suppress disposable not being disposed when used from `Owned.GetScope(out T)`."
+ ),
+ ]);
+ public override void ReportSuppressions(SuppressionAnalysisContext context)
+ {
+ var token = context.CancellationToken;
+
+ foreach (var diagnostic in context.ReportedDiagnostics)
+ {
+ token.ThrowIfCancellationRequested();
+
+ var syntaxTree = diagnostic.Location.SourceTree;
+
+ if (syntaxTree
+ ?.GetRoot(token)
+ .FindNode(diagnostic.Location.SourceSpan) is not ArgumentSyntax
+ {
+ Parent.Parent: InvocationExpressionSyntax
+ {
+ Expression: MemberAccessExpressionSyntax
+ {
+ Name: IdentifierNameSyntax
+ {
+ Identifier.Text: "GetScope",
+ },
+
+ Expression: { } expression,
+ },
+ },
+ })
+ {
+ continue;
+ }
+
+ var typeInfo = context.GetSemanticModel(syntaxTree).GetTypeInfo(expression, token);
+ var symbol = typeInfo.Type ?? typeInfo.ConvertedType;
+
+ if (symbol is not INamedTypeSymbol
+ {
+ Arity: 1,
+ Name: "Owned",
+ ContainingNamespace:
+ {
+ Name: "Cache",
+ ContainingNamespace:
+ {
+ Name: "Immediate",
+ ContainingNamespace.IsGlobalNamespace: true,
+ },
+ },
+ })
+ {
+ continue;
+ }
+
+ context.ReportSuppression(
+ Suppression.Create(
+ SupportedSuppressions[0],
+ diagnostic
+ )
+ );
+ }
+ }
+}
diff --git a/src/Immediate.Cache/Immediate.Cache.csproj b/src/Immediate.Cache/Immediate.Cache.csproj
index a1e4184..9d77a48 100644
--- a/src/Immediate.Cache/Immediate.Cache.csproj
+++ b/src/Immediate.Cache/Immediate.Cache.csproj
@@ -10,7 +10,7 @@
A collection of classes that simplify caching responses from Immediate.Handlers handlers.
Immediate.Cache Developers
- © 2024 Immediate.Cache Developers
+ © 2024-2026 Immediate.Cache Developers
MIT
readme.md
@@ -29,11 +29,13 @@
+
+
+
+
+ net8.0
+ Exe
+ <_SkipUpgradeNetAnalyzersNuGetWarning>true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs b/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs
new file mode 100644
index 0000000..af844fd
--- /dev/null
+++ b/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs
@@ -0,0 +1,251 @@
+using Immediate.Cache.Analyzers;
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.NetCore.Analyzers.Runtime;
+
+namespace Immediate.Cache.Tests.SuppressorTests;
+
+public sealed class OwnedDisposableScopeSuppressorTests
+{
+ public static readonly DiagnosticResult CA2000 =
+ DiagnosticResult.CompilerWarning("CA2000");
+
+ [Fact]
+ public async Task OutArgumentFromGetScopeAsParameterIsSuppressed() =>
+ await SuppressorTestHelpers
+ .CreateSuppressorTest(
+ """
+ #nullable enable
+
+ using System;
+ using System.Threading.Tasks;
+ using Immediate.Cache;
+
+ public sealed class Disposable : IDisposable, IAsyncDisposable
+ {
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ public sealed class Dummy
+ {
+ public async ValueTask Method(Owned owned)
+ {
+ await using var scope = owned.GetScope({|#0:out var service|});
+ }
+ }
+ """
+ )
+ .WithSpecificDiagnostics([CA2000])
+ .WithExpectedDiagnosticsResults([
+ CA2000.WithLocation(0).WithIsSuppressed(true),
+ ])
+ .RunAsync(TestContext.Current.CancellationToken);
+
+ [Fact]
+ public async Task OutArgumentFromGetScopeAsPropertyIsSuppressed() =>
+ await SuppressorTestHelpers
+ .CreateSuppressorTest(
+ """
+ #nullable enable
+
+ using System;
+ using System.Threading.Tasks;
+ using Immediate.Cache;
+
+ public sealed class Disposable : IDisposable, IAsyncDisposable
+ {
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ public sealed class Dummy
+ {
+ private Owned Owned { get; } = default!;
+
+ public async ValueTask Method()
+ {
+ await using var scope = Owned.GetScope({|#0:out var service|});
+ }
+ }
+ """
+ )
+ .WithSpecificDiagnostics([CA2000])
+ .WithExpectedDiagnosticsResults([
+ CA2000.WithLocation(0).WithIsSuppressed(true),
+ ])
+ .RunAsync(TestContext.Current.CancellationToken);
+
+ [Fact]
+ public async Task OutArgumentFromGetScopeAsFieldIsSuppressed() =>
+ await SuppressorTestHelpers
+ .CreateSuppressorTest(
+ """
+ #nullable enable
+
+ using System;
+ using System.Threading.Tasks;
+ using Immediate.Cache;
+
+ public sealed class Disposable : IDisposable, IAsyncDisposable
+ {
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ public sealed class Dummy
+ {
+ private readonly Owned owned = default!;
+
+ public async ValueTask Method()
+ {
+ await using var scope = owned.GetScope({|#0:out var service|});
+ }
+ }
+ """
+ )
+ .WithSpecificDiagnostics([CA2000])
+ .WithExpectedDiagnosticsResults([
+ CA2000.WithLocation(0).WithIsSuppressed(true),
+ ])
+ .RunAsync(TestContext.Current.CancellationToken);
+
+ [Fact]
+ public async Task OutArgumentFromGetScopeAsVariableIsSuppressed() =>
+ await SuppressorTestHelpers
+ .CreateSuppressorTest(
+ """
+ #nullable enable
+
+ using System;
+ using System.Threading.Tasks;
+ using Immediate.Cache;
+
+ public sealed class Disposable : IDisposable, IAsyncDisposable
+ {
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ public sealed class Dummy
+ {
+ public async ValueTask Method()
+ {
+ Owned owned = default!;
+ await using var scope = owned.GetScope({|#0:out var service|});
+ }
+ }
+ """
+ )
+ .WithSpecificDiagnostics([CA2000])
+ .WithExpectedDiagnosticsResults([
+ CA2000.WithLocation(0).WithIsSuppressed(true),
+ ])
+ .RunAsync(TestContext.Current.CancellationToken);
+
+ [Fact]
+ public async Task OutArgumentFromGeneralMethodIsNotSuppressed() =>
+ await SuppressorTestHelpers
+ .CreateSuppressorTest(
+ """
+ #nullable enable
+
+ using System;
+ using System.Threading.Tasks;
+ using Immediate.Cache;
+
+ public sealed class Disposable : IDisposable, IAsyncDisposable
+ {
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ public sealed class Dummy
+ {
+ public async ValueTask Method()
+ {
+ await using var scope = GetValue({|#0:out var service|});
+ }
+
+ private IAsyncDisposable GetValue(out Disposable disposable)
+ {
+ disposable = new();
+ return new Disposable();
+ }
+ }
+ """
+ )
+ .WithSpecificDiagnostics([CA2000])
+ .WithExpectedDiagnosticsResults([
+ CA2000.WithLocation(0).WithIsSuppressed(false),
+ ])
+ .RunAsync(TestContext.Current.CancellationToken);
+
+ [Fact]
+ public async Task ReturnValueFromGeneralMethodIsNotSuppressed() =>
+ await SuppressorTestHelpers
+ .CreateSuppressorTest(
+ """
+ #nullable enable
+
+ using System;
+ using System.Threading.Tasks;
+ using Immediate.Cache;
+
+ public sealed class Disposable : IDisposable, IAsyncDisposable
+ {
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ public sealed class Dummy
+ {
+ public void Method()
+ {
+ var scope = {|#0:GetValue()|};
+ }
+
+ private IDisposable GetValue()
+ {
+ return new Disposable();
+ }
+ }
+ """
+ )
+ .WithSpecificDiagnostics([CA2000])
+ .WithExpectedDiagnosticsResults([
+ CA2000.WithLocation(0).WithIsSuppressed(false),
+ ])
+ .RunAsync(TestContext.Current.CancellationToken);
+
+ [Fact]
+ public async Task NewValueIsNotSuppressed() =>
+ await SuppressorTestHelpers
+ .CreateSuppressorTest(
+ """
+ #nullable enable
+
+ using System;
+ using System.Threading.Tasks;
+ using Immediate.Cache;
+
+ public sealed class Disposable : IDisposable, IAsyncDisposable
+ {
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => default;
+ }
+
+ public sealed class Dummy
+ {
+ public void Method()
+ {
+ var service = {|#0:new Disposable()|};
+ }
+ }
+ """
+ )
+ .WithSpecificDiagnostics([CA2000])
+ .WithExpectedDiagnosticsResults([
+ CA2000.WithLocation(0).WithIsSuppressed(false),
+ ])
+ .RunAsync(TestContext.Current.CancellationToken);
+}
diff --git a/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs b/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs
new file mode 100644
index 0000000..f987e7d
--- /dev/null
+++ b/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs
@@ -0,0 +1,138 @@
+using System.Collections.Immutable;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace Immediate.Cache.Tests.SuppressorTests;
+
+public static class SuppressorTestHelpers
+{
+ public sealed class CSharpSuppressorTest : CSharpAnalyzerTest
+ where TSuppressor : DiagnosticSuppressor, new()
+ where TVerifier : IVerifier, new()
+ {
+ private readonly List _analyzers = [];
+
+ protected override IEnumerable GetDiagnosticAnalyzers() =>
+ base.GetDiagnosticAnalyzers().Concat(_analyzers);
+
+ public CSharpSuppressorTest WithAnalyzer(bool enableDiagnostics = false)
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ {
+ var analyzer = new TAnalyzer();
+ _analyzers.Add(analyzer);
+
+ if (enableDiagnostics)
+ {
+ var diagnosticOptions = analyzer.SupportedDiagnostics
+ .ToImmutableDictionary(
+ descriptor => descriptor.Id,
+ descriptor => descriptor.DefaultSeverity.ToReportDiagnostic()
+ );
+
+ SolutionTransforms.Clear();
+ SolutionTransforms.Add(EnableDiagnostics(diagnosticOptions));
+ }
+
+ return this;
+ }
+
+ public CSharpSuppressorTest WithSpecificDiagnostics(
+ params DiagnosticResult[] diagnostics
+ )
+ {
+ var diagnosticOptions = diagnostics
+ .ToImmutableDictionary(
+ descriptor => descriptor.Id,
+ descriptor => descriptor.Severity.ToReportDiagnostic()
+ );
+
+ SolutionTransforms.Clear();
+ SolutionTransforms.Add(EnableDiagnostics(diagnosticOptions));
+ return this;
+ }
+
+ private static Func EnableDiagnostics(
+ ImmutableDictionary diagnostics
+ ) =>
+ (solution, id) =>
+ {
+ var options = solution.GetProject(id)?.CompilationOptions
+ ?? throw new InvalidOperationException("Compilation options missing.");
+
+ return solution
+ .WithProjectCompilationOptions(
+ id,
+ options
+ .WithSpecificDiagnosticOptions(diagnostics)
+ );
+ };
+
+ public CSharpSuppressorTest WithExpectedDiagnosticsResults(
+ params DiagnosticResult[] diagnostics
+ )
+ {
+ ExpectedDiagnostics.AddRange(diagnostics);
+ return this;
+ }
+ }
+
+ public static CSharpSuppressorTest CreateSuppressorTest(
+ [StringSyntax("c#-test")] string inputSource
+ )
+ where TSuppressor : DiagnosticSuppressor, new()
+ {
+ var test = new CSharpSuppressorTest
+ {
+ TestCode = inputSource,
+ ReferenceAssemblies = new ReferenceAssemblies(
+ "net8.0",
+ new PackageIdentity(
+ "Microsoft.NETCore.App.Ref",
+ "8.0.0"),
+ Path.Combine("ref", "net8.0")
+ ),
+ CompilerDiagnostics = CompilerDiagnostics.Warnings,
+ DisabledDiagnostics =
+ {
+ "CS1591",
+ "CS8767",
+ },
+ TestState =
+ {
+ AdditionalReferences =
+ {
+ MetadataReference.CreateFromFile("./Immediate.Cache.Shared.dll"),
+ },
+ },
+ };
+
+ return test;
+ }
+
+ public static CSharpSuppressorTest CreateSuppressorTest(
+ [StringSyntax("c#-test")] string inputSource
+ )
+ where TSuppressor : DiagnosticSuppressor, new()
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ {
+ return CreateSuppressorTest(inputSource)
+ .WithAnalyzer(enableDiagnostics: true);
+ }
+}
+
+file static class DiagnosticSeverityExtensions
+{
+ public static ReportDiagnostic ToReportDiagnostic(this DiagnosticSeverity severity)
+ => severity switch
+ {
+ DiagnosticSeverity.Hidden => ReportDiagnostic.Hidden,
+ DiagnosticSeverity.Info => ReportDiagnostic.Info,
+ DiagnosticSeverity.Warning => ReportDiagnostic.Warn,
+ DiagnosticSeverity.Error => ReportDiagnostic.Error,
+ _ => throw new InvalidEnumArgumentException(nameof(severity), (int)severity, typeof(DiagnosticSeverity)),
+ };
+}