File: MultiThreadableTaskAnalyzerTests.cs
Web Access
Project: ..\..\..\src\ThreadSafeTaskAnalyzer.Tests\ThreadSafeTaskAnalyzer.Tests.csproj (ThreadSafeTaskAnalyzer.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Linq;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using static Microsoft.Build.TaskAuthoring.Analyzer.Tests.TestHelpers;
 
namespace Microsoft.Build.TaskAuthoring.Analyzer.Tests;
 
/// <summary>
/// Tests for <see cref="MultiThreadableTaskAnalyzer"/> covering all 4 diagnostic rules.
/// Uses manual compilation to avoid fragile message argument matching.
/// </summary>
public class MultiThreadableTaskAnalyzerTests
{
    // ═══════════════════════════════════════════════════════════════════════
    // MSBuildTask0001: Critical errors
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task ConsoleWriteLine_InAnyTask_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Console.WriteLine("hello");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
        diags.Length.ShouldBe(1);
    }
 
    [Fact]
    public async Task ConsoleWrite_MultipleOverloads_AllDetected()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Console.Write("a");
                    Console.Write(42);
                    Console.Write(true);
                    Console.Write('c');
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.CriticalError).Count().ShouldBe(4);
    }
 
    [Fact]
    public async Task ConsoleOut_PropertyAccess_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var writer = Console.Out;
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task EnvironmentExit_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Environment.Exit(1);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
        diags.Length.ShouldBe(1);
    }
 
    [Fact]
    public async Task EnvironmentFailFast_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Environment.FailFast("crash");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task ThreadPoolSetMinMaxThreads_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Threading;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    ThreadPool.SetMinThreads(4, 4);
                    ThreadPool.SetMaxThreads(16, 16);
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.CriticalError).Count().ShouldBe(2);
    }
 
    [Fact]
    public async Task CultureInfoDefaults_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Globalization;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
                    CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.CriticalError).Count().ShouldBe(2);
    }
 
    [Fact]
    public async Task ConsoleReadLine_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var input = Console.ReadLine();
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task ProcessKill_InAnyTask_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Diagnostics;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var p = Process.GetCurrentProcess();
                    p.Kill();
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task MSBuildTask0001_FiresForRegularTask()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class RegularTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Console.WriteLine("hello");
                    Environment.Exit(1);
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.CriticalError).Count().ShouldBe(2);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // MSBuildTask0002: TaskEnvironment required (only for IMultiThreadableTask)
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task ProcessStart_InMultiThreadableTask_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Diagnostics;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    Process.Start("cmd.exe");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task ProcessStartInfo_Constructor_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Diagnostics;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var psi = new ProcessStartInfo("cmd.exe");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task EnvironmentGetEnvVar_InRegularTask_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var val = Environment.GetEnvironmentVariable("PATH");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task ExpandEnvironmentVariables_InMultiThreadableTask_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var result = Environment.ExpandEnvironmentVariables("%PATH%");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // MSBuildTask0003: File path requires absolute (only for IMultiThreadableTask)
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task FileReadAllText_WithStringArg_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var content = File.ReadAllText("file.txt");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task DirectoryExists_WithStringArg_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    Directory.Exists("mydir");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task NewStreamReader_WithStringArg_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    using var sr = new StreamReader("file.txt");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // MSBuildTask0003 Safe Patterns: No diagnostic expected
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task FileExists_WithGetAbsolutePath_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    File.Exists(TaskEnvironment.GetAbsolutePath("foo.txt"));
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileExists_WithAbsolutePathVariable_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath p = TaskEnvironment.GetAbsolutePath("foo.txt");
                    File.Exists(p);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileDelete_WithNullableAbsolutePathVariable_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath? filePath = TaskEnvironment.GetAbsolutePath("foo.txt");
                    if (filePath.HasValue)
                    {
                        File.Delete(filePath.Value);
                    }
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileExists_WithGetMetadataFullPath_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public ITaskItem[] Items { get; set; }
                public override bool Execute()
                {
                    foreach (var item in Items)
                    {
                        File.Exists(item.GetMetadata("FullPath"));
                    }
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileExists_WithFullNameProperty_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var fi = new FileInfo(TaskEnvironment.GetAbsolutePath("path.txt"));
                    File.Exists(fi.FullName);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // Safe wrapper recognition: Path.GetDirectoryName, Path.Combine, Path.GetFullPath
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task DirectoryCreate_WithGetDirectoryNameOfAbsolutePath_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath filePath = TaskEnvironment.GetAbsolutePath("file.txt");
                    string dir = Path.GetDirectoryName(filePath);
                    Directory.CreateDirectory(dir);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task DirectoryCreate_WithGetDirectoryNameOfString_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    string dir = Path.GetDirectoryName("relative/file.txt");
                    Directory.CreateDirectory(dir);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileInfo_WithPathCombineSafeBase_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath baseDir = TaskEnvironment.GetAbsolutePath("dir");
                    string combined = Path.Combine(baseDir, "sub", "file.txt");
                    var fi = new FileInfo(combined);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileInfo_WithPathCombineUnsafeBase_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    string combined = Path.Combine("relative", "file.txt");
                    var fi = new FileInfo(combined);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileInfo_WithGetFullPathOfAbsolutePath_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath basePath = TaskEnvironment.GetAbsolutePath("dir");
                    string fullPath = Path.GetFullPath(basePath);
                    var fi = new FileInfo(fullPath);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileInfo_WithGetFullPathOfRelative_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    string fullPath = Path.GetFullPath("relative.txt");
                    var fi = new FileInfo(fullPath);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileWriteAllText_WithNestedPathCombineGetDirectoryName_NoDiagnostic()
    {
        // Simulates WriteLinesToFile pattern: Path.Combine(Path.GetDirectoryName(AbsolutePath), random)
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath filePath = TaskEnvironment.GetAbsolutePath("file.txt");
                    string dir = Path.GetDirectoryName(filePath);
                    string tempPath = Path.Combine(dir, Path.GetRandomFileName() + "~");
                    File.WriteAllText(tempPath, "contents");
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileInfo_WithPathCombineOfFullName_NoDiagnostic()
    {
        // Simulates DownloadFile pattern: Path.Combine(DirectoryInfo.FullName, filename)
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath dirPath = TaskEnvironment.GetAbsolutePath("dir");
                    DirectoryInfo di = new DirectoryInfo(dirPath);
                    string combined = Path.Combine(di.FullName, "file.txt");
                    var fi = new FileInfo(combined);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileInfo_WithGetFullPathOfPathCombineSafe_NoDiagnostic()
    {
        // Simulates Unzip pattern: Path.GetFullPath(Path.Combine(DirectoryInfo.FullName, entry))
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath dirPath = TaskEnvironment.GetAbsolutePath("dir");
                    DirectoryInfo di = new DirectoryInfo(dirPath);
                    string fullPath = Path.GetFullPath(Path.Combine(di.FullName, "sub/entry"));
                    var fi = new FileInfo(fullPath);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileApi_InRegularTask_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    File.Exists("foo.txt");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task DirectoryInfoFullName_SafePattern_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var di = new DirectoryInfo(TaskEnvironment.GetAbsolutePath("mydir"));
                    Directory.Exists(di.FullName);
                    return true;
                }
            }
            """);
 
        diags.ShouldNotContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // MSBuildTask0004: Potential issues (review required)
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task AssemblyLoadFrom_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Reflection;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Assembly.LoadFrom("mylib.dll");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.PotentialIssue);
    }
 
    [Fact]
    public async Task AssemblyLoad_ByteArray_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Reflection;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Assembly.Load(new byte[0]);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.PotentialIssue);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // Non-task classes: No diagnostics
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task NonTaskClass_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            public class NotATask
            {
                public void DoStuff()
                {
                    Console.WriteLine("hello");
                    File.Exists("foo.txt");
                    Environment.Exit(1);
                    var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe");
                }
            }
            """);
 
        diags.ShouldBeEmpty();
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // Edge Cases
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task Lambda_InsideTask_DetectsUnsafeApi()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.Collections.Generic;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var items = new List<string> { "a", "b" };
                    items.ForEach(x => Console.WriteLine(x));
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task DerivedTask_InheritsITask_DetectsUnsafeApi()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class BaseTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute() => true;
            }
            public class DerivedTask : BaseTask
            {
                public void DoWork()
                {
                    Console.WriteLine("derived");
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task GenericTask_DetectsUnsafeApi()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using Microsoft.Build.Framework;
            public class GenericTask<T> : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    Console.WriteLine(typeof(T));
                    var val = Environment.GetEnvironmentVariable("X");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task PropertyGetter_WithUnsafeApi_DetectsIt()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public string Dir => Environment.CurrentDirectory;
                public override bool Execute() => true;
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task MethodReference_AsDelegate_DetectsIt()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.Collections.Generic;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var items = new List<string> { "a", "b" };
                    items.ForEach(Console.WriteLine);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task MultipleUnsafeApis_AllDetected()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using System.Reflection;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    Console.WriteLine("hello");
                    var val = Environment.GetEnvironmentVariable("X");
                    File.Exists("foo.txt");
                    Assembly.LoadFrom("lib.dll");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
        diags.ShouldContain(d => d.Id == DiagnosticIds.PotentialIssue);
        diags.Length.ShouldBe(4);
    }
 
    [Fact]
    public async Task CorrectlyMigratedTask_NoDiagnostics()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using Microsoft.Build.Framework;
            public class CorrectTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public ITaskItem[] Items { get; set; }
                public override bool Execute()
                {
                    var dir = TaskEnvironment.ProjectDirectory;
                    var env = TaskEnvironment.GetEnvironmentVariable("X");
                    TaskEnvironment.SetEnvironmentVariable("X", "val");
                    AbsolutePath abs = TaskEnvironment.GetAbsolutePath("file.txt");
                    File.Exists(abs);
                    File.ReadAllText(TaskEnvironment.GetAbsolutePath("other.txt"));
 
                    foreach (var item in Items)
                    {
                        File.Exists(item.GetMetadata("FullPath"));
                    }
                    return true;
                }
            }
            """);
 
        diags.ShouldBeEmpty();
    }
 
    [Fact]
    public async Task FullyQualifiedConsole_DetectsIt()
    {
        var diags = await GetDiagnosticsAsync("""
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    System.Console.WriteLine("hello");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task TryCatchFinally_DetectsAllUnsafeApis()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    try { Console.WriteLine("try"); }
                    catch { Console.WriteLine("catch"); }
                    finally { Console.WriteLine("finally"); }
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.CriticalError).Count().ShouldBe(3);
    }
 
    [Fact]
    public async Task AsyncMethod_InsideTask_DetectsUnsafeApi()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    ExecuteAsync().Wait();
                    return true;
                }
                private async System.Threading.Tasks.Task ExecuteAsync()
                {
                    await System.Threading.Tasks.Task.Delay(1);
                    Console.WriteLine("async");
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task StringInterpolation_WithConsole_DetectsIt()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var name = "world";
                    Console.WriteLine($"Hello {name}");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task EmptyTask_NoDiagnostics()
    {
        var diags = await GetDiagnosticsAsync("""
            public class EmptyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute() => true;
            }
            """);
 
        diags.ShouldBeEmpty();
    }
 
    [Fact]
    public async Task GetMetadata_NonFullPath_StillTriggersWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public ITaskItem[] Items { get; set; }
                public override bool Execute()
                {
                    foreach (var item in Items)
                    {
                        File.Exists(item.GetMetadata("Identity"));
                    }
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task NestedClass_NotTask_NoFalsePositive()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class OuterTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    new Inner().DoWork();
                    return true;
                }
                private class Inner
                {
                    public void DoWork() { Console.WriteLine("nested"); }
                }
            }
            """);
 
        // Inner class is not an ITask - should NOT get diagnostics
        diags.ShouldBeEmpty();
    }
 
    [Fact]
    public async Task MSBuildTask0002_FiredForRegularTask()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using System.Diagnostics;
            public class RegularTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Environment.GetEnvironmentVariable("X");
                    Path.GetFullPath("foo");
                    Process.Start("cmd.exe");
                    var psi = new ProcessStartInfo("cmd.exe");
                    return true;
                }
            }
            """);
 
        // MSBuildTask0002 now fires for all ITask implementations
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task MSBuildTask0003_FiredForRegularTask()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            public class RegularTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    File.Exists("foo.txt");
                    new FileInfo("bar.txt");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task XDocumentSave_WithStringPath_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Xml.Linq;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var doc = new XDocument();
                    doc.Save("output.xml");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task XmlReaderCreate_WithStringPath_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Xml;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    using var reader = XmlReader.Create("input.xml");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task ZipFileOpenRead_WithStringPath_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO.Compression;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    using var archive = ZipFile.OpenRead("archive.zip");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // Iteration 9-13: New APIs and features
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task DirectorySetCurrentDirectory_ProducesCriticalError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Directory.SetCurrentDirectory("/tmp");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task DirectoryGetCurrentDirectory_InMultiThreadable_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var dir = Directory.GetCurrentDirectory();
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task PathGetTempPath_InMultiThreadable_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var tmp = Path.GetTempPath();
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task PathGetTempFileName_InMultiThreadable_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var f = Path.GetTempFileName();
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task EnvironmentGetFolderPath_InMultiThreadable_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var p = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task ConsoleSetOut_TypeLevelBan_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Console.ResetColor();
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task ConsoleForegroundColor_TypeLevelBan_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    Console.ForegroundColor = ConsoleColor.Red;
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task ConsoleTitle_TypeLevelBan_ProducesError()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var t = Console.Title;
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task ProcessStartWithPSI_InMultiThreadable_ProducesWarning()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.Diagnostics;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var psi = new ProcessStartInfo("cmd");
                    Process.Start(psi);
                    return true;
                }
            }
            """);
 
        // Should flag both: new ProcessStartInfo and Process.Start(ProcessStartInfo)
        diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).Count().ShouldBeGreaterThanOrEqualTo(2);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // [MSBuildMultiThreadableTaskAnalyzed] attribute on helper classes
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task HelperClass_WithAttribute_AnalyzedLikeMultiThreadableTask()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using Microsoft.Build.Framework;
 
            [MSBuildMultiThreadableTaskAnalyzed]
            public class FileHelper
            {
                public void ProcessFile(string path)
                {
                    File.Exists(path);
                    var env = Environment.GetEnvironmentVariable("PATH");
                }
            }
            """);
 
        // Should detect File.Exists with unwrapped path (MSBuildTask0003)
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
        // Should detect Environment.GetEnvironmentVariable (MSBuildTask0002)
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task HelperClass_WithoutAttribute_NotAnalyzed()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
 
            public class RegularHelper
            {
                public void ProcessFile(string path)
                {
                    File.Exists(path);
                    var env = Environment.GetEnvironmentVariable("PATH");
                    Console.WriteLine("hello");
                }
            }
            """);
 
        diags.ShouldBeEmpty();
    }
 
    [Fact]
    public async Task HelperClass_WithAttribute_ConsoleDetected()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using Microsoft.Build.Framework;
 
            [MSBuildMultiThreadableTaskAnalyzed]
            public class LogHelper
            {
                public void Log(string message)
                {
                    Console.WriteLine(message);
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task HelperClass_WithAttribute_SafePatterns_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
 
            [MSBuildMultiThreadableTaskAnalyzed]
            public class SafeHelper
            {
                public void Process(TaskEnvironment env, ITaskItem item)
                {
                    AbsolutePath abs = env.GetAbsolutePath("file.txt");
                    File.Exists(abs);
                    File.ReadAllText(item.GetMetadata("FullPath"));
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute).ShouldBeEmpty();
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // Multi-path parameter correctness (File.Copy has 2 path args)
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task FileCopy_SecondArgUnwrapped_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath src = TaskEnvironment.GetAbsolutePath("src.txt");
                    File.Copy(src, "dest.txt");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task FileCopy_BothArgsWrapped_NoDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath src = TaskEnvironment.GetAbsolutePath("src.txt");
                    AbsolutePath dst = TaskEnvironment.GetAbsolutePath("dst.txt");
                    File.Copy(src, dst);
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute).ShouldBeEmpty();
    }
 
    [Fact]
    public async Task FileCopy_FirstArgUnwrapped_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath dst = TaskEnvironment.GetAbsolutePath("dst.txt");
                    File.Copy("src.txt", dst);
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // Edge cases: LINQ lambdas, nested types, string literals
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task Lambda_FileExistsInsideLambda_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    Func<string, bool> check = path => File.Exists(path);
                    return check("test.txt");
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task NestedClass_InsideTask_NotAnalyzedSeparately()
    {
        // Nested class within a task should NOT independently trigger analysis
        // (it's not a task itself and doesn't have the attribute)
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var helper = new Helper();
                    return true;
                }
 
                private class Helper
                {
                    public void DoWork()
                    {
                        File.Exists("foo.txt");
                        Console.WriteLine("nested");
                    }
                }
            }
            """);
 
        // The outer task has Console.* in its scope but NOT in nested class
        // Nested class operations are NOT in the outer type's symbol scope
        diags.Where(d => d.Id == DiagnosticIds.CriticalError).ShouldBeEmpty();
    }
 
    [Fact]
    public async Task StringInterpolation_ConsoleWithInterpolation_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    int x = 42;
                    Console.WriteLine($"value = {x}");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task StaticMethod_InTask_DetectsUnsafeApis()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    LogMessage("hello");
                    return true;
                }
 
                private static void LogMessage(string msg)
                {
                    Console.WriteLine(msg);
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task PropertyGetter_WithBannedApi_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public string CurrentDir => Environment.CurrentDirectory;
                public override bool Execute() => true;
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task AsyncHelper_WithBannedApi_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using System.Threading.Tasks;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    ProcessAsync().Wait();
                    return true;
                }
 
                private async Task ProcessAsync()
                {
                    File.Exists("file.txt");
                    await Task.Delay(1);
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task ConditionalAccess_WithBannedApi_ProducesDiagnostic()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public string Path { get; set; }
                public override bool Execute()
                {
                    var dir = System.IO.Path.GetFullPath(Path ?? ".");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task FileApi_WithConstantPath_StillProducesDiagnostic()
    {
        // Even constant paths need absolutization - the task might be invoked from different directories
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                private const string LogFile = "build.log";
                public override bool Execute()
                {
                    File.WriteAllText(LogFile, "done");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    [Fact]
    public async Task MultipleViolationsInSingleMethod_AllDetected()
    {
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    Console.WriteLine("a");
                    var env = Environment.GetEnvironmentVariable("X");
                    File.Exists("foo");
                    Console.ReadLine();
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.CriticalError).Count().ShouldBeGreaterThanOrEqualTo(2);
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // [MSBuildMultiThreadableTask] attribute on task classes
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task Task_WithMultiThreadableAttribute_AnalyzedForAllRules()
    {
        // A task with [MSBuildMultiThreadableTask] but NOT IMultiThreadableTask
        // should still get MSBuildTask0002 and MSBuildTask0003
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            using Microsoft.Build.Framework;
 
            [MSBuildMultiThreadableTask]
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    File.Exists("foo.txt");
                    var env = Environment.GetEnvironmentVariable("PATH");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task Task_WithoutMultiThreadableAttribute_GetsAllRules()
    {
        // All rules now fire on all ITask implementations
        var diags = await GetDiagnosticsAsync("""
            using System;
            using System.IO;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    File.Exists("foo.txt");
                    var env = Environment.GetEnvironmentVariable("PATH");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute);
        diags.ShouldContain(d => d.Id == DiagnosticIds.TaskEnvironmentRequired);
    }
 
    [Fact]
    public async Task UsingStaticConsole_DetectedByTypeLevelBan()
    {
        var diags = await GetDiagnosticsAsync("""
            using static System.Console;
            public class MyTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    WriteLine("hello from using static");
                    return true;
                }
            }
            """);
 
        diags.ShouldContain(d => d.Id == DiagnosticIds.CriticalError);
    }
 
    [Fact]
    public async Task FileWriteAllText_NonPathStringParam_NoDiagnosticForContents()
    {
        // File.WriteAllText(string path, string contents) - "contents" should NOT be flagged
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    AbsolutePath p = TaskEnvironment.GetAbsolutePath("file.txt");
                    File.WriteAllText(p, "contents here");
                    return true;
                }
            }
            """);
 
        diags.Where(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute).ShouldBeEmpty();
    }
 
    [Fact]
    public async Task FileAppendAllText_PathUnwrapped_FlagsPath()
    {
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    File.AppendAllText("file.txt", "contents");
                    return true;
                }
            }
            """);
 
        // Should flag the path parameter but NOT the contents parameter
        diags.Where(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute).Count().ShouldBe(1);
    }
 
    [Fact]
    public async Task FileWriteAllText_NamedArguments_HandledCorrectly()
    {
        // Named arguments can change the order of arguments in source vs parameters
        var diags = await GetDiagnosticsAsync("""
            using System.IO;
            using Microsoft.Build.Framework;
            public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    // Named argument puts "contents" first in source, but "path" is still the path param
                    File.WriteAllText(contents: "some text", path: "file.txt");
                    return true;
                }
            }
            """);
 
        // Should still flag the path parameter
        diags.Where(d => d.Id == DiagnosticIds.FilePathRequiresAbsolute).Count().ShouldBe(1);
    }
 
    // ═══════════════════════════════════════════════════════════════════════
    // Scope option tests
    // ═══════════════════════════════════════════════════════════════════════
 
    [Fact]
    public async Task Scope_MultithreadableOnly_PlainTask_NoDiagnostic()
    {
        var diags = await GetDiagnosticsWithScopeAsync("""
            using System;
            public class PlainTask : Microsoft.Build.Utilities.Task
            {
                public override bool Execute()
                {
                    var val = Environment.GetEnvironmentVariable("KEY");
                    return true;
                }
            }
            """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly);
 
        // Plain ITask should NOT get MSBuildTask0002 when scope is multithreadable_only
        diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).ShouldBeEmpty();
    }
 
    [Fact]
    public async Task Scope_MultithreadableOnly_MultiThreadableTask_GetsDiagnostic()
    {
        var diags = await GetDiagnosticsWithScopeAsync("""
            using System;
            using Microsoft.Build.Framework;
            public class MtTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
            {
                public TaskEnvironment TaskEnvironment { get; set; }
                public override bool Execute()
                {
                    var val = Environment.GetEnvironmentVariable("KEY");
                    return true;
                }
            }
            """, SharedAnalyzerHelpers.ScopeMultiThreadableOnly);
 
        // IMultiThreadableTask SHOULD get MSBuildTask0002 even when scope is multithreadable_only
        diags.Where(d => d.Id == DiagnosticIds.TaskEnvironmentRequired).ShouldNotBeEmpty();
    }
}