From 56c974d5fc529ae97f97311156d5eecb78f2b4df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:51:31 +0000 Subject: [PATCH 1/2] Initial plan From b33d8e72ac64b0df32b1b9f8f174024eb7e0f031 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:08:04 +0000 Subject: [PATCH 2/2] Analyze base classes of multithreadable tasks in TaskAnalyzer Co-authored-by: ViktorHofer <7412651+ViktorHofer@users.noreply.github.com> --- .../multithreaded-task-migration/SKILL.md | 8 +- .../MultiThreadableTaskAnalyzerTests.cs | 157 ++++++++++++++++++ src/TaskAnalyzer.Tests/TestHelpers.cs | 20 ++- .../TransitiveCallChainAnalyzerTests.cs | 102 +++++++++++- .../MultiThreadableTaskAnalyzer.cs | 21 ++- src/TaskAnalyzer/README.md | 9 + src/TaskAnalyzer/SharedAnalyzerHelpers.cs | 130 +++++++++++++++ .../TransitiveCallChainAnalyzer.cs | 33 +++- 8 files changed, 466 insertions(+), 14 deletions(-) diff --git a/plugins/mt-migration/skills/multithreaded-task-migration/SKILL.md b/plugins/mt-migration/skills/multithreaded-task-migration/SKILL.md index 882071e7e0e..18f0fbfde20 100644 --- a/plugins/mt-migration/skills/multithreaded-task-migration/SKILL.md +++ b/plugins/mt-migration/skills/multithreaded-task-migration/SKILL.md @@ -315,16 +315,16 @@ Helpers reached from `Execute()` can quietly depend on process state in any of t ### Unsafe Code in an Unannotated Base Class -`[MSBuildMultiThreadableTask]` is `Inherited = false`, so it goes on each concrete task. The consequence people miss is the mirror image: **the base class runs multithreaded too, but nothing marks it as such** — and with `msbuild_task_analyzer.scope = multithreadable_only` (the recommended setting for incremental migration) the analyzer does not look inside it at all. +`[MSBuildMultiThreadableTask]` is `Inherited = false`, so it goes on each concrete task. The consequence people miss is the mirror image: **the base class runs multithreaded too, but nothing marks it as such**. The analyzer now follows the base chain of an annotated task and reports inherited code at its declaration site under either scope setting — but only for base classes declared in the same compilation. A base living in a referenced assembly is still unverified, so migrate that assembly first. -From dotnet/arcade: `CreateAkaMSLinks` and `DeleteAkaMSLinks` were both annotated, both analyzer-clean. Their shared base contained: +From dotnet/arcade, before the analyzer looked at base classes: `CreateAkaMSLinks` and `DeleteAkaMSLinks` were both annotated, both analyzer-clean. Their shared base contained: ```csharp -// AkaMSLinksBase - NOT annotated, so never analyzed +// AkaMSLinksBase - NOT annotated, and at the time not analyzed either File.ReadAllText(ClientCertificate) // ClientCertificate is a task input property ``` -A monitored API (`File`) on a raw task input — exactly what the analyzer exists to catch — invisible purely because of where it lived. +A monitored API (`File`) on a raw task input — exactly what the analyzer exists to catch — invisible purely because of where it lived. Audit the base chain yourself whenever any part of it lives outside the compilation the analyzer sees. **Resolution:** when you annotate a task, audit its full base chain up to `Task`/`ToolTask`. If the base holds shared input properties or path handling, have the **base** implement `IMultiThreadableTask` and do the resolution there, so every derived task inherits the fix: diff --git a/src/TaskAnalyzer.Tests/MultiThreadableTaskAnalyzerTests.cs b/src/TaskAnalyzer.Tests/MultiThreadableTaskAnalyzerTests.cs index cc204fdcddb..37ec7a2c20b 100644 --- a/src/TaskAnalyzer.Tests/MultiThreadableTaskAnalyzerTests.cs +++ b/src/TaskAnalyzer.Tests/MultiThreadableTaskAnalyzerTests.cs @@ -1909,4 +1909,161 @@ public override bool Execute() // IMultiThreadableTask SHOULD get MSBuildTask0002 even when scope is multithreadable_only diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).ShouldNotBeEmpty(); } + + [Fact] + public async Task Scope_MultithreadableOnly_UnannotatedBaseOfAnnotatedTask_GetsDiagnostic() + { + var diags = await GetDiagnosticsWithScopeAsync(""" + using System; + using System.IO; + using Microsoft.Build.Framework; + + public abstract class BaseWithEnv : Microsoft.Build.Utilities.Task + { + protected string? Token => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + protected string Here() => Directory.GetCurrentDirectory(); + } + + [MSBuildMultiThreadableTask] + public sealed class DerivedAnnotated : BaseWithEnv, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + public override bool Execute() => Token is not null && Here() is not null; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // The base class runs on the shared node as part of the annotated task, so its + // environment and working-directory reads are reported. + diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).Count().ShouldBe(2); + } + + [Fact] + public async Task Scope_MultithreadableOnly_UnannotatedBaseUsedByPlainTask_NoDiagnostic() + { + var diags = await GetDiagnosticsWithScopeAsync(""" + using System; + + public abstract class BaseWithEnv : Microsoft.Build.Utilities.Task + { + protected string? Token => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + } + + public sealed class PlainDerived : BaseWithEnv + { + public override bool Execute() => Token is not null; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // No task in the hierarchy opted into multithreaded execution + diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).ShouldBeEmpty(); + } + + [Fact] + public async Task Scope_MultithreadableOnly_GrandparentOfAnnotatedTask_GetsDiagnostic() + { + var diags = await GetDiagnosticsWithScopeAsync(""" + using System.IO; + using Microsoft.Build.Framework; + + public abstract class Grandparent : Microsoft.Build.Utilities.Task + { + protected string Read(string path) => File.ReadAllText(path); + } + + public abstract class Parent : Grandparent + { + } + + [MSBuildMultiThreadableTask] + public sealed class DerivedAnnotated : Parent, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + public string ClientCertificate { get; set; } = "cert.pfx"; + public override bool Execute() => Read(ClientCertificate) is not null; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // The whole base chain is in scope, not just the immediate base + diags.Where(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute).Count().ShouldBe(1); + } + + [Fact] + public async Task Scope_MultithreadableOnly_NonTaskBaseOfAnnotatedTask_GetsDiagnostic() + { + var diags = await GetDiagnosticsWithScopeAsync(""" + using System; + using Microsoft.Build.Framework; + + public abstract class NonTaskBase + { + protected string? Token => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + } + + [MSBuildMultiThreadableTask] + public sealed class DerivedAnnotated : NonTaskBase, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + public IBuildEngine BuildEngine { get; set; } = new BuildEngineStub(); + public bool Execute() => Token is not null; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // A base class that is not itself an ITask still runs as part of the annotated task + diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).Count().ShouldBe(1); + } + + [Fact] + public async Task Scope_MultithreadableOnly_GenericBaseOfAnnotatedTask_GetsDiagnostic() + { + var diags = await GetDiagnosticsWithScopeAsync(""" + using System; + using Microsoft.Build.Framework; + + public abstract class GenericBase : Microsoft.Build.Utilities.Task + { + protected string? Token => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + } + + [MSBuildMultiThreadableTask] + public sealed class DerivedAnnotated : GenericBase, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + public override bool Execute() => Token is not null; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // The base is referenced as GenericBase but declared as GenericBase + diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).Count().ShouldBe(1); + } + + [Fact] + public async Task Scope_MultithreadableOnly_BaseSharedByTwoAnnotatedTasks_ReportedOnce() + { + var diags = await GetDiagnosticsWithScopeAsync(""" + using System; + using Microsoft.Build.Framework; + + public abstract class SharedBase : Microsoft.Build.Utilities.Task + { + protected string? Token => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + } + + [MSBuildMultiThreadableTask] + public sealed class FirstTask : SharedBase, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + public override bool Execute() => Token is not null; + } + + [MSBuildMultiThreadableTask] + public sealed class SecondTask : SharedBase, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + public override bool Execute() => Token is not null; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // The shared base is analyzed once, not once per derived task + diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).Count().ShouldBe(1); + } } diff --git a/src/TaskAnalyzer.Tests/TestHelpers.cs b/src/TaskAnalyzer.Tests/TestHelpers.cs index 73bbbb2dbf2..3be316ba6a6 100644 --- a/src/TaskAnalyzer.Tests/TestHelpers.cs +++ b/src/TaskAnalyzer.Tests/TestHelpers.cs @@ -244,9 +244,24 @@ public static CSharpCompilation CreateCompilation(string source) /// Runs the MultiThreadableTaskAnalyzer with a specific scope option and returns analyzer diagnostics. /// public static async System.Threading.Tasks.Task> GetDiagnosticsWithScopeAsync(string source, string scope) + { + return await GetDiagnosticsWithScopeAsync(source, scope, new MultiThreadableTaskAnalyzer()); + } + + /// + /// Runs BOTH the direct and transitive analyzers with a specific scope option. + /// + public static async System.Threading.Tasks.Task> GetAllDiagnosticsWithScopeAsync(string source, string scope) + { + return await GetDiagnosticsWithScopeAsync(source, scope, new MultiThreadableTaskAnalyzer(), new TransitiveCallChainAnalyzer()); + } + + private static async System.Threading.Tasks.Task> GetDiagnosticsWithScopeAsync( + string source, + string scope, + params DiagnosticAnalyzer[] analyzers) { var compilation = CreateCompilation(source); - var analyzer = new MultiThreadableTaskAnalyzer(); var globalOptions = new Dictionary { @@ -255,8 +270,7 @@ public static async System.Threading.Tasks.Task> GetD var optionsProvider = new TestAnalyzerConfigOptionsProvider(globalOptions); var options = new AnalyzerOptions(ImmutableArray.Empty, optionsProvider); - var compilationWithAnalyzers = compilation.WithAnalyzers( - ImmutableArray.Create(analyzer), options); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzers), options); return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); } diff --git a/src/TaskAnalyzer.Tests/TransitiveCallChainAnalyzerTests.cs b/src/TaskAnalyzer.Tests/TransitiveCallChainAnalyzerTests.cs index 44e2e5a73bb..a99d460ced3 100644 --- a/src/TaskAnalyzer.Tests/TransitiveCallChainAnalyzerTests.cs +++ b/src/TaskAnalyzer.Tests/TransitiveCallChainAnalyzerTests.cs @@ -244,4 +244,104 @@ public override bool Execute() msg.ShouldContain("A.Step1"); msg.ShouldContain("B.Step2"); } -} \ No newline at end of file + + [Fact] + public async Task Scope_MultithreadableOnly_BaseClassMethodCallingHelper_ProducesDiagnostic() + { + var diags = await GetAllDiagnosticsWithScopeAsync(""" + using System; + using Microsoft.Build.Framework; + + public class UnsafeHelper + { + public static string? ReadToken() => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + } + + public abstract class BaseWithHelperCall : Microsoft.Build.Utilities.Task + { + public override bool Execute() => UnsafeHelper.ReadToken() is not null && ExecuteCore(); + + protected abstract bool ExecuteCore(); + } + + [MSBuildMultiThreadableTask] + public sealed class DerivedAnnotated : BaseWithHelperCall, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + + protected override bool ExecuteCore() => true; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // The call chain starts in the unannotated base, which the annotated task inherits + var transitive = diags.Where(d => d.Id == DiagnosticIds.TransitiveUnsafeCall).ToArray(); + transitive.Length.ShouldBe(1); + transitive[0].GetMessage().ShouldContain("UnsafeHelper.ReadToken"); + } + + [Fact] + public async Task Scope_MultithreadableOnly_BaseSharedByTwoAnnotatedTasks_ReportedOnce() + { + var diags = await GetAllDiagnosticsWithScopeAsync(""" + using System; + using Microsoft.Build.Framework; + + public class UnsafeHelper + { + public static string? ReadToken() => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + } + + public abstract class SharedBase : Microsoft.Build.Utilities.Task + { + public override bool Execute() => UnsafeHelper.ReadToken() is not null && ExecuteCore(); + + protected abstract bool ExecuteCore(); + } + + [MSBuildMultiThreadableTask] + public sealed class FirstTask : SharedBase, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + + protected override bool ExecuteCore() => true; + } + + [MSBuildMultiThreadableTask] + public sealed class SecondTask : SharedBase, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(); + + protected override bool ExecuteCore() => true; + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // The shared base is walked once, not once per derived task + var transitive = diags.Where(d => d.Id == DiagnosticIds.TransitiveUnsafeCall).ToArray(); + transitive.Length.ShouldBe(1); + } + + [Fact] + public async Task Scope_MultithreadableOnly_BaseOfPlainTaskOnly_NoDiagnostic() + { + var diags = await GetAllDiagnosticsWithScopeAsync(""" + using System; + + public class UnsafeHelper + { + public static string? ReadToken() => Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + } + + public abstract class PlainBase : Microsoft.Build.Utilities.Task + { + public override bool Execute() => UnsafeHelper.ReadToken() is not null; + } + + public sealed class PlainDerived : PlainBase + { + } + """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly); + + // No task in the hierarchy opted into multithreaded execution + diags.Where(d => d.Id == DiagnosticIds.TransitiveUnsafeCall).ShouldBeEmpty(); + } +} diff --git a/src/TaskAnalyzer/MultiThreadableTaskAnalyzer.cs b/src/TaskAnalyzer/MultiThreadableTaskAnalyzer.cs index 26a0d711125..fe7f02942d5 100644 --- a/src/TaskAnalyzer/MultiThreadableTaskAnalyzer.cs +++ b/src/TaskAnalyzer/MultiThreadableTaskAnalyzer.cs @@ -21,6 +21,9 @@ namespace Microsoft.Build.TaskAuthoring.Analyzer /// - "multithreadable_only": MSBuildTask0002, 0003 fire only on IMultiThreadableTask or [MSBuildMultiThreadableTask] /// (MSBuildTask0001 and MSBuildTask0004 always fire on all tasks regardless) /// + /// Base classes of a multithreadable task are analyzed as multithreadable under either scope, since the + /// members a task inherits run on the shared node just like the ones it declares. + /// /// Per review feedback from @rainersigwald: /// - Console.* promoted to MSBuildTask0001 (always wrong in tasks) /// - Helper classes can opt in via [MSBuildMultiThreadableTaskAnalyzed] attribute @@ -72,6 +75,16 @@ private void OnCompilationStart(CompilationStartAnalysisContext compilationConte // Build set of file-path types for MSBuildTask0003 var filePathTypes = ResolveFilePathTypes(compilationContext.Compilation); + // [MSBuildMultiThreadableTask] is not inherited, but the members a task inherits still run on + // the shared node. Base classes of a multithreadable task are therefore analyzed as + // multithreadable themselves. Computed once per compilation, on first use. + var multiThreadableBaseTypes = new Lazy>(() => + CollectMultiThreadableBaseTypes( + compilationContext.Compilation, + iMultiThreadableTaskType, + multiThreadableTaskAttributeType, + analyzedAttributeType)); + // Use RegisterSymbolStartAction for efficient per-type scoping compilationContext.RegisterSymbolStartAction(symbolStartContext => { @@ -89,13 +102,17 @@ private void OnCompilationStart(CompilationStartAnalysisContext compilationConte bool hasMultiThreadableAttribute = multiThreadableTaskAttributeType is not null && namedType.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, multiThreadableTaskAttributeType)); - if (!isTask && !hasAnalyzedAttribute) + // Base classes of a multithreadable task are in scope even without an annotation of their own + bool isMultiThreadableBaseType = !isMultiThreadableTask && !hasAnalyzedAttribute && !hasMultiThreadableAttribute && + multiThreadableBaseTypes.Value.Contains(namedType); + + if (!isTask && !hasAnalyzedAttribute && !isMultiThreadableBaseType) { return; } // Helper classes with the attribute or tasks with [MSBuildMultiThreadableTask] are treated as IMultiThreadableTask - bool analyzeAsMultiThreadable = isMultiThreadableTask || hasAnalyzedAttribute || hasMultiThreadableAttribute; + bool analyzeAsMultiThreadable = isMultiThreadableTask || hasAnalyzedAttribute || hasMultiThreadableAttribute || isMultiThreadableBaseType; // When scope is "multithreadable_only", only analyze MSBuildTask0002/0003 for multithreadable tasks bool reportEnvironmentRules = analyzeAllTasks || analyzeAsMultiThreadable; diff --git a/src/TaskAnalyzer/README.md b/src/TaskAnalyzer/README.md index d4e3cc2f511..6d6bf769acb 100644 --- a/src/TaskAnalyzer/README.md +++ b/src/TaskAnalyzer/README.md @@ -297,6 +297,7 @@ The analyzer determines what to check based on the type declaration: | Class with `[MSBuildMultiThreadableTask]` attribute applied directly | MSBuildTask0006–MSBuildTask0008 (in addition to MSBuildTask0001–0005) | | Concrete class implementing `IMultiThreadableTask` without the attribute | MSBuildTask0001–MSBuildTask0005 and MSBuildTask0009–MSBuildTask0011 | | Helper class with `[MSBuildMultiThreadableTaskAnalyzed]` attribute | MSBuildTask0001–MSBuildTask0005 | +| Base class of a multithreadable task, declared in the same compilation | MSBuildTask0001–MSBuildTask0005 | | Regular class (no task interface or attribute) | Not analyzed | MSBuildTask0006–MSBuildTask0008 apply only when the `[MSBuildMultiThreadableTask]` attribute is applied **directly** to the task class. The attribute is `Inherited = false`, so a task that merely derives from a base class implementing `IMultiThreadableTask` (or carrying the attribute) has not itself opted into multithreaded support and is not subject to these three rules. Input properties are collected from the task class **and its base classes**, so an `ITaskItem`/`string` input declared on a shared base task is still analyzed. @@ -305,6 +306,14 @@ The `[MSBuildMultiThreadableTaskAnalyzed]` attribute allows opting helper classe **When to use:** Apply `[MSBuildMultiThreadableTaskAnalyzed]` to utility or helper classes that are primarily used by multithreadable tasks and where you want immediate in-editor feedback (squiggles and code fixes) on unsafe APIs within those helpers. +### Inherited Code + +A task runs the members it inherits just like the ones it declares, so the base classes of a multithreadable task are analyzed as multithreadable themselves — even though `[MSBuildMultiThreadableTask]` is `Inherited = false` and the base carries no annotation of its own. This applies under both scope settings, and to the whole base chain, so an unannotated base shared by two annotated tasks still reports the `File.ReadAllText` it performs on a task input. + +Diagnostics are reported at the declaration site in the base, and a base shared by several tasks is analyzed once rather than once per derived task. + +**Limitation:** only base classes declared in the compilation being analyzed can be inspected. A task deriving from a base in a referenced assembly is analyzed for the members it declares itself; migrate the assembly holding the base first. + ### Severity Levels - **MSBuildTask0001** is always **Error** — these APIs are never safe in any MSBuild task. diff --git a/src/TaskAnalyzer/SharedAnalyzerHelpers.cs b/src/TaskAnalyzer/SharedAnalyzerHelpers.cs index 3c8f9643cb1..cdd2075a7e9 100644 --- a/src/TaskAnalyzer/SharedAnalyzerHelpers.cs +++ b/src/TaskAnalyzer/SharedAnalyzerHelpers.cs @@ -373,6 +373,136 @@ internal static ImmutableHashSet ResolveFilePathTypes(Compilat return builder.ToImmutable(); } + /// + /// Determines whether a type opts into multithreaded task execution, either by implementing + /// IMultiThreadableTask or by carrying [MSBuildMultiThreadableTask] or + /// [MSBuildMultiThreadableTaskAnalyzed]. + /// + internal static bool IsMultiThreadable( + INamedTypeSymbol type, + INamedTypeSymbol? iMultiThreadableTaskType, + INamedTypeSymbol? multiThreadableTaskAttributeType, + INamedTypeSymbol? analyzedAttributeType) + { + if (iMultiThreadableTaskType is not null && ImplementsInterface(type, iMultiThreadableTaskType)) + { + return true; + } + + if (multiThreadableTaskAttributeType is null && analyzedAttributeType is null) + { + return false; + } + + foreach (var attribute in type.GetAttributes()) + { + if ((multiThreadableTaskAttributeType is not null && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, multiThreadableTaskAttributeType)) + || (analyzedAttributeType is not null && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, analyzedAttributeType))) + { + return true; + } + } + + return false; + } + + /// + /// Collects every base type declared in this compilation that a multithreadable task derives from. + /// [MSBuildMultiThreadableTask] is not inherited, but the members a task inherits still run on + /// the shared node, so an unannotated base class of an annotated task is in scope for analysis. + /// Base types outside the compilation cannot be analyzed and are skipped. + /// + internal static ImmutableHashSet CollectMultiThreadableBaseTypes( + Compilation compilation, + INamedTypeSymbol? iMultiThreadableTaskType, + INamedTypeSymbol? multiThreadableTaskAttributeType, + INamedTypeSymbol? analyzedAttributeType) + { + if (iMultiThreadableTaskType is null && multiThreadableTaskAttributeType is null && analyzedAttributeType is null) + { + return ImmutableHashSet.Empty; + } + + var builder = ImmutableHashSet.CreateBuilder(SymbolEqualityComparer.Default); + foreach (var type in EnumerateTypes(compilation.Assembly.GlobalNamespace)) + { + // Only types with a base class other than object can contribute; checking that first + // avoids binding attributes for the majority of types in a compilation. + if (type.BaseType is null + || type.BaseType.SpecialType == SpecialType.System_Object + || builder.Contains(type.BaseType.OriginalDefinition)) + { + continue; + } + + if (!IsMultiThreadable(type, iMultiThreadableTaskType, multiThreadableTaskAttributeType, analyzedAttributeType)) + { + continue; + } + + for (INamedTypeSymbol? current = type.BaseType; + current is not null && current.SpecialType != SpecialType.System_Object; + current = current.BaseType) + { + // A type declared in metadata cannot derive from a type in this compilation, so the + // rest of the chain is outside the compilation and cannot be analyzed either. + if (current.DeclaringSyntaxReferences.Length == 0) + { + break; + } + + // A generic base is referenced here as a constructed type (Base), while the + // symbol the analyzer visits is the definition (Base). + if (!builder.Add(current.OriginalDefinition)) + { + // The rest of the chain was already collected through another derived task. + break; + } + } + } + + return builder.ToImmutable(); + } + + /// + /// Enumerates all named types declared under a namespace, including nested types. + /// + internal static IEnumerable EnumerateTypes(INamespaceSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + foreach (var type in EnumerateTypes(childNamespace)) + { + yield return type; + } + } + else if (member is INamedTypeSymbol type) + { + yield return type; + + foreach (var nested in EnumerateNestedTypes(type)) + { + yield return nested; + } + } + } + } + + private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) + { + foreach (var nested in type.GetTypeMembers()) + { + yield return nested; + + foreach (var deeper in EnumerateNestedTypes(nested)) + { + yield return deeper; + } + } + } + /// /// Checks if a type implements a given interface. /// diff --git a/src/TaskAnalyzer/TransitiveCallChainAnalyzer.cs b/src/TaskAnalyzer/TransitiveCallChainAnalyzer.cs index 9dc9d26aa99..e06cc72bbc7 100644 --- a/src/TaskAnalyzer/TransitiveCallChainAnalyzer.cs +++ b/src/TaskAnalyzer/TransitiveCallChainAnalyzer.cs @@ -246,10 +246,7 @@ private static void AnalyzeTransitiveViolations( // When scope is "multithreadable_only", filter to only multithreadable tasks if (!analyzeAllTasks) { - taskTypes = taskTypes.Where(t => - (iMultiThreadableTaskType is not null && t.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, iMultiThreadableTaskType))) || - (multiThreadableTaskAttributeType is not null && t.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, multiThreadableTaskAttributeType))) || - (analyzedAttributeType is not null && t.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, analyzedAttributeType)))).ToList(); + taskTypes = taskTypes.Where(t => IsMultiThreadable(t, iMultiThreadableTaskType, multiThreadableTaskAttributeType, analyzedAttributeType)).ToList(); if (taskTypes.Count == 0) { @@ -257,7 +254,35 @@ private static void AnalyzeTransitiveViolations( } } + // A task runs the members it inherits as well as the ones it declares, so the base chain is + // walked too. Types are visited once, so a base shared by several tasks is not reported twice. + var seedTypes = new List(taskTypes.Count); + var seenSeedTypes = new HashSet(SymbolEqualityComparer.Default); foreach (var taskType in taskTypes) + { + for (INamedTypeSymbol? current = taskType; + current is not null && current.SpecialType != SpecialType.System_Object; + current = current.BaseType) + { + // Types outside this compilation have no source to walk, and nothing above them does either. + if (current.DeclaringSyntaxReferences.Length == 0) + { + break; + } + + // Members of a constructed generic base (Base) are keyed in the call graph by + // their original definition, so walk the definition. + var definition = current.OriginalDefinition; + if (!seenSeedTypes.Add(definition)) + { + break; + } + + seedTypes.Add(definition); + } + } + + foreach (var taskType in seedTypes) { // Track reported violations per task type to avoid flooding with duplicates. // Key: target banned API display name. We report only the shortest chain per API.