From 3ad78cb73fe5ad59310ea0d2bb66657832fba6dc Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 7 Aug 2026 12:02:23 -0600 Subject: [PATCH 1/7] Match configured generic types by metadata name Preserve legacy arity-agnostic matching for configuration files without backticks while allowing exact metadata-name matching in versioned files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docfx/analyzers/configuration.md | 12 +++ .../CommonInterest.cs | 98 +++++++++++++++---- ...ncMethodsToExcludeFromVSTHRD103.legacy.txt | 2 + ...yncMethodsToExcludeFromVSTHRD103.mocks.txt | 3 +- .../VSTHRD103UseAsyncOptionAnalyzerTests.cs | 86 ++++++++++++++++ 5 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.legacy.txt diff --git a/docfx/analyzers/configuration.md b/docfx/analyzers/configuration.md index 77acf4d82..8af4563a2 100644 --- a/docfx/analyzers/configuration.md +++ b/docfx/analyzers/configuration.md @@ -25,6 +25,16 @@ all use `vs-threading.TopicA.txt` as the filename for their `AdditionalFiles` it These files may contain blank lines or comments that start with the `#` character. +Files that contain a backtick use metadata type names. In these files, generic types include +a backtick followed by the number of type parameters. For example, the metadata name for +`DbSet` is ``DbSet`1``. Including the arity distinguishes a generic type from a +non-generic type with the same name. + +For backward compatibility, files without any backticks match type names without regard +to generic arity. For example, `DbSet` in such a file matches both a non-generic `DbSet` +and every generic `DbSet` type. Add a backtick anywhere in the file to opt into +exact metadata-name matching for every entry in that file. + ## Methods that assert the main thread Code may assert it is running on the main thread by calling a method that is designed @@ -92,3 +102,5 @@ excluded from VSTHRD103 analysis by specifying them in a configuration file. **Line format:** `[Namespace.TypeName]::MethodName` **Sample:** `[System.Data.SqlClient.SqlDataReader]::Read` + +**Generic sample:** ``[Microsoft.EntityFrameworkCore.DbSet`1]::Add`` diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs index b4fdfc07a..859548b8f 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs @@ -40,7 +40,9 @@ public static class CommonInterest new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemThreadingTasks, nameof(Task)), nameof(Task.WaitAny)), null), new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemRuntimeCompilerServices, nameof(ConfiguredTaskAwaitable.ConfiguredTaskAwaiter)), nameof(ConfiguredTaskAwaitable.ConfiguredTaskAwaiter.GetResult)), null), new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemRuntimeCompilerServices, nameof(TaskAwaiter)), nameof(TaskAwaiter.GetResult)), null), + new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemRuntimeCompilerServices, nameof(TaskAwaiter) + "`1"), nameof(TaskAwaiter.GetResult)), null), new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemRuntimeCompilerServices, nameof(ValueTaskAwaiter)), nameof(ValueTaskAwaiter.GetResult)), null), + new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemRuntimeCompilerServices, nameof(ValueTaskAwaiter) + "`1"), nameof(ValueTaskAwaiter.GetResult)), null), new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemRuntimeCompilerServices, nameof(ConfiguredValueTaskAwaitable.ConfiguredValueTaskAwaiter)), nameof(ConfiguredValueTaskAwaitable.ConfiguredValueTaskAwaiter.GetResult)), null), ]; @@ -53,7 +55,9 @@ public static class CommonInterest public static readonly ImmutableArray SyncBlockingProperties = [ new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemThreadingTasks, nameof(Task)), nameof(Task.Result)), null), + new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemThreadingTasks, nameof(Task) + "`1"), nameof(Task.Result)), null), new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemThreadingTasks, nameof(ValueTask)), nameof(ValueTask.Result)), null), + new SyncBlockingMethod(new QualifiedMember(new QualifiedType(Namespaces.SystemThreadingTasks, nameof(ValueTask) + "`1"), nameof(ValueTask.Result)), null), ]; public static readonly IEnumerable ThreadAffinityTestingMethods = @@ -63,6 +67,7 @@ public static class CommonInterest public static readonly ImmutableArray TaskConfigureAwait = ImmutableArray.Create( new QualifiedMember(new QualifiedType(Types.Task.Namespace, Types.Task.TypeName), nameof(Task.ConfigureAwait)), + new QualifiedMember(new QualifiedType(Types.Task.Namespace, Types.Task.TypeName + "`1"), nameof(Task.ConfigureAwait)), new QualifiedMember(new QualifiedType(Types.AwaitExtensions.Namespace, Types.AwaitExtensions.TypeName), Types.AwaitExtensions.ConfigureAwaitRunInline)); private const RegexOptions FileNamePatternRegexOptions = RegexOptions.IgnoreCase | RegexOptions.Singleline; @@ -71,25 +76,33 @@ public static class CommonInterest public static IEnumerable ReadMethods(AnalyzerOptions analyzerOptions, Regex fileNamePattern, CancellationToken cancellationToken) { - foreach (string line in ReadAdditionalFiles(analyzerOptions, fileNamePattern, cancellationToken)) + foreach (SourceText text in ReadAdditionalFileTexts(analyzerOptions, fileNamePattern, cancellationToken)) { - yield return ParseAdditionalFileMethodLine(line); + bool matchAnyArity = !Contains(text, '`'); + foreach (string line in ReadLinesFromAdditionalFile(text)) + { + yield return ParseAdditionalFileMethodLine(line, matchAnyArity); + } } } public static IEnumerable ReadTypesAndMembers(AnalyzerOptions analyzerOptions, Regex fileNamePattern, CancellationToken cancellationToken) { - foreach (string line in ReadAdditionalFiles(analyzerOptions, fileNamePattern, cancellationToken)) + foreach (SourceText text in ReadAdditionalFileTexts(analyzerOptions, fileNamePattern, cancellationToken)) { - if (!CommonInterestParsing.TryParseNegatableTypeOrMemberReference(line, out bool negated, out ReadOnlyMemory typeNameMemory, out string? memberNameValue)) + bool matchAnyArity = !Contains(text, '`'); + foreach (string line in ReadLinesFromAdditionalFile(text)) { - throw new InvalidOperationException($"Parsing error on line: {line}"); - } + if (!CommonInterestParsing.TryParseNegatableTypeOrMemberReference(line, out bool negated, out ReadOnlyMemory typeNameMemory, out string? memberNameValue)) + { + throw new InvalidOperationException($"Parsing error on line: {line}"); + } - (ImmutableArray containingNamespace, string? typeName) = SplitQualifiedIdentifier(typeNameMemory); - var type = new QualifiedType(containingNamespace, typeName); - QualifiedMember member = memberNameValue is not null ? new QualifiedMember(type, memberNameValue) : default(QualifiedMember); - yield return new TypeMatchSpec(type, member, negated); + (ImmutableArray containingNamespace, string? typeName) = SplitQualifiedIdentifier(typeNameMemory); + var type = new QualifiedType(containingNamespace, typeName, matchAnyArity); + QualifiedMember member = memberNameValue is not null ? new QualifiedMember(type, memberNameValue) : default(QualifiedMember); + yield return new TypeMatchSpec(type, member, negated); + } } } @@ -105,12 +118,7 @@ public static IEnumerable ReadAdditionalFiles(AnalyzerOptions analyzerOp throw new ArgumentNullException(nameof(fileNamePattern)); } - IEnumerable? docs = from file in analyzerOptions.AdditionalFiles.OrderBy(x => x.Path, StringComparer.Ordinal) - let fileName = Path.GetFileName(file.Path) - where fileNamePattern.IsMatch(fileName) - let text = file.GetText(cancellationToken) - select text; - return docs.SelectMany(ReadLinesFromAdditionalFile); + return ReadAdditionalFileTexts(analyzerOptions, fileNamePattern, cancellationToken).SelectMany(ReadLinesFromAdditionalFile); } public static bool Contains(this ImmutableArray methods, ISymbol symbol) @@ -320,6 +328,9 @@ public static IEnumerable ReadLinesFromAdditionalFile(SourceText text) } public static QualifiedMember ParseAdditionalFileMethodLine(string line) + => ParseAdditionalFileMethodLine(line, matchAnyArity: false); + + private static QualifiedMember ParseAdditionalFileMethodLine(string line, bool matchAnyArity) { if (!CommonInterestParsing.TryParseMemberReference(line, out ReadOnlyMemory typeNameMemory, out string? memberName)) { @@ -327,7 +338,7 @@ public static QualifiedMember ParseAdditionalFileMethodLine(string line) } (ImmutableArray containingNamespace, string? typeName) = SplitQualifiedIdentifier(typeNameMemory); - var containingType = new QualifiedType(containingNamespace, typeName); + var containingType = new QualifiedType(containingNamespace, typeName, matchAnyArity); return new QualifiedMember(containingType, memberName!); } @@ -364,6 +375,38 @@ private static (ImmutableArray ContainingNamespace, string TypeName) Spl return (nsBuilder.ToImmutable(), typeName); } + private static IEnumerable ReadAdditionalFileTexts(AnalyzerOptions analyzerOptions, Regex fileNamePattern, CancellationToken cancellationToken) + { + if (analyzerOptions is null) + { + throw new ArgumentNullException(nameof(analyzerOptions)); + } + + if (fileNamePattern is null) + { + throw new ArgumentNullException(nameof(fileNamePattern)); + } + + return from file in analyzerOptions.AdditionalFiles.OrderBy(x => x.Path, StringComparer.Ordinal) + let fileName = Path.GetFileName(file.Path) + where fileNamePattern.IsMatch(fileName) + let text = file.GetText(cancellationToken) + select text; + } + + private static bool Contains(SourceText text, char value) + { + for (int i = 0; i < text.Length; i++) + { + if (text[i] == value) + { + return true; + } + } + + return false; + } + private static bool TestGetAwaiterMethod(IMethodSymbol getAwaiterMethod) { if (getAwaiterMethod.IsExtensionMethod) @@ -444,7 +487,7 @@ public bool IsMatch([NotNullWhen(true)] ITypeSymbol? typeSymbol, ISymbol? member } if (!this.IsMember - && (this.IsWildcard || typeSymbol.Name == this.Type.Name) + && (this.IsWildcard || typeSymbol.MetadataName == this.Type.Name) && typeSymbol.BelongsToNamespace(this.Type.Namespace)) { return true; @@ -452,7 +495,7 @@ public bool IsMatch([NotNullWhen(true)] ITypeSymbol? typeSymbol, ISymbol? member if (this.IsMember && memberSymbol?.Name == this.Member.Name - && typeSymbol.Name == this.Type.Name + && typeSymbol.MetadataName == this.Type.Name && typeSymbol.BelongsToNamespace(this.Type.Namespace)) { return true; @@ -465,18 +508,33 @@ public bool IsMatch([NotNullWhen(true)] ITypeSymbol? typeSymbol, ISymbol? member public readonly struct QualifiedType { public QualifiedType(ImmutableArray containingTypeNamespace, string typeName) + : this(containingTypeNamespace, typeName, matchAnyArity: false) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The namespace containing the type. + /// The simple or metadata name of the type. + /// to match the simple name across all generic arities; otherwise, to match the metadata name exactly. + internal QualifiedType(ImmutableArray containingTypeNamespace, string typeName, bool matchAnyArity) { this.Namespace = containingTypeNamespace; this.Name = typeName; + this.MatchAnyArity = matchAnyArity; } public ImmutableArray Namespace { get; } public string Name { get; } + private bool MatchAnyArity { get; } + public bool IsMatch(ISymbol symbol) { - return symbol?.Name == this.Name + return symbol is not null + && (this.MatchAnyArity ? symbol.Name : symbol.MetadataName) == this.Name && symbol.BelongsToNamespace(this.Namespace); } diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.legacy.txt b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.legacy.txt new file mode 100644 index 000000000..c43316883 --- /dev/null +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.legacy.txt @@ -0,0 +1,2 @@ +# This file has no metadata arity markers, so its type names match every arity. +[TestNamespace.LegacyTestClass]::SlowSyncMethod diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.mocks.txt b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.mocks.txt index 491c94c55..6ab299c75 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.mocks.txt +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/AdditionalFiles/vs-threading.SyncMethodsToExcludeFromVSTHRD103.mocks.txt @@ -1,2 +1,3 @@ # Test exclusions for VSTHRD103 analyzer -[TestNamespace.TestClass]::SlowSyncMethod \ No newline at end of file +[TestNamespace.TestClass]::SlowSyncMethod +[TestNamespace.GenericTestClass`1]::SlowSyncMethod \ No newline at end of file diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs index 0e27c2c55..524957dd8 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/VSTHRD103UseAsyncOptionAnalyzerTests.cs @@ -1363,6 +1363,92 @@ public static void SlowSyncMethod() { } await CSVerify.VerifyAnalyzerAsync(test); } + [Fact] + public async Task GenericTypeExclusion_DoesNotExcludeNonGenericType() + { + string test = """ + using System.Threading.Tasks; + + class Test { + async Task T() { + TestNamespace.GenericTestClass.SlowSyncMethod(); + TestNamespace.GenericTestClass.{|#0:SlowSyncMethod|}(); + } + } + + namespace TestNamespace { + class GenericTestClass { + public static void SlowSyncMethod() { } + public static Task SlowSyncMethodAsync() => Task.CompletedTask; + } + + class GenericTestClass { + public static void SlowSyncMethod() { } + public static Task SlowSyncMethodAsync() => Task.CompletedTask; + } + } + """; + + await CSVerify.VerifyAnalyzerAsync(test, CSVerify.Diagnostic(Descriptor).WithLocation(0).WithArguments("SlowSyncMethod", "SlowSyncMethodAsync")); + } + + [Fact] + public async Task NonGenericTypeExclusion_DoesNotExcludeGenericType() + { + string test = """ + using System.Threading.Tasks; + + class Test { + async Task T() { + TestNamespace.TestClass.{|#0:SlowSyncMethod|}(); + } + } + + namespace TestNamespace { + class TestClass { + public static void SlowSyncMethod() { } + public static Task SlowSyncMethodAsync() => Task.CompletedTask; + } + + class TestClass { + public static void SlowSyncMethod() { } + public static Task SlowSyncMethodAsync() => Task.CompletedTask; + } + } + """; + + await CSVerify.VerifyAnalyzerAsync(test, CSVerify.Diagnostic(Descriptor).WithLocation(0).WithArguments("SlowSyncMethod", "SlowSyncMethodAsync")); + } + + [Fact] + public async Task LegacyFileExclusion_MatchesAllArities() + { + string test = """ + using System.Threading.Tasks; + + class Test { + async Task T() { + TestNamespace.LegacyTestClass.SlowSyncMethod(); + TestNamespace.LegacyTestClass.SlowSyncMethod(); + } + } + + namespace TestNamespace { + class LegacyTestClass { + public static void SlowSyncMethod() { } + public static Task SlowSyncMethodAsync() => Task.CompletedTask; + } + + class LegacyTestClass { + public static void SlowSyncMethod() { } + public static Task SlowSyncMethodAsync() => Task.CompletedTask; + } + } + """; + + await CSVerify.VerifyAnalyzerAsync(test); + } + [Fact] public async Task SyncMethodCallInAsyncMethod_NotExcludedViaAdditionalFiles_GeneratesWarning() { From 6cee07e3dd1edf830c37dd2571e4f8e103502f89 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 7 Aug 2026 12:24:52 -0600 Subject: [PATCH 2/7] Address configuration matching review Honor legacy arity matching for all configured type paths and surface unreadable additional files explicitly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CommonInterest.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs index 859548b8f..a0e9bbaa1 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs @@ -390,7 +390,7 @@ private static IEnumerable ReadAdditionalFileTexts(AnalyzerOptions a return from file in analyzerOptions.AdditionalFiles.OrderBy(x => x.Path, StringComparer.Ordinal) let fileName = Path.GetFileName(file.Path) where fileNamePattern.IsMatch(fileName) - let text = file.GetText(cancellationToken) + let text = file.GetText(cancellationToken) ?? throw new InvalidOperationException($"Unable to read additional file: {file.Path}") select text; } @@ -487,16 +487,14 @@ public bool IsMatch([NotNullWhen(true)] ITypeSymbol? typeSymbol, ISymbol? member } if (!this.IsMember - && (this.IsWildcard || typeSymbol.MetadataName == this.Type.Name) - && typeSymbol.BelongsToNamespace(this.Type.Namespace)) + && ((this.IsWildcard && typeSymbol.BelongsToNamespace(this.Type.Namespace)) || this.Type.IsMatch(typeSymbol))) { return true; } if (this.IsMember && memberSymbol?.Name == this.Member.Name - && typeSymbol.MetadataName == this.Type.Name - && typeSymbol.BelongsToNamespace(this.Type.Namespace)) + && this.Type.IsMatch(typeSymbol)) { return true; } From 783058af6ab7f05b1d65c44c56b11fa68719f128 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 7 Aug 2026 12:31:32 -0600 Subject: [PATCH 3/7] Preserve code fix configuration compatibility Apply per-file metadata-name mode detection when code fixes read analyzer configuration, while retaining legacy behavior for individual line parsing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CommonFixes.cs | 29 +++++++++++++++---- .../CommonInterest.cs | 4 +-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs index 79015ac8d..11350e1e4 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs @@ -26,15 +26,19 @@ internal static class CommonFixes internal static async Task> ReadMethodsAsync(CodeFixContext codeFixContext, Regex fileNamePattern, CancellationToken cancellationToken) { ImmutableArray.Builder? result = ImmutableArray.CreateBuilder(); - foreach (string line in await ReadAdditionalFilesAsync(codeFixContext.Document.Project.AdditionalDocuments, fileNamePattern, cancellationToken)) + foreach (SourceText text in await ReadAdditionalFileTextsAsync(codeFixContext.Document.Project.AdditionalDocuments, fileNamePattern, cancellationToken)) { - result.Add(ParseAdditionalFileMethodLine(line)); + bool matchAnyArity = !Contains(text, '`'); + foreach (string line in ReadLinesFromAdditionalFile(text)) + { + result.Add(ParseAdditionalFileMethodLine(line, matchAnyArity)); + } } return result.ToImmutable(); } - internal static async Task> ReadAdditionalFilesAsync(IEnumerable additionalFiles, Regex fileNamePattern, CancellationToken cancellationToken) + internal static async Task> ReadAdditionalFileTextsAsync(IEnumerable additionalFiles, Regex fileNamePattern, CancellationToken cancellationToken) { if (additionalFiles is null) { @@ -50,13 +54,26 @@ internal static async Task> ReadAdditionalFilesAsync(IEnu let fileName = Path.GetFileName(doc.Name) where fileNamePattern.IsMatch(fileName) select doc; - ImmutableArray.Builder? result = ImmutableArray.CreateBuilder(); + ImmutableArray.Builder? result = ImmutableArray.CreateBuilder(); foreach (TextDocument? doc in docs) { - SourceText? text = await doc.GetTextAsync(cancellationToken); - result.AddRange(ReadLinesFromAdditionalFile(text)); + SourceText text = await doc.GetTextAsync(cancellationToken); + result.Add(text); } return result.ToImmutable(); } + + private static bool Contains(SourceText text, char value) + { + for (int i = 0; i < text.Length; i++) + { + if (text[i] == value) + { + return true; + } + } + + return false; + } } diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs index a0e9bbaa1..70c897372 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs @@ -328,9 +328,9 @@ public static IEnumerable ReadLinesFromAdditionalFile(SourceText text) } public static QualifiedMember ParseAdditionalFileMethodLine(string line) - => ParseAdditionalFileMethodLine(line, matchAnyArity: false); + => ParseAdditionalFileMethodLine(line, matchAnyArity: line.IndexOf('`') < 0); - private static QualifiedMember ParseAdditionalFileMethodLine(string line, bool matchAnyArity) + public static QualifiedMember ParseAdditionalFileMethodLine(string line, bool matchAnyArity) { if (!CommonInterestParsing.TryParseMemberReference(line, out ReadOnlyMemory typeNameMemory, out string? memberName)) { From 95d5d96d77cb074cdd2960f8ba9e09c097ab2b01 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 7 Aug 2026 12:41:57 -0600 Subject: [PATCH 4/7] Share SourceText character search Reuse the allocation-free SourceText search helper across analyzer and code-fix configuration readers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CommonFixes.cs | 13 ------------- .../CommonInterest.cs | 8 +++++++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs index 11350e1e4..1679cf0d3 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers.CodeFixes/CommonFixes.cs @@ -63,17 +63,4 @@ where fileNamePattern.IsMatch(fileName) return result.ToImmutable(); } - - private static bool Contains(SourceText text, char value) - { - for (int i = 0; i < text.Length; i++) - { - if (text[i] == value) - { - return true; - } - } - - return false; - } } diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs index 70c897372..2b10fe2f6 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs @@ -394,7 +394,13 @@ where fileNamePattern.IsMatch(fileName) select text; } - private static bool Contains(SourceText text, char value) + /// + /// Determines whether a character appears in source text without materializing the text as a string. + /// + /// The source text to search. + /// The character to find. + /// if appears in ; otherwise, . + public static bool Contains(SourceText text, char value) { for (int i = 0; i < text.Length; i++) { From 65c8711189441103ff071528a7a0f86662ddfb6c Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 7 Aug 2026 12:42:21 -0600 Subject: [PATCH 5/7] Order shared search helper with public APIs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CommonInterest.cs | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs index 2b10fe2f6..9600c3814 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs @@ -342,6 +342,25 @@ public static QualifiedMember ParseAdditionalFileMethodLine(string line, bool ma return new QualifiedMember(containingType, memberName!); } + /// + /// Determines whether a character appears in source text without materializing the text as a string. + /// + /// The source text to search. + /// The character to find. + /// if appears in ; otherwise, . + public static bool Contains(SourceText text, char value) + { + for (int i = 0; i < text.Length; i++) + { + if (text[i] == value) + { + return true; + } + } + + return false; + } + /// /// Splits a qualified type name (e.g. My.Namespace.MyType) into its containing namespace /// segments and the simple type name, without allocating an intermediate joined string. @@ -394,25 +413,6 @@ where fileNamePattern.IsMatch(fileName) select text; } - /// - /// Determines whether a character appears in source text without materializing the text as a string. - /// - /// The source text to search. - /// The character to find. - /// if appears in ; otherwise, . - public static bool Contains(SourceText text, char value) - { - for (int i = 0; i < text.Length; i++) - { - if (text[i] == value) - { - return true; - } - } - - return false; - } - private static bool TestGetAwaiterMethod(IMethodSymbol getAwaiterMethod) { if (getAwaiterMethod.IsExtensionMethod) From 00acfc4e267bf0e1c334d2533d0b2ecfce96f687 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 7 Aug 2026 12:48:07 -0600 Subject: [PATCH 6/7] Keep line parser arity-compatible Reserve exact metadata-name matching for callers that can provide the per-file mode explicitly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CommonInterest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs index 9600c3814..6cd292bf2 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs @@ -328,7 +328,7 @@ public static IEnumerable ReadLinesFromAdditionalFile(SourceText text) } public static QualifiedMember ParseAdditionalFileMethodLine(string line) - => ParseAdditionalFileMethodLine(line, matchAnyArity: line.IndexOf('`') < 0); + => ParseAdditionalFileMethodLine(line, matchAnyArity: true); public static QualifiedMember ParseAdditionalFileMethodLine(string line, bool matchAnyArity) { From fcecab77e378caac96ec80cdc989f2ae42449528 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 7 Aug 2026 12:54:39 -0600 Subject: [PATCH 7/7] Validate shared SourceText search input Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CommonInterest.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs index 6cd292bf2..282353e1c 100644 --- a/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs +++ b/src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs @@ -350,6 +350,11 @@ public static QualifiedMember ParseAdditionalFileMethodLine(string line, bool ma /// if appears in ; otherwise, . public static bool Contains(SourceText text, char value) { + if (text is null) + { + throw new ArgumentNullException(nameof(text)); + } + for (int i = 0; i < text.Length; i++) { if (text[i] == value)