From ef9493efba93885dcfbc0f70389833bc9d147d65 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sat, 2 May 2026 14:52:33 -0600 Subject: [PATCH 1/2] Skip char overloads in LuceneDev6001/6002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analyzer's literal-only guard only suppressed 6001/6002 when the first argument was a `'x'` char literal or `"x"` single-character string literal. Calls like `text.IndexOf(c)` where `c` is a char variable bound to `string.IndexOf(char)` — which has no StringComparison sibling — falsely tripped 6001 (#1211). Filter both the resolved-symbol and ambiguous-candidate paths to overloads whose first parameter is `System.String`, so char overloads are excluded from the rule entirely instead of relying on syntactic literal recognition. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...neDev6001_6002_StringComparisonAnalyzer.cs | 12 ++- ...neDev6001_6002_StringComparisonAnalyzer.cs | 74 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs index e395d65..cc5c6f1 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs @@ -118,6 +118,11 @@ static bool HasStringComparisonParameter(IMethodSymbol? m, INamedTypeSymbol scTy var (hasStringComparisonArg, isValidValue, invalidArgLocation, comparisonValueName) = CheckStringComparisonArgument(invocation, semantic, stringComparisonType); + // The rule only applies to overloads whose first parameter is a string — + // overloads like IndexOf(char) / StartsWith(char) have no StringComparison sibling. + static bool FirstParameterIsString(IMethodSymbol? m) + => m != null && m.Parameters.Length > 0 && m.Parameters[0].Type.SpecialType == SpecialType.System_String; + // If resolved symbol available if (methodSymbol != null) { @@ -125,6 +130,9 @@ static bool HasStringComparisonParameter(IMethodSymbol? m, INamedTypeSymbol scTy if (!ContainingTypeIsStringOrJ2N(methodSymbol.ContainingType)) return; + if (!FirstParameterIsString(methodSymbol)) + return; + // If the method has StringComparison parameter in signature bool methodHasComparisonParam = HasStringComparisonParameter(methodSymbol, stringComparisonType); @@ -160,9 +168,9 @@ static bool HasStringComparisonParameter(IMethodSymbol? m, INamedTypeSymbol scTy // Handle ambiguous candidates if (candidateSymbols.Length > 0) { - // Check if any candidate is from String or J2N types + // Check if any candidate is from String or J2N types and takes a string first parameter var relevantCandidates = candidateSymbols - .Where(c => ContainingTypeIsStringOrJ2N(c.ContainingType)) + .Where(c => ContainingTypeIsStringOrJ2N(c.ContainingType) && FirstParameterIsString(c)) .ToImmutableArray(); if (relevantCandidates.Length == 0) diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs index fcc4b1d..f9a9cc6 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs @@ -78,6 +78,80 @@ public void M() await test.RunAsync(); } + [Test] + public async Task Skips_IndexOf_CharLiteral() + { + var testCode = @" +using System; + +public class Sample +{ + public void M() + { + string text = ""Hello""; + int index = text.IndexOf('H'); + } +}"; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_6002_StringComparisonAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { } // IndexOf(char) has no StringComparison overload; no diagnostic + }; + + await test.RunAsync(); + } + + [Test] + public async Task Skips_IndexOf_CharVariable() + { + var testCode = @" +using System; + +public class Sample +{ + public void M() + { + string text = ""Hello""; + char c = 'H'; + int index = text.IndexOf(c); + } +}"; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_6002_StringComparisonAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { } // IndexOf(char) has no StringComparison overload; no diagnostic + }; + + await test.RunAsync(); + } + + [Test] + public async Task Skips_StartsWith_CharVariable() + { + var testCode = @" +using System; + +public class Sample +{ + public void M() + { + string text = ""Hello""; + char c = 'H'; + bool starts = text.StartsWith(c); + } +}"; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_6002_StringComparisonAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { } // StartsWith(char) has no StringComparison overload; no diagnostic + }; + + await test.RunAsync(); + } + [Test] public async Task Detects_IndexOf_MissingStringComparison() { From 95341210f5a9b5237cc4b9a398ff5987c3f7d2db Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sat, 2 May 2026 15:05:16 -0600 Subject: [PATCH 2/2] Address Copilot review: handle non-reduced extension calls The previous filter checked Parameters[0], which is correct for instance methods and reduced extension calls (sb.IndexOf("x")) but not for static-form extension calls (StringBuilderExtensions.IndexOf(sb, "x")) where Parameters[0] is the receiver. The two call shapes resolve to the same underlying API but my filter would silently skip the static form. Use the same value-parameter-index pattern as LuceneDev6005: index 1 when IsExtensionMethod && ReducedFrom == null, else 0. Adds tests for both reduced-form (already worked) and static-form (was the regression Copilot flagged) calls into a J2N-style StringBuilderExtensions stub. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...neDev6001_6002_StringComparisonAnalyzer.cs | 17 ++-- ...neDev6001_6002_StringComparisonAnalyzer.cs | 77 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs index cc5c6f1..eeeafed 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_6002_StringComparisonAnalyzer.cs @@ -118,10 +118,17 @@ static bool HasStringComparisonParameter(IMethodSymbol? m, INamedTypeSymbol scTy var (hasStringComparisonArg, isValidValue, invalidArgLocation, comparisonValueName) = CheckStringComparisonArgument(invocation, semantic, stringComparisonType); - // The rule only applies to overloads whose first parameter is a string — + // The rule only applies to overloads whose value parameter is a string — // overloads like IndexOf(char) / StartsWith(char) have no StringComparison sibling. - static bool FirstParameterIsString(IMethodSymbol? m) - => m != null && m.Parameters.Length > 0 && m.Parameters[0].Type.SpecialType == SpecialType.System_String; + // For non-reduced extension method calls (e.g. StringBuilderExtensions.IndexOf(sb, "x")), + // Parameters[0] is the receiver, so skip past it. + static bool ValueParameterIsString(IMethodSymbol? m) + { + if (m == null || m.Parameters.Length == 0) return false; + int valueIndex = m.IsExtensionMethod && m.ReducedFrom == null ? 1 : 0; + return m.Parameters.Length > valueIndex + && m.Parameters[valueIndex].Type.SpecialType == SpecialType.System_String; + } // If resolved symbol available if (methodSymbol != null) @@ -130,7 +137,7 @@ static bool FirstParameterIsString(IMethodSymbol? m) if (!ContainingTypeIsStringOrJ2N(methodSymbol.ContainingType)) return; - if (!FirstParameterIsString(methodSymbol)) + if (!ValueParameterIsString(methodSymbol)) return; // If the method has StringComparison parameter in signature @@ -170,7 +177,7 @@ static bool FirstParameterIsString(IMethodSymbol? m) { // Check if any candidate is from String or J2N types and takes a string first parameter var relevantCandidates = candidateSymbols - .Where(c => ContainingTypeIsStringOrJ2N(c.ContainingType) && FirstParameterIsString(c)) + .Where(c => ContainingTypeIsStringOrJ2N(c.ContainingType) && ValueParameterIsString(c)) .ToImmutableArray(); if (relevantCandidates.Length == 0) diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs index f9a9cc6..3d52076 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_6002_StringComparisonAnalyzer.cs @@ -581,5 +581,82 @@ public void M() await test.RunAsync(); } + + [Test] + public async Task Detects_J2NStringBuilderExtensions_ReducedForm_MissingStringComparison() + { + var testCode = @" +using System.Text; +using J2N.Text; + +namespace J2N.Text +{ + public static class StringBuilderExtensions + { + public static int IndexOf(this StringBuilder sb, string value) => 0; + } +} + +public class Sample +{ + public void M() + { + var sb = new StringBuilder(""hello""); + int index = sb.IndexOf(""he""); + } +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison.Id, DiagnosticSeverity.Error) + .WithSeverity(DiagnosticSeverity.Error) + .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat) + .WithArguments("IndexOf") + .WithLocation("/0/Test0.cs", line: 18, column: 24); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_6002_StringComparisonAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task Detects_J2NStringBuilderExtensions_StaticForm_MissingStringComparison() + { + var testCode = @" +using System.Text; + +namespace J2N.Text +{ + public static class StringBuilderExtensions + { + public static int IndexOf(this StringBuilder sb, string value) => 0; + } +} + +public class Sample +{ + public void M() + { + var sb = new StringBuilder(""hello""); + int index = J2N.Text.StringBuilderExtensions.IndexOf(sb, ""he""); + } +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison.Id, DiagnosticSeverity.Error) + .WithSeverity(DiagnosticSeverity.Error) + .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat) + .WithArguments("IndexOf") + .WithLocation("/0/Test0.cs", line: 17, column: 54); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_6002_StringComparisonAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } } }