From 961976de2b10ac6a3f6fae110dbe06c0cdc9b0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 29 Oct 2026 03:24:06 +0100 Subject: [PATCH 1/3] refactor(analyser): split null guard from out-var check in code fix providers In RegisterCodeFixesAsync of the Count, Length, NullOrEmpty and OutOfRange code fix providers the null check of the if statement was chained with the TryGet... out-var check in a single || condition. Stryker mutants of that guard break definite assignment of the out variable (CS0165), which makes Stryker enter Safe Mode and drop all mutants of the whole method as compile errors. Splitting the guard into two statements keeps the behaviour identical and lets the mutants be compiled and tested. --- .../ThrowIfCountCodeFixProvider.cs | 9 ++++++--- .../ThrowIfLengthCodeFixProvider.cs | 8 ++++++-- .../ThrowIfNullOrEmptyCodeFixProvider.cs | 8 ++++++-- .../ThrowIfOutOfRangeCodeFixProvider.cs | 9 ++++++--- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs index 1df1ffa9..3b27c2f5 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs @@ -37,10 +37,13 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var diagnostic = context.Diagnostics[0]; var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + if (ifStatement is null) + { + return; + } + if ( - ifStatement is null - || !ThrowIfCountAnalyzer.TryGetCountComparison(ifStatement.Condition, out var comparison) - || comparison is null + !ThrowIfCountAnalyzer.TryGetCountComparison(ifStatement.Condition, out var comparison) || comparison is null ) { return; diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs index bdb0a4e0..8e393d72 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs @@ -37,9 +37,13 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var diagnostic = context.Diagnostics[0]; var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + if (ifStatement is null) + { + return; + } + if ( - ifStatement is null - || !ThrowIfLengthAnalyzer.TryGetLengthComparison(ifStatement.Condition, out var comparison) + !ThrowIfLengthAnalyzer.TryGetLengthComparison(ifStatement.Condition, out var comparison) || comparison is null ) { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs index 4518e947..81398b70 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs @@ -36,9 +36,13 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var diagnostic = context.Diagnostics[0]; var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + if (ifStatement is null) + { + return; + } + if ( - ifStatement is null - || !ThrowIfNullOrEmptyAnalyzer.TryGetStringCheck(ifStatement.Condition, out _, out var helperName) + !ThrowIfNullOrEmptyAnalyzer.TryGetStringCheck(ifStatement.Condition, out _, out var helperName) || helperName is null ) { diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs index 140c7321..76e5311d 100644 --- a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs @@ -37,10 +37,13 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var diagnostic = context.Diagnostics[0]; var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + if (ifStatement is null) + { + return; + } + if ( - ifStatement is null - || !ThrowIfOutOfRangeAnalyzer.TryGetComparison(ifStatement.Condition, out var comparison) - || comparison is null + !ThrowIfOutOfRangeAnalyzer.TryGetComparison(ifStatement.Condition, out var comparison) || comparison is null ) { return; From 80479653e10c997589b7d2065da6c395d748ec57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Thu, 29 Oct 2026 17:13:34 +0100 Subject: [PATCH 2/3] test(analyser): cover defensive exits in code fix providers --- .../AnalyzerVerifier.cs | 38 +++++++++++++ .../CodeFixProviderDefensiveTests.cs | 56 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs index 4a39a436..239b7ce6 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs @@ -52,6 +52,44 @@ public static Task ApplyFixAllAsync( CancellationToken cancellationToken = default ) => ApplyFixAllCoreAsync(analyzer, codeFix, source, useLegacyReferences, cancellationToken: cancellationToken); + /// + /// Raises a synthetic diagnostic at the first occurrence of and returns how many + /// code actions the provider registers for it (used to cover the defensive "pattern no longer matches" exits). + /// + public static async Task CountRegisteredFixesAsync( + CodeFixProvider codeFix, + string source, + string spanText, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + using var workspace = new AdhocWorkspace(); + var project = workspace + .AddProject("TestProject", LanguageNames.CSharp) + .WithMetadataReferences(ModernReferences); + var document = workspace.AddDocument(project.Id, "Test.cs", SourceText.From(source)); + var tree = (await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false))!; + + var descriptor = new DiagnosticDescriptor( + codeFix.FixableDiagnosticIds[0], + "t", + "t", + "t", + DiagnosticSeverity.Warning, + true + ); + var start = source.IndexOf(spanText, StringComparison.Ordinal); + var diagnostic = Diagnostic.Create(descriptor, Location.Create(tree, new TextSpan(start, spanText.Length))); + + var count = 0; + var fixContext = new CodeFixContext(document, diagnostic, (_, _) => count++, cancellationToken); + await codeFix.RegisterCodeFixesAsync(fixContext).ConfigureAwait(false); + + return count; + } + private static async Task> GetDiagnosticsCoreAsync( DiagnosticAnalyzer analyzer, string source, diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs new file mode 100644 index 00000000..31588211 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs @@ -0,0 +1,56 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +using System.Threading; +using Microsoft.CodeAnalysis.CodeFixes; + +public sealed class CodeFixProviderDefensiveTests +{ + private const string Source = """ + class C + { + void M(bool argument) + { + if (argument) { } + } + } + """; + + [Test] + [Arguments(typeof(ThrowIfCountCodeFixProvider))] + [Arguments(typeof(ThrowIfLengthCodeFixProvider))] + [Arguments(typeof(ThrowIfNullOrEmptyCodeFixProvider))] + [Arguments(typeof(ThrowIfOutOfRangeCodeFixProvider))] + public async Task RegisterCodeFixes_WhenDiagnosticIsOutsideIfStatement_RegistersNothing( + Type providerType, + CancellationToken cancellationToken = default + ) + { + var provider = (CodeFixProvider)Activator.CreateInstance(providerType)!; + + var count = await AnalyzerVerifier.CountRegisteredFixesAsync(provider, Source, "void M", cancellationToken); + + _ = await Assert.That(count).IsEqualTo(0); + } + + [Test] + [Arguments(typeof(ThrowIfCountCodeFixProvider))] + [Arguments(typeof(ThrowIfLengthCodeFixProvider))] + [Arguments(typeof(ThrowIfNullOrEmptyCodeFixProvider))] + [Arguments(typeof(ThrowIfOutOfRangeCodeFixProvider))] + public async Task RegisterCodeFixes_WhenIfConditionDoesNotMatchPattern_RegistersNothing( + Type providerType, + CancellationToken cancellationToken = default + ) + { + var provider = (CodeFixProvider)Activator.CreateInstance(providerType)!; + + var count = await AnalyzerVerifier.CountRegisteredFixesAsync( + provider, + Source, + "if (argument)", + cancellationToken + ); + + _ = await Assert.That(count).IsEqualTo(0); + } +} From 44d8e7c400338dfe7622a3d25799b3a6bc5d816a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Tue, 3 Nov 2026 19:27:09 +0100 Subject: [PATCH 3/3] test(analyser): check cancellation at start of code fix defensive tests --- .../CodeFixProviderDefensiveTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs index 31588211..776c6303 100644 --- a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/CodeFixProviderDefensiveTests.cs @@ -25,6 +25,8 @@ public async Task RegisterCodeFixes_WhenDiagnosticIsOutsideIfStatement_Registers CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + var provider = (CodeFixProvider)Activator.CreateInstance(providerType)!; var count = await AnalyzerVerifier.CountRegisteredFixesAsync(provider, Source, "void M", cancellationToken); @@ -42,6 +44,8 @@ public async Task RegisterCodeFixes_WhenIfConditionDoesNotMatchPattern_Registers CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + var provider = (CodeFixProvider)Activator.CreateInstance(providerType)!; var count = await AnalyzerVerifier.CountRegisteredFixesAsync(